From 9dc35a57a289abe98ee1abf28149db9da194beb4 Mon Sep 17 00:00:00 2001 From: nearxos Date: Mon, 2 Feb 2026 10:34:25 +0200 Subject: [PATCH] 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. --- 5G_MODEM_TROUBLESHOOTING.md | 14 +- README.md | 9 +- configure_fm350_5g.sh | 6 +- docs/QUICKSTART.md | 54 +++- docs/WEBGUI.md | 39 ++- etc/init.d/5g-webgui | 16 +- scripts/connect-5g.sh | 67 ++++- scripts/install.sh | 55 ++-- scripts/modem-status-at.sh | 10 +- scripts/troubleshoot-5g.sh | 56 +++- web/app.py | 164 ++++++++--- web/at_client.py | 316 +++++++++++++++++++++ web/connection_manager.py | 532 ++++++++++++++++++++++++++++++++++++ web/requirements.txt | 1 + 14 files changed, 1224 insertions(+), 115 deletions(-) create mode 100644 web/at_client.py create mode 100644 web/connection_manager.py diff --git a/5G_MODEM_TROUBLESHOOTING.md b/5G_MODEM_TROUBLESHOOTING.md index 4cf4fd1..e094840 100644 --- a/5G_MODEM_TROUBLESHOOTING.md +++ b/5G_MODEM_TROUBLESHOOTING.md @@ -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 doesn’t exist. + +**Prevention:** All project scripts that write to the AT port now check `[ -c "$AT_PORT" ]` immediately before writing and skip/exit if it’s 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 don’t create the file in the first place. ### Stray /dev/ttyUSB file (no number) diff --git a/README.md b/README.md index 176a96a..b57a475 100644 --- a/README.md +++ b/README.md @@ -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://\: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)**. diff --git a/configure_fm350_5g.sh b/configure_fm350_5g.sh index beba52d..b3f5420 100755 --- a/configure_fm350_5g.sh +++ b/configure_fm350_5g.sh @@ -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 diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 91c34a0..8131ac0 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -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://\: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. diff --git a/docs/WEBGUI.md b/docs/WEBGUI.md index ce85f3a..78dc5ea 100644 --- a/docs/WEBGUI.md +++ b/docs/WEBGUI.md @@ -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://: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). diff --git a/etc/init.d/5g-webgui b/etc/init.d/5g-webgui index 6d8343e..4f656a5 100644 --- a/etc/init.d/5g-webgui +++ b/etc/init.d/5g-webgui @@ -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 } diff --git a/scripts/connect-5g.sh b/scripts/connect-5g.sh index 80e501b..7f2f6d5 100644 --- a/scripts/connect-5g.sh +++ b/scripts/connect-5g.sh @@ -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 3–5s 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 5–15s 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 } diff --git a/scripts/install.sh b/scripts/install.sh index c26edbf..c049a5a 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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://: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://:5000 or run: $BIN/status-5g.sh" diff --git a/scripts/modem-status-at.sh b/scripts/modem-status-at.sh index 4de5887..b4ca96b 100644 --- a/scripts/modem-status-at.sh +++ b/scripts/modem-status-at.sh @@ -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 diff --git a/scripts/troubleshoot-5g.sh b/scripts/troubleshoot-5g.sh index 385c7c1..2724dcf 100644 --- a/scripts/troubleshoot-5g.sh +++ b/scripts/troubleshoot-5g.sh @@ -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" diff --git a/web/app.py b/web/app.py index e8722ef..8ce43a1 100644 --- a/web/app.py +++ b/web/app.py @@ -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,36 +255,33 @@ 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: - out = subprocess.run( - [STATUS_SCRIPT, "--json"], - capture_output=True, - text=True, - timeout=10, - ) - if out.returncode != 0: - return jsonify({"error": "status script failed", "stderr": out.stderr}), 500 - 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"] = {} + # 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"], + capture_output=True, + text=True, + timeout=10, + ) + if out.returncode == 0: + data = json.loads(out.stdout) + 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, - ) - return jsonify({"ok": True, "message": "Restart initiated"}) - except Exception as e: - return jsonify({"error": str(e)}), 500 + + 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"}) + + +@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) ---- diff --git a/web/at_client.py b/web/at_client.py new file mode 100644 index 0000000..6c0dcda --- /dev/null +++ b/web/at_client.py @@ -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() diff --git a/web/connection_manager.py b/web/connection_manager.py new file mode 100644 index 0000000..1907cab --- /dev/null +++ b/web/connection_manager.py @@ -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, + } diff --git a/web/requirements.txt b/web/requirements.txt index 8a45a7c..85ef81b 100644 --- a/web/requirements.txt +++ b/web/requirements.txt @@ -1 +1,2 @@ Flask>=2.3.0 +pyserial>=3.5