Enhance 5G modem management with integrated web GUI and connection control

- Introduced a web GUI for managing 5G connections, replacing the standalone 5g-router service.
- Updated scripts to ensure exclusive access to the AT port, preventing conflicts.
- Improved troubleshooting documentation in 5G_MODEM_TROUBLESHOOTING.md, adding checks for processes using the AT port.
- Enhanced connection management in the web app, including auto-connect and detailed status APIs.
- Updated installation scripts to reflect changes in service management and dependencies.
This commit is contained in:
nearxos
2026-02-02 10:34:25 +02:00
parent 78f7ccc6db
commit 9dc35a57a2
14 changed files with 1224 additions and 115 deletions

View File

@@ -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 doesnt exist.
**Prevention:** All project scripts that write to the AT port now check `[ -c "$AT_PORT" ]` immediately before writing and skip/exit if its 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 dont create the file in the first place.
### Stray /dev/ttyUSB file (no number) ### Stray /dev/ttyUSB file (no number)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 35s to respond after boot; use 5s so service (started at boot) gets OK # Modem can take 515s 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
} }

View File

@@ -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)
echo " Login at http://<device-ip>:5000 (default: admin/admin, support/support change passwords)" if command -v rc-update >/dev/null 2>&1; then
else rc-update add 5g-webgui default 2>/dev/null || true
echo "Web GUI source not found; skipping." 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 fi
echo "" echo ""
echo "Done. Edit $CONFIG if needed, then: service 5g-router start" echo "Installed Web GUI with 5G connection management at $WEBGUI_DEST"
echo "Or run once: $BIN/connect-5g.sh" echo " Install dependencies: apk add python3 py3-flask && pip install pyserial"
echo "Status: $BIN/status-5g.sh" 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"

View File

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

View File

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

View File

@@ -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,6 +255,16 @@ 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:
# 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: try:
out = subprocess.run( out = subprocess.run(
[STATUS_SCRIPT, "--json"], [STATUS_SCRIPT, "--json"],
@@ -214,29 +272,16 @@ def api_status():
text=True, text=True,
timeout=10, timeout=10,
) )
if out.returncode != 0: if out.returncode == 0:
return jsonify({"error": "status script failed", "stderr": out.stderr}), 500
data = json.loads(out.stdout) data = json.loads(out.stdout)
# Enrich with modem AT status (AT_COMMANDS_REFERENCE.md) except Exception:
if Path(MODEM_STATUS_SCRIPT).exists(): pass
try:
mod_out = subprocess.run( # Merge connection manager status
[MODEM_STATUS_SCRIPT], data["connection"] = full_status.get("connection", {})
capture_output=True, data["modem"] = full_status.get("modem", {})
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"] = {}
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
threading.Thread(target=do_restart, name="5g-restart", daemon=True).start()
return jsonify({"ok": True, "message": "Restart initiated"}) 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) ---- # ---- API: Speedtest (support + admin) ----

316
web/at_client.py Normal file
View File

