Files
onepilot/system/ui/lib/wifi_manager.py
T

1205 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import atexit
import os
import threading
import time
import uuid
import subprocess
import shutil
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
try:
from jeepney import DBusAddress, new_method_call
from jeepney.bus_messages import MatchRule, message_bus
from jeepney.io.blocking import open_dbus_connection as open_dbus_connection_blocking
from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading
from jeepney.low_level import MessageType
from jeepney.wrappers import Properties
JEEPNY_AVAILABLE = True
JEEPNY_IMPORT_ERROR: Exception | None = None
except Exception as e:
JEEPNY_AVAILABLE = False
JEEPNY_IMPORT_ERROR = e
DBusAddress = Any # type: ignore[assignment]
DBusRouter = Any # type: ignore[assignment]
MatchRule = Any # type: ignore[assignment]
MessageType = Any # type: ignore[assignment]
Properties = Any # type: ignore[assignment]
def new_method_call(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
def message_bus(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
def open_dbus_connection_blocking(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
def open_dbus_connection_threading(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_802_11_AP_SEC_PAIR_WEP40,
NM_802_11_AP_SEC_PAIR_WEP104, NM_802_11_AP_SEC_GROUP_WEP40,
NM_802_11_AP_SEC_GROUP_WEP104, NM_802_11_AP_SEC_KEY_MGMT_PSK,
NM_802_11_AP_SEC_KEY_MGMT_802_1X, NM_802_11_AP_FLAGS_NONE,
NM_802_11_AP_FLAGS_PRIVACY, NM_802_11_AP_FLAGS_WPS,
NM_PATH, NM_IFACE, NM_ACCESS_POINT_IFACE, NM_SETTINGS_PATH,
NM_SETTINGS_IFACE, NM_CONNECTION_IFACE, NM_DEVICE_IFACE,
NM_DEVICE_TYPE_WIFI, NM_DEVICE_TYPE_MODEM, NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT,
NM_DEVICE_STATE_REASON_NEW_ACTIVATION, NM_ACTIVE_CONNECTION_IFACE,
NM_IP4_CONFIG_IFACE, NMDeviceState)
try:
from openpilot.common.params import Params
except Exception:
Params = None
TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "swagswagcomma"
SIGNAL_QUEUE_SIZE = 10
SCAN_PERIOD_SECONDS = 5
DESKTOP_FAKE_IP = "192.168.1.42"
TRUE_VALUES = {"1", "true", "yes", "on"}
def _canonicalize_ssid(ssid: str) -> str:
# iPhone hotspots can alternate between unicode and ASCII apostrophes.
return ssid.replace("", "'")
class SecurityType(IntEnum):
OPEN = 0
WPA = 1
WPA2 = 2
WPA3 = 3
UNSUPPORTED = 4
class MeteredType(IntEnum):
UNKNOWN = 0
YES = 1
NO = 2
def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType:
wpa_props = wpa_flags | rsn_flags
# obtained by looking at flags of networks in the office as reported by an Android phone
supports_wpa = (NM_802_11_AP_SEC_PAIR_WEP40 | NM_802_11_AP_SEC_PAIR_WEP104 | NM_802_11_AP_SEC_GROUP_WEP40 |
NM_802_11_AP_SEC_GROUP_WEP104 | NM_802_11_AP_SEC_KEY_MGMT_PSK)
if (flags == NM_802_11_AP_FLAGS_NONE) or ((flags & NM_802_11_AP_FLAGS_WPS) and not (wpa_props & supports_wpa)):
return SecurityType.OPEN
elif (flags & NM_802_11_AP_FLAGS_PRIVACY) and (wpa_props & supports_wpa) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_802_1X):
return SecurityType.WPA
else:
cloudlog.warning(f"Unsupported network! flags: {flags}, wpa_flags: {wpa_flags}, rsn_flags: {rsn_flags}")
return SecurityType.UNSUPPORTED
@dataclass(frozen=True)
class Network:
ssid: str
strength: int
is_connected: bool
security_type: SecurityType
is_saved: bool
ip_address: str = "" # TODO: implement
@classmethod
def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_saved: bool) -> "Network":
# we only want to show the strongest AP for each Network/SSID
strongest_ap = max(aps, key=lambda ap: ap.strength)
is_connected = any(ap.is_connected for ap in aps)
security_type = get_security_type(strongest_ap.flags, strongest_ap.wpa_flags, strongest_ap.rsn_flags)
return cls(
ssid=ssid,
strength=strongest_ap.strength,
is_connected=is_connected and is_saved,
security_type=security_type,
is_saved=is_saved,
)
@dataclass(frozen=True)
class AccessPoint:
ssid: str
bssid: str
strength: int
is_connected: bool
flags: int
wpa_flags: int
rsn_flags: int
ap_path: str
@classmethod
def from_dbus(cls, ap_props: dict[str, tuple[str, Any]], ap_path: str, active_ap_path: str) -> "AccessPoint":
ssid = bytes(ap_props['Ssid'][1]).decode("utf-8", "replace")
bssid = str(ap_props['HwAddress'][1])
strength = int(ap_props['Strength'][1])
flags = int(ap_props['Flags'][1])
wpa_flags = int(ap_props['WpaFlags'][1])
rsn_flags = int(ap_props['RsnFlags'][1])
return cls(
ssid=ssid,
bssid=bssid,
strength=strength,
is_connected=ap_path == active_ap_path,
flags=flags,
wpa_flags=wpa_flags,
rsn_flags=rsn_flags,
ap_path=ap_path,
)
class WifiManager:
def __init__(self):
self._networks: list[Network] = [] # a network can be comprised of multiple APs
self._active = True # used to not run when not in settings
self._exit = False
self._fake_networking = False
self._nmcli_networking = False
self._dbus_available = False
allow_desktop_fake = PC and os.getenv("SP_ALLOW_DESKTOP_FAKE_WIFI", "0").lower() in TRUE_VALUES
has_nmcli = shutil.which("nmcli") is not None
# DBus connections
if not JEEPNY_AVAILABLE:
cloudlog.warning(f"jeepney unavailable: {JEEPNY_IMPORT_ERROR}")
self._router_main = None
self._conn_monitor = None
self._nm = None
if allow_desktop_fake:
self._fake_networking = True
elif has_nmcli:
self._nmcli_networking = True
else:
cloudlog.error("No networking backend available (jeepney missing, nmcli unavailable)")
else:
try:
self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls
self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread
self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE)
self._dbus_available = True
except Exception as e:
cloudlog.warning(f"Failed to connect to system D-Bus: {e}")
self._router_main = None
self._conn_monitor = None
self._nm = None
if allow_desktop_fake:
self._fake_networking = True
elif has_nmcli:
self._nmcli_networking = True
else:
cloudlog.error("No networking backend available (D-Bus unavailable, nmcli unavailable)")
# Store wifi device path
self._wifi_device: str | None = None
# State
self._connecting_to_ssid: str = ""
self._ipv4_address: str = ""
self._current_network_metered: MeteredType = MeteredType.UNKNOWN
self._tethering_password: str = ""
self._ipv4_forward = False
self._last_network_update: float = 0.0
self._callback_queue: list[Callable] = []
self._fake_connected_ssid: str | None = None
self._fake_known_networks: dict[str, dict[str, Any]] = {}
self._tethering_ssid = "weedle"
if Params is not None:
dongle_id = Params().get("DongleId")
if dongle_id:
self._tethering_ssid += "-" + dongle_id[:4]
if self._fake_networking:
self._init_fake_networking()
# Callbacks
self._need_auth: list[Callable[[str], None]] = []
self._activated: list[Callable[[], None]] = []
self._forgotten: list[Callable[[], None]] = []
self._networks_updated: list[Callable[[list[Network]], None]] = []
self._disconnected: list[Callable[[], None]] = []
self._lock = threading.Lock()
self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True)
self._state_thread = threading.Thread(target=self._monitor_state, daemon=True)
self._initialize()
atexit.register(self.stop)
def _initialize(self):
def worker():
if self._fake_networking:
self._update_networks()
cloudlog.debug("WifiManager initialized in fake networking mode")
return
if self._nmcli_networking:
self._update_networks()
self._scan_thread.start()
cloudlog.debug("WifiManager initialized in nmcli networking mode")
return
if not self._dbus_available:
cloudlog.error("WifiManager unavailable: no active networking backend")
return
self._wait_for_wifi_device()
self._scan_thread.start()
self._state_thread.start()
if Params is not None and self._tethering_ssid not in self._get_connections():
self._add_tethering_connection()
self._tethering_password = self._get_tethering_password()
cloudlog.debug("WifiManager initialized")
threading.Thread(target=worker, daemon=True).start()
def _init_fake_networking(self):
primary_ssid = os.getenv("FAKE_WIFI_SSID", "Laptop Wi-Fi")
self._fake_known_networks = {
primary_ssid: {"security": SecurityType.WPA, "saved": True, "strength": 96},
"Coffee Shop": {"security": SecurityType.OPEN, "saved": False, "strength": 68},
"Phone Hotspot": {"security": SecurityType.WPA, "saved": False, "strength": 54},
}
self._fake_connected_ssid = primary_ssid
self._tethering_password = DEFAULT_TETHERING_PASSWORD
self._current_network_metered = MeteredType.NO
self._ipv4_address = DESKTOP_FAKE_IP
def _update_networks_fake(self):
with self._lock:
networks: list[Network] = []
for ssid, values in self._fake_known_networks.items():
networks.append(Network(
ssid=ssid,
strength=int(values["strength"]),
is_connected=ssid == self._fake_connected_ssid,
security_type=values["security"],
is_saved=bool(values["saved"]),
))
if self._fake_connected_ssid == self._tethering_ssid:
if self._tethering_ssid not in self._fake_known_networks:
networks.append(Network(
ssid=self._tethering_ssid,
strength=100,
is_connected=True,
security_type=SecurityType.WPA,
is_saved=True,
))
self._ipv4_address = TETHERING_IP_ADDRESS
self._current_network_metered = MeteredType.UNKNOWN
elif self._fake_connected_ssid is None:
self._ipv4_address = ""
self._current_network_metered = MeteredType.UNKNOWN
else:
self._ipv4_address = DESKTOP_FAKE_IP
networks.sort(key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower()))
self._networks = networks
self._enqueue_callbacks(self._networks_updated, self._networks)
def add_callbacks(self, need_auth: Callable[[str], None] | None = None,
activated: Callable[[], None] | None = None,
forgotten: Callable[[], None] | None = None,
networks_updated: Callable[[list[Network]], None] | None = None,
disconnected: Callable[[], None] | None = None):
if need_auth is not None:
self._need_auth.append(need_auth)
if activated is not None:
self._activated.append(activated)
if forgotten is not None:
self._forgotten.append(forgotten)
if networks_updated is not None:
self._networks_updated.append(networks_updated)
if disconnected is not None:
self._disconnected.append(disconnected)
@property
def ipv4_address(self) -> str:
return self._ipv4_address
@property
def current_network_metered(self) -> MeteredType:
return self._current_network_metered
@property
def tethering_password(self) -> str:
return self._tethering_password
def _enqueue_callbacks(self, cbs: list[Callable], *args):
for cb in cbs:
self._callback_queue.append(lambda _cb=cb: _cb(*args))
def process_callbacks(self):
# Call from UI thread to run any pending callbacks
to_run, self._callback_queue = self._callback_queue, []
for cb in to_run:
cb()
def set_active(self, active: bool):
self._active = active
if self._fake_networking or self._nmcli_networking:
if active:
self._update_networks()
return
# Scan immediately if we haven't scanned in a while
if active and time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS / 2:
self._last_network_update = 0.0
def _monitor_state(self):
if not self._dbus_available:
return
rule = MatchRule(
type="signal",
interface=NM_DEVICE_IFACE,
member="StateChanged",
path=self._wifi_device,
)
# Filter for StateChanged signal
self._conn_monitor.send_and_get_reply(message_bus.AddMatch(rule))
with self._conn_monitor.filter(rule, bufsize=SIGNAL_QUEUE_SIZE) as q:
while not self._exit:
if not self._active:
time.sleep(1)
continue
# Block until a matching signal arrives
try:
msg = self._conn_monitor.recv_until_filtered(q, timeout=1)
except TimeoutError:
continue
new_state, previous_state, change_reason = msg.body
# BAD PASSWORD
if new_state == NMDeviceState.NEED_AUTH and change_reason == NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT and len(self._connecting_to_ssid):
self.forget_connection(self._connecting_to_ssid, block=True)
self._enqueue_callbacks(self._need_auth, self._connecting_to_ssid)
self._connecting_to_ssid = ""
elif new_state == NMDeviceState.ACTIVATED:
if len(self._activated):
self._update_networks()
self._enqueue_callbacks(self._activated)
self._connecting_to_ssid = ""
elif new_state == NMDeviceState.DISCONNECTED and change_reason != NM_DEVICE_STATE_REASON_NEW_ACTIVATION:
self._connecting_to_ssid = ""
self._enqueue_callbacks(self._forgotten)
def _network_scanner(self):
while not self._exit:
if self._active:
if time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS:
# Scan for networks every 10 seconds
# TODO: should update when scan is complete (PropertiesChanged), but this is more than good enough for now
self._update_networks()
self._request_scan()
self._last_network_update = time.monotonic()
time.sleep(1 / 2.)
def _wait_for_wifi_device(self):
while not self._exit:
device_path = self._get_adapter(NM_DEVICE_TYPE_WIFI)
if device_path is not None:
self._wifi_device = device_path
break
time.sleep(1)
def _get_adapter(self, adapter_type: int) -> str | None:
# Return the first NetworkManager device path matching adapter_type
try:
device_paths = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')).body[0]
for device_path in device_paths:
dev_addr = DBusAddress(device_path, bus_name=NM, interface=NM_DEVICE_IFACE)
dev_type = self._router_main.send_and_get_reply(Properties(dev_addr).get('DeviceType')).body[0][1]
if dev_type == adapter_type:
return str(device_path)
except Exception as e:
cloudlog.exception(f"Error getting adapter type {adapter_type}: {e}")
return None
def _get_connections(self) -> dict[str, str]:
settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE)
known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0]
conns: dict[str, str] = {}
for conn_path in known_connections:
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get connection settings for {conn_path}')
continue
if "802-11-wireless" in settings:
ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace")
if ssid != "":
conns[ssid] = conn_path
return conns
def _get_active_connections(self):
return self._router_main.send_and_get_reply(Properties(self._nm).get('ActiveConnections')).body[0][1]
def _get_connection_settings(self, conn_path: str) -> dict:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'GetSettings'))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to get connection settings: {reply}')
return {}
return dict(reply.body[0])
def _add_tethering_connection(self):
connection = {
'connection': {
'type': ('s', '802-11-wireless'),
'uuid': ('s', str(uuid.uuid4())),
'id': ('s', 'Hotspot'),
'autoconnect-retries': ('i', 0),
'interface-name': ('s', 'wlan0'),
'autoconnect': ('b', False),
},
'802-11-wireless': {
'band': ('s', 'bg'),
'mode': ('s', 'ap'),
'ssid': ('ay', self._tethering_ssid.encode("utf-8")),
},
'802-11-wireless-security': {
'group': ('as', ['ccmp']),
'key-mgmt': ('s', 'wpa-psk'),
'pairwise': ('as', ['ccmp']),
'proto': ('as', ['rsn']),
'psk': ('s', DEFAULT_TETHERING_PASSWORD),
},
'ipv4': {
'method': ('s', 'shared'),
'address-data': ('aa{sv}', [[
('address', ('s', TETHERING_IP_ADDRESS)),
('prefix', ('u', 24)),
]]),
'gateway': ('s', TETHERING_IP_ADDRESS),
'never-default': ('b', True),
},
'ipv6': {'method': ('s', 'ignore')},
}
settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE)
self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,)))
def connect_to_network(self, ssid: str, password: str, hidden: bool = False):
if not (self._dbus_available or self._fake_networking or self._nmcli_networking):
cloudlog.warning("connect_to_network called with no available networking backend")
return
if self._fake_networking:
def worker():
self._connecting_to_ssid = ssid
security = SecurityType.WPA if password else SecurityType.OPEN
if ssid not in self._fake_known_networks:
self._fake_known_networks[ssid] = {"security": security, "saved": True, "strength": 82}
else:
self._fake_known_networks[ssid]["saved"] = True
self._fake_known_networks[ssid]["security"] = security
self._fake_connected_ssid = ssid
self._connecting_to_ssid = ""
self._update_networks()
self._enqueue_callbacks(self._activated)
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
def worker():
self._connecting_to_ssid = ssid
cmd = ["nmcli", "device", "wifi", "connect", ssid]
if password:
cmd += ["password", password]
if hidden:
cmd += ["hidden", "yes"]
result = subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._connecting_to_ssid = ""
self._update_networks()
if result.returncode == 0:
self._enqueue_callbacks(self._activated)
else:
self._enqueue_callbacks(self._need_auth, ssid)
threading.Thread(target=worker, daemon=True).start()
return
def worker():
# Clear all connections that may already exist to the network we are connecting to
self._connecting_to_ssid = ssid
self.forget_connection(ssid, block=True)
connection = {
'connection': {
'type': ('s', '802-11-wireless'),
'uuid': ('s', str(uuid.uuid4())),
'id': ('s', f'openpilot connection {ssid}'),
'autoconnect-retries': ('i', 0),
},
'802-11-wireless': {
'ssid': ('ay', ssid.encode("utf-8")),
'hidden': ('b', hidden),
'mode': ('s', 'infrastructure'),
},
'ipv4': {
'method': ('s', 'auto'),
'dns-priority': ('i', 600),
},
'ipv6': {'method': ('s', 'ignore')},
}
if password:
connection['802-11-wireless-security'] = {
'key-mgmt': ('s', 'wpa-psk'),
'auth-alg': ('s', 'open'),
'psk': ('s', password),
}
settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE)
self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,)))
self.activate_connection(ssid, block=True)
threading.Thread(target=worker, daemon=True).start()
def forget_connection(self, ssid: str, block: bool = False):
if not (self._dbus_available or self._fake_networking or self._nmcli_networking):
cloudlog.warning("forget_connection called with no available networking backend")
return
if self._fake_networking:
def worker():
self._fake_known_networks.pop(ssid, None)
was_connected = self._fake_connected_ssid == ssid
if was_connected:
replacement = next((s for s in self._fake_known_networks.keys() if s != self._tethering_ssid), None)
self._fake_connected_ssid = replacement
self._update_networks()
self._enqueue_callbacks(self._forgotten)
if was_connected and self._fake_connected_ssid is None:
self._enqueue_callbacks(self._disconnected)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
def worker():
try:
conns = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
check=False, capture_output=True, text=True,
)
deleted = False
for line in conns.stdout.splitlines():
parts = self._parse_nmcli_line(line)
if len(parts) >= 3 and parts[1] == "802-11-wireless" and (parts[0] == ssid or parts[2] == ssid):
subprocess.run(["nmcli", "connection", "delete", "id", parts[0]], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deleted = True
if not deleted:
subprocess.run(["nmcli", "connection", "delete", "id", ssid], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
cloudlog.warning(f"nmcli forget failed for {ssid}: {e}")
self._update_networks()
self._enqueue_callbacks(self._forgotten)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
def worker():
conn_path = self._get_connections().get(ssid, None)
if conn_path is not None:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Delete'))
if len(self._forgotten):
self._update_networks()
self._enqueue_callbacks(self._forgotten)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
def activate_connection(self, ssid: str, block: bool = False):
if not (self._dbus_available or self._fake_networking or self._nmcli_networking):
cloudlog.warning("activate_connection called with no available networking backend")
return
if self._fake_networking:
def worker():
if ssid not in self._fake_known_networks and ssid != self._tethering_ssid:
return
self._connecting_to_ssid = ssid
if ssid == self._tethering_ssid and ssid not in self._fake_known_networks:
self._fake_known_networks[ssid] = {"security": SecurityType.WPA, "saved": True, "strength": 100}
else:
self._fake_known_networks[ssid]["saved"] = True
self._fake_connected_ssid = ssid
self._connecting_to_ssid = ""
self._update_networks()
self._enqueue_callbacks(self._activated)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
def worker():
self._connecting_to_ssid = ssid
result = subprocess.run(["nmcli", "connection", "up", "id", ssid], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._connecting_to_ssid = ""
self._update_networks()
if result.returncode == 0:
self._enqueue_callbacks(self._activated)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
def worker():
conn_path = self._get_connections().get(ssid, None)
if conn_path is not None:
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
self._connecting_to_ssid = ssid
self._router_main.send(new_method_call(self._nm, 'ActivateConnection', 'ooo',
(conn_path, self._wifi_device, "/")))
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
def _deactivate_connection(self, ssid: str):
if self._fake_networking:
if self._fake_connected_ssid == ssid:
self._fake_connected_ssid = None
self._update_networks()
self._enqueue_callbacks(self._disconnected)
return
if self._nmcli_networking:
subprocess.run(["nmcli", "connection", "down", "id", ssid], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._update_networks()
self._enqueue_callbacks(self._disconnected)
return
for conn_path in self._get_active_connections():
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
specific_obj_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')).body[0][1]
if specific_obj_path != "/":
ap_addr = DBusAddress(specific_obj_path, bus_name=NM, interface=NM_ACCESS_POINT_IFACE)
ap_ssid = bytes(self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid')).body[0][1]).decode("utf-8", "replace")
if ap_ssid == ssid:
self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (conn_path,)))
return
def is_tethering_active(self) -> bool:
for network in self._networks:
if network.is_connected:
return bool(network.ssid == self._tethering_ssid)
return False
def set_tethering_password(self, password: str):
if self._fake_networking:
self._tethering_password = password
return
if self._nmcli_networking:
self._tethering_password = password
return
def worker():
conn_path = self._get_connections().get(self._tethering_ssid, None)
if conn_path is None:
cloudlog.warning('No tethering connection found')
return
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get tethering settings for {conn_path}')
return
settings['802-11-wireless-security']['psk'] = ('s', password)
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to update tethering settings: {reply}')
return
self._tethering_password = password
if self.is_tethering_active():
self.activate_connection(self._tethering_ssid, block=True)
threading.Thread(target=worker, daemon=True).start()
def _get_tethering_password(self) -> str:
if self._fake_networking:
return self._tethering_password
if self._nmcli_networking:
return self._tethering_password or DEFAULT_TETHERING_PASSWORD
conn_path = self._get_connections().get(self._tethering_ssid, None)
if conn_path is None:
cloudlog.warning('No tethering connection found')
return ''
reply = self._router_main.send_and_get_reply(new_method_call(
DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE),
'GetSecrets', 's', ('802-11-wireless-security',)
))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to get tethering password: {reply}')
return ''
secrets = reply.body[0]
if '802-11-wireless-security' not in secrets:
return ''
return str(secrets['802-11-wireless-security'].get('psk', ('s', ''))[1])
def set_ipv4_forward(self, enabled: bool):
self._ipv4_forward = enabled
def set_tethering_active(self, active: bool):
if self._fake_networking:
def worker():
if active:
if self._tethering_ssid not in self._fake_known_networks:
self._fake_known_networks[self._tethering_ssid] = {"security": SecurityType.WPA, "saved": True, "strength": 100}
self._fake_connected_ssid = self._tethering_ssid
else:
if self._fake_connected_ssid == self._tethering_ssid:
replacement = next((s for s in self._fake_known_networks.keys() if s != self._tethering_ssid), None)
self._fake_connected_ssid = replacement
self._update_networks()
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
cloudlog.warning("Tethering control is not supported via nmcli fallback backend")
return
def worker():
if active:
self.activate_connection(self._tethering_ssid, block=True)
if not self._ipv4_forward:
time.sleep(5)
cloudlog.warning("net.ipv4.ip_forward = 0")
subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=False)
else:
self._deactivate_connection(self._tethering_ssid)
threading.Thread(target=worker, daemon=True).start()
def _update_current_network_metered(self) -> None:
if self._nmcli_networking:
self._current_network_metered = MeteredType.UNKNOWN
return
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
self._current_network_metered = MeteredType.UNKNOWN
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1]
if conn_type == '802-11-wireless':
conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1]
if conn_path == "/":
continue
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get connection settings for {conn_path}')
continue
metered_prop = settings['connection'].get('metered', ('i', 0))[1]
if metered_prop == MeteredType.YES:
self._current_network_metered = MeteredType.YES
elif metered_prop == MeteredType.NO:
self._current_network_metered = MeteredType.NO
return
def set_current_network_metered(self, metered: MeteredType):
if self._fake_networking:
self._current_network_metered = metered
self._enqueue_callbacks(self._networks_updated, self._networks)
return
if self._nmcli_networking:
self._current_network_metered = metered
self._enqueue_callbacks(self._networks_updated, self._networks)
return
def worker():
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1]
if conn_type == '802-11-wireless' and not self.is_tethering_active():
conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1]
if conn_path == "/":
continue
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get connection settings for {conn_path}')
return
settings['connection']['metered'] = ('i', int(metered))
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to update tethering settings: {reply}')
return
threading.Thread(target=worker, daemon=True).start()
def _request_scan(self):
if self._nmcli_networking:
subprocess.run(["nmcli", "device", "wifi", "rescan"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
wifi_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_WIRELESS_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'RequestScan', 'a{sv}', ({},)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to request scan: {reply}")
def _update_networks(self):
if self._fake_networking:
self._update_networks_fake()
return
if self._nmcli_networking:
self._update_networks_nmcli()
return
with self._lock:
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
# returns '/' if no active AP
wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE)
active_ap_path = self._router_main.send_and_get_reply(Properties(wifi_addr).get('ActiveAccessPoint')).body[0][1]
ap_paths = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'GetAllAccessPoints')).body[0]
aps: dict[str, list[AccessPoint]] = {}
for ap_path in ap_paths:
ap_addr = DBusAddress(ap_path, NM, interface=NM_ACCESS_POINT_IFACE)
ap_props = self._router_main.send_and_get_reply(Properties(ap_addr).get_all())
# some APs have been seen dropping off during iteration
if ap_props.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to get AP properties for {ap_path}")
continue
try:
ap = AccessPoint.from_dbus(ap_props.body[0], ap_path, active_ap_path)
if ap.ssid == "":
continue
if ap.ssid not in aps:
aps[ap.ssid] = []
aps[ap.ssid].append(ap)
except Exception:
# catch all for parsing errors
cloudlog.exception(f"Failed to parse AP properties for {ap_path}")
known_connections = self._get_connections()
networks = [Network.from_dbus(ssid, ap_list, ssid in known_connections) for ssid, ap_list in aps.items()]
# sort with quantized strength to reduce jumping
networks.sort(key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower()))
self._networks = networks
self._update_ipv4_address()
self._update_current_network_metered()
self._enqueue_callbacks(self._networks_updated, self._networks)
def _update_ipv4_address(self):
if self._nmcli_networking:
self._ipv4_address = ""
try:
status = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device", "status"],
check=False, capture_output=True, text=True,
)
wifi_dev = None
for line in status.stdout.splitlines():
parts = line.split(":")
if len(parts) >= 3 and parts[1] == "wifi" and parts[2].startswith("connected"):
wifi_dev = parts[0]
break
if wifi_dev:
addr = subprocess.run(
["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", wifi_dev],
check=False, capture_output=True, text=True,
)
for row in addr.stdout.splitlines():
if row:
self._ipv4_address = row.split(":", 1)[-1].split("/", 1)[0]
break
except Exception as e:
cloudlog.warning(f"nmcli ipv4 lookup failed: {e}")
return
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
self._ipv4_address = ""
for conn_path in self._get_active_connections():
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1]
if conn_type == '802-11-wireless':
ip4config_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Ip4Config')).body[0][1]
if ip4config_path != "/":
ip4config_addr = DBusAddress(ip4config_path, bus_name=NM, interface=NM_IP4_CONFIG_IFACE)
address_data = self._router_main.send_and_get_reply(Properties(ip4config_addr).get('AddressData')).body[0][1]
for entry in address_data:
if 'address' in entry:
self._ipv4_address = entry['address'][1]
return
def __del__(self):
self.stop()
def update_gsm_settings(self, roaming: bool, apn: str, metered: bool):
"""Update GSM settings for cellular connection"""
if self._fake_networking:
return
if self._nmcli_networking:
cloudlog.warning("GSM settings update is unavailable in nmcli fallback mode")
return
def worker():
try:
lte_connection_path = self._get_lte_connection_path()
if not lte_connection_path:
cloudlog.warning("No LTE connection found")
return
settings = self._get_connection_settings(lte_connection_path)
if len(settings) == 0:
cloudlog.warning(f"Failed to get connection settings for {lte_connection_path}")
return
# Ensure dicts exist
if 'gsm' not in settings:
settings['gsm'] = {}
if 'connection' not in settings:
settings['connection'] = {}
changes = False
auto_config = apn == ""
if settings['gsm'].get('auto-config', ('b', False))[1] != auto_config:
cloudlog.warning(f'Changing gsm.auto-config to {auto_config}')
settings['gsm']['auto-config'] = ('b', auto_config)
changes = True
if settings['gsm'].get('apn', ('s', ''))[1] != apn:
cloudlog.warning(f'Changing gsm.apn to {apn}')
settings['gsm']['apn'] = ('s', apn)
changes = True
if settings['gsm'].get('home-only', ('b', False))[1] == roaming:
cloudlog.warning(f'Changing gsm.home-only to {not roaming}')
settings['gsm']['home-only'] = ('b', not roaming)
changes = True
# Unknown means NetworkManager decides
metered_int = int(MeteredType.UNKNOWN if metered else MeteredType.NO)
if settings['connection'].get('metered', ('i', 0))[1] != metered_int:
cloudlog.warning(f'Changing connection.metered to {metered_int}')
settings['connection']['metered'] = ('i', metered_int)
changes = True
if changes:
# Update the connection settings (temporary update)
conn_addr = DBusAddress(lte_connection_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'UpdateUnsaved', 'a{sa{sv}}', (settings,)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to update GSM settings: {reply}")
return
self._activate_modem_connection(lte_connection_path)
except Exception as e:
cloudlog.exception(f"Error updating GSM settings: {e}")
threading.Thread(target=worker, daemon=True).start()
def _get_lte_connection_path(self) -> str | None:
try:
settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE)
known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0]
for conn_path in known_connections:
settings = self._get_connection_settings(conn_path)
if settings and settings.get('connection', {}).get('id', ('s', ''))[1] == 'lte':
return str(conn_path)
except Exception as e:
cloudlog.exception(f"Error finding LTE connection: {e}")
return None
def _activate_modem_connection(self, connection_path: str):
try:
modem_device = self._get_adapter(NM_DEVICE_TYPE_MODEM)
if modem_device and connection_path:
self._router_main.send_and_get_reply(new_method_call(self._nm, 'ActivateConnection', 'ooo', (connection_path, modem_device, "/")))
except Exception as e:
cloudlog.exception(f"Error activating modem connection: {e}")
def stop(self):
if not self._exit:
self._exit = True
if self._scan_thread.is_alive():
self._scan_thread.join()
if self._state_thread.is_alive():
self._state_thread.join()
if self._router_main is not None:
self._router_main.close()
self._router_main.conn.close()
if self._conn_monitor is not None:
self._conn_monitor.close()
def _parse_nmcli_line(self, line: str) -> list[str]:
out: list[str] = []
cur = []
escaped = False
for ch in line:
if escaped:
cur.append(ch)
escaped = False
elif ch == "\\":
escaped = True
elif ch == ":":
out.append("".join(cur))
cur = []
else:
cur.append(ch)
out.append("".join(cur))
return out
def _update_networks_nmcli(self):
with self._lock:
networks_by_ssid: dict[str, Network] = {}
saved_ssids: set[str] = set()
try:
saved = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
check=False, capture_output=True, text=True,
)
for line in saved.stdout.splitlines():
parts = self._parse_nmcli_line(line)
if len(parts) >= 3 and parts[1] == "802-11-wireless" and parts[2]:
saved_ssids.add(_canonicalize_ssid(parts[2]))
saved_ssids.add(_canonicalize_ssid(parts[0]))
except Exception as e:
cloudlog.warning(f"nmcli saved networks query failed: {e}")
try:
result = subprocess.run(
["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "--rescan", "no"],
check=False, capture_output=True, text=True,
)
for line in result.stdout.splitlines():
parts = self._parse_nmcli_line(line)
if len(parts) < 4:
continue
in_use, ssid, signal, security = parts[:4]
if not ssid:
continue
ssid_key = _canonicalize_ssid(ssid)
try:
strength = int(signal or 0)
except ValueError:
strength = 0
security_type = SecurityType.OPEN if security in ("", "--") else SecurityType.WPA
is_connected = in_use.startswith("*")
is_saved = ssid_key in saved_ssids
existing = networks_by_ssid.get(ssid_key)
should_replace = (
existing is None or
(is_connected and not existing.is_connected) or
(existing is not None and is_connected == existing.is_connected and strength > existing.strength)
)
if should_replace:
prior_saved = existing.is_saved if existing is not None else False
networks_by_ssid[ssid_key] = Network(
ssid=ssid,
strength=strength,
is_connected=is_connected,
security_type=security_type,
is_saved=is_saved or prior_saved,
)
elif existing is not None:
networks_by_ssid[ssid_key] = Network(
ssid=existing.ssid,
strength=existing.strength,
is_connected=existing.is_connected or is_connected,
security_type=existing.security_type,
is_saved=existing.is_saved or is_saved,
)
except Exception as e:
cloudlog.warning(f"nmcli scan failed: {e}")
self._networks = sorted(
networks_by_ssid.values(),
key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower()),
)
self._update_ipv4_address()
self._current_network_metered = MeteredType.UNKNOWN
self._enqueue_callbacks(self._networks_updated, self._networks)