#!/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 serial import subprocess import time from typing import Optional from modules.base import BaseModule logger = logging.getLogger("bb.connectivity.cellular_backup") # 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 # 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