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:
@@ -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)
|
||||
|
||||
|
||||
@@ -52,10 +52,13 @@ Internet (CYTA 5G)
|
||||
For a **single-command flow** from a fresh device, see **[docs/QUICKSTART.md](docs/QUICKSTART.md)**. Summary:
|
||||
|
||||
1. Clone or copy this repo to the device.
|
||||
2. Install packages: `apk add iptables libmbim-tools qmi-utils` (and optionally dnsmasq, speedtest-cli).
|
||||
3. Run **`./scripts/install.sh`** – installs scripts, OpenRC service, firewall rules, and `/etc/5g-router.conf`.
|
||||
2. Install packages: `apk add iptables python3 py3-flask` and `pip install pyserial`.
|
||||
3. Run **`./scripts/install.sh`** – installs Web GUI, config, and enables 5g-webgui service.
|
||||
4. Edit `/etc/5g-router.conf` if needed (APN, interfaces).
|
||||
5. Start: `service 5g-router start` or `/usr/local/bin/connect-5g.sh`.
|
||||
5. Start: `service 5g-webgui start`
|
||||
6. Open **http://\<device-ip\>:5000** to manage the router.
|
||||
|
||||
**Note:** The Web GUI now includes integrated 5G connection management. The standalone `5g-router` service is kept for manual/fallback use but is no longer the default.
|
||||
|
||||
For SSH and key setup: **[docs/DEPLOY.md](docs/DEPLOY.md)**.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,11 @@ cd /tmp/Alpine_5G
|
||||
# Enable community repo if needed
|
||||
sed -i 's|#.*community|http://mirrors.neterra.net/alpine/v3.23/community|' /etc/apk/repositories
|
||||
apk update
|
||||
apk add iptables libmbim-tools qmi-utils
|
||||
|
||||
# Core packages
|
||||
apk add iptables python3 py3-flask
|
||||
pip install pyserial
|
||||
|
||||
# Optional: dnsmasq for LAN DHCP/DNS, speedtest-cli for speedtests
|
||||
apk add dnsmasq speedtest-cli
|
||||
```
|
||||
@@ -42,10 +46,10 @@ chmod +x scripts/install.sh
|
||||
This installs:
|
||||
|
||||
- `/etc/5g-router.conf` (from example – edit if needed)
|
||||
- `/usr/local/bin/connect-5g.sh`, `status-5g.sh`, `healthcheck-5g.sh`, etc.
|
||||
- `/etc/init.d/5g-router` (OpenRC service)
|
||||
- `/usr/local/share/5g-webgui/` – Web GUI with integrated connection management
|
||||
- `/etc/init.d/5g-webgui` (OpenRC service – handles both Web GUI and 5G connection)
|
||||
- `/etc/iptables/rules.v4`
|
||||
- Enables `5g-router` at boot
|
||||
- Enables `5g-webgui` at boot
|
||||
|
||||
## 4. Edit config (if needed)
|
||||
|
||||
@@ -58,14 +62,14 @@ Set at least:
|
||||
- `APN` – e.g. `internet` for CYTA
|
||||
- `WAN_IF` / `LAN_IF` – default `eth1` and `eth0.100` are usually correct
|
||||
|
||||
Optional: `FAILOVER_ENABLED=yes` and `FAILOVER_IF=eth0` to use ethernet when 5G is down.
|
||||
Optional:
|
||||
- `WATCHDOG_INTERVAL=60` – check connection every 60s and reconnect if needed
|
||||
- `FAILOVER_ENABLED=yes` and `FAILOVER_IF=eth0` to use ethernet when 5G is down
|
||||
|
||||
## 5. Start 5G and test
|
||||
## 5. Start Web GUI (includes 5G connection)
|
||||
|
||||
```bash
|
||||
service 5g-router start
|
||||
# Or run once:
|
||||
/usr/local/bin/connect-5g.sh
|
||||
service 5g-webgui start
|
||||
|
||||
# Check status
|
||||
/usr/local/bin/status-5g.sh
|
||||
@@ -74,7 +78,16 @@ service 5g-router start
|
||||
ping -c 3 8.8.8.8
|
||||
```
|
||||
|
||||
## 6. Enable iptables restore at boot (if not already)
|
||||
## 6. Access Web GUI
|
||||
|
||||
Open **http://\<device-ip\>:5000** in your browser.
|
||||
|
||||
- **admin** / **admin** – full access
|
||||
- **support** / **support** – view-only + restart 5G
|
||||
|
||||
**Change passwords after first login!**
|
||||
|
||||
## 7. Enable iptables restore at boot (if not already)
|
||||
|
||||
```bash
|
||||
rc-update add iptables-restore default
|
||||
@@ -82,5 +95,22 @@ rc-update add iptables-restore default
|
||||
|
||||
## Done
|
||||
|
||||
The device will bring up 5G at boot. To restart 5G: `service 5g-router restart`.
|
||||
For full docs see [README.md](../README.md) and [5G_MODEM_TROUBLESHOOTING.md](../5G_MODEM_TROUBLESHOOTING.md).
|
||||
The device will bring up 5G at boot via the Web GUI service. Manage via web interface at port 5000.
|
||||
|
||||
To restart 5G: use the Web GUI, or `service 5g-webgui restart`.
|
||||
|
||||
For full docs see [README.md](../README.md), [WEBGUI.md](WEBGUI.md), and [5G_MODEM_TROUBLESHOOTING.md](../5G_MODEM_TROUBLESHOOTING.md).
|
||||
|
||||
---
|
||||
|
||||
## Legacy: Standalone 5g-router service
|
||||
|
||||
If you prefer to run without the Web GUI, you can still use the legacy service:
|
||||
|
||||
```bash
|
||||
rc-update del 5g-webgui default
|
||||
rc-update add 5g-router default
|
||||
service 5g-router start
|
||||
```
|
||||
|
||||
This runs `connect-5g.sh` without the web interface.
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
Web interface with login and role-based access (admin and support). **One HTML page per function** (Status, Logs, Restart 5G, Config, Firewall, Routes, Users) with shared navigation.
|
||||
|
||||
**As of Rev 2, the Web GUI includes integrated 5G connection management.** It now handles:
|
||||
- Modem AT commands via native Python (pyserial)
|
||||
- 5G connection lifecycle (connect, disconnect, reconnect)
|
||||
- Watchdog for automatic reconnection
|
||||
- NAT/firewall configuration
|
||||
|
||||
The standalone `5g-router` service is no longer needed; `5g-webgui` is the recommended single service.
|
||||
|
||||
## Access
|
||||
|
||||
- **URL:** `http://<device-ip>:5000` (e.g. `http://10.130.60.121:5000`)
|
||||
@@ -18,6 +26,7 @@ Web interface with login and role-based access (admin and support). **One HTML p
|
||||
| View status | ✓ | ✓ |
|
||||
| View logs | ✓ | ✓ |
|
||||
| Restart 5G | ✓ | ✓ |
|
||||
| Connect/Disconnect | ✓ | ✓ |
|
||||
| Edit config | ✓ | – |
|
||||
| Edit firewall | ✓ | – |
|
||||
| View routes | ✓ | – |
|
||||
@@ -28,8 +37,9 @@ Web interface with login and role-based access (admin and support). **One HTML p
|
||||
### On the device (after main install)
|
||||
|
||||
```bash
|
||||
# Install Python and Flask (Alpine)
|
||||
# Install Python, Flask, and pyserial (Alpine)
|
||||
apk add python3 py3-flask
|
||||
pip install pyserial
|
||||
|
||||
# If you used scripts/install.sh, Web GUI is already under /usr/local/share/5g-webgui
|
||||
# Enable and start the service:
|
||||
@@ -44,11 +54,36 @@ cd /usr/local/share/5g-webgui && ./run.sh
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pip install -r requirements.txt # or: apk add py3-flask
|
||||
pip install -r requirements.txt # Flask and pyserial
|
||||
python3 app.py
|
||||
# Open http://localhost:5000
|
||||
```
|
||||
|
||||
## API Endpoints for 5G Control
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/status` | GET | Connection status + modem info |
|
||||
| `/api/5g/status` | GET | Detailed connection status |
|
||||
| `/api/5g/connect` | POST | Manually connect 5G |
|
||||
| `/api/5g/disconnect` | POST | Manually disconnect 5G |
|
||||
| `/api/5g/restart` | POST | Disconnect then reconnect |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
5g-webgui service
|
||||
├── Flask App (port 5000)
|
||||
├── ATClient (pyserial → /dev/ttyUSB1)
|
||||
└── ConnectionManager
|
||||
├── connect() - APN, PDP, IP, NAT
|
||||
├── disconnect()
|
||||
├── check_health()
|
||||
└── watchdog_loop() (background thread)
|
||||
```
|
||||
|
||||
The connection auto-starts when the service starts (on first web request). If `WATCHDOG_INTERVAL` is set in `/etc/5g-router.conf`, a background thread periodically checks connection health and reconnects if needed.
|
||||
|
||||
## Security
|
||||
|
||||
- Set **SECRET_KEY** in production: `export SECRET_KEY="your-random-secret"` before starting the app (or in the OpenRC service).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
echo " Login at http://<device-ip>:5000 (default: admin/admin, support/support – change passwords)"
|
||||
else
|
||||
echo "Web GUI source not found; skipping."
|
||||
|
||||
# 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 "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 "Installed Web GUI with 5G connection management at $WEBGUI_DEST"
|
||||
echo " Install dependencies: apk add python3 py3-flask && pip install pyserial"
|
||||
echo " Start service: service 5g-webgui start"
|
||||
echo " Login at http://<device-ip>:5000 (default: admin/admin, support/support – change passwords)"
|
||||
else
|
||||
echo "Web GUI source not found; skipping."
|
||||
echo "Enabling legacy 5g-router service instead..."
|
||||
if command -v rc-update >/dev/null 2>&1; then
|
||||
rc-update add 5g-router default 2>/dev/null || true
|
||||
echo "Added 5g-router to default runlevel"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done. Edit $CONFIG if needed."
|
||||
echo ""
|
||||
echo "To start:"
|
||||
echo " service 5g-webgui start (recommended: includes Web GUI + 5G connection)"
|
||||
echo " OR"
|
||||
echo " service 5g-router start (legacy: 5G connection only, no Web GUI)"
|
||||
echo ""
|
||||
echo "Status: Visit http://<device-ip>:5000 or run: $BIN/status-5g.sh"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
148
web/app.py
148
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,6 +255,16 @@ def api_me():
|
||||
def api_status():
|
||||
if not can_view_status(_current_user().get("role")):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
|
||||
# Trigger auto-connect on first status request (deferred startup)
|
||||
_startup_connect()
|
||||
|
||||
try:
|
||||
# Get connection and modem status from ConnectionManager (native Python)
|
||||
full_status = _conn_manager.get_full_status()
|
||||
|
||||
# Also include basic system status from status script for interface info
|
||||
data = {}
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[STATUS_SCRIPT, "--json"],
|
||||
@@ -214,29 +272,16 @@ def api_status():
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return jsonify({"error": "status script failed", "stderr": out.stderr}), 500
|
||||
if out.returncode == 0:
|
||||
data = json.loads(out.stdout)
|
||||
# Enrich with modem AT status (AT_COMMANDS_REFERENCE.md)
|
||||
if Path(MODEM_STATUS_SCRIPT).exists():
|
||||
try:
|
||||
mod_out = subprocess.run(
|
||||
[MODEM_STATUS_SCRIPT],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd="/",
|
||||
)
|
||||
if mod_out.returncode == 0 and mod_out.stdout.strip():
|
||||
mod_data = json.loads(mod_out.stdout)
|
||||
data["modem"] = mod_data
|
||||
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired):
|
||||
data["modem"] = {}
|
||||
else:
|
||||
data["modem"] = {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Merge connection manager status
|
||||
data["connection"] = full_status.get("connection", {})
|
||||
data["modem"] = full_status.get("modem", {})
|
||||
|
||||
return jsonify(data)
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"error": "timeout"}), 504
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@@ -458,21 +503,58 @@ def api_firewall_apply():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ---- API: 5G restart (support + admin) ----
|
||||
# ---- API: 5G Connection Control (support + admin) ----
|
||||
@app.route("/api/5g/restart", methods=["POST"])
|
||||
@_require_login
|
||||
def api_5g_restart():
|
||||
"""Restart 5G connection (disconnect then reconnect)."""
|
||||
if not can_restart_5g(_current_user().get("role")):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[CONNECT_SCRIPT],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
def do_restart():
|
||||
_conn_manager.disconnect()
|
||||
_conn_manager.connect()
|
||||
|
||||
# Run in background so API returns immediately
|
||||
threading.Thread(target=do_restart, name="5g-restart", daemon=True).start()
|
||||
return jsonify({"ok": True, "message": "Restart initiated"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/5g/connect", methods=["POST"])
|
||||
@_require_login
|
||||
def api_5g_connect():
|
||||
"""Manually connect 5G."""
|
||||
if not can_restart_5g(_current_user().get("role")):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
|
||||
def do_connect():
|
||||
_conn_manager.connect()
|
||||
|
||||
threading.Thread(target=do_connect, name="5g-connect", daemon=True).start()
|
||||
return jsonify({"ok": True, "message": "Connect initiated"})
|
||||
|
||||
|
||||
@app.route("/api/5g/disconnect", methods=["POST"])
|
||||
@_require_login
|
||||
def api_5g_disconnect():
|
||||
"""Manually disconnect 5G."""
|
||||
if not can_restart_5g(_current_user().get("role")):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
|
||||
def do_disconnect():
|
||||
_conn_manager.disconnect()
|
||||
|
||||
threading.Thread(target=do_disconnect, name="5g-disconnect", daemon=True).start()
|
||||
return jsonify({"ok": True, "message": "Disconnect initiated"})
|
||||
|
||||
|
||||
@app.route("/api/5g/status")
|
||||
@_require_login
|
||||
def api_5g_connection_status():
|
||||
"""Get detailed 5G connection status."""
|
||||
if not can_view_status(_current_user().get("role")):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
return jsonify(_conn_manager.get_full_status())
|
||||
|
||||
|
||||
# ---- API: Speedtest (support + admin) ----
|
||||
|
||||
316
web/at_client.py
Normal file
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