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

@@ -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) ----

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