@@ -0,0 +1,316 @@
"""
AT Command Client for Fibocom FM350-GL modem using pyserial.
Thread-safe with exclusive port access via Lock.
"""
import os
import re
import threading
import time
import logging
from typing import Optional, Dict, Any, List
try:
import serial
except ImportError:
serial = None # Will raise error when used
logger = logging.getLogger(__name__)
class ATClient:
"""
Thread-safe AT command interface for Fibocom FM350-GL modem.
Uses pyserial for serial communication.
"""
DEFAULT_PORTS = ["/dev/ttyUSB1", "/dev/ttyUSB0", "/dev/ttyUSB2"]
DEFAULT_BAUDRATE = 115200
DEFAULT_TIMEOUT = 5
def __init__(self, port: str = "/dev/ttyUSB1", baudrate: int = DEFAULT_BAUDRATE):
if serial is None:
raise ImportError("pyserial is required. Install with: pip install pyserial")
self.port = port
self.baudrate = baudrate
self.lock = threading.Lock()
self._serial: Optional[serial.Serial] = None
def _open(self) -> bool:
"""Open serial port if not already open."""
if self._serial and self._serial.is_open:
return True
try:
self._serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=1,
write_timeout=1,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
)
# Disable echo
self._send_raw("ATE0\r", timeout=2)
return True
except (serial.SerialException, OSError) as e:
logger.warning(f"Failed to open {self.port}: {e}")
self._serial = None
return False
def _close(self):
"""Close serial port."""
if self._serial and self._serial.is_open:
try:
self._serial.close()
except Exception:
pass
self._serial = None
def _send_raw(self, cmd: str, timeout: float = DEFAULT_TIMEOUT) -> str:
"""
Send raw AT command and read response. Must hold lock.
Returns the response string (may contain OK, ERROR, or data).
"""
if not self._serial or not self._serial.is_open:
return ""
# Clear input buffer
self._serial.reset_input_buffer()
# Send command
if not cmd.endswith("\r"):
cmd += "\r"
self._serial.write(cmd.encode("utf-8"))
self._serial.flush()
# Read response until OK, ERROR, or timeout
response_lines = []
start = time.time()
while (time.time() - start) < timeout:
if self._serial.in_waiting > 0:
line = self._serial.readline().decode("utf-8", errors="replace").strip()
if line:
response_lines.append(line)
# Check for terminal responses
if line in ("OK", "ERROR") or line.startswith("+CME ERROR") or line.startswith("+CMS ERROR"):
break
else:
time.sleep(0.05)
return "\n".join(response_lines)
def send_command(self, cmd: str, timeout: float = DEFAULT_TIMEOUT) -> str:
"""
Send AT command and return response. Thread-safe.
"""
with self.lock:
if not self._open():
return ""
try:
return self._send_raw(cmd, timeout)
except Exception as e:
logger.error(f"AT command error: {e}")
self._close()
return ""
def test(self) -> bool:
"""Test AT communication. Returns True if modem responds with OK."""
response = self.send_command("AT", timeout=3)
return "OK" in response
def find_working_port(self) -> Optional[str]:
"""
Try to find a working AT port from the default list.
Updates self.port if found. Returns the working port or None.
"""
for port in self.DEFAULT_PORTS:
if not os.path.exists(port):
continue
old_port = self.port
self.port = port
with self.lock:
self._close()
if self.test():
logger.info(f"Found working AT port: {port}")
return port
self.port = old_port
with self.lock:
self._close()
return None
def get_manufacturer(self) -> str:
"""Get modem manufacturer (AT+CGMI)."""
response = self.send_command("AT+CGMI")
for line in response.split("\n"):
line = line.strip()
if line and line not in ("OK", "AT+CGMI") and not line.startswith("+"):
return line
return ""
def get_model(self) -> str:
"""Get modem model (AT+CGMM)."""
response = self.send_command("AT+CGMM")
for line in response.split("\n"):
line = line.strip()
if line and line not in ("OK", "AT+CGMM") and not line.startswith("+"):
return line
return ""
def get_revision(self) -> str:
"""Get firmware revision (AT+CGMR)."""
response = self.send_command("AT+CGMR")
for line in response.split("\n"):
line = line.strip()
if line and line not in ("OK", "AT+CGMR", "ERROR") and not line.startswith("+"):
return line
return ""
def get_imei(self) -> str:
"""Get IMEI (AT+CGSN)."""
response = self.send_command("AT+CGSN")
match = re.search(r"\d{15}", response)
return match.group(0) if match else ""
def get_iccid(self) -> str:
"""Get SIM ICCID (AT+CCID)."""
response = self.send_command("AT+CCID")
match = re.search(r"\+CCID:\s*\"?(\d+)\"?", response)
if match:
return match.group(1)
# Try alternate format
for line in response.split("\n"):
line = line.strip()
if line and re.match(r"^\d{18,22}$", line):
return line
return ""
def get_signal_csq(self) -> Optional[int]:
"""
Get signal strength as CSQ value (AT+CSQ).
CSQ 0-31 = signal, 99 = unknown.
Returns None if unavailable.
"""
response = self.send_command("AT+CSQ")
match = re.search(r"\+CSQ:\s*(\d+)", response)
if match:
csq = int(match.group(1))
return csq if csq <= 31 else None
return None
def get_registration_status(self) -> Dict[str, str]:
"""
Get network registration status (AT+CREG?, AT+CEREG?).
Returns dict with 'creg' and 'cereg' status strings.
"""
result = {"creg": "unknown", "cereg": "unknown"}
# Parse registration: ,1 = home, ,5 = roaming, ,2 = searching, ,0 = not registered
def parse_reg(response: str) -> str:
if ",1" in response:
return "registered_home"
elif ",5" in response:
return "registered_roaming"
elif ",2" in response:
return "searching"
elif ",0" in response:
return "not_registered"
return "unknown"
creg_resp = self.send_command("AT+CREG?")
cereg_resp = self.send_command("AT+CEREG?")
if "+CREG:" in creg_resp:
result["creg"] = parse_reg(creg_resp)
if "+CEREG:" in cereg_resp:
result["cereg"] = parse_reg(cereg_resp)
return result
def get_operator(self) -> str:
"""Get current operator name (AT+COPS?)."""
response = self.send_command("AT+COPS?")
# Format: +COPS: 0,0,"Vodafone",7
match = re.search(r'"([^"]+)"', response)
return match.group(1) if match else ""
def get_usb_mode(self) -> Optional[int]:
"""Get USB mode (AT+GTUSBMODE?). 40 = RNDIS, 41 = extended."""
response = self.send_command("AT+GTUSBMODE?")
match = re.search(r"\+GTUSBMODE:\s*(\d+)", response)
return int(match.group(1)) if match else None
def get_pdp_address(self, cid: int = 1) -> Optional[str]:
"""Get IP address assigned to PDP context (AT+CGPADDR=cid)."""
response = self.send_command(f"AT+CGPADDR={cid}")
match = re.search(r"(\d+\.\d+\.\d+\.\d+)", response)
return match.group(1) if match else None
def get_connection_params(self, cid: int = 1) -> Dict[str, Any]:
"""
Get connection dynamic parameters (AT+CGCONTRDP=cid).
Returns dict with 'ip', 'dns1', 'dns2', etc.
"""
response = self.send_command(f"AT+CGCONTRDP={cid}", timeout=5)
result = {"ip": None, "dns1": None, "dns2": None}
# Extract all IP addresses from response
ips = re.findall(r"(\d+\.\d+\.\d+\.\d+)", response)
if ips:
# First IP is usually the assigned IP, rest are DNS
result["ip"] = ips[0]
if len(ips) > 1:
result["dns1"] = ips[1]
if len(ips) > 2:
result["dns2"] = ips[2]
return result
def configure_apn(self, apn: str, cid: int = 1) -> bool:
"""Configure APN for PDP context (AT+CGDCONT)."""
response = self.send_command(f'AT+CGDCONT={cid},"IP","{apn}"', timeout=5)
return "OK" in response
def activate_pdp(self, cid: int = 1) -> bool:
"""Activate PDP context (AT+CGACT=1,cid)."""
response = self.send_command(f"AT+CGACT=1,{cid}", timeout=10)
return "OK" in response or "CGEV" in response
def deactivate_pdp(self, cid: int = 1) -> bool:
"""Deactivate PDP context (AT+CGACT=0,cid)."""
response = self.send_command(f"AT+CGACT=0,{cid}", timeout=5)
return "OK" in response
def reset_modem(self) -> bool:
"""Reset modem (AT+CFUN=1,1)."""
response = self.send_command("AT+CFUN=1,1", timeout=5)
self._close()
return "OK" in response
def get_full_status(self) -> Dict[str, Any]:
"""
Get complete modem status. Replaces modem-status-at.sh functionality.
"""
reg = self.get_registration_status()
return {
"at_port": self.port,
"manufacturer": self.get_manufacturer(),
"model": self.get_model(),
"revision": self.get_revision(),
"imei": self.get_imei(),
"signal_csq": self.get_signal_csq(),
"creg_status": reg["creg"],
"cereg_status": reg["cereg"],
"usb_mode": self.get_usb_mode(),
"iccid": self.get_iccid(),
"operator": self.get_operator(),
"modem_ip": self.get_pdp_address(1),
}
def close(self):
"""Close serial connection."""
with self.lock:
self._close()
def __del__(self):
self.close()

