Enhance 5G modem management with integrated web GUI and connection control

- Introduced a web GUI for managing 5G connections, replacing the standalone 5g-router service.
- Updated scripts to ensure exclusive access to the AT port, preventing conflicts.
- Improved troubleshooting documentation in 5G_MODEM_TROUBLESHOOTING.md, adding checks for processes using the AT port.
- Enhanced connection management in the web app, including auto-connect and detailed status APIs.
- Updated installation scripts to reflect changes in service management and dependencies.
This commit is contained in:
nearxos
2026-02-02 10:34:25 +02:00
parent 78f7ccc6db
commit 9dc35a57a2
14 changed files with 1224 additions and 115 deletions

View File

@@ -197,9 +197,10 @@ The script prints:
- **5g-router service** status
- **WAN interface** and default route
- **Last 60 lines** of `/var/log/5g-router.log`
- **Processes using the AT port** (lsof) see if ModemManager or another process is holding the port
- **modem-status-at.sh** output (registration, signal) if installed
Copy the full output and use it to see: wrong AT port, broken ttyUSB node, modem in Mode 41, or service/APN issues. Then apply the fixes listed at the end of the script or in the sections below.
Copy the full output and use it to see: wrong AT port, broken ttyUSB node, modem in Mode 41, another process holding the port, or service/APN issues. Then apply the fixes listed at the end of the script or in the sections below.
### AT Commands Not Responding / "AT not OK"
@@ -214,13 +215,18 @@ Copy the full output and use it to see: wrong AT port, broken ttyUSB node, modem
( timeout 5 cat /dev/ttyUSB1 & ); sleep 0.5; echo -e 'AT\r' > /dev/ttyUSB1; sleep 3; kill %1 2>/dev/null
```
If you see `OK` in the output, that port works. Set `AT_PORT="/dev/ttyUSB1"` (or the working port) in `/etc/5g-router.conf`.
5. Ensure no other process is holding the port (e.g. ModemManager, or a stuck cat). Stop ModemManager if present: `rc-service ModemManager stop`
5. **Ensure no other process is holding the port** (e.g. ModemManager, a stuck `cat`, or the Web GUI polling modem-status-at.sh). Run `lsof /dev/ttyUSB1` to see who has it open. Stop ModemManager if present: `rc-service ModemManager stop`. If the 5g-router service is stuck with an open `cat`, restart it: `service 5g-router restart`.
### ttyUSB port shows as regular file (AT not responding)
**Symptoms:** `ls -la /dev/ttyUSB1` shows `-rw-rw----` (regular file) instead of `crw-rw----` (character device). AT commands get no reply or garbage.
**Cause:** Sometimes after modem disconnect/reconnect (or a script writing to the port when it was missing), a regular file is created at `/dev/ttyUSB1` (or another number). The kernel then attaches the real device to a different name or the file blocks the node.
**Cause:** A **shell redirect** to `/dev/ttyUSB1` when the device **does not exist yet** (e.g. modem not bound, or device removed) **creates a regular file** at that path. That file then “steals” the name: when the kernel later creates the real modem port, it may get a different name (e.g. ttyUSB5). Typical causes:
- A script or cron job (e.g. Web GUI status poll, modem-status-at.sh) runs **before** the modem is ready and does `echo ... > /dev/ttyUSB1`.
- The 5g-router service runs at boot; between “port available” and sending AT, the modem briefly disappears (USB glitch) and the next write creates a file.
- ModemManager or another daemon opening the port when it doesnt exist.
**Prevention:** All project scripts that write to the AT port now check `[ -c "$AT_PORT" ]` immediately before writing and skip/exit if its not a character device, so they never create a regular file. The connection script also removes a stray `/dev/ttyUSB` (no number) file at startup.
**Fix (one-time):**
```bash
@@ -231,7 +237,7 @@ chmod 660 /dev/ttyUSB1
chown root:dialout /dev/ttyUSB1
```
**Prevention:** `connect-5g.sh` now checks and fixes a broken AT port automatically before use (recreates the device node if it is a regular file).
**Prevention:** `connect-5g.sh` now checks and fixes a broken AT port automatically before use (recreates the device node if it is a regular file). Scripts never write to the port unless it is currently a character device, so they dont create the file in the first place.
### Stray /dev/ttyUSB file (no number)

View File

@@ -52,10 +52,13 @@ Internet (CYTA 5G)
For a **single-command flow** from a fresh device, see **[docs/QUICKSTART.md](docs/QUICKSTART.md)**. Summary:
1. Clone or copy this repo to the device.
2. Install packages: `apk add iptables libmbim-tools qmi-utils` (and optionally dnsmasq, speedtest-cli).
3. Run **`./scripts/install.sh`** installs scripts, OpenRC service, firewall rules, and `/etc/5g-router.conf`.
2. Install packages: `apk add iptables python3 py3-flask` and `pip install pyserial`.
3. Run **`./scripts/install.sh`** installs Web GUI, config, and enables 5g-webgui service.
4. Edit `/etc/5g-router.conf` if needed (APN, interfaces).
5. Start: `service 5g-router start` or `/usr/local/bin/connect-5g.sh`.
5. Start: `service 5g-webgui start`
6. Open **http://\<device-ip\>:5000** to manage the router.
**Note:** The Web GUI now includes integrated 5G connection management. The standalone `5g-router` service is kept for manual/fallback use but is no longer the default.
For SSH and key setup: **[docs/DEPLOY.md](docs/DEPLOY.md)**.

View File

@@ -29,10 +29,11 @@ log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
send_at_cmd() {
local cmd="$1"
local wait="${2:-2}"
[ -c "$AT_PORT" ] || return 1
cat $AT_PORT 2>/dev/null &
local CAT_PID=$!
sleep 0.3
[ -c "$AT_PORT" ] || { kill $CAT_PID 2>/dev/null; return 1; }
echo -e "${cmd}\r" > $AT_PORT
sleep $wait
kill $CAT_PID 2>/dev/null || true
@@ -41,8 +42,9 @@ send_at_cmd() {
get_at_response() {
local cmd="$1"
local wait="${2:-2}"
[ -c "$AT_PORT" ] || return 1
timeout $((wait + 3)) sh -c "
[ -c \"$AT_PORT\" ] || exit 1
cat $AT_PORT 2>/dev/null &
CAT_PID=\$!
sleep 0.3

View File

@@ -24,7 +24,11 @@ cd /tmp/Alpine_5G
# Enable community repo if needed
sed -i 's|#.*community|http://mirrors.neterra.net/alpine/v3.23/community|' /etc/apk/repositories
apk update
apk add iptables libmbim-tools qmi-utils
# Core packages
apk add iptables python3 py3-flask
pip install pyserial
# Optional: dnsmasq for LAN DHCP/DNS, speedtest-cli for speedtests
apk add dnsmasq speedtest-cli
```
@@ -42,10 +46,10 @@ chmod +x scripts/install.sh
This installs:
- `/etc/5g-router.conf` (from example edit if needed)
- `/usr/local/bin/connect-5g.sh`, `status-5g.sh`, `healthcheck-5g.sh`, etc.
- `/etc/init.d/5g-router` (OpenRC service)
- `/usr/local/share/5g-webgui/` Web GUI with integrated connection management
- `/etc/init.d/5g-webgui` (OpenRC service handles both Web GUI and 5G connection)
- `/etc/iptables/rules.v4`
- Enables `5g-router` at boot
- Enables `5g-webgui` at boot
## 4. Edit config (if needed)
@@ -58,14 +62,14 @@ Set at least:
- `APN` e.g. `internet` for CYTA
- `WAN_IF` / `LAN_IF` default `eth1` and `eth0.100` are usually correct
Optional: `FAILOVER_ENABLED=yes` and `FAILOVER_IF=eth0` to use ethernet when 5G is down.
Optional:
- `WATCHDOG_INTERVAL=60` check connection every 60s and reconnect if needed
- `FAILOVER_ENABLED=yes` and `FAILOVER_IF=eth0` to use ethernet when 5G is down
## 5. Start 5G and test
## 5. Start Web GUI (includes 5G connection)
```bash
service 5g-router start
# Or run once:
/usr/local/bin/connect-5g.sh
service 5g-webgui start
# Check status
/usr/local/bin/status-5g.sh
@@ -74,7 +78,16 @@ service 5g-router start
ping -c 3 8.8.8.8
```
## 6. Enable iptables restore at boot (if not already)
## 6. Access Web GUI
Open **http://\<device-ip\>:5000** in your browser.
- **admin** / **admin** full access
- **support** / **support** view-only + restart 5G
**Change passwords after first login!**
## 7. Enable iptables restore at boot (if not already)
```bash
rc-update add iptables-restore default
@@ -82,5 +95,22 @@ rc-update add iptables-restore default
## Done
The device will bring up 5G at boot. To restart 5G: `service 5g-router restart`.
For full docs see [README.md](../README.md) and [5G_MODEM_TROUBLESHOOTING.md](../5G_MODEM_TROUBLESHOOTING.md).
The device will bring up 5G at boot via the Web GUI service. Manage via web interface at port 5000.
To restart 5G: use the Web GUI, or `service 5g-webgui restart`.
For full docs see [README.md](../README.md), [WEBGUI.md](WEBGUI.md), and [5G_MODEM_TROUBLESHOOTING.md](../5G_MODEM_TROUBLESHOOTING.md).
---
## Legacy: Standalone 5g-router service
If you prefer to run without the Web GUI, you can still use the legacy service:
```bash
rc-update del 5g-webgui default
rc-update add 5g-router default
service 5g-router start
```
This runs `connect-5g.sh` without the web interface.

View File

@@ -2,6 +2,14 @@
Web interface with login and role-based access (admin and support). **One HTML page per function** (Status, Logs, Restart 5G, Config, Firewall, Routes, Users) with shared navigation.
**As of Rev 2, the Web GUI includes integrated 5G connection management.** It now handles:
- Modem AT commands via native Python (pyserial)
- 5G connection lifecycle (connect, disconnect, reconnect)
- Watchdog for automatic reconnection
- NAT/firewall configuration
The standalone `5g-router` service is no longer needed; `5g-webgui` is the recommended single service.
## Access
- **URL:** `http://<device-ip>:5000` (e.g. `http://10.130.60.121:5000`)
@@ -18,6 +26,7 @@ Web interface with login and role-based access (admin and support). **One HTML p
| View status | ✓ | ✓ |
| View logs | ✓ | ✓ |
| Restart 5G | ✓ | ✓ |
| Connect/Disconnect | ✓ | ✓ |
| Edit config | ✓ | |
| Edit firewall | ✓ | |
| View routes | ✓ | |
@@ -28,8 +37,9 @@ Web interface with login and role-based access (admin and support). **One HTML p
### On the device (after main install)
```bash
# Install Python and Flask (Alpine)
# Install Python, Flask, and pyserial (Alpine)
apk add python3 py3-flask
pip install pyserial
# If you used scripts/install.sh, Web GUI is already under /usr/local/share/5g-webgui
# Enable and start the service:
@@ -44,11 +54,36 @@ cd /usr/local/share/5g-webgui && ./run.sh
```bash
cd web
pip install -r requirements.txt # or: apk add py3-flask
pip install -r requirements.txt # Flask and pyserial
python3 app.py
# Open http://localhost:5000
```
## API Endpoints for 5G Control
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/status` | GET | Connection status + modem info |
| `/api/5g/status` | GET | Detailed connection status |
| `/api/5g/connect` | POST | Manually connect 5G |
| `/api/5g/disconnect` | POST | Manually disconnect 5G |
| `/api/5g/restart` | POST | Disconnect then reconnect |
## Architecture
```
5g-webgui service
├── Flask App (port 5000)
├── ATClient (pyserial → /dev/ttyUSB1)
└── ConnectionManager
├── connect() - APN, PDP, IP, NAT
├── disconnect()
├── check_health()
└── watchdog_loop() (background thread)
```
The connection auto-starts when the service starts (on first web request). If `WATCHDOG_INTERVAL` is set in `/etc/5g-router.conf`, a background thread periodically checks connection health and reconnects if needed.
## Security
- Set **SECRET_KEY** in production: `export SECRET_KEY="your-random-secret"` before starting the app (or in the OpenRC service).

