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:
164
web/app.py
164
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) ----
|
||||
|
||||
316
web/at_client.py
Normal file
316
web/at_client.py
Normal 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
532
web/connection_manager.py
Normal 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,
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
Flask>=2.3.0
|
||||
pyserial>=3.5
|
||||
|
||||
Reference in New Issue
Block a user