532
web/connection_manager.py Normal file
View File

@@ -0,0 +1,532 @@
"""
5G Connection Manager - manages modem connection lifecycle.
Migrates functionality from connect-5g.sh to native Python.
"""
import logging
import os
import subprocess
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional, Callable
from at_client import ATClient
logger = logging.getLogger(__name__)
class ConnectionState(Enum):
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
FAILED = "failed"
RECONNECTING = "reconnecting"
@dataclass
class ConnectionConfig:
"""Configuration for 5G connection."""
at_port: str = "/dev/ttyUSB1"
apn: str = "internet"
wan_if: str = "eth1"
lan_if: str = "eth0.100"
failover_enabled: bool = False
failover_if: str = "eth0"
failover_metric: int = 202
watchdog_interval: int = 0 # 0 = disabled
log_signal: bool = True
weak_signal_csq: int = 10
dns_servers: Optional[str] = None # Comma-separated fallback DNS
@classmethod
def from_file(cls, path: str = "/etc/5g-router.conf") -> "ConnectionConfig":
"""Load config from shell-style config file."""
config = cls()
if not os.path.exists(path):
return config
try:
with open(path, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key == "AT_PORT":
config.at_port = value
elif key == "APN":
config.apn = value
elif key == "WAN_IF":
config.wan_if = value
elif key == "LAN_IF":
config.lan_if = value
elif key == "FAILOVER_ENABLED":
config.failover_enabled = value.lower() == "yes"
elif key == "FAILOVER_IF":
config.failover_if = value
elif key == "FAILOVER_METRIC":
config.failover_metric = int(value)
elif key == "WATCHDOG_INTERVAL":
config.watchdog_interval = int(value)
elif key == "LOG_SIGNAL":
config.log_signal = value.lower() == "yes"
elif key == "WEAK_SIGNAL_CSQ":
config.weak_signal_csq = int(value)
elif key == "DNS_SERVERS":
config.dns_servers = value
except Exception as e:
logger.warning(f"Error reading config {path}: {e}")
return config
@dataclass
class ConnectionStatus:
"""Current connection status."""
state: ConnectionState = ConnectionState.DISCONNECTED
ip_address: Optional[str] = None
dns1: Optional[str] = None
dns2: Optional[str] = None
signal_csq: Optional[int] = None
last_connect_time: Optional[datetime] = None
last_error: Optional[str] = None
connect_attempts: int = 0
class ConnectionManager:
"""
Manages 5G modem connection lifecycle.
Thread-safe, supports background watchdog.
"""
LOG_FILE = "/var/log/5g-router.log"
def __init__(self, at_client: ATClient, config: Optional[ConnectionConfig] = None):
self.at = at_client
self.config = config or ConnectionConfig()
self.status = ConnectionStatus()
self._lock = threading.Lock()
self._watchdog_thread: Optional[threading.Thread] = None
self._watchdog_stop = threading.Event()
self._log_callbacks: List[Callable[[str], None]] = []
def _log(self, message: str):
"""Log message to file and callbacks."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {message}"
logger.info(message)
# Write to log file
try:
with open(self.LOG_FILE, "a") as f:
f.write(line + "\n")
except Exception:
pass
# Notify callbacks
for cb in self._log_callbacks:
try:
cb(line)
except Exception:
pass
def add_log_callback(self, callback: Callable[[str], None]):
"""Add callback to receive log messages."""
self._log_callbacks.append(callback)
def _run_cmd(self, cmd: List[str], check: bool = False) -> subprocess.CompletedProcess:
"""Run shell command."""
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=30, check=check)
except subprocess.TimeoutExpired:
logger.warning(f"Command timed out: {' '.join(cmd)}")
return subprocess.CompletedProcess(cmd, 1, "", "timeout")
except subprocess.CalledProcessError as e:
return subprocess.CompletedProcess(cmd, e.returncode, e.stdout or "", e.stderr or "")
except Exception as e:
logger.error(f"Command failed: {e}")
return subprocess.CompletedProcess(cmd, 1, "", str(e))
def _check_usb_mode(self) -> bool:
"""Check if modem is in correct USB mode (40)."""
result = self._run_cmd(["lsusb"])
if "0e8d:7127" in result.stdout:
self._log("WARN: Modem is in Mode 41 (7127). AT commands may not work.")
return False
return True
def _wait_for_modem(self, max_wait: int = 30) -> bool:
"""Wait for modem AT port to be available."""
self._log("Waiting for modem AT port...")
for i in range(max_wait // 2):
# Check if port exists as char device
if os.path.exists(self.config.at_port):
import stat
mode = os.stat(self.config.at_port).st_mode
if stat.S_ISCHR(mode):
# Try to communicate
if self.at.test():
self._log("Modem AT port available")
return True
# Try to find working port
working = self.at.find_working_port()
if working:
self.config.at_port = working
self._log(f"Found working AT port: {working}")
return True
time.sleep(2)
self._log("Modem AT port not available")
return False
def _configure_interface(self, ip: str) -> bool:
"""Configure network interface with IP from modem."""
wan_if = self.config.wan_if
self._log(f"Configuring {wan_if} with IP {ip}")
try:
# Bring up interface
self._run_cmd(["ip", "link", "set", wan_if, "up"])
# Flush existing addresses
self._run_cmd(["ip", "addr", "flush", "dev", wan_if])
# Add IP address
result = self._run_cmd(["ip", "addr", "add", f"{ip}/32", "dev", wan_if])
if result.returncode != 0 and "RTNETLINK answers: File exists" not in result.stderr:
self._log(f"Failed to add IP: {result.stderr}")
return False
# Add default route
self._run_cmd(["ip", "route", "add", "default", "dev", wan_if, "metric", "50"])
self._log("Interface configured")
return True
except Exception as e:
self._log(f"Interface configuration failed: {e}")
return False
def _configure_nat(self) -> bool:
"""Setup NAT/masquerading for LAN clients."""
self._log("Setting up NAT...")
wan_if = self.config.wan_if
lan_if = self.config.lan_if
try:
# Enable IP forwarding
with open("/proc/sys/net/ipv4/ip_forward", "w") as f:
f.write("1")
# Masquerade outgoing traffic
self._run_cmd([
"iptables", "-t", "nat", "-C", "POSTROUTING",
"-o", wan_if, "-j", "MASQUERADE"
])
if self._run_cmd([
"iptables", "-t", "nat", "-C", "POSTROUTING",
"-o", wan_if, "-j", "MASQUERADE"
]).returncode != 0:
self._run_cmd([
"iptables", "-t", "nat", "-A", "POSTROUTING",
"-o", wan_if, "-j", "MASQUERADE"
])
# Forward from LAN to WAN
if self._run_cmd([
"iptables", "-C", "FORWARD",
"-i", lan_if, "-o", wan_if, "-j", "ACCEPT"
]).returncode != 0:
self._run_cmd([
"iptables", "-A", "FORWARD",
"-i", lan_if, "-o", wan_if, "-j", "ACCEPT"
])
# Allow established/related back
if self._run_cmd([
"iptables", "-C", "FORWARD",
"-i", wan_if, "-o", lan_if,
"-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"
]).returncode != 0:
self._run_cmd([
"iptables", "-A", "FORWARD",
"-i", wan_if, "-o", lan_if,
"-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"
])
self._log("NAT configured")
return True
except Exception as e:
self._log(f"NAT configuration failed: {e}")
return False
def _configure_dns(self, dns1: Optional[str], dns2: Optional[str]):
"""Configure /etc/resolv.conf with DNS servers."""
if not dns1:
# Try fallback from config
if self.config.dns_servers:
parts = self.config.dns_servers.split(",")
dns1 = parts[0].strip() if len(parts) > 0 else None
dns2 = parts[1].strip() if len(parts) > 1 else None
if dns1:
try:
with open("/etc/resolv.conf", "w") as f:
f.write(f"nameserver {dns1}\n")
if dns2:
f.write(f"nameserver {dns2}\n")
self._log("DNS configured")
except Exception as e:
self._log(f"DNS configuration failed: {e}")
def _do_failover(self):
"""Setup failover route if enabled."""
if not self.config.failover_enabled:
return
self._run_cmd([
"ip", "route", "add", "default",
"dev", self.config.failover_if,
"metric", str(self.config.failover_metric)
])
self._log(f"Failover route via {self.config.failover_if} configured")
def connect(self) -> bool:
"""
Establish 5G connection.
Returns True on success.
"""
with self._lock:
self.status.state = ConnectionState.CONNECTING
self.status.connect_attempts += 1
self.status.last_error = None
self._log("Starting 5G connection...")
try:
# Check USB mode
if not self._check_usb_mode():
self._set_failed("Modem in wrong USB mode")
self._do_failover()
return False
# Wait for modem
if not self._wait_for_modem():
self._set_failed("Modem AT port not available")
self._do_failover()
return False
# Test AT communication with retries
at_ok = False
for attempt in range(1, 4):
if self.at.test():
at_ok = True
break
if attempt < 3:
self._log(f"AT not OK, retry {attempt}/3 in 5s...")
time.sleep(5)
if not at_ok:
self._set_failed("AT communication failed")
self._do_failover()
return False
# Log signal strength
csq = self.at.get_signal_csq()
if csq is not None:
self.status.signal_csq = csq
if self.config.log_signal:
self._log(f"Signal CSQ={csq}")
if csq < self.config.weak_signal_csq:
self._log(f"WARN: weak signal CSQ={csq}")
# Configure APN
self._log(f"Configuring APN: {self.config.apn}")
self.at.configure_apn(self.config.apn)
# Activate PDP context
self._log("Activating PDP context...")
self.at.activate_pdp()
# Get IP address with retries
ip = None
for attempt in range(1, 6):
ip = self.at.get_pdp_address()
if ip:
break
if attempt < 5:
self._log(f"No IP yet, retry {attempt}/5 in 3s...")
time.sleep(3)
if not ip:
self._set_failed("Could not get modem IP")
self._do_failover()
return False
# Get DNS from modem
params = self.at.get_connection_params()
dns1 = params.get("dns1")
dns2 = params.get("dns2")
# Configure DNS
self._configure_dns(dns1, dns2)
# Configure interface
if not self._configure_interface(ip):
self._set_failed("Interface configuration failed")
self._do_failover()
return False
# Setup NAT
if not self._configure_nat():
self._set_failed("NAT configuration failed")
# Don't fail completely, connection might still work
pass
# Success!
with self._lock:
self.status.state = ConnectionState.CONNECTED
self.status.ip_address = ip
self.status.dns1 = dns1
self.status.dns2 = dns2
self.status.last_connect_time = datetime.now()
self._log("5G connection successful!")
return True
except Exception as e:
self._set_failed(f"Connection error: {e}")
self._do_failover()
return False
def _set_failed(self, error: str):
"""Set connection state to failed."""
self._log(error)
with self._lock:
self.status.state = ConnectionState.FAILED
self.status.last_error = error
def disconnect(self) -> bool:
"""
Disconnect 5G connection.
"""
self._log("Disconnecting 5G...")
with self._lock:
self.status.state = ConnectionState.DISCONNECTED
self.status.ip_address = None
try:
# Deactivate PDP
self.at.deactivate_pdp()
# Bring down interface
self._run_cmd(["ip", "link", "set", self.config.wan_if, "down"])
# Remove routes
self._run_cmd(["ip", "route", "del", "default", "dev", self.config.wan_if])
self._log("5G disconnected")
return True
except Exception as e:
self._log(f"Disconnect error: {e}")
return False
def check_health(self) -> bool:
"""
Check if connection is healthy.
Returns True if connected and working.
"""
if self.status.state != ConnectionState.CONNECTED:
return False
# Check if interface is up with IP
result = self._run_cmd(["ip", "addr", "show", self.config.wan_if])
if self.status.ip_address not in result.stdout:
return False
# Ping test (optional)
result = self._run_cmd(["ping", "-c", "1", "-W", "3", "8.8.8.8"])
if result.returncode != 0:
self._log("Health check: ping failed")
return False
return True
def _watchdog_loop(self):
"""Background thread: periodically check and reconnect if needed."""
self._log(f"Watchdog started (interval: {self.config.watchdog_interval}s)")
while not self._watchdog_stop.is_set():
try:
if not self.check_health():
self._log("Watchdog: connection unhealthy, reconnecting...")
with self._lock:
self.status.state = ConnectionState.RECONNECTING
self.connect()
except Exception as e:
logger.error(f"Watchdog error: {e}")
# Wait for interval or stop signal
self._watchdog_stop.wait(timeout=self.config.watchdog_interval)
self._log("Watchdog stopped")
def start_watchdog(self):
"""Start background watchdog thread."""
if self.config.watchdog_interval <= 0:
return
if self._watchdog_thread and self._watchdog_thread.is_alive():
return
self._watchdog_stop.clear()
self._watchdog_thread = threading.Thread(
target=self._watchdog_loop,
name="5g-watchdog",
daemon=True
)
self._watchdog_thread.start()
def stop_watchdog(self):
"""Stop background watchdog thread."""
self._watchdog_stop.set()
if self._watchdog_thread:
self._watchdog_thread.join(timeout=5)
self._watchdog_thread = None
def get_status(self) -> Dict[str, Any]:
"""Get current connection status as dict."""
with self._lock:
return {
"state": self.status.state.value,
"ip_address": self.status.ip_address,
"dns1": self.status.dns1,
"dns2": self.status.dns2,
"signal_csq": self.status.signal_csq,
"last_connect_time": self.status.last_connect_time.isoformat() if self.status.last_connect_time else None,
"last_error": self.status.last_error,
"connect_attempts": self.status.connect_attempts,
"watchdog_running": self._watchdog_thread is not None and self._watchdog_thread.is_alive(),
}
def get_full_status(self) -> Dict[str, Any]:
"""Get combined connection and modem status."""
conn_status = self.get_status()
modem_status = self.at.get_full_status()
return {
"connection": conn_status,
"modem": modem_status,
}

View File

@@ -1 +1,2 @@
Flask>=2.3.0 Flask>=2.3.0
pyserial>=3.5