View File

@@ -1,8 +1,11 @@
#!/sbin/openrc-run
# Alpine 5G Router Web GUI (Flask). Login: admin/support with different permissions.
# Rev: 1 (see REVISION in repo root)
# Alpine 5G Router Web GUI with integrated 5G connection management.
# Rev: 2 (see REVISION in repo root)
#
# This service now handles both the Web GUI and 5G modem connection.
# The standalone 5g-router service is no longer needed.
description="5G Router Web GUI (port 5000)"
description="5G Router Web GUI with connection management (port 5000)"
command="/usr/local/share/5g-webgui/run.sh"
command_background="yes"
pidfile="/run/5g-webgui.pid"
@@ -12,6 +15,7 @@ error_log="/var/log/5g-webgui.log"
depend() {
need net
after bootmisc
# Note: 5g-router service is no longer needed; connection is managed by this service
}
start_pre() {
@@ -20,8 +24,12 @@ start_pre() {
return 1
fi
if ! command -v python3 >/dev/null 2>&1; then
eend 1 "python3 not found. Install: apk add python3 py3-flask"
eend 1 "python3 not found. Install: apk add python3 py3-flask py3-serial"
return 1
fi
# Ensure pyserial is available
if ! python3 -c "import serial" 2>/dev/null; then
ewarn "pyserial not found. Install: pip install pyserial"
fi
return 0
}

View File

@@ -20,17 +20,32 @@ WATCHDOG_INTERVAL="0"
LOG_SIGNAL="yes"
WEAK_SIGNAL_CSQ="10"
[ -f "$CONFIG" ] && . "$CONFIG"
[ -f "$CONFIG" ] && . "$CONFIG" || true
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" 2>/dev/null || echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
# Lock file for exclusive AT port access (shared with modem-status-at.sh)
AT_LOCK="/var/lock/5g-at.lock"
exec 9>"$AT_LOCK"
flock -n 9 || { echo "Another process is using the AT port, waiting..." >&2; flock 9; }
# Log to file; also to stdout only when running in a terminal (avoids duplicate lines when service redirects stdout to log)
log() {
_m="[$(date '+%Y-%m-%d %H:%M:%S')] $1"
echo "$_m" >> "$LOG_FILE" 2>/dev/null || true
[ -t 1 ] && echo "$_m" || true
}
# Fix broken /dev/ttyUSB* node (if it is a regular file instead of char device - e.g. after modem reconnect)
# Also remove stray /dev/ttyUSB (no digit) which can appear and steal the name from real devices
fix_ttyusb_if_needed() {
# Remove stray /dev/ttyUSB (no number) if it's a regular file
if [ -e /dev/ttyUSB ] && [ -f /dev/ttyUSB ] && [ ! -c /dev/ttyUSB ]; then
rm -f /dev/ttyUSB
fi
case "$AT_PORT" in
/dev/ttyUSB[0-9]*) ;;
*) return 0 ;;
esac
[ ! -e "$AT_PORT" ] && return 0
[ ! -e "$AT_PORT" ] && return 0 || true
if [ -f "$AT_PORT" ] && [ ! -c "$AT_PORT" ]; then
_num="${AT_PORT#/dev/ttyUSB}"
log "Fixing $AT_PORT (was regular file, recreating as char device)"
@@ -41,10 +56,12 @@ fix_ttyusb_if_needed() {
fi
}
# Never write to AT_PORT unless it is a character device; otherwise a redirect would create a regular file and break the port
get_at_response() {
_cmd="$1"
_wait="${2:-2}"
timeout $((_wait + 4)) sh -c "
[ -c \"$AT_PORT\" ] || exit 1
cat $AT_PORT 2>/dev/null &
_pid=\$!
sleep 0.5
@@ -80,14 +97,28 @@ do_connect() {
# eth1 (RNDIS) does NOT provide DHCP. We get IP (and DNS) only via AT commands
# and configure the interface manually. Do not run dhclient on eth1.
check_usb_mode || return 1
# Modem can take 35s to respond after boot; use 5s so service (started at boot) gets OK
get_at_response "AT" 5 | grep -q "OK" || { log "AT not OK (try longer wait or different AT port)"; return 1; }
# Modem can take 515s to respond after port appears at boot; retry initial AT up to 3 times
_at_ok=0
for _try in 1 2 3; do
if get_at_response "AT" 5 | grep -q "OK"; then
_at_ok=1
break
fi
if [ $_try -lt 3 ]; then
log "AT not OK, retry $_try/3 in 5s..."
sleep 5
fi
done
if [ $_at_ok -eq 0 ]; then
log "AT not OK (try longer wait or different AT port)"
return 1
fi
# Signal (optional log)
_resp=$(get_at_response "AT+CSQ" 1)
_csq=$(echo "$_resp" | grep "+CSQ:" | grep -oE '[0-9]+' | head -1)
[ -n "$_csq" ] && [ "$LOG_SIGNAL" = "yes" ] && echo "$(date -Iseconds) CSQ=$_csq" >> "$LOG_FILE" 2>/dev/null || true
[ -n "$_csq" ] && [ -n "$WEAK_SIGNAL_CSQ" ] && [ "$WEAK_SIGNAL_CSQ" -gt 0 ] && [ "$_csq" -lt "$WEAK_SIGNAL_CSQ" ] && log "WARN weak signal CSQ=$_csq"
[ -n "$_csq" ] && [ -n "$WEAK_SIGNAL_CSQ" ] && [ "$WEAK_SIGNAL_CSQ" -gt 0 ] && [ "$_csq" -lt "$WEAK_SIGNAL_CSQ" ] && log "WARN weak signal CSQ=$_csq" || true
log "Configuring APN: $APN"
get_at_response "AT+CGDCONT=1,\"IP\",\"$APN\"" 2 | grep -q "OK" || true
@@ -98,25 +129,35 @@ do_connect() {
for _retry in 1 2 3 4 5; do
_resp=$(get_at_response "AT+CGPADDR=1" 2)
_ip=$(echo "$_resp" | grep "+CGPADDR:" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
[ -n "$_ip" ] && break
[ $_retry -lt 5 ] && log "No IP yet, retry $_retry/5 in 3s..." && sleep 3
if [ -n "$_ip" ]; then break; fi
if [ $_retry -lt 5 ]; then
log "No IP yet, retry $_retry/5 in 3s..."
sleep 3
fi
done
[ -z "$_ip" ] && log "Could not get modem IP (check signal/registration: AT+CSQ, AT+CEREG?)" && return 1
if [ -z "$_ip" ]; then
log "Could not get modem IP (check signal/registration: AT+CSQ, AT+CEREG?)"
return 1
fi
# Get DNS from modem (AT+CGCONTRDP=1); modem does not provide DHCP, only IP/DNS via AT
_dns1=""; _dns2=""
_contrdp=$(get_at_response "AT+CGCONTRDP=1" 2)
_allips=""
for _a in $(echo "$_contrdp" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'); do
[ "$_a" = "$_ip" ] && continue
[ "$_a" = "$_ip" ] && continue || true
_allips="${_allips} ${_a}"
done
for _a in $_allips; do [ -z "$_a" ] && continue; _dns2="$_dns1"; _dns1="$_a"; done
for _a in $_allips; do
[ -z "$_a" ] && continue || true
_dns2="$_dns1"
_dns1="$_a"
done
[ -z "$_dns1" ] && [ -n "$DNS_SERVERS" ] && _dns1=$(echo "$DNS_SERVERS" | cut -d',' -f1 | tr -d ' ') && _dns2=$(echo "$DNS_SERVERS" | cut -d',' -f2 | tr -d ' ')
if [ -n "$_dns1" ]; then
: > /etc/resolv.conf
echo "nameserver $_dns1" >> /etc/resolv.conf
[ -n "$_dns2" ] && echo "nameserver $_dns2" >> /etc/resolv.conf
[ -n "$_dns2" ] && echo "nameserver $_dns2" >> /etc/resolv.conf || true
log "DNS set (from modem or config)"
fi
@@ -137,7 +178,7 @@ do_connect() {
}
do_failover() {
[ "$FAILOVER_ENABLED" != "yes" ] && return 0
[ "$FAILOVER_ENABLED" != "yes" ] && return 0 || true
# Ensure there is a default route via failover interface (higher metric so 5G wins when up)
ip route add default dev "$FAILOVER_IF" metric "$FAILOVER_METRIC" 2>/dev/null || true
}

View File

@@ -2,7 +2,11 @@
# Alpine 5G Router install/deploy scripts and config to this device
# Run from repo root: ./scripts/install.sh
# Or from scripts/: ./install.sh (uses script dir to find repo root)
# Rev: 1 (see REVISION in repo root)
# Rev: 2 (see REVISION in repo root)
#
# NOTE: As of Rev 2, 5g-webgui handles both Web GUI AND 5G connection management.
# The standalone 5g-router service is kept for manual/fallback use but is
# NOT enabled by default. Use 5g-webgui instead.
set -e
@@ -27,7 +31,7 @@ else
echo "Keeping existing $CONFIG"
fi
# Scripts
# Scripts (kept for manual use and diagnostics)
install -m 755 "$SCRIPTS/connect-5g.sh" "$BIN/connect-5g.sh"
install -m 755 "$SCRIPTS/status-5g.sh" "$BIN/status-5g.sh"
install -m 755 "$SCRIPTS/modem-status-at.sh" "$BIN/modem-status-at.sh"
@@ -45,9 +49,9 @@ if [ -f "$ROOT/configure_fm350_5g.sh" ]; then
echo "Installed configure_fm350_5g.sh"
fi
# OpenRC service
# Legacy 5g-router service (kept for manual/fallback use, NOT enabled by default)
install -m 755 "$ETC/init.d/5g-router" "$INITD/5g-router"
echo "Installed $INITD/5g-router"
echo "Installed $INITD/5g-router (legacy use 5g-webgui instead)"
# iptables rules (restore at boot ensure iptables-restore service exists)
mkdir -p "$IPTABLES_SAVE"
@@ -57,17 +61,12 @@ if [ -d /etc/init.d ] && [ ! -f /etc/init.d/iptables-restore ]; then
echo "Tip: enable iptables restore at boot: rc-update add iptables-restore"
fi
# Log file
# Log files
touch "$LOG_DIR/5g-router.log" 2>/dev/null && chmod 644 "$LOG_DIR/5g-router.log" || true
touch "$LOG_DIR/speedtest-5g.log" 2>/dev/null && chmod 644 "$LOG_DIR/speedtest-5g.log" || true
touch "$LOG_DIR/5g-webgui.log" 2>/dev/null && chmod 644 "$LOG_DIR/5g-webgui.log" || true
# Enable 5g-router at boot
if command -v rc-update >/dev/null 2>&1; then
rc-update add 5g-router default 2>/dev/null || true
echo "Added 5g-router to default runlevel"
fi
# Web GUI (optional)
# Web GUI with integrated 5G connection management
WEBGUI_SRC="$ROOT/web"
WEBGUI_DEST="/usr/local/share/5g-webgui"
if [ -d "$WEBGUI_SRC" ] && [ -f "$WEBGUI_SRC/app.py" ]; then
@@ -75,14 +74,36 @@ if [ -d "$WEBGUI_SRC" ] && [ -f "$WEBGUI_SRC/app.py" ]; then
cp -r "$WEBGUI_SRC"/* "$WEBGUI_DEST/"
chmod 755 "$WEBGUI_DEST/run.sh" 2>/dev/null || true
install -m 755 "$ETC/init.d/5g-webgui" "$INITD/5g-webgui"
echo "Installed Web GUI at $WEBGUI_DEST. Enable with: rc-update add 5g-webgui default"
echo " Then: apk add python3 py3-flask && service 5g-webgui start"
# Enable 5g-webgui at boot (this now handles connection management)
if command -v rc-update >/dev/null 2>&1; then
rc-update add 5g-webgui default 2>/dev/null || true
echo "Added 5g-webgui to default runlevel"
# Disable old 5g-router if it was enabled
rc-update del 5g-router default 2>/dev/null || true
fi
echo ""
echo "Installed Web GUI with 5G connection management at $WEBGUI_DEST"
echo " Install dependencies: apk add python3 py3-flask && pip install pyserial"
echo " Start service: service 5g-webgui start"
echo " Login at http://<device-ip>:5000 (default: admin/admin, support/support change passwords)"
else
echo "Web GUI source not found; skipping."
echo "Enabling legacy 5g-router service instead..."
if command -v rc-update >/dev/null 2>&1; then
rc-update add 5g-router default 2>/dev/null || true
echo "Added 5g-router to default runlevel"
fi
fi
echo ""
echo "Done. Edit $CONFIG if needed, then: service 5g-router start"
echo "Or run once: $BIN/connect-5g.sh"
echo "Status: $BIN/status-5g.sh"
echo "Done. Edit $CONFIG if needed."
echo ""
echo "To start:"
echo " service 5g-webgui start (recommended: includes Web GUI + 5G connection)"
echo " OR"
echo " service 5g-router start (legacy: 5G connection only, no Web GUI)"
echo ""
echo "Status: Visit http://<device-ip>:5000 or run: $BIN/status-5g.sh"

View File

@@ -1,16 +1,22 @@
#!/bin/sh
# Alpine 5G Router modem status via AT commands (AT_COMMANDS_REFERENCE.md)
# Outputs JSON. Used by Web GUI /api/status.
# Rev: 2 (see REVISION in repo root)
# Rev: 3 (see REVISION in repo root)
CONFIG="/etc/5g-router.conf"
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
[ -f "$CONFIG" ] && . "$CONFIG"
AT_LOCK="/var/lock/5g-at.lock"
[ -f "$CONFIG" ] && . "$CONFIG" || true
# Acquire exclusive lock on AT port to avoid conflicts with connect-5g.sh
exec 9>"$AT_LOCK"
flock -w 10 9 || { echo '{"error":"AT port busy"}'; exit 1; }
get_at() {
_cmd="$1"
_wait="${2:-1}"
timeout $((_wait + 2)) sh -c "
[ -c \"$AT_PORT\" ] || exit 1
cat $AT_PORT 2>/dev/null &
_p=\$!
sleep 0.3

View File

@@ -8,8 +8,13 @@ CONFIG="/etc/5g-router.conf"
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
WAN_IF="${WAN_IF:-eth1}"
LOG_FILE="/var/log/5g-router.log"
AT_LOCK="/var/lock/5g-at.lock"
[ -f "$CONFIG" ] && . "$CONFIG"
[ -f "$CONFIG" ] && . "$CONFIG" || true
# Acquire lock on AT port so we don't conflict with connect-5g.sh or modem-status-at.sh
exec 9>"$AT_LOCK"
flock -w 15 9 || echo "Warning: could not acquire AT lock (another process using port)"
echo "=============================================="
echo " Alpine 5G Router Full Troubleshoot"
@@ -59,10 +64,29 @@ else
fi
echo ""
# --- 5) AT port test (with longer wait like connect-5g.sh) ---
echo "--- 5) AT command test on $AT_PORT (wait 3s) ---"
# --- 5) Processes using the AT port (can block or conflict) ---
echo "--- 5) Processes using $AT_PORT (lsof) ---"
if [ -e "$AT_PORT" ]; then
if command -v lsof >/dev/null 2>&1; then
_lsof=$(lsof 2>/dev/null | grep -F "$AT_PORT")
if [ -n "$_lsof" ]; then
echo "$_lsof" | sed 's/^/ /'
else
echo " No process has $AT_PORT open (good)"
fi
else
echo " (lsof not installed install with: apk add lsof)"
fi
else
echo " Port does not exist (nothing to list)"
fi
echo ""
# --- 6) AT port test (with longer wait like connect-5g.sh) ---
echo "--- 6) AT command test on $AT_PORT (wait 3s) ---"
if [ -c "$AT_PORT" ]; then
out=$(timeout 8 sh -c "
[ -c \"$AT_PORT\" ] || exit 1
cat $AT_PORT 2>/dev/null &
_p=\$!
sleep 0.5
@@ -82,11 +106,11 @@ else
fi
echo ""
# --- 6) AT probe on all ttyUSB (which port responds) ---
echo "--- 6) AT probe on each ttyUSB port ---"
# --- 7) AT probe on all ttyUSB (which port responds) ---
echo "--- 7) AT probe on each ttyUSB port ---"
for port in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3; do
[ -c "$port" ] || continue
out=$(timeout 5 sh -c "cat $port 2>/dev/null & _p=\$!; sleep 0.3; echo -e 'AT\r' > $port; sleep 2; kill \$_p 2>/dev/null" 2>/dev/null)
out=$(timeout 5 sh -c "[ -c \"$port\" ] || exit 1; cat $port 2>/dev/null & _p=\$!; sleep 0.3; echo -e 'AT\r' > $port; sleep 2; kill \$_p 2>/dev/null" 2>/dev/null)
if echo "$out" | grep -q 'OK'; then
echo " $port: OK (use this as AT_PORT if different from config)"
else
@@ -95,8 +119,8 @@ for port in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3; do
done
echo ""
# --- 7) 5g-router service ---
echo "--- 7) 5g-router service ---"
# --- 8) 5g-router service ---
echo "--- 8) 5g-router service ---"
if command -v rc-service >/dev/null 2>&1; then
rc-service 5g-router status 2>&1 | sed 's/^/ /'
else
@@ -104,8 +128,8 @@ else
fi
echo ""
# --- 8) WAN interface and routing ---
echo "--- 8) WAN interface ($WAN_IF) and default route ---"
# --- 9) WAN interface and routing ---
echo "--- 9) WAN interface ($WAN_IF) and default route ---"
if ip link show "$WAN_IF" >/dev/null 2>&1; then
ip link show "$WAN_IF" 2>/dev/null | sed 's/^/ /'
ip -4 addr show "$WAN_IF" 2>/dev/null | sed 's/^/ /'
@@ -115,8 +139,8 @@ fi
ip route show default 2>/dev/null | sed 's/^/ /'
echo ""
# --- 9) Full 5g-router log ---
echo "--- 9) Full 5g-router log (last 60 lines of $LOG_FILE) ---"
# --- 10) Full 5g-router log ---
echo "--- 10) Full 5g-router log (last 60 lines of $LOG_FILE) ---"
if [ -f "$LOG_FILE" ]; then
tail -60 "$LOG_FILE" 2>/dev/null | sed 's/^/ /'
else
@@ -124,12 +148,12 @@ else
fi
echo ""
# --- 10) Optional: modem status script ---
# --- 11) Optional: modem status script ---
if [ -x "/usr/local/bin/modem-status-at.sh" ]; then
echo "--- 10) modem-status-at.sh (registration, signal) ---"
echo "--- 11) modem-status-at.sh (registration, signal) ---"
/usr/local/bin/modem-status-at.sh 2>&1 | head -30 | sed 's/^/ /'
else
echo "--- 10) modem-status-at.sh ---"
echo "--- 11) modem-status-at.sh ---"
echo " Not installed"
fi
echo ""
@@ -140,6 +164,8 @@ echo "=============================================="
echo ""
echo "Quick fixes to try:"
echo " - ttyUSB is regular file: rm $AT_PORT && mknod $AT_PORT c 188 1 && chmod 660 $AT_PORT && chown root:dialout $AT_PORT"
echo " - Stray /dev/ttyUSB (no number): rm /dev/ttyUSB"
echo " - Port held by another process (see section 5): stop ModemManager (rc-service ModemManager stop) or restart 5g-router"
echo " - Modem in Mode 41 (7127): power-cycle modem or reboot; need Mode 40 (7126) for AT"
echo " - AT not OK: set AT_PORT in $CONFIG to the port that showed OK above (e.g. /dev/ttyUSB0)"
echo " - Restart connection: service 5g-router restart or /usr/local/bin/connect-5g.sh"

View File

@@ -1,12 +1,17 @@
"""
Alpine 5G Router Web GUI
Login: admin/support with different permissions. Run: python app.py or gunicorn.
Rev: 1 (see REVISION in repo root)
Rev: 2 (see REVISION in repo root)
Now includes integrated 5G connection management (replaces standalone 5g-router service).
"""
import atexit
import json
import logging
import os
import subprocess
import threading
from pathlib import Path
from flask import (
@@ -48,6 +53,16 @@ from db import (
routes_update,
)
# Connection management (replaces 5g-router service)
from at_client import ATClient
from connection_manager import ConnectionConfig, ConnectionManager
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
app = Flask(__name__, static_folder="static", static_url_path="", template_folder="templates")
app.secret_key = os.environ.get("SECRET_KEY", "change-me-in-production-alpine-5g")
app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 # 512KB max for config/log uploads
@@ -56,11 +71,44 @@ CONFIG_PATH = "/etc/5g-router.conf"
IPTABLES_RULES = "/etc/iptables/rules.v4"
LOG_5G = "/var/log/5g-router.log"
LOG_SPEEDTEST = "/var/log/speedtest-5g.log"
CONNECT_SCRIPT = "/usr/local/bin/connect-5g.sh"
CONNECT_SCRIPT = "/usr/local/bin/connect-5g.sh" # Kept for manual use
STATUS_SCRIPT = "/usr/local/bin/status-5g.sh"
MODEM_STATUS_SCRIPT = "/usr/local/bin/modem-status-at.sh"
MODEM_STATUS_SCRIPT = "/usr/local/bin/modem-status-at.sh" # Deprecated: using ATClient
SPEEDTEST_SCRIPT = "speedtest-cli"
# ---- Connection Manager (replaces 5g-router service) ----
_conn_config = ConnectionConfig.from_file(CONFIG_PATH)
_at_client = ATClient(port=_conn_config.at_port)
_conn_manager = ConnectionManager(_at_client, _conn_config)
_startup_connect_done = False
def _startup_connect():
"""Auto-connect on first request (deferred startup)."""
global _startup_connect_done
if _startup_connect_done:
return
_startup_connect_done = True
def do_connect():
logging.info("Auto-connecting 5G on startup...")
_conn_manager.connect()
if _conn_config.watchdog_interval > 0:
_conn_manager.start_watchdog()
# Run in background thread so startup doesn't block
threading.Thread(target=do_connect, name="5g-startup", daemon=True).start()
@atexit.register
def _shutdown():
"""Cleanup on shutdown."""
try:
_conn_manager.stop_watchdog()
_at_client.close()
except Exception:
pass
def _current_user():
if not session.get("username"):
@@ -207,6 +255,16 @@ def api_me():
def api_status():
if not can_view_status(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
# Trigger auto-connect on first status request (deferred startup)
_startup_connect()
try:
# Get connection and modem status from ConnectionManager (native Python)
full_status = _conn_manager.get_full_status()
# Also include basic system status from status script for interface info
data = {}
try:
out = subprocess.run(
[STATUS_SCRIPT, "--json"],
@@ -214,29 +272,16 @@ def api_status():
text=True,
timeout=10,
)
if out.returncode != 0:
return jsonify({"error": "status script failed", "stderr": out.stderr}), 500
if out.returncode == 0:
data = json.loads(out.stdout)
# Enrich with modem AT status (AT_COMMANDS_REFERENCE.md)
if Path(MODEM_STATUS_SCRIPT).exists():
try:
mod_out = subprocess.run(
[MODEM_STATUS_SCRIPT],
capture_output=True,
text=True,
timeout=15,
cwd="/",
)
if mod_out.returncode == 0 and mod_out.stdout.strip():
mod_data = json.loads(mod_out.stdout)
data["modem"] = mod_data
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired):
data["modem"] = {}
else:
data["modem"] = {}
except Exception:
pass
# Merge connection manager status
data["connection"] = full_status.get("connection", {})
data["modem"] = full_status.get("modem", {})
return jsonify(data)
except subprocess.TimeoutExpired:
return jsonify({"error": "timeout"}), 504
except Exception as e:
return jsonify({"error": str(e)}), 500
@@ -458,21 +503,58 @@ def api_firewall_apply():
return jsonify({"error": str(e)}), 500
# ---- API: 5G restart (support + admin) ----
# ---- API: 5G Connection Control (support + admin) ----
@app.route("/api/5g/restart", methods=["POST"])
@_require_login
def api_5g_restart():
"""Restart 5G connection (disconnect then reconnect)."""
if not can_restart_5g(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
try:
subprocess.Popen(
[CONNECT_SCRIPT],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def do_restart():
_conn_manager.disconnect()
_conn_manager.connect()
# Run in background so API returns immediately
threading.Thread(target=do_restart, name="5g-restart", daemon=True).start()
return jsonify({"ok": True, "message": "Restart initiated"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/5g/connect", methods=["POST"])
@_require_login
def api_5g_connect():
"""Manually connect 5G."""
if not can_restart_5g(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
def do_connect():
_conn_manager.connect()
threading.Thread(target=do_connect, name="5g-connect", daemon=True).start()
return jsonify({"ok": True, "message": "Connect initiated"})
@app.route("/api/5g/disconnect", methods=["POST"])
@_require_login
def api_5g_disconnect():
"""Manually disconnect 5G."""
if not can_restart_5g(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
def do_disconnect():
_conn_manager.disconnect()
threading.Thread(target=do_disconnect, name="5g-disconnect", daemon=True).start()
return jsonify({"ok": True, "message": "Disconnect initiated"})
@app.route("/api/5g/status")
@_require_login
def api_5g_connection_status():
"""Get detailed 5G connection status."""
if not can_view_status(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
return jsonify(_conn_manager.get_full_status())
# ---- API: Speedtest (support + admin) ----

316
web/at_client.py Normal file
View File

@@ -0,0 +1,316 @@
"""
AT Command Client for Fibocom FM350-GL modem using pyserial.
Thread-safe with exclusive port access via Lock.
"""
import os
import re
import threading
import time
import logging
from typing import Optional, Dict, Any, List
try:
import serial
except ImportError:
serial = None # Will raise error when used
logger = logging.getLogger(__name__)
class ATClient:
"""
Thread-safe AT command interface for Fibocom FM350-GL modem.
Uses pyserial for serial communication.
"""
DEFAULT_PORTS = ["/dev/ttyUSB1", "/dev/ttyUSB0", "/dev/ttyUSB2"]
DEFAULT_BAUDRATE = 115200
DEFAULT_TIMEOUT = 5
def __init__(self, port: str = "/dev/ttyUSB1", baudrate: int = DEFAULT_BAUDRATE):
if serial is None:
raise ImportError("pyserial is required. Install with: pip install pyserial")
self.port = port
self.baudrate = baudrate
self.lock = threading.Lock()
self._serial: Optional[serial.Serial] = None
def _open(self) -> bool:
"""Open serial port if not already open."""
if self._serial and self._serial.is_open:
return True
try:
self._serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=1,
write_timeout=1,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
)
# Disable echo
self._send_raw("ATE0\r", timeout=2)
return True
except (serial.SerialException, OSError) as e:
logger.warning(f"Failed to open {self.port}: {e}")
self._serial = None
return False
def _close(self):
"""Close serial port."""
if self._serial and self._serial.is_open:
try:
self._serial.close()
except Exception:
pass
self._serial = None
def _send_raw(self, cmd: str, timeout: float = DEFAULT_TIMEOUT) -> str:
"""
Send raw AT command and read response. Must hold lock.
Returns the response string (may contain OK, ERROR, or data).
"""
if not self._serial or not self._serial.is_open:
return ""
# Clear input buffer
self._serial.reset_input_buffer()
# Send command
if not cmd.endswith("\r"):
cmd += "\r"
self._serial.write(cmd.encode("utf-8"))
self._serial.flush()
# Read response until OK, ERROR, or timeout
response_lines = []
start = time.time()
while (time.time() - start) < timeout:
if self._serial.in_waiting > 0:
line = self._serial.readline().decode("utf-8", errors="replace").strip()
if line:
response_lines.append(line)
# Check for terminal responses
if line in ("OK", "ERROR") or line.startswith("+CME ERROR") or line.startswith("+CMS ERROR"):
break
else:
time.sleep(0.05)
return "\n".join(response_lines)
def send_command(self, cmd: str, timeout: float = DEFAULT_TIMEOUT) -> str:
"""
Send AT command and return response. Thread-safe.
"""
with self.lock:
if not self._open():
return ""
try:
return self._send_raw(cmd, timeout)
except Exception as e:
logger.error(f"AT command error: {e}")
self._close()
return ""
def test(self) -> bool:
"""Test AT communication. Returns True if modem responds with OK."""
response = self.send_command("AT", timeout=3)
return "OK" in response
def find_working_port(self) -> Optional[str]:
"""
Try to find a working AT port from the default list.
Updates self.port if found. Returns the working port or None.
"""
for port in self.DEFAULT_PORTS:
if not os.path.exists(port):
continue
old_port = self.port
self.port = port
with self.lock:
self._close()
if self.test():
logger.info(f"Found working AT port: {port}")
return port
self.port = old_port
with self.lock:
self._close()
return None
def get_manufacturer(self) -> str:
"""Get modem manufacturer (AT+CGMI)."""
response = self.send_command("AT+CGMI")
for line in response.split("\n"):
line = line.strip()
if line and line not in ("OK", "AT+CGMI") and not line.startswith("+"):
return line
return ""
def get_model(self) -> str:
"""Get modem model (AT+CGMM)."""
response = self.send_command("AT+CGMM")
for line in response.split("\n"):
line = line.strip()
if line and line not in ("OK", "AT+CGMM") and not line.startswith("+"):
return line
return ""
def get_revision(self) -> str:
"""Get firmware revision (AT+CGMR)."""
response = self.send_command("AT+CGMR")
for line in response.split("\n"):
line = line.strip()
if line and line not in ("OK", "AT+CGMR", "ERROR") and not line.startswith("+"):
return line
return ""
def get_imei(self) -> str:
"""Get IMEI (AT+CGSN)."""
response = self.send_command("AT+CGSN")
match = re.search(r"\d{15}", response)
return match.group(0) if match else ""
def get_iccid(self) -> str:
"""Get SIM ICCID (AT+CCID)."""
response = self.send_command("AT+CCID")
match = re.search(r"\+CCID:\s*\"?(\d+)\"?", response)
if match:
return match.group(1)
# Try alternate format
for line in response.split("\n"):
line = line.strip()
if line and re.match(r"^\d{18,22}$", line):
return line
return ""
def get_signal_csq(self) -> Optional[int]:
"""
Get signal strength as CSQ value (AT+CSQ).
CSQ 0-31 = signal, 99 = unknown.
Returns None if unavailable.
"""
response = self.send_command("AT+CSQ")
match = re.search(r"\+CSQ:\s*(\d+)", response)
if match:
csq = int(match.group(1))
return csq if csq <= 31 else None
return None
def get_registration_status(self) -> Dict[str, str]:
"""
Get network registration status (AT+CREG?, AT+CEREG?).
Returns dict with 'creg' and 'cereg' status strings.
"""
result = {"creg": "unknown", "cereg": "unknown"}
# Parse registration: ,1 = home, ,5 = roaming, ,2 = searching, ,0 = not registered
def parse_reg(response: str) -> str:
if ",1" in response:
return "registered_home"
elif ",5" in response:
return "registered_roaming"
elif ",2" in response:
return "searching"
elif ",0" in response:
return "not_registered"
return "unknown"
creg_resp = self.send_command("AT+CREG?")
cereg_resp = self.send_command("AT+CEREG?")
if "+CREG:" in creg_resp:
result["creg"] = parse_reg(creg_resp)
if "+CEREG:" in cereg_resp:
result["cereg"] = parse_reg(cereg_resp)
return result
def get_operator(self) -> str:
"""Get current operator name (AT+COPS?)."""
response = self.send_command("AT+COPS?")
# Format: +COPS: 0,0,"Vodafone",7
match = re.search(r'"([^"]+)"', response)
return match.group(1) if match else ""
def get_usb_mode(self) -> Optional[int]:
"""Get USB mode (AT+GTUSBMODE?). 40 = RNDIS, 41 = extended."""
response = self.send_command("AT+GTUSBMODE?")
match = re.search(r"\+GTUSBMODE:\s*(\d+)", response)
return int(match.group(1)) if match else None
def get_pdp_address(self, cid: int = 1) -> Optional[str]:
"""Get IP address assigned to PDP context (AT+CGPADDR=cid)."""
response = self.send_command(f"AT+CGPADDR={cid}")
match = re.search(r"(\d+\.\d+\.\d+\.\d+)", response)
return match.group(1) if match else None
def get_connection_params(self, cid: int = 1) -> Dict[str, Any]:
"""
Get connection dynamic parameters (AT+CGCONTRDP=cid).
Returns dict with 'ip', 'dns1', 'dns2', etc.
"""
response = self.send_command(f"AT+CGCONTRDP={cid}", timeout=5)
result = {"ip": None, "dns1": None, "dns2": None}
# Extract all IP addresses from response
ips = re.findall(r"(\d+\.\d+\.\d+\.\d+)", response)
if ips:
# First IP is usually the assigned IP, rest are DNS
result["ip"] = ips[0]
if len(ips) > 1:
result["dns1"] = ips[1]
if len(ips) > 2:
result["dns2"] = ips[2]
return result
def configure_apn(self, apn: str, cid: int = 1) -> bool:
"""Configure APN for PDP context (AT+CGDCONT)."""
response = self.send_command(f'AT+CGDCONT={cid},"IP","{apn}"', timeout=5)
return "OK" in response
def activate_pdp(self, cid: int = 1) -> bool:
"""Activate PDP context (AT+CGACT=1,cid)."""
response = self.send_command(f"AT+CGACT=1,{cid}", timeout=10)
return "OK" in response or "CGEV" in response
def deactivate_pdp(self, cid: int = 1) -> bool:
"""Deactivate PDP context (AT+CGACT=0,cid)."""
response = self.send_command(f"AT+CGACT=0,{cid}", timeout=5)
return "OK" in response
def reset_modem(self) -> bool:
"""Reset modem (AT+CFUN=1,1)."""
response = self.send_command("AT+CFUN=1,1", timeout=5)
self._close()
return "OK" in response
def get_full_status(self) -> Dict[str, Any]:
"""
Get complete modem status. Replaces modem-status-at.sh functionality.
"""
reg = self.get_registration_status()
return {
"at_port": self.port,
"manufacturer": self.get_manufacturer(),
"model": self.get_model(),
"revision": self.get_revision(),
"imei": self.get_imei(),
"signal_csq": self.get_signal_csq(),
"creg_status": reg["creg"],
"cereg_status": reg["cereg"],
"usb_mode": self.get_usb_mode(),
"iccid": self.get_iccid(),
"operator": self.get_operator(),
"modem_ip": self.get_pdp_address(1),
}
def close(self):
"""Close serial connection."""
with self.lock:
self._close()
def __del__(self):
self.close()

532
web/connection_manager.py Normal file
View File

@@ -0,0 +1,532 @@
"""
5G Connection Manager - manages modem connection lifecycle.
Migrates functionality from connect-5g.sh to native Python.
"""
import logging
import os
import subprocess
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional, Callable
from at_client import ATClient
logger = logging.getLogger(__name__)
class ConnectionState(Enum):
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
FAILED = "failed"
RECONNECTING = "reconnecting"
@dataclass
class ConnectionConfig:
"""Configuration for 5G connection."""
at_port: str = "/dev/ttyUSB1"
apn: str = "internet"
wan_if: str = "eth1"
lan_if: str = "eth0.100"
failover_enabled: bool = False
failover_if: str = "eth0"
failover_metric: int = 202
watchdog_interval: int = 0 # 0 = disabled
log_signal: bool = True
weak_signal_csq: int = 10
dns_servers: Optional[str] = None # Comma-separated fallback DNS
@classmethod
def from_file(cls, path: str = "/etc/5g-router.conf") -> "ConnectionConfig":
"""Load config from shell-style config file."""
config = cls()
if not os.path.exists(path):
return config
try:
with open(path, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key == "AT_PORT":
config.at_port = value
elif key == "APN":
config.apn = value
elif key == "WAN_IF":
config.wan_if = value
elif key == "LAN_IF":
config.lan_if = value
elif key == "FAILOVER_ENABLED":
config.failover_enabled = value.lower() == "yes"
elif key == "FAILOVER_IF":
config.failover_if = value
elif key == "FAILOVER_METRIC":
config.failover_metric = int(value)
elif key == "WATCHDOG_INTERVAL":
config.watchdog_interval = int(value)
elif key == "LOG_SIGNAL":
config.log_signal = value.lower() == "yes"
elif key == "WEAK_SIGNAL_CSQ":
config.weak_signal_csq = int(value)
elif key == "DNS_SERVERS":
config.dns_servers = value
except Exception as e:
logger.warning(f"Error reading config {path}: {e}")
return config
@dataclass
class ConnectionStatus:
"""Current connection status."""
state: ConnectionState = ConnectionState.DISCONNECTED
ip_address: Optional[str] = None
dns1: Optional[str] = None
dns2: Optional[str] = None
signal_csq: Optional[int] = None
last_connect_time: Optional[datetime] = None
last_error: Optional[str] = None
connect_attempts: int = 0
class ConnectionManager:
"""
Manages 5G modem connection lifecycle.
Thread-safe, supports background watchdog.
"""
LOG_FILE = "/var/log/5g-router.log"
def __init__(self, at_client: ATClient, config: Optional[ConnectionConfig] = None):
self.at = at_client
self.config = config or ConnectionConfig()
self.status = ConnectionStatus()
self._lock = threading.Lock()
self._watchdog_thread: Optional[threading.Thread] = None
self._watchdog_stop = threading.Event()
self._log_callbacks: List[Callable[[str], None]] = []
def _log(self, message: str):
"""Log message to file and callbacks."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {message}"
logger.info(message)
# Write to log file
try:
with open(self.LOG_FILE, "a") as f:
f.write(line + "\n")
except Exception:
pass
# Notify callbacks
for cb in self._log_callbacks:
try:
cb(line)
except Exception:
pass
def add_log_callback(self, callback: Callable[[str], None]):
"""Add callback to receive log messages."""
self._log_callbacks.append(callback)
def _run_cmd(self, cmd: List[str], check: bool = False) -> subprocess.CompletedProcess:
"""Run shell command."""
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=30, check=check)
except subprocess.TimeoutExpired:
logger.warning(f"Command timed out: {' '.join(cmd)}")
return subprocess.CompletedProcess(cmd, 1, "", "timeout")
except subprocess.CalledProcessError as e:
return subprocess.CompletedProcess(cmd, e.returncode, e.stdout or "", e.stderr or "")
except Exception as e:
logger.error(f"Command failed: {e}")
return subprocess.CompletedProcess(cmd, 1, "", str(e))
def _check_usb_mode(self) -> bool:
"""Check if modem is in correct USB mode (40)."""
result = self._run_cmd(["lsusb"])
if "0e8d:7127" in result.stdout:
self._log("WARN: Modem is in Mode 41 (7127). AT commands may not work.")
return False
return True
def _wait_for_modem(self, max_wait: int = 30) -> bool:
"""Wait for modem AT port to be available."""
self._log("Waiting for modem AT port...")
for i in range(max_wait // 2):
# Check if port exists as char device
if os.path.exists(self.config.at_port):
import stat
mode = os.stat(self.config.at_port).st_mode
if stat.S_ISCHR(mode):
# Try to communicate
if self.at.test():
self._log("Modem AT port available")
return True
# Try to find working port
working = self.at.find_working_port()
if working:
self.config.at_port = working
self._log(f"Found working AT port: {working}")
return True
time.sleep(2)
self._log("Modem AT port not available")
return False
def _configure_interface(self, ip: str) -> bool:
"""Configure network interface with IP from modem."""
wan_if = self.config.wan_if
self._log(f"Configuring {wan_if} with IP {ip}")
try:
# Bring up interface
self._run_cmd(["ip", "link", "set", wan_if, "up"])
# Flush existing addresses
self._run_cmd(["ip", "addr", "flush", "dev", wan_if])
# Add IP address
result = self._run_cmd(["ip", "addr", "add", f"{ip}/32", "dev", wan_if])
if result.returncode != 0 and "RTNETLINK answers: File exists" not in result.stderr:
self._log(f"Failed to add IP: {result.stderr}")
return False
# Add default route
self._run_cmd(["ip", "route", "add", "default", "dev", wan_if, "metric", "50"])
self._log("Interface configured")
return True
except Exception as e:
self._log(f"Interface configuration failed: {e}")
return False
def _configure_nat(self) -> bool:
"""Setup NAT/masquerading for LAN clients."""
self._log("Setting up NAT...")
wan_if = self.config.wan_if
lan_if = self.config.lan_if
try:
# Enable IP forwarding
with open("/proc/sys/net/ipv4/ip_forward", "w") as f:
f.write("1")
# Masquerade outgoing traffic
self._run_cmd([
"iptables", "-t", "nat", "-C", "POSTROUTING",
"-o", wan_if, "-j", "MASQUERADE"
])
if self._run_cmd([
"iptables", "-t", "nat", "-C", "POSTROUTING",
"-o", wan_if, "-j", "MASQUERADE"
]).returncode != 0:
self._run_cmd([
"iptables", "-t", "nat", "-A", "POSTROUTING",
"-o", wan_if, "-j", "MASQUERADE"
])
# Forward from LAN to WAN
if self._run_cmd([
"iptables", "-C", "FORWARD",
"-i", lan_if, "-o", wan_if, "-j", "ACCEPT"
]).returncode != 0:
self._run_cmd([
"iptables", "-A", "FORWARD",
"-i", lan_if, "-o", wan_if, "-j", "ACCEPT"
])
# Allow established/related back
if self._run_cmd([
"iptables", "-C", "FORWARD",
"-i", wan_if, "-o", lan_if,
"-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"
]).returncode != 0:
self._run_cmd([
"iptables", "-A", "FORWARD",
"-i", wan_if, "-o", lan_if,
"-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"
])
self._log("NAT configured")
return True
except Exception as e:
self._log(f"NAT configuration failed: {e}")
return False
def _configure_dns(self, dns1: Optional[str], dns2: Optional[str]):
"""Configure /etc/resolv.conf with DNS servers."""
if not dns1:
# Try fallback from config
if self.config.dns_servers:
parts = self.config.dns_servers.split(",")
dns1 = parts[0].strip() if len(parts) > 0 else None
dns2 = parts[1].strip() if len(parts) > 1 else None
if dns1:
try:
with open("/etc/resolv.conf", "w") as f:
f.write(f"nameserver {dns1}\n")
if dns2:
f.write(f"nameserver {dns2}\n")
self._log("DNS configured")
except Exception as e:
self._log(f"DNS configuration failed: {e}")
def _do_failover(self):
"""Setup failover route if enabled."""
if not self.config.failover_enabled:
return
self._run_cmd([
"ip", "route", "add", "default",
"dev", self.config.failover_if,
"metric", str(self.config.failover_metric)
])
self._log(f"Failover route via {self.config.failover_if} configured")
def connect(self) -> bool:
"""
Establish 5G connection.
Returns True on success.
"""
with self._lock:
self.status.state = ConnectionState.CONNECTING
self.status.connect_attempts += 1
self.status.last_error = None
self._log("Starting 5G connection...")
try:
# Check USB mode
if not self._check_usb_mode():
self._set_failed("Modem in wrong USB mode")
self._do_failover()
return False
# Wait for modem
if not self._wait_for_modem():
self._set_failed("Modem AT port not available")
self._do_failover()
return False
# Test AT communication with retries
at_ok = False
for attempt in range(1, 4):
if self.at.test():
at_ok = True
break
if attempt < 3:
self._log(f"AT not OK, retry {attempt}/3 in 5s...")
time.sleep(5)
if not at_ok:
self._set_failed("AT communication failed")
self._do_failover()
return False
# Log signal strength
csq = self.at.get_signal_csq()
if csq is not None:
self.status.signal_csq = csq
if self.config.log_signal:
self._log(f"Signal CSQ={csq}")
if csq < self.config.weak_signal_csq:
self._log(f"WARN: weak signal CSQ={csq}")
# Configure APN
self._log(f"Configuring APN: {self.config.apn}")
self.at.configure_apn(self.config.apn)
# Activate PDP context
self._log("Activating PDP context...")
self.at.activate_pdp()
# Get IP address with retries
ip = None
for attempt in range(1, 6):
ip = self.at.get_pdp_address()
if ip:
break
if attempt < 5:
self._log(f"No IP yet, retry {attempt}/5 in 3s...")
time.sleep(3)
if not ip:
self._set_failed("Could not get modem IP")
self._do_failover()
return False
# Get DNS from modem
params = self.at.get_connection_params()
dns1 = params.get("dns1")
dns2 = params.get("dns2")
# Configure DNS
self._configure_dns(dns1, dns2)
# Configure interface
if not self._configure_interface(ip):
self._set_failed("Interface configuration failed")
self._do_failover()
return False
# Setup NAT
if not self._configure_nat():
self._set_failed("NAT configuration failed")
# Don't fail completely, connection might still work
pass
# Success!
with self._lock:
self.status.state = ConnectionState.CONNECTED
self.status.ip_address = ip
self.status.dns1 = dns1
self.status.dns2 = dns2
self.status.last_connect_time = datetime.now()
self._log("5G connection successful!")
return True
except Exception as e:
self._set_failed(f"Connection error: {e}")
self._do_failover()
return False
def _set_failed(self, error: str):
"""Set connection state to failed."""
self._log(error)
with self._lock:
self.status.state = ConnectionState.FAILED
self.status.last_error = error
def disconnect(self) -> bool:
"""
Disconnect 5G connection.
"""
self._log("Disconnecting 5G...")
with self._lock:
self.status.state = ConnectionState.DISCONNECTED
self.status.ip_address = None
try:
# Deactivate PDP
self.at.deactivate_pdp()
# Bring down interface
self._run_cmd(["ip", "link", "set", self.config.wan_if, "down"])
# Remove routes
self._run_cmd(["ip", "route", "del", "default", "dev", self.config.wan_if])
self._log("5G disconnected")
return True
except Exception as e:
self._log(f"Disconnect error: {e}")
return False
def check_health(self) -> bool:
"""
Check if connection is healthy.
Returns True if connected and working.
"""
if self.status.state != ConnectionState.CONNECTED:
return False
# Check if interface is up with IP
result = self._run_cmd(["ip", "addr", "show", self.config.wan_if])
if self.status.ip_address not in result.stdout:
return False
# Ping test (optional)
result = self._run_cmd(["ping", "-c", "1", "-W", "3", "8.8.8.8"])
if result.returncode != 0:
self._log("Health check: ping failed")
return False
return True
def _watchdog_loop(self):
"""Background thread: periodically check and reconnect if needed."""
self._log(f"Watchdog started (interval: {self.config.watchdog_interval}s)")
while not self._watchdog_stop.is_set():
try:
if not self.check_health():
self._log("Watchdog: connection unhealthy, reconnecting...")
with self._lock:
self.status.state = ConnectionState.RECONNECTING
self.connect()
except Exception as e:
logger.error(f"Watchdog error: {e}")
# Wait for interval or stop signal
self._watchdog_stop.wait(timeout=self.config.watchdog_interval)
self._log("Watchdog stopped")
def start_watchdog(self):
"""Start background watchdog thread."""
if self.config.watchdog_interval <= 0:
return
if self._watchdog_thread and self._watchdog_thread.is_alive():
return
self._watchdog_stop.clear()
self._watchdog_thread = threading.Thread(
target=self._watchdog_loop,
name="5g-watchdog",
daemon=True
)
self._watchdog_thread.start()
def stop_watchdog(self):
"""Stop background watchdog thread."""
self._watchdog_stop.set()
if self._watchdog_thread:
self._watchdog_thread.join(timeout=5)
self._watchdog_thread = None
def get_status(self) -> Dict[str, Any]:
"""Get current connection status as dict."""
with self._lock:
return {
"state": self.status.state.value,
"ip_address": self.status.ip_address,
"dns1": self.status.dns1,
"dns2": self.status.dns2,
"signal_csq": self.status.signal_csq,
"last_connect_time": self.status.last_connect_time.isoformat() if self.status.last_connect_time else None,
"last_error": self.status.last_error,
"connect_attempts": self.status.connect_attempts,
"watchdog_running": self._watchdog_thread is not None and self._watchdog_thread.is_alive(),
}
def get_full_status(self) -> Dict[str, Any]:
"""Get combined connection and modem status."""
conn_status = self.get_status()
modem_status = self.at.get_full_status()
return {
"connection": conn_status,
"modem": modem_status,
}

View File

@@ -1 +1,2 @@
Flask>=2.3.0
pyserial>=3.5