mirror of
https://github.com/commaai/agnos-builder.git
synced 2026-10-28 00:53:48 +08:00
642 lines
21 KiB
Python
642 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
# Source: commaai/openpilot@6b592ae2ccf68d1b3b89a8c544b12ecb074f3a8f
|
|
# openpilot/common/hardware/comma/modem.py; Webbing ICCID prefix inlined.
|
|
# Copyright (c) 2018, Comma.ai, Inc.
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
import fcntl
|
|
import json
|
|
import logging
|
|
import os
|
|
import select
|
|
import signal
|
|
import string
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import termios
|
|
import time
|
|
|
|
from contextlib import contextmanager
|
|
from ipaddress import IPv4Address, AddressValueError
|
|
|
|
from enum import Enum
|
|
|
|
WEBBING_ICCID_PREFIX = "8985235"
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s.%(msecs)03d %(levelname)-7s modem: %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
|
|
AT_PORT = "/dev/modem_at0"
|
|
PPP_PORT = "/dev/modem_at1"
|
|
STATE_PATH = "/dev/shm/modem"
|
|
AT_LOCK = "/dev/shm/modem.lock" # shared with LPA
|
|
AT_INIT = [
|
|
"ATE0", # disable command echo
|
|
"ATV1", # verbose result codes (CONNECT/BUSY/NO CARRIER, not numeric)
|
|
"AT+CMEE=1", # numeric +CME ERROR codes on failures (per 3GPP 27.007)
|
|
"ATX4", # extended result codes: busy + dial tone detection, line speed in CONNECT
|
|
"AT&C1", # DCD pin follows carrier state (V.250 default)
|
|
"AT+CREG=2", # registration URCs include location info
|
|
"AT+CGREG=2", # GPRS registration URCs include location info
|
|
]
|
|
CREG = {0: "not_registered", 1: "home", 2: "searching", 3: "denied", 4: "unknown", 5: "roaming"}
|
|
# 3GPP TS 27.007 +COPS <AcT> -> network type
|
|
NETWORK_TYPE = {0: "gsm", 1: "gsm", 3: "gsm", 8: "gsm",
|
|
2: "utran", 4: "utran", 5: "utran", 6: "utran",
|
|
7: "lte", 9: "lte", 10: "lte",
|
|
11: "nr", 12: "nr", 13: "nr"}
|
|
|
|
DIAL_CID = 1
|
|
|
|
PPPD_CMD = [
|
|
"sudo", "pppd", PPP_PORT, "460800", "noauth", "nodetach", "noipdefault", "usepeerdns",
|
|
"nodefaultroute", "connect",
|
|
"/usr/sbin/chat -v ABORT 'NO CARRIER' ABORT 'NO DIALTONE' ABORT 'BUSY' " +
|
|
f"ABORT 'NO ANSWER' ABORT 'ERROR' TIMEOUT 5 '' AT OK ATD*99***{DIAL_CID}# CONNECT ''",
|
|
"lcp-echo-interval", "30", "lcp-echo-failure", "4", "mtu", "1500", "mru", "1500",
|
|
"novj", "novjccomp", "ipcp-accept-local", "ipcp-accept-remote", "nomagic",
|
|
"user", '""', "password", '""',
|
|
]
|
|
INITIAL_STATE: dict[str, object] = {
|
|
"seconds_since_boot": 0,
|
|
"state": "INITIALIZING",
|
|
"connected": False, "ip_address": "",
|
|
"iccid": "", "mcc_mnc": "", "imei": "", "modem_version": "",
|
|
"signal_strength": 0, "signal_quality": 0,
|
|
"network_type": "unknown", "operator": "", "band": "", "channel": 0,
|
|
"registration": "unknown", "temperatures": [], "extra": "",
|
|
"tx_bytes": 0, "rx_bytes": 0,
|
|
}
|
|
|
|
|
|
@contextmanager
|
|
def _serial_port(port: str, baudrate: int):
|
|
fd = os.open(port, os.O_RDWR | os.O_NOCTTY)
|
|
try:
|
|
attrs = termios.tcgetattr(fd)
|
|
attrs[0] = 0
|
|
attrs[1] = 0
|
|
attrs[2] = termios.CLOCAL | termios.CREAD | termios.CS8
|
|
attrs[3] = 0
|
|
attrs[4] = attrs[5] = getattr(termios, f"B{baudrate}")
|
|
attrs[6][termios.VMIN] = 0
|
|
attrs[6][termios.VTIME] = 0
|
|
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
|
yield fd
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _read_line(fd: int, timeout: float) -> bytes:
|
|
data = bytearray()
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
readable, _, _ = select.select([fd], [], [], max(0.0, deadline - time.monotonic()))
|
|
if not readable:
|
|
return bytes(data)
|
|
byte = os.read(fd, 1)
|
|
if not byte:
|
|
return bytes(data)
|
|
data.extend(byte)
|
|
if byte == b"\n":
|
|
return bytes(data)
|
|
|
|
|
|
class State(Enum):
|
|
INITIALIZING = "INITIALIZING"
|
|
SEARCHING = "SEARCHING"
|
|
CONNECTING = "CONNECTING"
|
|
CONNECTED = "CONNECTED"
|
|
DISCONNECTING = "DISCONNECTING"
|
|
|
|
|
|
STATE_WAIT = 1.0 # seconds to wait after each state handler returns
|
|
|
|
|
|
class PPPSession:
|
|
"""Owns pppd lifecycle, fail tracking, and PPP routing."""
|
|
MAX_FAILS = 3
|
|
|
|
def __init__(self):
|
|
self._proc: subprocess.Popen | None = None
|
|
self._fails = 0
|
|
self._peer = ""
|
|
|
|
def start(self):
|
|
self._proc = subprocess.Popen(PPPD_CMD, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
self._peer = ""
|
|
logging.info(f"PPP dialing CID {DIAL_CID}")
|
|
|
|
def kill(self):
|
|
subprocess.run(["sudo", "killall", "-9", "pppd"], capture_output=True)
|
|
self._peer = ""
|
|
|
|
@staticmethod
|
|
def reset_data_port():
|
|
"""Drop DTR on PPP_PORT so the modem terminates any stuck PPP session."""
|
|
try:
|
|
with _serial_port(PPP_PORT, 460800) as fd:
|
|
dtr = struct.pack("I", termios.TIOCM_DTR)
|
|
fcntl.ioctl(fd, termios.TIOCMBIC, dtr)
|
|
time.sleep(0.2)
|
|
fcntl.ioctl(fd, termios.TIOCMBIS, dtr)
|
|
except Exception as e:
|
|
logging.warning(f"data port reset failed: {e}")
|
|
|
|
def has_exited(self) -> bool:
|
|
return self._proc is not None and self._proc.poll() is not None
|
|
|
|
def reset_fail_counter(self):
|
|
self._fails = 0
|
|
|
|
def record_fail(self) -> bool:
|
|
"""Bump fail counter; return True if at the give-up limit."""
|
|
self._fails += 1
|
|
return self._fails >= self.MAX_FAILS
|
|
|
|
@property
|
|
def fails(self) -> int:
|
|
return self._fails
|
|
|
|
def maybe_install_routes(self, ip: str, peer: str) -> bool:
|
|
"""Install routes if peer changed; kill the session on failure so the state machine reconnects."""
|
|
if not peer or peer == self._peer:
|
|
return False
|
|
try:
|
|
IPv4Address(ip)
|
|
IPv4Address(peer)
|
|
except AddressValueError:
|
|
logging.warning(f"refusing route install with non-IPv4 ip={ip!r} peer={peer!r}")
|
|
self.kill()
|
|
return False
|
|
self.cleanup_routes()
|
|
cmds = [
|
|
["sudo", "ip", "route", "add", "default", "via", peer, "dev", "ppp0", "metric", "1000"],
|
|
["sudo", "ip", "route", "add", "default", "via", peer, "dev", "ppp0", "table", "1000"],
|
|
["sudo", "ip", "rule", "add", "from", ip, "table", "1000"],
|
|
]
|
|
for cmd in cmds:
|
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
logging.warning(f"route install failed ({' '.join(cmd[1:])}): {r.stderr.strip()}")
|
|
self.cleanup_routes()
|
|
self.kill()
|
|
return False
|
|
logging.info(f"route set up for {ip} via {peer}")
|
|
self._peer = peer
|
|
return True
|
|
|
|
def maybe_install_dns(self, dns_servers: list[str]) -> bool:
|
|
"""Register DNS servers with systemd-resolved; kill the session on failure to force a retry."""
|
|
if not dns_servers:
|
|
return False
|
|
for cmd in (["sudo", "resolvectl", "dns", "ppp0", *dns_servers],
|
|
["sudo", "resolvectl", "default-route", "ppp0", "yes"]):
|
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
logging.warning(f"resolvectl failed ({' '.join(cmd[1:])}): {r.stderr.strip()}")
|
|
self.kill()
|
|
return False
|
|
logging.info(f"resolvectl: ppp0 DNS = {dns_servers}")
|
|
return True
|
|
|
|
@staticmethod
|
|
def cleanup_routes():
|
|
subprocess.run(["sudo", "ip", "route", "del", "default", "dev", "ppp0"], capture_output=True)
|
|
subprocess.run(["sudo", "ip", "route", "flush", "table", "1000"], capture_output=True)
|
|
# rules don't have a flush; delete until none remain
|
|
while subprocess.run(["sudo", "ip", "rule", "del", "table", "1000"], capture_output=True).returncode == 0:
|
|
pass
|
|
subprocess.run(["sudo", "resolvectl", "revert", "ppp0"], capture_output=True)
|
|
|
|
|
|
class Modem:
|
|
def __init__(self):
|
|
self._ppp = PPPSession()
|
|
self._sim_change = False
|
|
self._apn = "" # blank = network-provided via PCO
|
|
self._roaming_allowed = True
|
|
self.running = True
|
|
self.S = INITIAL_STATE.copy()
|
|
|
|
@staticmethod
|
|
def _read_param(key):
|
|
try:
|
|
with open(f"/data/params/d/{key}") as f:
|
|
return f.read().strip()
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _parse_reg(v: str) -> str:
|
|
try:
|
|
return CREG.get(int(v.split(",")[1].strip('"')), "unknown")
|
|
except (ValueError, IndexError):
|
|
return "unknown"
|
|
|
|
@staticmethod
|
|
def _has_modem_manager() -> bool:
|
|
return os.path.isfile("/lib/systemd/system/ModemManager.service")
|
|
|
|
def _is_roaming_allowed(self) -> bool:
|
|
if self.S["iccid"].startswith(WEBBING_ICCID_PREFIX):
|
|
return True
|
|
return self._read_param("GsmRoaming") == "1"
|
|
|
|
def _publish_state(self, **kwargs):
|
|
self.S.update(kwargs)
|
|
self.S["seconds_since_boot"] = time.monotonic()
|
|
with tempfile.NamedTemporaryFile(mode="w", dir="/dev/shm", delete=False) as f:
|
|
json.dump(self.S, f, indent=2)
|
|
os.chmod(f.name, 0o644)
|
|
os.replace(f.name, STATE_PATH)
|
|
|
|
def _at(self, cmd):
|
|
"""Send AT command, return response lines. [] on error or if LPA holds port."""
|
|
fd = os.open(AT_LOCK, os.O_CREAT | os.O_RDWR, 0o666)
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
os.close(fd)
|
|
return []
|
|
try:
|
|
with _serial_port(AT_PORT, 9600) as serial_fd:
|
|
termios.tcflush(serial_fd, termios.TCIFLUSH)
|
|
command = (cmd + "\r").encode()
|
|
while command:
|
|
command = command[os.write(serial_fd, command):]
|
|
lines = []
|
|
while True:
|
|
raw = _read_line(serial_fd, 5)
|
|
if not raw:
|
|
raise TimeoutError("AT timeout")
|
|
line = raw.decode(errors="ignore").strip()
|
|
if not line:
|
|
continue
|
|
if line == "OK":
|
|
break
|
|
if line == "ERROR" or line.startswith("+CME ERROR"):
|
|
raise RuntimeError(line)
|
|
lines.append(line)
|
|
return lines
|
|
except (RuntimeError, TimeoutError, OSError) as e:
|
|
logging.info(f"AT {cmd} failed: {e}")
|
|
return []
|
|
finally:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
os.close(fd)
|
|
|
|
def _atv(self, cmd, pfx):
|
|
for line in self._at(cmd):
|
|
if pfx in line and ":" in line:
|
|
return line.split(":", 1)[1].strip()
|
|
return None
|
|
|
|
def _init_at_channel(self) -> bool:
|
|
"""Run AT_INIT and confirm ATE0 took effect. Returns False if echo is still on."""
|
|
for c in AT_INIT:
|
|
self._at(c)
|
|
r = self._at("AT+CGMI")
|
|
return bool(r) and not r[0].startswith("AT")
|
|
|
|
def _configure_modem(self, modem_version: str):
|
|
if not modem_version.startswith("EG25"):
|
|
return
|
|
cmds = [
|
|
# clear initial EPS bearer APN (some carriers reject the default)
|
|
'AT+CGDCONT=0,"IP",""',
|
|
|
|
# SIM hot swap
|
|
'AT+QSIMDET=1,0',
|
|
'AT+QSIMSTAT=1',
|
|
|
|
# configure modem as data-centric
|
|
'AT+QNVW=5280,0,"0102000000000000"',
|
|
'AT+QNVFW="/nv/item_files/ims/IMS_enable",00',
|
|
'AT+QNVFW="/nv/item_files/modem/mmode/ue_usage_setting",01',
|
|
]
|
|
for c in cmds:
|
|
self._at(c)
|
|
|
|
def _do_initializing(self):
|
|
if not os.path.exists(AT_PORT):
|
|
return State.INITIALIZING
|
|
logging.info("port found, initializing")
|
|
self._ppp.kill()
|
|
self._ppp.cleanup_routes()
|
|
|
|
if not self._init_at_channel():
|
|
logging.warning("AT echo still on, retrying")
|
|
return State.INITIALIZING
|
|
|
|
identity = self._read_identity()
|
|
if not identity["iccid"] or not identity["imei"]:
|
|
logging.warning(f"identity read incomplete: {identity}, retrying")
|
|
return State.INITIALIZING
|
|
|
|
self._configure_modem(identity["modem_version"])
|
|
|
|
self.S.update(identity)
|
|
self._apn = self._read_param("GsmApn")
|
|
self._roaming_allowed = self._is_roaming_allowed()
|
|
# blank APN lets the carrier supply one via PCO
|
|
self._at(f'AT+CGDCONT={DIAL_CID},"IP","{self._apn}"')
|
|
logging.info(f"APN '{self._apn or '(network-provided)'}' written to CID {DIAL_CID}, roaming={'on' if self._roaming_allowed else 'off'}")
|
|
|
|
self._sim_change = False # clear since we just re-read identity with the new SIM
|
|
self._publish_state(**identity)
|
|
return State.SEARCHING
|
|
|
|
def _read_identity(self):
|
|
def first_line(cmd):
|
|
r = self._at(cmd)
|
|
return r[0].strip() if r else ""
|
|
|
|
imei = first_line("AT+CGSN")
|
|
if not (imei.isdigit() and 14 <= len(imei) <= 17): # 3GPP TS 23.003
|
|
imei = ""
|
|
|
|
iccid = (self._atv("AT+QCCID", "+QCCID:") or "").rstrip("F")
|
|
if not all(c in string.hexdigits for c in iccid):
|
|
iccid = ""
|
|
|
|
imsi = first_line("AT+CIMI")
|
|
mcc_mnc = imsi[:6] if imsi.isdigit() and len(imsi) >= 6 else ""
|
|
|
|
modem_version = first_line("AT+GMR")
|
|
|
|
logging.info(f"imei={imei} iccid={iccid} mcc_mnc={mcc_mnc} ver={modem_version}")
|
|
return {"imei": imei, "iccid": iccid, "mcc_mnc": mcc_mnc, "modem_version": modem_version}
|
|
|
|
def _do_searching(self):
|
|
new_roaming = self._is_roaming_allowed()
|
|
if new_roaming != self._roaming_allowed:
|
|
logging.info(f"roaming changed: {self._roaming_allowed} -> {new_roaming}")
|
|
self._roaming_allowed = new_roaming
|
|
|
|
v = self._atv("AT+CREG?", "+CREG:")
|
|
if not v:
|
|
return self._searching_idle()
|
|
|
|
reg = self._parse_reg(v)
|
|
greg = self._parse_reg(self._atv("AT+CGREG?", "+CGREG:") or "")
|
|
logging.debug(f"creg={reg} cgreg={greg} roaming_allowed={self._roaming_allowed}")
|
|
|
|
if reg == "roaming" and not self._roaming_allowed:
|
|
self._publish_state(registration=reg)
|
|
return State.SEARCHING
|
|
|
|
if reg in ("home", "roaming") and greg in ("home", "roaming"):
|
|
self._publish_state(registration=reg)
|
|
return State.CONNECTING
|
|
|
|
if reg != self.S.get("registration"):
|
|
self._publish_state(registration=reg)
|
|
return self._searching_idle()
|
|
|
|
def _searching_idle(self):
|
|
if self._sim_change or not os.path.exists(AT_PORT):
|
|
logging.info(f"-> reconnecting (sim_change={self._sim_change} port={os.path.exists(AT_PORT)})")
|
|
return State.DISCONNECTING
|
|
return State.SEARCHING
|
|
|
|
def _do_connecting(self):
|
|
logging.info("starting pppd")
|
|
self._ppp.reset_fail_counter()
|
|
self._sim_change = False
|
|
self._ppp.start()
|
|
return State.CONNECTED
|
|
|
|
def _handle_pppd_exit(self):
|
|
if self._sim_change or not os.path.exists(AT_PORT):
|
|
return State.DISCONNECTING
|
|
give_up = self._ppp.record_fail()
|
|
if give_up:
|
|
logging.warning(f"PPP fail {self._ppp.fails}/{self._ppp.MAX_FAILS}, reconnecting")
|
|
return State.DISCONNECTING
|
|
logging.warning(f"PPP fail {self._ppp.fails}/{self._ppp.MAX_FAILS}, retrying")
|
|
self._ppp.reset_data_port()
|
|
if not os.path.exists(AT_PORT):
|
|
return State.DISCONNECTING
|
|
self._ppp.start()
|
|
return State.CONNECTED
|
|
|
|
def _params_changed(self) -> bool:
|
|
new_apn = self._read_param("GsmApn")
|
|
if new_apn != self._apn:
|
|
logging.info(f"GsmApn changed: '{self._apn}' -> '{new_apn}'")
|
|
return True
|
|
new_roaming = self._is_roaming_allowed()
|
|
if new_roaming != self._roaming_allowed:
|
|
logging.info(f"roaming changed: {self._roaming_allowed} -> {new_roaming}")
|
|
return True
|
|
return False
|
|
|
|
def _check_iccid(self, state):
|
|
if state in (State.INITIALIZING, State.DISCONNECTING) or not self.S["iccid"]:
|
|
return
|
|
iccid = (self._atv("AT+QCCID", "+QCCID:") or "").rstrip("F")
|
|
if iccid and iccid != self.S["iccid"]:
|
|
logging.warning(f"iccid changed: {self.S['iccid']} -> {iccid}")
|
|
self._sim_change = True
|
|
|
|
def _do_connected(self):
|
|
if self._ppp.has_exited():
|
|
return self._handle_pppd_exit()
|
|
|
|
if self._sim_change or not os.path.exists(AT_PORT) or self._params_changed():
|
|
return State.DISCONNECTING
|
|
|
|
self._poll()
|
|
return State.CONNECTED
|
|
|
|
def _do_disconnecting(self):
|
|
logging.warning("reconnecting")
|
|
self._publish_state(**INITIAL_STATE)
|
|
self._ppp.kill()
|
|
self._ppp.cleanup_routes()
|
|
self._ppp.reset_data_port()
|
|
self._sim_change = False
|
|
return State.INITIALIZING
|
|
|
|
def _poll_signal(self) -> dict:
|
|
v = self._atv("AT+CSQ", "+CSQ:")
|
|
if not v:
|
|
return {}
|
|
try:
|
|
rssi = int(v.split(",")[0])
|
|
if rssi == 99:
|
|
return {}
|
|
return {"signal_strength": rssi, "signal_quality": min(100, int(rssi / 31 * 100))}
|
|
except (ValueError, IndexError):
|
|
return {}
|
|
|
|
def _poll_operator(self) -> dict:
|
|
v = self._atv("AT+COPS?", "+COPS:")
|
|
if not v:
|
|
return {}
|
|
p = v.split(",")
|
|
out: dict = {}
|
|
try:
|
|
if len(p) >= 3:
|
|
out["operator"] = p[2].strip('"')
|
|
if len(p) >= 4:
|
|
out["network_type"] = NETWORK_TYPE.get(int(p[3]), "unknown")
|
|
except (ValueError, IndexError):
|
|
pass
|
|
return out
|
|
|
|
def _poll_band(self) -> dict:
|
|
v = self._atv("AT+QNWINFO", "+QNWINFO:")
|
|
if not v:
|
|
return {}
|
|
info = v.replace('"', '').split(",")
|
|
try:
|
|
if len(info) >= 4:
|
|
return {"band": info[2], "channel": int(info[3])}
|
|
except ValueError:
|
|
pass
|
|
return {}
|
|
|
|
def _poll_extra(self) -> dict:
|
|
v = self._atv('AT+QENG="servingcell"', "+QENG:")
|
|
return {"extra": v.replace('"', '')} if v else {}
|
|
|
|
def _poll_temps(self) -> dict:
|
|
v = self._atv("AT+QTEMP", "+QTEMP:")
|
|
if not v:
|
|
return {}
|
|
try:
|
|
return {"temperatures": [t for t in (int(x) for x in v.split(",") if x.strip()) if t != 255]}
|
|
except (ValueError, IndexError):
|
|
return {}
|
|
|
|
def _poll_iface(self) -> dict:
|
|
try:
|
|
r = subprocess.run(["ip", "-4", "addr", "show", "ppp0"], capture_output=True, text=True, timeout=2)
|
|
ip, peer = "", ""
|
|
for line in r.stdout.splitlines():
|
|
# `inet 10.x.x.x peer 10.64.64.64/32 ...`
|
|
parts = line.strip().split()
|
|
if "inet" in parts:
|
|
i = parts.index("inet")
|
|
ip = parts[i + 1].split("/")[0]
|
|
if "peer" in parts:
|
|
peer = parts[parts.index("peer") + 1].split("/")[0]
|
|
break
|
|
if ip:
|
|
if self._ppp.maybe_install_routes(ip, peer):
|
|
self._ppp.maybe_install_dns(self._read_cellular_dns())
|
|
return {"ip_address": ip, "connected": True}
|
|
if self.S["connected"]:
|
|
return {"connected": False, "ip_address": ""}
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
def _read_cellular_dns(self) -> list[str]:
|
|
v = self._atv(f"AT+CGCONTRDP={DIAL_CID}", "+CGCONTRDP:")
|
|
if not v:
|
|
return []
|
|
# +CGCONTRDP: <cid>,<bearer_id>,<apn>,<local_addr>,<gw_addr>,<dns_prim>,<dns_sec>,...
|
|
fields = [f.strip().strip('"') for f in v.split(",")]
|
|
dns_servers = []
|
|
for d in fields[5:7]:
|
|
try:
|
|
dns_servers.append(str(IPv4Address(d)))
|
|
except (AddressValueError, ValueError):
|
|
pass
|
|
if not dns_servers:
|
|
dns_servers = [
|
|
"8.8.8.8", # Google
|
|
"1.1.1.1", # Cloudflare
|
|
]
|
|
logging.warning(f"no cellular DNS servers reported by modem: {v!r}; using fallback {dns_servers}")
|
|
return dns_servers
|
|
|
|
def _poll_byte_counters(self) -> dict:
|
|
try:
|
|
with open("/sys/class/net/ppp0/statistics/tx_bytes") as f:
|
|
tx = int(f.read().strip())
|
|
with open("/sys/class/net/ppp0/statistics/rx_bytes") as f:
|
|
rx = int(f.read().strip())
|
|
except Exception:
|
|
return {}
|
|
return {"tx_bytes": tx, "rx_bytes": rx}
|
|
|
|
def _poll(self):
|
|
s: dict = {}
|
|
for fn in (self._poll_signal, self._poll_operator, self._poll_band,
|
|
self._poll_extra, self._poll_temps, self._poll_iface,
|
|
self._poll_byte_counters):
|
|
s.update(fn())
|
|
if s:
|
|
self._publish_state(**s)
|
|
|
|
def run(self):
|
|
logging.info("starting")
|
|
self._publish_state(state=State.INITIALIZING.value)
|
|
if self._has_modem_manager():
|
|
subprocess.run(["sudo", "systemctl", "mask", "--runtime", "ModemManager"], capture_output=True)
|
|
subprocess.run(["sudo", "systemctl", "stop", "ModemManager"], capture_output=True)
|
|
self._ppp.kill()
|
|
|
|
state = State.INITIALIZING
|
|
|
|
handlers = {
|
|
State.INITIALIZING: self._do_initializing,
|
|
State.SEARCHING: self._do_searching,
|
|
State.CONNECTING: self._do_connecting,
|
|
State.CONNECTED: self._do_connected,
|
|
State.DISCONNECTING: self._do_disconnecting,
|
|
}
|
|
|
|
while self.running:
|
|
try:
|
|
self._check_iccid(state)
|
|
prev = state
|
|
state = handlers[state]()
|
|
if state != prev:
|
|
self._publish_state(state=state.value)
|
|
logging.info(f"{prev.value} -> {state.value}")
|
|
except Exception:
|
|
logging.exception(f"error in {state.value}")
|
|
state = State.DISCONNECTING
|
|
time.sleep(STATE_WAIT)
|
|
|
|
def stop(self):
|
|
self.running = False
|
|
self._ppp.kill()
|
|
self._ppp.cleanup_routes()
|
|
try:
|
|
os.remove(STATE_PATH)
|
|
except FileNotFoundError:
|
|
pass
|
|
if self._has_modem_manager():
|
|
subprocess.run(["sudo", "systemctl", "unmask", "--runtime", "ModemManager"], capture_output=True)
|
|
subprocess.run(["sudo", "systemctl", "start", "ModemManager"], capture_output=True)
|
|
|
|
|
|
def main():
|
|
m = Modem()
|
|
|
|
def _sig(*_):
|
|
m.running = False
|
|
|
|
signal.signal(signal.SIGINT, _sig)
|
|
signal.signal(signal.SIGTERM, _sig)
|
|
m.run()
|
|
m.stop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|