#!/usr/bin/env python3 """RM520N-GL RNDIS 自动拨号脚本。 流程: 1. 检测 USB 设备是否存在 2. 打开 AT 口并检查 SIM 状态 3. 配置 RNDIS 模式: AT+QCFG="usbnet",3 4. 重启模块: AT+CFUN=1,1 5. 等待模块重新枚举并识别 5G 网卡 6. 如果网卡还没有 IPv4, 自动尝试 DHCP 用法: sudo python3 rndis_dial.py sudo python3 rndis_dial.py --serial-port /dev/ttyUSB7 sudo python3 rndis_dial.py --interface eth0 #指定网口 """ from __future__ import annotations import argparse import errno import ipaddress import json import os import select import shlex import shutil import subprocess import sys import termios import time import tty USB_ID = "2c7c:0801" DEFAULT_SERIAL_PORT = "/dev/ttyUSB7" #串口设备节点 DEFAULT_BAUD_RATE = 115200 CHECK_INTERVAL = 2 SERIAL_READ_TIMEOUT = 0.2 SERIAL_POLL_INTERVAL = 0.1 SERIAL_SETTLE_DELAY = 0.3 AT_SYNC_RETRIES = 3 AT_SYNC_TIMEOUT = 2.5 # 示例地址 192.168.225.38/22 所在网段。 # 拨号成功后会用这个网段来最终确认哪个接口是 5G 模组。 DEFAULT_MODEM_SUBNET = "192.168.224.0/22" DEFAULT_MODEM_GATEWAY = "192.168.225.1" DEFAULT_PUBLIC_TARGETS = ("81.70.156.140", "106.55.173.235") DEFAULT_INFO_JSON = "modem_network_info.json" SKIP_INTERFACES = {"lo", "docker0", "l4tbr0"} BAUD_RATE_MAP = { 9600: termios.B9600, 19200: termios.B19200, 38400: termios.B38400, 57600: termios.B57600, 115200: termios.B115200, } def run_cmd(cmd, timeout=30, check=False): print(f"[CMD] {format_shell_cmd(cmd)}") result = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, check=False, ) output = (result.stdout or "") + (result.stderr or "") if check and result.returncode != 0: raise RuntimeError(f"命令执行失败: {' '.join(cmd)}\n{output.strip()}") return result.returncode, output.strip() def format_shell_cmd(cmd): """把命令参数格式化成可直接阅读的 shell 形式。""" return " ".join(shlex.quote(part) for part in cmd) def parse_ipv4_address(value): try: return str(ipaddress.IPv4Address(value)) except ipaddress.AddressValueError as exc: raise argparse.ArgumentTypeError(f"无效的 IPv4 地址: {value}") from exc def dedupe_keep_order(values): seen = set() result = [] for value in values: if value in seen: continue seen.add(value) result.append(value) return result def require_root(): if os.geteuid() != 0: print("[FAIL] 请使用 sudo 运行此脚本") sys.exit(1) def require_commands(): missing = [cmd for cmd in ("lsusb", "ip") if shutil.which(cmd) is None] if missing: print(f"[FAIL] 缺少系统命令: {', '.join(missing)}") sys.exit(1) def usb_device_present(): # 1. 第一次检测 lsusb,确认模块已经被系统识别。 """通过 lsusb 检查模块是否已经被系统识别。""" code, output = run_cmd(["lsusb"], timeout=10) if code != 0: return False, output for line in output.splitlines(): if USB_ID in line: return True, line.strip() return False, output def wait_for_usb_device(expected_present, timeout): """等待模块 USB 设备下线或重新上线。""" deadline = time.time() + timeout last_seen = "" while time.time() < deadline: present, detail = usb_device_present() last_seen = detail if present == expected_present: return True, detail time.sleep(CHECK_INTERVAL) return False, last_seen def wait_for_path(path, timeout): """等待串口节点或其他路径重新出现。""" deadline = time.time() + timeout while time.time() < deadline: if os.path.exists(path): return True time.sleep(1) return False def normalize_serial_output(text): """整理串口原始输出,便于后续匹配关键字。""" cleaned = text.replace("\r", "\n") return "\n".join(line for line in cleaned.splitlines() if line.strip()).strip() def serial_response_complete(text): if not text: return False for line in reversed(text.splitlines()): stripped = line.strip() if stripped == "OK": return True if "ERROR" in stripped: return True return False class RawSerialSession: """使用 Python 标准库直接控制 Linux 串口,尽量贴近 stty/raw 行为。""" def __init__(self, port, baudrate): if baudrate not in BAUD_RATE_MAP: raise RuntimeError(f"不支持的波特率: {baudrate}") self.port = port self.fd = None self._original_attrs = None try: self.fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) self._original_attrs = termios.tcgetattr(self.fd) tty.setraw(self.fd, when=termios.TCSANOW) attrs = termios.tcgetattr(self.fd) attrs[0] = 0 attrs[1] = 0 attrs[2] &= ~(termios.PARENB | termios.CSTOPB | termios.CSIZE) attrs[2] |= termios.CS8 | termios.CLOCAL | termios.CREAD attrs[3] = 0 attrs[4] = BAUD_RATE_MAP[baudrate] attrs[5] = BAUD_RATE_MAP[baudrate] attrs[6][termios.VMIN] = 0 attrs[6][termios.VTIME] = 0 termios.tcsetattr(self.fd, termios.TCSANOW, attrs) termios.tcflush(self.fd, termios.TCIOFLUSH) except OSError as exc: self.close() raise RuntimeError(f"无法打开串口 {port}: {exc}") from exc @property def is_open(self): return self.fd is not None def reset_input_buffer(self): if self.fd is not None: termios.tcflush(self.fd, termios.TCIFLUSH) def reset_output_buffer(self): if self.fd is not None: termios.tcflush(self.fd, termios.TCOFLUSH) def write(self, data): if self.fd is None: raise OSError("串口未打开") sent = 0 while sent < len(data): try: written = os.write(self.fd, data[sent:]) except BlockingIOError: time.sleep(SERIAL_POLL_INTERVAL) continue if written <= 0: raise OSError("串口写入返回 0 字节") sent += written def flush(self): if self.fd is not None: termios.tcdrain(self.fd) def read_chunk(self, timeout, size=4096): if self.fd is None: return b"" ready, _, _ = select.select([self.fd], [], [], timeout) if not ready: return b"" try: return os.read(self.fd, size) except BlockingIOError: return b"" def close(self): if self.fd is None: return fd = self.fd self.fd = None if self._original_attrs is not None: try: termios.tcsetattr(fd, termios.TCSANOW, self._original_attrs) except termios.error: pass os.close(fd) def read_serial_output(session, timeout, allow_disconnect=False): """在给定时间窗口内读取 AT 响应,直到出现结束标记或超时。""" deadline = time.time() + timeout chunks = [] saw_terminal_line = False last_data_time = None while time.time() < deadline: try: chunk = session.read_chunk(timeout=min(SERIAL_READ_TIMEOUT, max(deadline - time.time(), 0))) except OSError as exc: if allow_disconnect and exc.errno in (errno.EIO, errno.ENODEV, errno.EBADF): break raise RuntimeError(f"读取串口响应失败: {exc}") from exc if chunk: chunks.append(chunk.decode(errors="ignore")) last_data_time = time.time() current_text = normalize_serial_output("".join(chunks)) if serial_response_complete(current_text): saw_terminal_line = True continue if saw_terminal_line and last_data_time is not None and time.time() - last_data_time >= SERIAL_SETTLE_DELAY: break time.sleep(SERIAL_POLL_INTERVAL) return normalize_serial_output("".join(chunks)) def open_serial_session(port): """打开 AT 串口会话,后续在同一连接里顺序发送多条命令。""" ser = RawSerialSession(port=port, baudrate=DEFAULT_BAUD_RATE) time.sleep(0.2) ser.reset_input_buffer() ser.reset_output_buffer() return ser def execute_serial_step(ser, command, expect=None, timeout=3, allow_disconnect=False): """在当前串口会话里发送一条 AT 命令并校验响应。""" print(f"[AT] {command}") try: ser.reset_input_buffer() ser.write((command + "\r").encode()) ser.flush() except OSError as exc: raise RuntimeError(f"AT 命令 `{command}` 发送失败: {exc}") from exc response = read_serial_output(ser, timeout=timeout, allow_disconnect=allow_disconnect) if response: print(response) else: print("(无响应)") if "ERROR" in response: raise RuntimeError(f"AT 命令 `{command}` 执行失败: {response}") if expect and expect not in response and not allow_disconnect: raise RuntimeError(f"AT 命令 `{command}` 响应异常: {response or '空响应'}") return response def synchronize_at_channel(ser): """某些模组 AT 口在刚打开时需要先用 AT 做一次预热。""" last_error = None for attempt in range(1, AT_SYNC_RETRIES + 1): try: print(f"[INFO] 预热 AT 通道,第 {attempt} 次") response = execute_serial_step(ser, "AT", expect="OK", timeout=AT_SYNC_TIMEOUT) if "OK" in response: return except RuntimeError as exc: last_error = exc time.sleep(0.5) if last_error is not None: raise RuntimeError( "AT 通道预热失败,请确认串口是否是 AT 命令口,例如 /dev/ttyUSB2" ) from last_error raise RuntimeError("AT 通道预热失败") def run_serial_steps(port, steps): """在同一个串口会话里顺序执行多条 AT 命令。""" ser = None try: ser = open_serial_session(port) synchronize_at_channel(ser) for step in steps: execute_serial_step( ser, step["command"], expect=step.get("expect"), timeout=step.get("timeout", 3), allow_disconnect=step.get("allow_disconnect", False), ) finally: if ser is not None and ser.is_open: ser.close() def configure_rndis(port): # 2. 用 Python 串口库在同一会话里顺序执行拨号相关 AT 命令。 """切换到 RNDIS 模式并触发模块重启。""" if not wait_for_path(port, timeout=30): raise RuntimeError(f"串口不存在: {port}") print(f"[OK] 串口已打开: {port}") run_serial_steps( port, [ {"command": "AT+CPIN?", "expect": "READY", "timeout": 4}, {"command": 'AT+QCFG="usbnet",3', "expect": "OK", "timeout": 5}, {"command": "AT+CFUN=1,1", "timeout": 4, "allow_disconnect": True}, ], ) def get_interfaces(): """列出当前系统中的接口,过滤明显无关的本地接口。""" interfaces = [] try: for name in os.listdir("/sys/class/net"): if name in SKIP_INTERFACES or is_usb_gadget(name): continue interfaces.append(name) except FileNotFoundError: return [] return sorted(interfaces) def is_usb_gadget(iface): """过滤 Jetson 自己暴露出去的 gadget 网卡。""" sysfs_path = f"/sys/class/net/{iface}" if not os.path.exists(sysfs_path): return False return "/gadget/" in os.path.realpath(sysfs_path) def is_usb_network_interface(iface): """判断接口是否来自 USB 设备。""" device_path = f"/sys/class/net/{iface}/device" if not os.path.exists(device_path): return False real_path = os.path.realpath(device_path) return "/usb" in real_path def get_ipv4_addrs(): """返回所有接口的 IPv4/CIDR 信息。""" code, output = run_cmd(["ip", "-o", "-4", "addr", "show"], timeout=10) if code != 0: return {} ipv4_addrs = {} for line in output.splitlines(): parts = line.split() if len(parts) >= 4: iface = parts[1] ipv4_addrs.setdefault(iface, []).append(parts[3]) return ipv4_addrs def get_ipv6_addrs(): """返回所有接口的 IPv6/CIDR 信息。""" code, output = run_cmd(["ip", "-o", "-6", "addr", "show"], timeout=10) if code != 0: return {} ipv6_addrs = {} for line in output.splitlines(): parts = line.split() if len(parts) >= 4: iface = parts[1] ipv6_addrs.setdefault(iface, []).append(parts[3]) return ipv6_addrs def interface_priority(iface): if iface.startswith("wwan"): return 0 if iface.startswith("enx"): return 1 if iface.startswith("usb"): return 2 return 10 def list_usb_network_candidates(explicit_iface=None): """列出拨号前可尝试的 USB 网卡候选项。 这里不靠固定网口名确认 5G 模组,只是在还没有 IP 的时候先缩小范围。 真正确认模组接口,会在 DHCP 之后根据 IP 网段判断。 """ candidates = [] for iface in get_interfaces(): if explicit_iface and iface != explicit_iface: continue if not is_usb_network_interface(iface): continue candidates.append((interface_priority(iface), iface)) if not candidates: return [] candidates.sort() return [iface for _, iface in candidates] def ip_in_subnet(ip_cidr, subnet): """判断接口地址是否落在指定网段内。""" try: return ipaddress.ip_interface(ip_cidr).ip in ipaddress.ip_network(subnet, strict=False) except ValueError: return False def find_interface_by_subnet(modem_subnet, explicit_iface=None): """拨号成功后,通过 IP 网段确认 5G 模组网卡。""" candidates = [] for iface, addrs in get_ipv4_addrs().items(): if iface in SKIP_INTERFACES or is_usb_gadget(iface): continue if not is_usb_network_interface(iface): continue if explicit_iface and iface != explicit_iface: continue matched_addrs = [addr for addr in addrs if ip_in_subnet(addr, modem_subnet)] if matched_addrs: candidates.append((interface_priority(iface), iface, matched_addrs)) if not candidates: return None, [] candidates.sort() _, iface, matched_addrs = candidates[0] return iface, matched_addrs def wait_for_usb_candidates(explicit_iface=None, timeout=90): """等待模块枚举出 USB 网卡候选项。""" deadline = time.time() + timeout while time.time() < deadline: candidates = list_usb_network_candidates(explicit_iface=explicit_iface) if candidates: return candidates time.sleep(CHECK_INTERVAL) return [] def bring_interface_up(iface): code, output = run_cmd(["ip", "link", "set", "dev", iface, "up"], timeout=10) if code != 0: raise RuntimeError(f"拉起网卡失败: {iface}\n{output}") def renew_dhcp(iface): dhclient = shutil.which("dhclient") udhcpc = shutil.which("udhcpc") if dhclient: print(f"[INFO] 使用 dhclient 为 {iface} 获取 IP") code, output = run_cmd(["dhclient", "-1", "-v", iface], timeout=45) return code == 0, output if udhcpc: print(f"[INFO] 使用 udhcpc 为 {iface} 获取 IP") code, output = run_cmd(["udhcpc", "-n", "-q", "-i", iface], timeout=45) return code == 0, output return False, "系统中未找到 dhclient 或 udhcpc" def get_default_routes(iface): code, output = run_cmd(["ip", "-o", "route", "show", "default", "dev", iface], timeout=10) if code != 0: return [] return [line.strip() for line in output.splitlines() if line.strip()] def resolve_gateway(iface, fallback_gateway): for route in get_default_routes(iface): tokens = route.split() for index, token in enumerate(tokens[:-1]): if token == "via": gateway = tokens[index + 1] print(f"[INFO] 从默认路由检测到 {iface} 网关: {gateway}") return gateway print(f"[INFO] 未从默认路由检测到 {iface} 网关,回退到 {fallback_gateway}") return fallback_gateway def delete_default_routes(iface): removed = 0 while True: routes = get_default_routes(iface) if not routes: return removed deleted_this_round = False for route in routes: cmd = ["ip", "route", "del", *route.split()] code, output = run_cmd(cmd, timeout=10) if code != 0: code, output = run_cmd(["ip", "route", "del", "default", "dev", iface], timeout=10) if code != 0: raise RuntimeError(f"删除默认路由失败: {iface}\n{output}") removed += 1 deleted_this_round = True if not deleted_this_round: raise RuntimeError(f"未能删除 {iface} 的默认路由") def install_host_routes(iface, gateway, targets): for target in dedupe_keep_order(targets): cmd = ["ip", "route", "replace", f"{target}/32", "via", gateway, "dev", iface] code, output = run_cmd(cmd, timeout=10) if code != 0: raise RuntimeError(f"添加主机路由失败: {target} via {gateway} dev {iface}\n{output}") print(f"[OK] 已添加主机路由: {target}/32 via {gateway} dev {iface}") def enforce_route_policy(iface, fallback_gateway, route_targets): gateway = resolve_gateway(iface, fallback_gateway) removed = delete_default_routes(iface) print(f"[OK] 已删除 {iface} 上的 {removed} 条默认路由") if route_targets: install_host_routes(iface, gateway, route_targets) else: print(f"[WARN] {iface} 未配置任何主机路由目标,5G 将不再承载公网流量") def ensure_ipv4(iface): """为指定接口申请 IPv4 地址。""" ipv4_addrs = get_ipv4_addrs().get(iface, []) if ipv4_addrs: return ipv4_addrs bring_interface_up(iface) ok, output = renew_dhcp(iface) if output: print(output) if not ok: return [] return get_ipv4_addrs().get(iface, []) def acquire_modem_interface(modem_subnet, explicit_iface=None): """通过 DHCP + IP 网段识别真正的模组接口。""" iface, matched_addrs = find_interface_by_subnet( modem_subnet, explicit_iface=explicit_iface, ) if iface: return iface, matched_addrs candidates = list_usb_network_candidates(explicit_iface=explicit_iface) if not candidates: raise RuntimeError("未找到可尝试 DHCP 的 USB 网卡候选项") print(f"[INFO] 当前 USB 网卡候选项: {', '.join(candidates)}") for iface in candidates: print(f"[INFO] 尝试为 {iface} 获取 IPv4") ensure_ipv4(iface) matched_iface, matched_addrs = find_interface_by_subnet( modem_subnet, explicit_iface=explicit_iface, ) if matched_iface: return matched_iface, matched_addrs return None, [] def print_interface_status(iface): # 3. 拨号成功后,打印 ip/ifconfig,确认模组网口和地址。 print(f"[OK] 检测到 5G 网卡: {iface}") code, output = run_cmd(["ip", "-4", "addr", "show", "dev", iface], timeout=10) if code == 0 and output: print(output) if shutil.which("ifconfig"): code, ifconfig_output = run_cmd(["ifconfig", iface], timeout=10) if code == 0 and ifconfig_output: print("\n===== ifconfig =====") print(ifconfig_output) def save_interface_info(iface, output_file=DEFAULT_INFO_JSON): """把网口名称、IPv4、IPv6 保存到 JSON 文件。""" data = { "interface": iface, "ipv4": get_ipv4_addrs().get(iface, []), "ipv6": get_ipv6_addrs().get(iface, []), } with open(output_file, "w", encoding="utf-8") as json_file: json.dump(data, json_file, ensure_ascii=False, indent=2) print(f"[OK] 网口信息已保存到 {output_file}") def ping_target(iface, target, count=3, timeout=15): """通过指定网口 ping 一个目标。""" code, output = run_cmd( ["ping", "-I", iface, "-c", str(count), "-W", "3", target], timeout=timeout, ) return code == 0, output def print_ping_summary(output): """只打印 ping 的关键结果。""" for line in output.splitlines(): if "packets transmitted" in line or "rtt " in line or "Destination " in line: print(line) def verify_connectivity(iface, gateway=DEFAULT_MODEM_GATEWAY, targets=DEFAULT_PUBLIC_TARGETS, retry_interval=3, max_wait=45): # 4. 最后先 ping 模组网关,再重试公网连通性。 """先测模组网关,再轮询公网目标地址。""" ok, output = ping_target(iface, gateway, count=3, timeout=15) if ok: print(f"[OK] {iface} 可到达模组网关 {gateway}") print_ping_summary(output) else: print(f"[WARN] {iface} 无法到达模组网关 {gateway}") if output: print(output) return False deadline = time.time() + max_wait attempt = 1 while True: for target in targets: ok, output = ping_target(iface, target, count=3, timeout=15) if ok: print(f"[OK] {iface} 可通过 {target}") print_ping_summary(output) return True print(f"[WARN] 第 {attempt} 次 Ping {target} 失败") if output: print_ping_summary(output) if time.time() >= deadline: print(f"[WARN] {iface} 在 {max_wait} 秒内仍无法连通 {', '.join(targets)}") return False attempt += 1 time.sleep(retry_interval) def ping_via_interface(iface, targets=DEFAULT_PUBLIC_TARGETS): """保留原调用点,内部走完整连通性检查。""" return verify_connectivity(iface, targets=targets) def parse_args(): parser = argparse.ArgumentParser(description="RM520N-GL RNDIS 自动拨号脚本") parser.add_argument( "--serial-port", default=DEFAULT_SERIAL_PORT, help=f"AT 串口路径,默认 {DEFAULT_SERIAL_PORT}", ) parser.add_argument( "--interface", help="指定期望的 5G 网卡名,例如 eth0", ) parser.add_argument( "--modem-subnet", default=DEFAULT_MODEM_SUBNET, help=f"拨号成功后用于识别模组接口的 IPv4 网段,默认 {DEFAULT_MODEM_SUBNET}", ) parser.add_argument( "--gateway", type=parse_ipv4_address, default=DEFAULT_MODEM_GATEWAY, help=f"5G 模组网关地址,默认 {DEFAULT_MODEM_GATEWAY}", ) parser.add_argument( "--skip-dhcp", action="store_true", help="只等待 USB 网卡出现,不主动申请 IPv4", ) parser.add_argument( "--remove-default-route", action="store_true", help="拨号成功后删除 5G 接口上的默认路由,只保留显式主机路由", ) parser.add_argument( "--route-target", action="append", default=[], type=parse_ipv4_address, help="拨号完成后通过 5G 接口保留的 IPv4 主机路由目标,可重复传入", ) return parser.parse_args() def main(): args = parse_args() require_root() require_commands() print("===== RM520N-GL RNDIS 自动拨号 =====") print(f"[INFO] 目标模组网段: {args.modem_subnet}") #1.检测 lsusb,确认是否识别到模块 present, detail = usb_device_present() if not present: print(f"[FAIL] 未检测到模块 USB 设备 {USB_ID}") if detail: print(detail) sys.exit(1) print(f"[OK] 检测到 USB 设备: {detail}") print(f"[INFO] 使用 AT 口: {args.serial_port}") #2.进行 Python 串口拨号 try: configure_rndis(args.serial_port) print("[INFO] 已发送 AT+CFUN=1,1,等待模块重启") disappeared, _ = wait_for_usb_device(expected_present=False, timeout=25) if disappeared: print("[OK] 模块已下线,继续等待重新枚举") else: print("[WARN] 未观察到模块下线,继续等待重新枚举") reappeared, detail = wait_for_usb_device(expected_present=True, timeout=90) if not reappeared: print(f"[FAIL] 模块重启后未重新枚举: {USB_ID}") sys.exit(1) print(f"[OK] 模块已重新枚举: {detail}") candidates = wait_for_usb_candidates(explicit_iface=args.interface, timeout=90) if not candidates: print("[FAIL] 未检测到 5G 模组枚举出的 USB 网卡") sys.exit(1) if args.skip_dhcp: print(f"[INFO] 当前 USB 网卡候选项: {', '.join(candidates)}") iface, ipv4_addrs = find_interface_by_subnet( args.modem_subnet, explicit_iface=args.interface, ) if not iface: print(f"[WARN] 当前还没有接口拿到目标网段 {args.modem_subnet} 的地址") sys.exit(1) else: iface, ipv4_addrs = acquire_modem_interface( args.modem_subnet, explicit_iface=args.interface, ) if not iface: print(f"[FAIL] 未找到落在目标网段 {args.modem_subnet} 内的模组接口") sys.exit(1) print_interface_status(iface) if ipv4_addrs: for addr in ipv4_addrs: print(f"[OK] {iface} 已获取 IPv4: {addr}") save_interface_info(iface) route_targets = dedupe_keep_order(args.route_target) if args.remove_default_route: enforce_route_policy(iface, args.gateway, route_targets) connectivity_targets = route_targets or list(DEFAULT_PUBLIC_TARGETS) ping_via_interface(iface, targets=connectivity_targets) print(f"[DONE] RNDIS 拨号完成,可执行: sudo python3 speed_test.py {iface}") return print(f"[WARN] {iface} 已出现,但还没有 IPv4 地址") print(f"[INFO] 可手动检查: ip addr show {iface}") sys.exit(1) except (RuntimeError, subprocess.TimeoutExpired) as exc: print(f"[FAIL] {exc}") sys.exit(1) if __name__ == "__main__": main()