diff --git a/main-ui/devices/gkd/connman_wifi_menu.py b/main-ui/devices/gkd/connman_wifi_menu.py new file mode 100644 index 00000000..778d42d7 --- /dev/null +++ b/main-ui/devices/gkd/connman_wifi_menu.py @@ -0,0 +1,223 @@ +from asyncio import subprocess +from pathlib import Path +import configparser +import time +from controller.controller_inputs import ControllerInput +from devices.device import Device +from devices.gkd.connman_wifi_scanner import WiFiNetwork +from devices.utils.process_runner import ProcessRunner +from display.display import Display +from display.on_screen_keyboard import OnScreenKeyboard +from utils.logger import PyUiLogger +from views.grid_or_list_entry import GridOrListEntry +from views.selection import Selection +from views.view_creator import ViewCreator +from views.view_type import ViewType + +from menus.language.language import Language + +class ConnmanWifiMenu: + def __init__(self): + self.on_screen_keyboard = OnScreenKeyboard() + + def wifi_adjust(self): + if Device.get_device().is_wifi_enabled(): + Device.get_device().disable_wifi() + else: + Device.get_device().enable_wifi() + + + def write_connman_conf(self, network: WiFiNetwork, passwd: str): + config_folder = Path("/storage/.cache/connman") + + # Build config options + config = configparser.RawConfigParser() + config.optionxform = lambda option: option + + config.add_section("Settings") + config["Settings"]["AutoConnect"] = "true" + + net_section = f"service_{network.id_str}" + config.add_section(net_section) + config[net_section]["Type"] = "wifi" + config[net_section]["Name"] = network.ssid + config[net_section]["Passphrase"] = passwd + + filename = network.id_str.split("_")[2] + full_path = config_folder.joinpath(filename).with_suffix(".config") + + # Write to file + try: + with open(full_path, "w") as f: + config.write(f) + + PyUiLogger.get_logger().info( + f"Installed network '{network.ssid}' into {str(full_path)}" + ) + + except OSError as e: + PyUiLogger.get_logger().error(f"Failed writing {str(full_path)}: {e}") + + + def connman_connect(self, id_str: str): + try: + ProcessRunner.run(["connmanctl", "connect", id_str]) + PyUiLogger.get_logger().info(f"Connected to {id_str}.") + except subprocess.CalledProcessError as e: + PyUiLogger.get_logger().error(f"Error connecting to {id_str}: {e}") + + + #TODO add confirmation or failed popups + def switch_network(self, net: WiFiNetwork): + PyUiLogger.get_logger().info(f"Selected {net.ssid}!") + if(net.requires_password()): + password = self.on_screen_keyboard.get_input("WiFi Password") + if(password is not None and 8 <= len(password) <= 63): + self.write_connman_conf(net, password) + Display.display_message(f"Updating config file for {net.ssid} with password {password}", duration_ms=5000) + else: + Display.display_message("Invalid WiFi password length! Must be between 8 and 63", duration_ms=5000) + + self.connman_connect(net.id_str) + + def _build_options( + self, + wifi_enabled: bool, + networks: list[WiFiNetwork], + connected_ssid: str | None, + ): + option_list = [] + + # WiFi toggle entry + option_list.append( + GridOrListEntry( + primary_text=Language.status(), + value_text="< " + ("On" if wifi_enabled else "Off") + " >", + image_path=None, + image_path_selected=None, + description=None, + icon=None, + value=self.wifi_adjust, + ) + ) + + # Network entries + if wifi_enabled: + if not networks: + option_list.append( + GridOrListEntry( + primary_text="Scanning for networks...", + value_text=None, + image_path=None, + image_path_selected=None, + description=None, + icon=None, + value=lambda: None, + ) + ) + else: + seen_names = set() + for net in networks: + name = net.ssid + + if name in seen_names: + continue + + seen_names.add(name) + connected = connected_ssid == net.ssid + + option_list.append( + GridOrListEntry( + primary_text=name, + value_text="✓" if connected else None, + image_path=None, + image_path_selected=None, + description=None, + icon=None, + value=lambda net=net: self.switch_network(net), + ) + ) + + return option_list + + def adapter_is_connected(self): + interfaces = Path("/sys/class/net/") + + for i in interfaces.iterdir(): + if i.name == "wlan0": + return True + + return False + + def show_wifi_menu(self): + if self.adapter_is_connected(): + self._show_menu() + else: + message = "USB adapter not connected.\n" \ + "Connect a compatible adapter to use WiFi.\n" \ + "The device must be restarted to use WiFi after using sleep." + Display.display_message(message, 5000) + + def _show_menu(self): + selected = Selection(None, None, 0) + self.wifi_scanner = Device.get_device().get_new_wifi_scanner() + + # Start background scanning immediately + self.wifi_scanner.scan_networks() + + connected_ssid = None + + accepted_inputs = [ + ControllerInput.A, + ControllerInput.DPAD_LEFT, + ControllerInput.DPAD_RIGHT, + ControllerInput.L1, + ControllerInput.R1, + ] + + try: + while selected is not None: + wifi_enabled = Device.get_device().is_wifi_enabled() + + # Pull latest scan snapshot (non-blocking) + networks = ( + self.wifi_scanner.scan_networks() + if wifi_enabled + else [] + ) + + ssid = self.wifi_scanner.get_connected_ssid() + connected_ssid = ssid + + # Build options (single source of truth) + option_list = self._build_options( + wifi_enabled=wifi_enabled, + networks=networks, + connected_ssid=connected_ssid, + ) + + # Render view + list_view = ViewCreator.create_view( + view_type=ViewType.ICON_AND_DESC, + top_bar_text="WiFi Configuration", + options=option_list, + selected_index=selected.get_index(), + ) + + # Single non-blocking poll + selected = list_view.get_selection(accepted_inputs) + + if selected is None: + break + + if selected.get_input() in accepted_inputs: + selected.get_selection().value() + elif ControllerInput.B == selected.get_input(): + break + + # Prevent CPU spin + time.sleep(0.05) + + finally: + Display.display_message("Stopping WiFi scanner...") + self.wifi_scanner.stop() diff --git a/main-ui/devices/gkd/connman_wifi_scanner.py b/main-ui/devices/gkd/connman_wifi_scanner.py new file mode 100644 index 00000000..446fca65 --- /dev/null +++ b/main-ui/devices/gkd/connman_wifi_scanner.py @@ -0,0 +1,179 @@ +import subprocess +import json +import time +import threading +from dataclasses import dataclass +from typing import List, Set + +from devices.device import Device +from devices.utils.process_runner import ProcessRunner +from utils.logger import PyUiLogger + +@dataclass +class WiFiNetwork: + id_str: str + signal_level: int + security: str + ssid: str + + def requires_password(self) -> bool: + return "psk" in self.security or "wep" in self.security + +class ConnmanWiFiScanner: + def __init__(self, interface="wlan0", delay=2): + self.interface = interface + self.delay = delay + + # Thread state + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + + # Shared scan results + self._lock = threading.Lock() + self._known_ssids: Set[str] = set() + self._known_id_str: Set[str] = set() + self._networks: List[WiFiNetwork] = [] + + # ---------------------------- + # Worker thread + # ---------------------------- + + def _scan_worker(self): + log = PyUiLogger.get_logger() + log.info("WiFi scan thread started") + + while not self._stop_event.is_set(): + try: + self._scan_once_internal() + except Exception: + log.exception("WiFi scan worker error") + + # Cooperative sleep so stop() reacts immediately + self._stop_event.wait(self.delay) + + log.info("WiFi scan thread stopped") + + def _scan_once_internal(self): + """ + Runs inside worker thread only. + """ + log = PyUiLogger.get_logger() + + result = ProcessRunner.run(["connmanctl", "scan", "wifi"]) + if "Scan completed" not in result.stdout: + log.error("wlan0 seems broken, restarting and retrying") + Device.get_device().wifi_error_detected() + time.sleep(15) + ProcessRunner.run(["connmanctl", "scan", "wifi"]) + + time.sleep(self.delay) + + jdata = self._get_connman_services() + new_networks: List[WiFiNetwork] = [] + + for service in jdata: + if 'Name' in service[1].keys(): + ssid = service[1]['Name']['data'] + else: + continue + + id_str = service[0].split("/")[-1] # Connman uses it's own ID string + signal = service[1]['Strength']['data'] + security = " ".join(service[1]['Security']['data']) + + network = WiFiNetwork( + id_str=id_str, + signal_level=int(signal), + security=security, + ssid=ssid, + ) + + new_networks.append(network) + + # Merge uniquely seen networks + with self._lock: + for net in new_networks: + if net.id_str not in self._known_id_str: + self._known_id_str.add(net.id_str) + self._known_ssids.add(net.ssid) + self._networks.append(net) + + # ---------------------------- + # Public API + # ---------------------------- + + def scan_networks(self) -> List[WiFiNetwork]: + """ + Non-blocking. + Starts the worker thread if not already running and + returns currently known networks immediately. + """ + if not self._thread or not self._thread.is_alive(): + self._start_thread() + + with self._lock: + # Return a snapshot copy + return list(self._networks) + + def _start_thread(self): + PyUiLogger.get_logger().info("Starting WiFi scan thread") + self._stop_event.clear() + self._thread = threading.Thread( + target=self._scan_worker, + name="WiFiScannerThread", + daemon=True, + ) + self._thread.start() + + def stop(self): + """ + Stops the worker thread and clears scanned networks. + """ + log = PyUiLogger.get_logger() + log.info("Stopping WiFi scan thread") + self._stop_event.set() + + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5) + + self._thread = None + + with self._lock: + self._known_ssids.clear() + self._known_id_str.clear() + self._networks.clear() + + # ---------------------------- + # Other helpers (unchanged) + # ---------------------------- + + def get_connected_ssid(self): + ssid = None + + jdata = self._get_connman_services() + + if jdata: + for service in jdata: + if service[1]['State']['data'] == "online": + ssid = service[1]['Name']['data'] + break + else: + PyUiLogger.get_logger().error("Failed to get Wi-Fi details") + + return ssid + + def _get_connman_services(self): + res = [] + try: + result = ProcessRunner.run([ + "busctl", "-j", "--system", + "call", "net.connman", "/", + "net.connman.Manager", "GetServices" + ]) + + jdata = json.loads(result.stdout) + res = jdata['data'][0] + except (subprocess.CalledProcessError, json.JSONDecodeError) as e: + PyUiLogger.get_logger().error(f"Failed to get connman services: {e}") + + return res diff --git a/main-ui/devices/gkd/gkd_device.py b/main-ui/devices/gkd/gkd_device.py index f0744f8a..c6130f40 100644 --- a/main-ui/devices/gkd/gkd_device.py +++ b/main-ui/devices/gkd/gkd_device.py @@ -1,13 +1,10 @@ -import ctypes -import fcntl -import math import re +import socket import subprocess from apps.miyoo.miyoo_app_finder import MiyooAppFinder from controller.controller_inputs import ControllerInput from controller.sdl.sdl2_controller_interface import Sdl2ControllerInterface from devices.charge.charge_status import ChargeStatus -import os from devices.device_common import DeviceCommon from devices.miyoo_trim_common import MiyooTrimCommon from devices.utils.process_runner import ProcessRunner @@ -37,10 +34,7 @@ def on_system_config_changed(self): def get_controller_interface(self): return self.sdl2_controller_interface - def ensure_wpa_supplicant_conf(self): - # TODO: - pass - + def clear_framebuffer(self): pass @@ -143,61 +137,83 @@ def map_analog_input(self, sdl_axis, sdl_value): def get_wifi_connection_quality_info(self) -> WiFiConnectionQualityInfo: try: - result = subprocess.run( - ["iw", "dev", "wlan0", "link"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output = result.stdout.strip() - - if "Not connected." in output or result.returncode != 0: - return WiFiConnectionQualityInfo(noise_level=0, signal_level=0, link_quality=0) - - signal_level = 0 - link_quality = 0 # This won't be available directly via iw, unless you derive it - - # Extract signal level (in dBm) - signal_match = re.search(r"signal:\s*(-?\d+)\s*dBm", output) - if signal_match: - signal_level = int(signal_match.group(1)) - - # Optional: derive link quality heuristically (e.g., map signal strength to 0–70 or 0–100) - # Example rough mapping: - if signal_level <= -100: - link_quality = 0 - elif signal_level >= -50: - link_quality = 70 + with open("/proc/net/wireless", "r") as f: + output = f.read().strip().splitlines() + + if len(output) >= 3: + # The 3rd line contains the actual wireless stats + data_line = output[2] + parts = data_line.split() + + # According to the standard format: + # parts[2] = link quality (float ending in '.') + # parts[3] = signal level + # parts[4] = noise level + link_quality = int(float(parts[2].strip('.'))) + signal_level = int(float(parts[3].strip('.'))) + noise_level = int(float(parts[4].strip('.'))) + + return WiFiConnectionQualityInfo( + noise_level=noise_level, + signal_level=signal_level, + link_quality=link_quality + ) else: - link_quality = int((signal_level + 100) * 1.4) # Maps -100..-50 dBm to 0..70 - - return WiFiConnectionQualityInfo( - noise_level=0, # Not available via `iw` - signal_level=signal_level, - link_quality=link_quality - ) + return WiFiConnectionQualityInfo(noise_level=0, signal_level=0, link_quality=0) except Exception as e: PyUiLogger.get_logger().error(f"An error occurred {e}") return WiFiConnectionQualityInfo(noise_level=0, signal_level=0, link_quality=0) - - def set_wifi_power(self, value): + + def get_wpa_supplicant_conf_path(self): + return None + + def start_wifi_services(self): pass def stop_wifi_services(self): - MiyooTrimCommon.stop_wifi_services(self) - - def start_wpa_supplicant(self): - MiyooTrimCommon.start_wpa_supplicant(self) + pass def is_wifi_enabled(self): return self.system_config.is_wifi_enabled() + @throttle.limit_refresh(10) + def get_ip_addr_text(self): + import psutil + if self.is_wifi_enabled(): + if not self.get_wifi_menu().adapter_is_connected(): + return "No USB adapter" + + try: + addrs = psutil.net_if_addrs().get("wlan0") + if addrs: + for addr in addrs: + if addr.family == socket.AF_INET: + return addr.address + return "Connecting" + else: + return "Connecting" + except Exception: + return "Error" + + return "Off" + def disable_wifi(self): - MiyooTrimCommon.disable_wifi(self) + self.system_config.reload_config() + self.system_config.set_wifi(0) + self.system_config.save_config() + ProcessRunner.run(["connmanctl", "disable", "wifi"]) + self.get_wifi_status.force_refresh() + self.get_ip_addr_text.force_refresh() def enable_wifi(self): - MiyooTrimCommon.enable_wifi(self) + self.system_config.reload_config() + self.system_config.set_wifi(1) + self.system_config.save_config() + ProcessRunner.run(["systemctl", "restart", "connman"]) + ProcessRunner.run(["connmanctl", "enable", "wifi"]) + self.get_wifi_status.force_refresh() + self.get_ip_addr_text.force_refresh() @throttle.limit_refresh(5) def get_charge_status(self): @@ -272,7 +288,7 @@ def remap_buttons(self): self.button_remapper.remap_buttons() def supports_wifi(self): - return False + return True def get_game_system_utils(self): return self.game_utils @@ -283,10 +299,6 @@ def get_roms_dir(self): def take_snapshot(self, path): return None - def get_wpa_supplicant_conf_path(self): - # TODO: - return None - def supports_brightness_calibration(): return True diff --git a/main-ui/devices/gkd/gkd_pixel2.py b/main-ui/devices/gkd/gkd_pixel2.py index 90ffb0bd..1898f271 100644 --- a/main-ui/devices/gkd/gkd_pixel2.py +++ b/main-ui/devices/gkd/gkd_pixel2.py @@ -9,12 +9,13 @@ from controller.key_watcher_controller_dataclasses import InputResult, KeyEvent from devices.miyoo.miyoo_games_file_parser import MiyooGamesFileParser from devices.gkd.gkd_device import GKDDevice +from devices.gkd.connman_wifi_scanner import ConnmanWiFiScanner +from devices.gkd.connman_wifi_menu import ConnmanWifiMenu from devices.utils.file_watcher import FileWatcher from devices.utils.process_runner import ProcessRunner from menus.settings.timezone_menu import TimezoneMenu from utils import throttle -from utils.config_copier import ConfigCopier from utils.ffmpeg_image_utils import FfmpegImageUtils from utils.logger import PyUiLogger from utils.py_ui_config import PyUiConfig @@ -29,7 +30,6 @@ def __init__(self, device_name, main_ui_mode): if(main_ui_mode): self.miyoo_games_file_parser = MiyooGamesFileParser() - self.ensure_wpa_supplicant_conf() threading.Thread(target=self.monitor_wifi, daemon=True).start() threading.Thread(target=self.startup_init, daemon=True).start() self.config_watcher_thread, self.config_watcher_thread_stop_event = FileWatcher().start_file_watcher( @@ -193,6 +193,11 @@ def _set_volume(self, user_volume): Display.volume_changed(user_volume) return user_volume - def might_require_surface_format_conversion(self): return True # RA save state images don't seem to load w/o conversion? + + def get_wifi_menu(self): + return ConnmanWifiMenu() + + def get_new_wifi_scanner(self): + return ConnmanWiFiScanner() \ No newline at end of file