Files
IQ.Pilot/system/ui/lib/wifi_manager.py
2026-07-20 12:00:10 -05:00

1009 lines
37 KiB
Python
Raw Permalink 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.
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import atexit
import os
import threading
import time
import uuid
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
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
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_SEC_KEY_MGMT_SAE,
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
SYSTEM_DBUS_SOCKET_CANDIDATES = (
"/run/dbus/system_bus_socket",
"/var/run/dbus/system_bus_socket",
)
def normalize_ssid(ssid: str) -> str:
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) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_SAE)):
return SecurityType.OPEN
elif (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_SAE) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_PSK) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_802_1X):
# WPA3-Personal only (SAE without PSK fallback)
return SecurityType.WPA3
elif (flags & NM_802_11_AP_FLAGS_PRIVACY) and (wpa_props & (supports_wpa | NM_802_11_AP_SEC_KEY_MGMT_SAE)) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_802_1X):
# WPA2, WPA2+WPA3 mixed, or WPA — all handled via WPA key_mgmt (NM negotiates SAE if available)
return SecurityType.WPA2
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,
)
def wifi_network_sort_key(network: Network, network_missing: bool = False) -> tuple[int, int]:
if network.is_connected:
return (0, 0)
if network_missing or network.strength <= 0:
return (2, 0)
return (1, -network.strength)
@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
# DBus connections
self._router_main: DBusRouter | None = None # used by scanner / general method calls
self._conn_monitor = None # used by state monitor thread
self._nm: DBusAddress | None = None
self._dbus_connected = False
self._last_dbus_connect_log = 0.0
# 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._tethering_transition = False
self._last_network_update: float = 0.0
self._callback_queue: list[Callable] = []
self._scanning = False
self._tethering_ssid = "weedle"
if Params is not None:
dongle_id = Params().get("DongleId")
if dongle_id:
self._tethering_ssid += "-" + dongle_id[:4]
# 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._dbus_lock = threading.Lock()
self._bootstrap_thread = threading.Thread(target=self._bootstrap_loop, daemon=True)
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):
self._bootstrap_thread.start()
self._scan_thread.start()
self._state_thread.start()
def _bootstrap_loop(self):
while not self._exit:
if not self._ensure_wifi_ready():
time.sleep(1)
continue
if self._dbus_connected:
time.sleep(1)
continue
try:
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()
self._dbus_connected = True
cloudlog.debug("WifiManager initialized")
except Exception:
cloudlog.exception("WifiManager initialization failed")
self._reset_dbus()
time.sleep(1)
def _connect_dbus(self) -> bool:
with self._dbus_lock:
if self._router_main is not None and self._conn_monitor is not None and self._nm is not None:
return True
dbus_socket = next((path for path in SYSTEM_DBUS_SOCKET_CANDIDATES if os.path.exists(path)), None)
if dbus_socket is not None:
os.environ["DBUS_SYSTEM_BUS_ADDRESS"] = f"unix:path={dbus_socket}"
try:
router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM"))
conn_monitor = open_dbus_connection_blocking(bus="SYSTEM")
except FileNotFoundError:
now = time.monotonic()
if now - self._last_dbus_connect_log > 10:
cloudlog.warning(f"WifiManager waiting for system D-Bus socket={dbus_socket}")
self._last_dbus_connect_log = now
return False
except Exception:
cloudlog.exception("WifiManager failed to connect to system D-Bus")
return False
self._router_main = router_main
self._conn_monitor = conn_monitor
self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE)
self._last_dbus_connect_log = 0.0
return True
def _reset_dbus(self):
with self._dbus_lock:
router_main = self._router_main
conn_monitor = self._conn_monitor
self._router_main = None
self._conn_monitor = None
self._nm = None
self._wifi_device = None
self._dbus_connected = False
if router_main is not None:
try:
router_main.close()
except Exception:
pass
try:
router_main.conn.close()
except Exception:
pass
if conn_monitor is not None:
try:
conn_monitor.close()
except Exception:
pass
def _ensure_wifi_ready(self) -> bool:
if not self._connect_dbus():
return False
if self._wifi_device is not None:
return True
self._wait_for_wifi_device()
return self._wifi_device is not None
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)
def get_networks(self) -> list[Network]:
# Snapshot of the current scan results — used by non-UI consumers
# (e.g. the BLE setup transport) that can't drive process_callbacks().
with self._lock:
return list(self._networks)
@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
@property
def is_scanning(self) -> bool:
return self._scanning
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
# 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):
while not self._exit:
if not self._active:
time.sleep(1)
continue
if not self._ensure_wifi_ready():
time.sleep(1)
continue
conn_monitor = self._conn_monitor
wifi_device = self._wifi_device
if conn_monitor is None or wifi_device is None:
time.sleep(1)
continue
rule = MatchRule(
type="signal",
interface=NM_DEVICE_IFACE,
member="StateChanged",
path=wifi_device,
)
try:
conn_monitor.send_and_get_reply(message_bus.AddMatch(rule))
with conn_monitor.filter(rule, bufsize=SIGNAL_QUEUE_SIZE) as q:
while not self._exit and self._conn_monitor is conn_monitor and self._wifi_device == wifi_device:
if not self._active:
time.sleep(1)
continue
try:
msg = conn_monitor.recv_until_filtered(q, timeout=1)
except TimeoutError:
continue
new_state, previous_state, change_reason = msg.body
if self._tethering_transition:
continue
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)
except Exception:
if not self._exit:
cloudlog.exception("WifiManager state monitor lost D-Bus connection")
self._reset_dbus()
time.sleep(1)
def _network_scanner(self):
while not self._exit:
if self._active:
if time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS:
self._scanning = True
try:
if self._ensure_wifi_ready():
self._update_networks()
self._request_scan()
self._last_network_update = time.monotonic()
except Exception:
if not self._exit:
cloudlog.exception("WifiManager scanner lost D-Bus connection")
self._reset_dbus()
finally:
self._scanning = False
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
if self._router_main is None or self._nm is None:
return None
try:
reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices'))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"NetworkManager GetDevices failed: {reply.body!r}")
return None
if len(reply.body) != 1 or not isinstance(reply.body[0], (list, tuple)):
cloudlog.warning(f"Unexpected NetworkManager GetDevices reply: {reply.body!r}")
return None
device_paths = reply.body[0]
for device_path in device_paths:
if not isinstance(device_path, str) or not device_path.startswith("/"):
cloudlog.warning(f"Skipping malformed NetworkManager device path: {device_path!r}")
continue
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]:
if self._router_main is None:
return {}
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):
if self._router_main is None or self._nm is None:
return []
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:
if self._router_main is None:
return {}
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):
if self._router_main is None:
return
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, security_type: "SecurityType | None" = None):
def worker():
if not self._ensure_wifi_ready() or self._router_main is None:
cloudlog.warning(f"WiFi not ready while connecting to {ssid}")
return
# Resolve security type from current scan if not explicitly provided
resolved_security = security_type
if resolved_security is None:
for net in self._networks:
if net.ssid == ssid:
resolved_security = net.security_type
break
# 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'iqpilot 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:
if resolved_security == SecurityType.WPA3:
# WPA3-Personal (SAE) — PMF is mandatory for WPA3
connection['802-11-wireless-security'] = {
'key-mgmt': ('s', 'sae'),
'psk': ('s', password),
'pmf': ('i', 2),
}
else:
# WPA/WPA2/mixed — NM negotiates SAE automatically in transition mode
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):
def worker():
if self._router_main is None:
cloudlog.warning(f"WiFi not ready while forgetting {ssid}")
return
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):
def worker():
if not self._ensure_wifi_ready() or self._router_main is None:
cloudlog.warning(f"WiFi not ready while activating {ssid}")
return
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):
target_conn_path = self._get_connections().get(ssid, None)
if target_conn_path is None:
return
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
active_conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1]
if active_conn_path == target_conn_path:
self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (active_conn,)))
return
def _deactivate_active_wifi_client(self):
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':
continue
active_conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1]
tether_conn_path = self._get_connections().get(self._tethering_ssid, None)
if active_conn_path == tether_conn_path:
continue
self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (active_conn,)))
return
def is_tethering_active(self) -> bool:
tether_conn_path = self._get_connections().get(self._tethering_ssid, None)
if tether_conn_path is None:
return False
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
active_conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1]
if active_conn_path == tether_conn_path:
return True
return False
def set_tethering_password(self, password: str):
def worker():
if not self._ensure_wifi_ready() or self._router_main is None:
cloudlog.warning("WiFi not ready while updating tethering password")
return
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._router_main is None:
return ''
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):
def worker():
if not self._ensure_wifi_ready():
cloudlog.warning("WiFi not ready while changing tethering state")
return
if active:
self._tethering_transition = True
self._connecting_to_ssid = ""
self._deactivate_active_wifi_client()
self.activate_connection(self._tethering_ssid, block=True)
time.sleep(5)
subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=1"], check=False)
nat_rule = ["POSTROUTING", "-t", "nat", "-s", "192.168.43.0/24", "-o", "wwan0", "-j", "MASQUERADE"]
rule_check = subprocess.run(["sudo", "iptables-legacy", "-C", *nat_rule], check=False)
if rule_check.returncode != 0:
subprocess.run(["sudo", "iptables-legacy", "-A", *nat_rule], check=False)
else:
self._deactivate_connection(self._tethering_ssid)
self._tethering_transition = False
threading.Thread(target=worker, daemon=True).start()
def _update_current_network_metered(self) -> None:
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):
def worker():
if self._router_main is None:
cloudlog.warning("WiFi not ready while updating metered setting")
return
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._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):
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._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"""
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 == ""
initial_eps_apn = apn if not auto_config else ""
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
if settings['gsm'].get('initial-eps-bearer-configure', ('b', False))[1] != bool(initial_eps_apn):
cloudlog.warning(f'Changing gsm.initial-eps-bearer-configure to {bool(initial_eps_apn)}')
settings['gsm']['initial-eps-bearer-configure'] = ('b', bool(initial_eps_apn))
changes = True
if settings['gsm'].get('initial-eps-bearer-apn', ('s', ''))[1] != initial_eps_apn:
cloudlog.warning(f'Changing gsm.initial-eps-bearer-apn to {initial_eps_apn}')
settings['gsm']['initial-eps-bearer-apn'] = ('s', initial_eps_apn)
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:
if PC or self._router_main is None:
return 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._bootstrap_thread.is_alive():
self._bootstrap_thread.join()
if self._scan_thread.is_alive():
self._scan_thread.join()
if self._state_thread.is_alive():
self._state_thread.join()
self._reset_dbus()