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
|
- **5g-router service** status
|
||||||
- **WAN interface** and default route
|
- **WAN interface** and default route
|
||||||
- **Last 60 lines** of `/var/log/5g-router.log`
|
- **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
|
- **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"
|
### 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
|
( 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`.
|
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)
|
### 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.
|
**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):**
|
**Fix (one-time):**
|
||||||
```bash
|
```bash
|
||||||
@@ -231,7 +237,7 @@ chmod 660 /dev/ttyUSB1
|
|||||||
chown root:dialout /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)
|
### 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:
|
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.
|
1. Clone or copy this repo to the device.
|
||||||
2. Install packages: `apk add iptables libmbim-tools qmi-utils` (and optionally dnsmasq, speedtest-cli).
|
2. Install packages: `apk add iptables python3 py3-flask` and `pip install pyserial`.
|
||||||
3. Run **`./scripts/install.sh`** – installs scripts, OpenRC service, firewall rules, and `/etc/5g-router.conf`.
|
3. Run **`./scripts/install.sh`** – installs Web GUI, config, and enables 5g-webgui service.
|
||||||
4. Edit `/etc/5g-router.conf` if needed (APN, interfaces).
|
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)**.
|
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() {
|
send_at_cmd() {
|
||||||
local cmd="$1"
|
local cmd="$1"
|
||||||
local wait="${2:-2}"
|
local wait="${2:-2}"
|
||||||
|
[ -c "$AT_PORT" ] || return 1
|
||||||
cat $AT_PORT 2>/dev/null &
|
cat $AT_PORT 2>/dev/null &
|
||||||
local CAT_PID=$!
|
local CAT_PID=$!
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
|
[ -c "$AT_PORT" ] || { kill $CAT_PID 2>/dev/null; return 1; }
|
||||||
echo -e "${cmd}\r" > $AT_PORT
|
echo -e "${cmd}\r" > $AT_PORT
|
||||||
sleep $wait
|
sleep $wait
|
||||||
kill $CAT_PID 2>/dev/null || true
|
kill $CAT_PID 2>/dev/null || true
|
||||||
@@ -41,8 +42,9 @@ send_at_cmd() {
|
|||||||
get_at_response() {
|
get_at_response() {
|
||||||
local cmd="$1"
|
local cmd="$1"
|
||||||
local wait="${2:-2}"
|
local wait="${2:-2}"
|
||||||
|
[ -c "$AT_PORT" ] || return 1
|
||||||
timeout $((wait + 3)) sh -c "
|
timeout $((wait + 3)) sh -c "
|
||||||
|
[ -c \"$AT_PORT\" ] || exit 1
|
||||||
cat $AT_PORT 2>/dev/null &
|
cat $AT_PORT 2>/dev/null &
|
||||||
CAT_PID=\$!
|
CAT_PID=\$!
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ cd /tmp/Alpine_5G
|
|||||||
# Enable community repo if needed
|
# Enable community repo if needed
|
||||||
sed -i 's|#.*community|http://mirrors.neterra.net/alpine/v3.23/community|' /etc/apk/repositories
|
sed -i 's|#.*community|http://mirrors.neterra.net/alpine/v3.23/community|' /etc/apk/repositories
|
||||||
apk update
|
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
|
# Optional: dnsmasq for LAN DHCP/DNS, speedtest-cli for speedtests
|
||||||
apk add dnsmasq speedtest-cli
|
apk add dnsmasq speedtest-cli
|
||||||
```
|
```
|
||||||
@@ -42,10 +46,10 @@ chmod +x scripts/install.sh
|
|||||||
This installs:
|
This installs:
|
||||||
|
|
||||||
- `/etc/5g-router.conf` (from example – edit if needed)
|
- `/etc/5g-router.conf` (from example – edit if needed)
|
||||||
- `/usr/local/bin/connect-5g.sh`, `status-5g.sh`, `healthcheck-5g.sh`, etc.
|
- `/usr/local/share/5g-webgui/` – Web GUI with integrated connection management
|
||||||
- `/etc/init.d/5g-router` (OpenRC service)
|
- `/etc/init.d/5g-webgui` (OpenRC service – handles both Web GUI and 5G connection)
|
||||||
- `/etc/iptables/rules.v4`
|
- `/etc/iptables/rules.v4`
|
||||||
- Enables `5g-router` at boot
|
- Enables `5g-webgui` at boot
|
||||||
|
|
||||||
## 4. Edit config (if needed)
|
## 4. Edit config (if needed)
|
||||||
|
|
||||||
@@ -58,14 +62,14 @@ Set at least:
|
|||||||
- `APN` – e.g. `internet` for CYTA
|
- `APN` – e.g. `internet` for CYTA
|
||||||
- `WAN_IF` / `LAN_IF` – default `eth1` and `eth0.100` are usually correct
|
- `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
|
```bash
|
||||||
service 5g-router start
|
service 5g-webgui start
|
||||||
# Or run once:
|
|
||||||
/usr/local/bin/connect-5g.sh
|
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
/usr/local/bin/status-5g.sh
|
/usr/local/bin/status-5g.sh
|
||||||
@@ -74,7 +78,16 @@ service 5g-router start
|
|||||||
ping -c 3 8.8.8.8
|
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
|
```bash
|
||||||
rc-update add iptables-restore default
|
rc-update add iptables-restore default
|
||||||
@@ -82,5 +95,22 @@ rc-update add iptables-restore default
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
The device will bring up 5G at boot. To restart 5G: `service 5g-router restart`.
|
The device will bring up 5G at boot via the Web GUI service. Manage via web interface at port 5000.
|
||||||
For full docs see [README.md](../README.md) and [5G_MODEM_TROUBLESHOOTING.md](../5G_MODEM_TROUBLESHOOTING.md).
|
|
||||||
|
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.
|
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
|
## Access
|
||||||
|
|
||||||
- **URL:** `http://<device-ip>:5000` (e.g. `http://10.130.60.121:5000`)
|
- **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 status | ✓ | ✓ |
|
||||||
| View logs | ✓ | ✓ |
|
| View logs | ✓ | ✓ |
|
||||||
| Restart 5G | ✓ | ✓ |
|
| Restart 5G | ✓ | ✓ |
|
||||||
|
| Connect/Disconnect | ✓ | ✓ |
|
||||||
| Edit config | ✓ | – |
|
| Edit config | ✓ | – |
|
||||||
| Edit firewall | ✓ | – |
|
| Edit firewall | ✓ | – |
|
||||||
| View routes | ✓ | – |
|
| 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)
|
### On the device (after main install)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Python and Flask (Alpine)
|
# Install Python, Flask, and pyserial (Alpine)
|
||||||
apk add python3 py3-flask
|
apk add python3 py3-flask
|
||||||
|
pip install pyserial
|
||||||
|
|
||||||
# If you used scripts/install.sh, Web GUI is already under /usr/local/share/5g-webgui
|
# If you used scripts/install.sh, Web GUI is already under /usr/local/share/5g-webgui
|
||||||
# Enable and start the service:
|
# Enable and start the service:
|
||||||
@@ -44,11 +54,36 @@ cd /usr/local/share/5g-webgui && ./run.sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web
|
cd web
|
||||||
pip install -r requirements.txt # or: apk add py3-flask
|
pip install -r requirements.txt # Flask and pyserial
|
||||||
python3 app.py
|
python3 app.py
|
||||||
# Open http://localhost:5000
|
# 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
|
## Security
|
||||||
|
|
||||||
- Set **SECRET_KEY** in production: `export SECRET_KEY="your-random-secret"` before starting the app (or in the OpenRC service).
|
- 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
|
#!/sbin/openrc-run
|
||||||
# Alpine 5G Router – Web GUI (Flask). Login: admin/support with different permissions.
|
# Alpine 5G Router – Web GUI with integrated 5G connection management.
|
||||||
# Rev: 1 (see REVISION in repo root)
|
# 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="/usr/local/share/5g-webgui/run.sh"
|
||||||
command_background="yes"
|
command_background="yes"
|
||||||
pidfile="/run/5g-webgui.pid"
|
pidfile="/run/5g-webgui.pid"
|
||||||
@@ -12,6 +15,7 @@ error_log="/var/log/5g-webgui.log"
|
|||||||
depend() {
|
depend() {
|
||||||
need net
|
need net
|
||||||
after bootmisc
|
after bootmisc
|
||||||
|
# Note: 5g-router service is no longer needed; connection is managed by this service
|
||||||
}
|
}
|
||||||
|
|
||||||
start_pre() {
|
start_pre() {
|
||||||
@@ -20,8 +24,12 @@ start_pre() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if ! command -v python3 >/dev/null 2>&1; then
|
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
|
return 1
|
||||||
fi
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,17 +20,32 @@ WATCHDOG_INTERVAL="0"
|
|||||||
LOG_SIGNAL="yes"
|
LOG_SIGNAL="yes"
|
||||||
WEAK_SIGNAL_CSQ="10"
|
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)
|
# 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() {
|
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
|
case "$AT_PORT" in
|
||||||
/dev/ttyUSB[0-9]*) ;;
|
/dev/ttyUSB[0-9]*) ;;
|
||||||
*) return 0 ;;
|
*) return 0 ;;
|
||||||
esac
|
esac
|
||||||
[ ! -e "$AT_PORT" ] && return 0
|
[ ! -e "$AT_PORT" ] && return 0 || true
|
||||||
if [ -f "$AT_PORT" ] && [ ! -c "$AT_PORT" ]; then
|
if [ -f "$AT_PORT" ] && [ ! -c "$AT_PORT" ]; then
|
||||||
_num="${AT_PORT#/dev/ttyUSB}"
|
_num="${AT_PORT#/dev/ttyUSB}"
|
||||||
log "Fixing $AT_PORT (was regular file, recreating as char device)"
|
log "Fixing $AT_PORT (was regular file, recreating as char device)"
|
||||||
@@ -41,10 +56,12 @@ fix_ttyusb_if_needed() {
|
|||||||
fi
|
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() {
|
get_at_response() {
|
||||||
_cmd="$1"
|
_cmd="$1"
|
||||||
_wait="${2:-2}"
|
_wait="${2:-2}"
|
||||||
timeout $((_wait + 4)) sh -c "
|
timeout $((_wait + 4)) sh -c "
|
||||||
|
[ -c \"$AT_PORT\" ] || exit 1
|
||||||
cat $AT_PORT 2>/dev/null &
|
cat $AT_PORT 2>/dev/null &
|
||||||
_pid=\$!
|
_pid=\$!
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
@@ -80,14 +97,28 @@ do_connect() {
|
|||||||
# eth1 (RNDIS) does NOT provide DHCP. We get IP (and DNS) only via AT commands
|
# 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.
|
# and configure the interface manually. Do not run dhclient on eth1.
|
||||||
check_usb_mode || return 1
|
check_usb_mode || return 1
|
||||||
# Modem can take 3–5s to respond after boot; use 5s so service (started at boot) gets OK
|
# Modem can take 5–15s to respond after port appears at boot; retry initial AT up to 3 times
|
||||||
get_at_response "AT" 5 | grep -q "OK" || { log "AT not OK (try longer wait or different AT port)"; return 1; }
|
_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)
|
# Signal (optional log)
|
||||||
_resp=$(get_at_response "AT+CSQ" 1)
|
_resp=$(get_at_response "AT+CSQ" 1)
|
||||||
_csq=$(echo "$_resp" | grep "+CSQ:" | grep -oE '[0-9]+' | head -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" ] && [ "$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"
|
log "Configuring APN: $APN"
|
||||||
get_at_response "AT+CGDCONT=1,\"IP\",\"$APN\"" 2 | grep -q "OK" || true
|
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
|
for _retry in 1 2 3 4 5; do
|
||||||
_resp=$(get_at_response "AT+CGPADDR=1" 2)
|
_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)
|
_ip=$(echo "$_resp" | grep "+CGPADDR:" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||||
[ -n "$_ip" ] && break
|
if [ -n "$_ip" ]; then break; fi
|
||||||
[ $_retry -lt 5 ] && log "No IP yet, retry $_retry/5 in 3s..." && sleep 3
|
if [ $_retry -lt 5 ]; then
|
||||||
|
log "No IP yet, retry $_retry/5 in 3s..."
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
done
|
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
|
# Get DNS from modem (AT+CGCONTRDP=1); modem does not provide DHCP, only IP/DNS via AT
|
||||||
_dns1=""; _dns2=""
|
_dns1=""; _dns2=""
|
||||||
_contrdp=$(get_at_response "AT+CGCONTRDP=1" 2)
|
_contrdp=$(get_at_response "AT+CGCONTRDP=1" 2)
|
||||||
_allips=""
|
_allips=""
|
||||||
for _a in $(echo "$_contrdp" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'); do
|
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}"
|
_allips="${_allips} ${_a}"
|
||||||
done
|
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 ' ')
|
[ -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
|
if [ -n "$_dns1" ]; then
|
||||||
: > /etc/resolv.conf
|
: > /etc/resolv.conf
|
||||||
echo "nameserver $_dns1" >> /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)"
|
log "DNS set (from modem or config)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -137,7 +178,7 @@ do_connect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do_failover() {
|
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)
|
# 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
|
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
|
# Alpine 5G Router – install/deploy scripts and config to this device
|
||||||
# Run from repo root: ./scripts/install.sh
|
# Run from repo root: ./scripts/install.sh
|
||||||
# Or from scripts/: ./install.sh (uses script dir to find repo root)
|
# 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
|
set -e
|
||||||
|
|
||||||
@@ -27,7 +31,7 @@ else
|
|||||||
echo "Keeping existing $CONFIG"
|
echo "Keeping existing $CONFIG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Scripts
|
# Scripts (kept for manual use and diagnostics)
|
||||||
install -m 755 "$SCRIPTS/connect-5g.sh" "$BIN/connect-5g.sh"
|
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/status-5g.sh" "$BIN/status-5g.sh"
|
||||||
install -m 755 "$SCRIPTS/modem-status-at.sh" "$BIN/modem-status-at.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"
|
echo "Installed configure_fm350_5g.sh"
|
||||||
fi
|
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"
|
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)
|
# iptables rules (restore at boot – ensure iptables-restore service exists)
|
||||||
mkdir -p "$IPTABLES_SAVE"
|
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"
|
echo "Tip: enable iptables restore at boot: rc-update add iptables-restore"
|
||||||
fi
|
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/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/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
|
# Web GUI with integrated 5G connection management
|
||||||
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)
|
|
||||||
WEBGUI_SRC="$ROOT/web"
|
WEBGUI_SRC="$ROOT/web"
|
||||||
WEBGUI_DEST="/usr/local/share/5g-webgui"
|
WEBGUI_DEST="/usr/local/share/5g-webgui"
|
||||||
if [ -d "$WEBGUI_SRC" ] && [ -f "$WEBGUI_SRC/app.py" ]; then
|
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/"
|
cp -r "$WEBGUI_SRC"/* "$WEBGUI_DEST/"
|
||||||
chmod 755 "$WEBGUI_DEST/run.sh" 2>/dev/null || true
|
chmod 755 "$WEBGUI_DEST/run.sh" 2>/dev/null || true
|
||||||
install -m 755 "$ETC/init.d/5g-webgui" "$INITD/5g-webgui"
|
install -m 755 "$ETC/init.d/5g-webgui" "$INITD/5g-webgui"
|
||||||
echo "Installed Web GUI at $WEBGUI_DEST. Enable with: rc-update add 5g-webgui default"
|
|
||||||
echo " Then: apk add python3 py3-flask && service 5g-webgui start"
|
# Enable 5g-webgui at boot (this now handles connection management)
|
||||||
|
if command -v rc-update >/dev/null 2>&1; then
|
||||||
|
rc-update add 5g-webgui default 2>/dev/null || true
|
||||||
|
echo "Added 5g-webgui to default runlevel"
|
||||||
|
|
||||||
|
# Disable old 5g-router if it was enabled
|
||||||
|
rc-update del 5g-router default 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Installed Web GUI with 5G connection management at $WEBGUI_DEST"
|
||||||
|
echo " Install dependencies: apk add python3 py3-flask && pip install pyserial"
|
||||||
|
echo " Start service: service 5g-webgui start"
|
||||||
echo " Login at http://<device-ip>:5000 (default: admin/admin, support/support – change passwords)"
|
echo " Login at http://<device-ip>:5000 (default: admin/admin, support/support – change passwords)"
|
||||||
else
|
else
|
||||||
echo "Web GUI source not found; skipping."
|
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
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Done. Edit $CONFIG if needed, then: service 5g-router start"
|
echo "Done. Edit $CONFIG if needed."
|
||||||
echo "Or run once: $BIN/connect-5g.sh"
|
echo ""
|
||||||
echo "Status: $BIN/status-5g.sh"
|
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
|
#!/bin/sh
|
||||||
# Alpine 5G Router – modem status via AT commands (AT_COMMANDS_REFERENCE.md)
|
# Alpine 5G Router – modem status via AT commands (AT_COMMANDS_REFERENCE.md)
|
||||||
# Outputs JSON. Used by Web GUI /api/status.
|
# 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"
|
CONFIG="/etc/5g-router.conf"
|
||||||
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
|
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() {
|
get_at() {
|
||||||
_cmd="$1"
|
_cmd="$1"
|
||||||
_wait="${2:-1}"
|
_wait="${2:-1}"
|
||||||
timeout $((_wait + 2)) sh -c "
|
timeout $((_wait + 2)) sh -c "
|
||||||
|
[ -c \"$AT_PORT\" ] || exit 1
|
||||||
cat $AT_PORT 2>/dev/null &
|
cat $AT_PORT 2>/dev/null &
|
||||||
_p=\$!
|
_p=\$!
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ CONFIG="/etc/5g-router.conf"
|
|||||||
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
|
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
|
||||||
WAN_IF="${WAN_IF:-eth1}"
|
WAN_IF="${WAN_IF:-eth1}"
|
||||||
LOG_FILE="/var/log/5g-router.log"
|
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 "=============================================="
|
||||||
echo " Alpine 5G Router – Full Troubleshoot"
|
echo " Alpine 5G Router – Full Troubleshoot"
|
||||||
@@ -59,10 +64,29 @@ else
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# --- 5) AT port test (with longer wait like connect-5g.sh) ---
|
# --- 5) Processes using the AT port (can block or conflict) ---
|
||||||
echo "--- 5) AT command test on $AT_PORT (wait 3s) ---"
|
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
|
if [ -c "$AT_PORT" ]; then
|
||||||
out=$(timeout 8 sh -c "
|
out=$(timeout 8 sh -c "
|
||||||
|
[ -c \"$AT_PORT\" ] || exit 1
|
||||||
cat $AT_PORT 2>/dev/null &
|
cat $AT_PORT 2>/dev/null &
|
||||||
_p=\$!
|
_p=\$!
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
@@ -82,11 +106,11 @@ else
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# --- 6) AT probe on all ttyUSB (which port responds) ---
|
# --- 7) AT probe on all ttyUSB (which port responds) ---
|
||||||
echo "--- 6) AT probe on each ttyUSB port ---"
|
echo "--- 7) AT probe on each ttyUSB port ---"
|
||||||
for port in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3; do
|
for port in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3; do
|
||||||
[ -c "$port" ] || continue
|
[ -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
|
if echo "$out" | grep -q 'OK'; then
|
||||||
echo " $port: OK (use this as AT_PORT if different from config)"
|
echo " $port: OK (use this as AT_PORT if different from config)"
|
||||||
else
|
else
|
||||||
@@ -95,8 +119,8 @@ for port in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3; do
|
|||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# --- 7) 5g-router service ---
|
# --- 8) 5g-router service ---
|
||||||
echo "--- 7) 5g-router service ---"
|
echo "--- 8) 5g-router service ---"
|
||||||
if command -v rc-service >/dev/null 2>&1; then
|
if command -v rc-service >/dev/null 2>&1; then
|
||||||
rc-service 5g-router status 2>&1 | sed 's/^/ /'
|
rc-service 5g-router status 2>&1 | sed 's/^/ /'
|
||||||
else
|
else
|
||||||
@@ -104,8 +128,8 @@ else
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# --- 8) WAN interface and routing ---
|
# --- 9) WAN interface and routing ---
|
||||||
echo "--- 8) WAN interface ($WAN_IF) and default route ---"
|
echo "--- 9) WAN interface ($WAN_IF) and default route ---"
|
||||||
if ip link show "$WAN_IF" >/dev/null 2>&1; then
|
if ip link show "$WAN_IF" >/dev/null 2>&1; then
|
||||||
ip link show "$WAN_IF" 2>/dev/null | sed 's/^/ /'
|
ip link show "$WAN_IF" 2>/dev/null | sed 's/^/ /'
|
||||||
ip -4 addr 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/^/ /'
|
ip route show default 2>/dev/null | sed 's/^/ /'
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# --- 9) Full 5g-router log ---
|
# --- 10) Full 5g-router log ---
|
||||||
echo "--- 9) Full 5g-router log (last 60 lines of $LOG_FILE) ---"
|
echo "--- 10) Full 5g-router log (last 60 lines of $LOG_FILE) ---"
|
||||||
if [ -f "$LOG_FILE" ]; then
|
if [ -f "$LOG_FILE" ]; then
|
||||||
tail -60 "$LOG_FILE" 2>/dev/null | sed 's/^/ /'
|
tail -60 "$LOG_FILE" 2>/dev/null | sed 's/^/ /'
|
||||||
else
|
else
|
||||||
@@ -124,12 +148,12 @@ else
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# --- 10) Optional: modem status script ---
|
# --- 11) Optional: modem status script ---
|
||||||
if [ -x "/usr/local/bin/modem-status-at.sh" ]; then
|
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/^/ /'
|
/usr/local/bin/modem-status-at.sh 2>&1 | head -30 | sed 's/^/ /'
|
||||||
else
|
else
|
||||||
echo "--- 10) modem-status-at.sh ---"
|
echo "--- 11) modem-status-at.sh ---"
|
||||||
echo " Not installed"
|
echo " Not installed"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
@@ -140,6 +164,8 @@ echo "=============================================="
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Quick fixes to try:"
|
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 " - 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 " - 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 " - 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"
|
echo " - Restart connection: service 5g-router restart or /usr/local/bin/connect-5g.sh"
|
||||||
|
|||||||
164
web/app.py
164
web/app.py
@@ -1,12 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Alpine 5G Router – Web GUI
|
Alpine 5G Router – Web GUI
|
||||||
Login: admin/support with different permissions. Run: python app.py or gunicorn.
|
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 json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
@@ -48,6 +53,16 @@ from db import (
|
|||||||
routes_update,
|
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 = 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.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
|
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"
|
IPTABLES_RULES = "/etc/iptables/rules.v4"
|
||||||
LOG_5G = "/var/log/5g-router.log"
|
LOG_5G = "/var/log/5g-router.log"
|
||||||
LOG_SPEEDTEST = "/var/log/speedtest-5g.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"
|
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"
|
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():
|
def _current_user():
|
||||||
if not session.get("username"):
|
if not session.get("username"):
|
||||||
@@ -207,36 +255,33 @@ def api_me():
|
|||||||
def api_status():
|
def api_status():
|
||||||
if not can_view_status(_current_user().get("role")):
|
if not can_view_status(_current_user().get("role")):
|
||||||
return jsonify({"error": "Forbidden"}), 403
|
return jsonify({"error": "Forbidden"}), 403
|
||||||
|
|
||||||
|
# Trigger auto-connect on first status request (deferred startup)
|
||||||
|
_startup_connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(
|
# Get connection and modem status from ConnectionManager (native Python)
|
||||||
[STATUS_SCRIPT, "--json"],
|
full_status = _conn_manager.get_full_status()
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
# Also include basic system status from status script for interface info
|
||||||
timeout=10,
|
data = {}
|
||||||
)
|
try:
|
||||||
if out.returncode != 0:
|
out = subprocess.run(
|
||||||
return jsonify({"error": "status script failed", "stderr": out.stderr}), 500
|
[STATUS_SCRIPT, "--json"],
|
||||||
data = json.loads(out.stdout)
|
capture_output=True,
|
||||||
# Enrich with modem AT status (AT_COMMANDS_REFERENCE.md)
|
text=True,
|
||||||
if Path(MODEM_STATUS_SCRIPT).exists():
|
timeout=10,
|
||||||
try:
|
)
|
||||||
mod_out = subprocess.run(
|
if out.returncode == 0:
|
||||||
[MODEM_STATUS_SCRIPT],
|
data = json.loads(out.stdout)
|
||||||
capture_output=True,
|
except Exception:
|
||||||
text=True,
|
pass
|
||||||
timeout=15,
|
|
||||||
cwd="/",
|
# Merge connection manager status
|
||||||
)
|
data["connection"] = full_status.get("connection", {})
|
||||||
if mod_out.returncode == 0 and mod_out.stdout.strip():
|
data["modem"] = full_status.get("modem", {})
|
||||||
mod_data = json.loads(mod_out.stdout)
|
|
||||||
data["modem"] = mod_data
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired):
|
|
||||||
data["modem"] = {}
|
|
||||||
else:
|
|
||||||
data["modem"] = {}
|
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return jsonify({"error": "timeout"}), 504
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@@ -458,21 +503,58 @@ def api_firewall_apply():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ---- API: 5G restart (support + admin) ----
|
# ---- API: 5G Connection Control (support + admin) ----
|
||||||
@app.route("/api/5g/restart", methods=["POST"])
|
@app.route("/api/5g/restart", methods=["POST"])
|
||||||
@_require_login
|
@_require_login
|
||||||
def api_5g_restart():
|
def api_5g_restart():
|
||||||
|
"""Restart 5G connection (disconnect then reconnect)."""
|
||||||
if not can_restart_5g(_current_user().get("role")):
|
if not can_restart_5g(_current_user().get("role")):
|
||||||
return jsonify({"error": "Forbidden"}), 403
|
return jsonify({"error": "Forbidden"}), 403
|
||||||
try:
|
|
||||||
subprocess.Popen(
|
def do_restart():
|
||||||
[CONNECT_SCRIPT],
|
_conn_manager.disconnect()
|
||||||
stdout=subprocess.DEVNULL,
|
_conn_manager.connect()
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
# Run in background so API returns immediately
|
||||||
return jsonify({"ok": True, "message": "Restart initiated"})
|
threading.Thread(target=do_restart, name="5g-restart", daemon=True).start()
|
||||||
except Exception as e:
|
return jsonify({"ok": True, "message": "Restart initiated"})
|
||||||
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) ----
|
# ---- 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
|
Flask>=2.3.0
|
||||||
|
pyserial>=3.5
|
||||||
|
|||||||
Reference in New Issue
Block a user