ffd384f64b
Replace all sensor.* logger namespaces with __name__ (generic module identifiers instead of discoverable 'sensor.*' prefixes). Change hardcoded 'bb' API user to 'admin' in config and code defaults. Change hardcoded relay_user 'bb' to 'operator' — prevents network profiling from exposing tool identity via SSH config. Fixes #457, #458, #459
463 lines
16 KiB
Python
463 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Cellular backup module — LTE modem for out-of-band C2.
|
|
|
|
Manages an LTE modem (SIM7600/EC25) connected via USB through the 13-pin
|
|
header on the Orange Pi Zero 3. Provides a completely separate network path
|
|
from the wired/WiFi connection, enabling out-of-band command and control.
|
|
|
|
OPi Zero 3+ only — skips on pi_zero tier (no USB bandwidth for modem).
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from typing import Optional
|
|
|
|
try:
|
|
import serial
|
|
except ImportError:
|
|
serial = None
|
|
|
|
from modules.base import BaseModule
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Default serial device for modem AT commands
|
|
DEFAULT_MODEM_DEVICE = "/dev/ttyUSB2"
|
|
DEFAULT_BAUD_RATE = 115200
|
|
AT_TIMEOUT = 5 # seconds for AT command responses
|
|
|
|
|
|
class CellularBackup(BaseModule):
|
|
"""LTE modem management for out-of-band C2."""
|
|
|
|
name = "cellular_backup"
|
|
module_type = "connectivity"
|
|
priority = -50
|
|
requires_root = True
|
|
|
|
def __init__(self, bus, state, config, engine=None):
|
|
super().__init__(bus, state, config, engine)
|
|
self._serial: Optional[serial.Serial] = None
|
|
self._modem_device: Optional[str] = None
|
|
self._apn: Optional[str] = None
|
|
self._interface: Optional[str] = None
|
|
self._connected = False
|
|
self._modem_model: Optional[str] = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# BaseModule interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
|
|
if serial is None:
|
|
logger.error("pyserial not installed — cellular backup unavailable")
|
|
self.state.set_module_status(self.name, "error",
|
|
extra={"reason": "pyserial_missing"})
|
|
return
|
|
|
|
# Check hardware tier
|
|
device_tier = self.config.get("device", {}).get("tier", "")
|
|
if device_tier == "pi_zero":
|
|
logger.info("Cellular module skipped — pi_zero tier has no USB bandwidth")
|
|
self.state.set_module_status(self.name, "skipped",
|
|
extra={"reason": "pi_zero_tier"})
|
|
return
|
|
|
|
cell_cfg = self.config.get("connectivity", {}).get("cellular", {})
|
|
if not cell_cfg:
|
|
logger.error("No cellular config in bigbrother.yaml")
|
|
self.state.set_module_status(self.name, "error")
|
|
return
|
|
|
|
self._modem_device = cell_cfg.get("device", DEFAULT_MODEM_DEVICE)
|
|
self._apn = cell_cfg.get("apn")
|
|
self._interface = cell_cfg.get("interface", "wwan0")
|
|
|
|
if not self._apn:
|
|
logger.error("Cellular APN not configured")
|
|
self.state.set_module_status(self.name, "error")
|
|
return
|
|
|
|
# Open serial connection to modem
|
|
if not self._open_serial():
|
|
self.state.set_module_status(self.name, "error")
|
|
return
|
|
|
|
# Initialize modem
|
|
if not self._init_modem():
|
|
self._close_serial()
|
|
self.state.set_module_status(self.name, "error")
|
|
return
|
|
|
|
# Enable data connection
|
|
if not self.enable():
|
|
logger.warning("Failed to enable data — modem initialized but no data yet")
|
|
|
|
self._running = True
|
|
self._pid = os.getpid()
|
|
self._start_time = time.time()
|
|
|
|
self.state.set_module_status(self.name, "running", pid=self._pid,
|
|
extra={"modem": self._modem_model,
|
|
"apn": self._apn})
|
|
self.bus.emit("CONNECTIVITY_CHANGED",
|
|
{"module": self.name, "state": "up",
|
|
"modem": self._modem_model, "apn": self._apn},
|
|
source_module=self.name)
|
|
logger.info("Cellular backup active — modem=%s, APN=%s",
|
|
self._modem_model, self._apn)
|
|
|
|
def stop(self) -> None:
|
|
if not self._running:
|
|
return
|
|
|
|
self.disable()
|
|
self._close_serial()
|
|
self._running = False
|
|
self._connected = False
|
|
|
|
self.state.set_module_status(self.name, "stopped")
|
|
self.bus.emit("CONNECTIVITY_CHANGED",
|
|
{"module": self.name, "state": "down"},
|
|
source_module=self.name)
|
|
logger.info("Cellular backup stopped")
|
|
|
|
def status(self) -> dict:
|
|
signal_info = self.get_signal() if self._running else None
|
|
return {
|
|
"running": self._running,
|
|
"pid": self._pid,
|
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
|
"connected": self._connected,
|
|
"modem_model": self._modem_model,
|
|
"apn": self._apn,
|
|
"interface": self._interface,
|
|
"signal": signal_info,
|
|
"ip": self.get_ip() if self._connected else None,
|
|
}
|
|
|
|
def configure(self, config: dict) -> None:
|
|
self.config.update(config)
|
|
|
|
def health_check(self) -> bool:
|
|
if not self._running:
|
|
return False
|
|
# Check modem responds to AT
|
|
resp = self._at_command("AT")
|
|
return resp is not None and "OK" in resp
|
|
|
|
# ------------------------------------------------------------------
|
|
# Cellular operations
|
|
# ------------------------------------------------------------------
|
|
|
|
def enable(self) -> bool:
|
|
"""Configure APN and enable data connection."""
|
|
# Try ModemManager first (mmcli)
|
|
if self._enable_via_mmcli():
|
|
self._connected = True
|
|
return True
|
|
|
|
# Fallback: direct AT commands
|
|
if self._enable_via_at():
|
|
self._connected = True
|
|
return True
|
|
|
|
logger.error("Failed to enable cellular data via mmcli or AT commands")
|
|
return False
|
|
|
|
def disable(self) -> bool:
|
|
"""Disable data connection."""
|
|
# Try mmcli disconnect
|
|
try:
|
|
modem_index = self._get_modem_index()
|
|
if modem_index is not None:
|
|
subprocess.run(
|
|
["mmcli", "-m", str(modem_index), "--simple-disconnect"],
|
|
capture_output=True, timeout=15,
|
|
)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
|
|
# AT command fallback
|
|
self._at_command("AT+CGACT=0,1")
|
|
self._at_command("AT+CFUN=4") # Flight mode
|
|
|
|
self._connected = False
|
|
return True
|
|
|
|
def get_signal(self) -> Optional[dict]:
|
|
"""Get signal quality information."""
|
|
result = {}
|
|
|
|
# AT+CSQ — signal quality
|
|
resp = self._at_command("AT+CSQ")
|
|
if resp:
|
|
match = re.search(r'\+CSQ:\s*(\d+),(\d+)', resp)
|
|
if match:
|
|
rssi_raw = int(match.group(1))
|
|
ber = int(match.group(2))
|
|
# Convert to dBm: -113 + (2 * rssi_raw)
|
|
if 0 <= rssi_raw <= 31:
|
|
result["rssi_dbm"] = -113 + (2 * rssi_raw)
|
|
result["ber"] = ber
|
|
|
|
# AT+COPS? — operator
|
|
resp = self._at_command("AT+COPS?")
|
|
if resp:
|
|
match = re.search(r'\+COPS:\s*\d+,\d+,"([^"]+)"', resp)
|
|
if match:
|
|
result["operator"] = match.group(1)
|
|
|
|
# AT+CREG? — registration status
|
|
resp = self._at_command("AT+CREG?")
|
|
if resp:
|
|
match = re.search(r'\+CREG:\s*\d+,(\d+)', resp)
|
|
if match:
|
|
reg_status = int(match.group(1))
|
|
result["registered"] = reg_status in (1, 5) # 1=home, 5=roaming
|
|
|
|
return result if result else None
|
|
|
|
def get_ip(self) -> Optional[str]:
|
|
"""Get the IP address assigned to the cellular interface."""
|
|
# Check interface
|
|
try:
|
|
result = subprocess.run(
|
|
["ip", "-4", "addr", "show", self._interface],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
match = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', result.stdout)
|
|
if match:
|
|
return match.group(1)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
|
|
# Fallback: AT command
|
|
resp = self._at_command("AT+CGPADDR=1")
|
|
if resp:
|
|
match = re.search(r'\+CGPADDR:\s*\d+,"?(\d+\.\d+\.\d+\.\d+)"?', resp)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
return None
|
|
|
|
def send_sms(self, number: str, msg: str) -> bool:
|
|
"""Send an SMS message via the modem."""
|
|
# Set text mode
|
|
resp = self._at_command("AT+CMGF=1")
|
|
if not resp or "OK" not in resp:
|
|
logger.error("Failed to set SMS text mode")
|
|
return False
|
|
|
|
# Send message
|
|
self._at_command(f'AT+CMGS="{number}"', expect_prompt=True)
|
|
time.sleep(0.5)
|
|
|
|
if self._serial and self._serial.is_open:
|
|
try:
|
|
self._serial.write(msg.encode() + b"\x1a") # Ctrl+Z to send
|
|
time.sleep(3.0)
|
|
resp = self._serial.read(self._serial.in_waiting).decode(errors='replace')
|
|
if "+CMGS:" in resp:
|
|
logger.info("SMS sent to %s", number)
|
|
return True
|
|
except (serial.SerialException, OSError) as exc:
|
|
logger.error("SMS send error: %s", exc)
|
|
|
|
return False
|
|
|
|
# ------------------------------------------------------------------
|
|
# ModemManager (mmcli) path
|
|
# ------------------------------------------------------------------
|
|
|
|
def _enable_via_mmcli(self) -> bool:
|
|
"""Enable cellular data via ModemManager."""
|
|
modem_index = self._get_modem_index()
|
|
if modem_index is None:
|
|
return False
|
|
|
|
try:
|
|
# Enable modem
|
|
subprocess.run(
|
|
["mmcli", "-m", str(modem_index), "--enable"],
|
|
check=True, capture_output=True, timeout=15,
|
|
)
|
|
|
|
# Connect with APN
|
|
subprocess.run(
|
|
["mmcli", "-m", str(modem_index), "--simple-connect",
|
|
f"apn={self._apn}"],
|
|
check=True, capture_output=True, timeout=30,
|
|
)
|
|
|
|
# Wait for interface to come up
|
|
time.sleep(3.0)
|
|
|
|
# Configure interface
|
|
bearer_result = subprocess.run(
|
|
["mmcli", "-m", str(modem_index), "--list-bearers"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
|
|
logger.info("Cellular data enabled via mmcli (modem %d)", modem_index)
|
|
return True
|
|
|
|
except subprocess.CalledProcessError as exc:
|
|
logger.debug("mmcli connect failed: %s",
|
|
exc.stderr.decode(errors='replace') if exc.stderr else "")
|
|
return False
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
return False
|
|
|
|
def _get_modem_index(self) -> Optional[int]:
|
|
"""Get the ModemManager modem index."""
|
|
try:
|
|
result = subprocess.run(
|
|
["mmcli", "-L"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
if result.returncode == 0:
|
|
match = re.search(r'/Modem/(\d+)', result.stdout)
|
|
if match:
|
|
return int(match.group(1))
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
# AT command path
|
|
# ------------------------------------------------------------------
|
|
|
|
def _enable_via_at(self) -> bool:
|
|
"""Enable cellular data via direct AT commands."""
|
|
# Exit flight mode
|
|
resp = self._at_command("AT+CFUN=1")
|
|
if not resp:
|
|
return False
|
|
|
|
time.sleep(2.0)
|
|
|
|
# Set APN
|
|
resp = self._at_command(f'AT+CGDCONT=1,"IP","{self._apn}"')
|
|
if not resp or "OK" not in resp:
|
|
logger.error("Failed to set APN")
|
|
return False
|
|
|
|
# Activate PDP context
|
|
resp = self._at_command("AT+CGACT=1,1")
|
|
if not resp or "OK" not in resp:
|
|
logger.error("Failed to activate PDP context")
|
|
return False
|
|
|
|
# Wait for data connection
|
|
time.sleep(3.0)
|
|
|
|
# Check registration
|
|
signal = self.get_signal()
|
|
if signal and signal.get("registered"):
|
|
logger.info("Cellular data enabled via AT commands")
|
|
return True
|
|
|
|
logger.warning("Modem registered but data connection uncertain")
|
|
return True # Modem is up; data may need a moment
|
|
|
|
# ------------------------------------------------------------------
|
|
# Serial / AT helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _open_serial(self) -> bool:
|
|
"""Open serial connection to modem."""
|
|
try:
|
|
self._serial = serial.Serial(
|
|
port=self._modem_device,
|
|
baudrate=DEFAULT_BAUD_RATE,
|
|
timeout=AT_TIMEOUT,
|
|
write_timeout=AT_TIMEOUT,
|
|
)
|
|
logger.debug("Opened serial port %s", self._modem_device)
|
|
return True
|
|
except serial.SerialException as exc:
|
|
logger.error("Cannot open modem serial port %s: %s",
|
|
self._modem_device, exc)
|
|
return False
|
|
|
|
def _close_serial(self) -> None:
|
|
"""Close serial connection."""
|
|
if self._serial and self._serial.is_open:
|
|
try:
|
|
self._serial.close()
|
|
except Exception:
|
|
pass
|
|
self._serial = None
|
|
|
|
def _init_modem(self) -> bool:
|
|
"""Initialize modem with basic AT commands."""
|
|
# Basic AT test
|
|
resp = self._at_command("AT")
|
|
if not resp or "OK" not in resp:
|
|
logger.error("Modem not responding to AT commands on %s", self._modem_device)
|
|
return False
|
|
|
|
# Disable echo
|
|
self._at_command("ATE0")
|
|
|
|
# Get modem model
|
|
resp = self._at_command("AT+CGMM")
|
|
if resp:
|
|
lines = [l.strip() for l in resp.splitlines() if l.strip() and l.strip() != "OK"]
|
|
if lines:
|
|
self._modem_model = lines[0]
|
|
|
|
# Check SIM
|
|
resp = self._at_command("AT+CPIN?")
|
|
if not resp or "READY" not in resp:
|
|
logger.error("SIM not ready: %s", resp)
|
|
return False
|
|
|
|
logger.info("Modem initialized: %s, SIM ready", self._modem_model)
|
|
return True
|
|
|
|
def _at_command(self, cmd: str, expect_prompt: bool = False) -> Optional[str]:
|
|
"""Send an AT command and return the response."""
|
|
if not self._serial or not self._serial.is_open:
|
|
return None
|
|
|
|
try:
|
|
# Flush input
|
|
self._serial.reset_input_buffer()
|
|
|
|
# Send command
|
|
self._serial.write((cmd + "\r\n").encode())
|
|
|
|
if expect_prompt:
|
|
# Wait for > prompt
|
|
time.sleep(0.5)
|
|
data = self._serial.read(self._serial.in_waiting)
|
|
return data.decode(errors='replace')
|
|
|
|
# Read response
|
|
time.sleep(0.5)
|
|
response = b""
|
|
deadline = time.time() + AT_TIMEOUT
|
|
while time.time() < deadline:
|
|
if self._serial.in_waiting:
|
|
response += self._serial.read(self._serial.in_waiting)
|
|
# Check for final result codes
|
|
decoded = response.decode(errors='replace')
|
|
if any(code in decoded for code in ("OK", "ERROR", "+CME ERROR", "+CMS ERROR")):
|
|
return decoded
|
|
time.sleep(0.1)
|
|
|
|
return response.decode(errors='replace') if response else None
|
|
|
|
except (serial.SerialException, OSError) as exc:
|
|
logger.debug("AT command error: %s", exc)
|
|
return None
|