You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							754 lines
						
					
					
						
							28 KiB
						
					
					
				
			
		
		
	
	
							754 lines
						
					
					
						
							28 KiB
						
					
					
				| import atexit
 | |
| 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.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 = 10
 | |
| 
 | |
| 
 | |
| 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
 | |
| 
 | |
|   @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
 | |
| 
 | |
|     # DBus connections
 | |
|     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)
 | |
|     except FileNotFoundError:
 | |
|       cloudlog.exception("Failed to connect to system D-Bus")
 | |
|       self._exit = True
 | |
| 
 | |
|     # 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._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._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():
 | |
|       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 set_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
 | |
| 
 | |
|     # 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):
 | |
|     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):
 | |
|     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):
 | |
|     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):
 | |
|     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):
 | |
|     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):
 | |
|     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:
 | |
|     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 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._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():
 | |
|       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()]
 | |
|       networks.sort(key=lambda n: (-n.is_connected, -n.strength, 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 == ""
 | |
| 
 | |
|         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
 | |
|       self._scan_thread.join()
 | |
|       self._state_thread.join()
 | |
| 
 | |
|       self._router_main.close()
 | |
|       self._router_main.conn.close()
 | |
|       self._conn_monitor.close()
 | |
| 
 |