From f336c01d59b28f3cb085ef559f0e7e24c83f5c15 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Thu, 30 Apr 2026 22:01:07 -0400 Subject: [PATCH 1/9] Multi-wifi! --- modalapi/wifi.py | 94 +++++++++++++++++++++++++++++++++++-------- pistomp/lcd320x240.py | 70 +++++++++++++++++++++++--------- 2 files changed, 129 insertions(+), 35 deletions(-) diff --git a/modalapi/wifi.py b/modalapi/wifi.py index eccf15c1..5aa50814 100644 --- a/modalapi/wifi.py +++ b/modalapi/wifi.py @@ -18,6 +18,7 @@ # Copyright (C) 2017 Vilniaus Blokas UAB, https://blokas.io/pisound import os +import re import threading import subprocess import logging @@ -31,10 +32,11 @@ class WifiManager(): # proper network management, but we aren't there. Alternatively, we could # monitor for hotplug events via dbus... # + HOTSPOT_PROFILE = 'pistomp-hotspot' + def __init__(self, ifname = 'wlan0'): # Grab default wifi interface self.iface_name = ifname - self.connection_name = 'preconfigured' # Name given by Rpi imager self.ssid = None self.psk = None self.lock = threading.Lock() @@ -110,7 +112,9 @@ def _polling_thread(self): logging.debug("Wifi status changed:" + str(new_status)) creds=() if supported and connected: - creds = self._acquire_creds() + active_conn = new_status.get('connection') + if active_conn: + creds = self._acquire_creds(active_conn) self.lock.acquire() if supported and connected and creds and len(creds)==2: @@ -151,18 +155,65 @@ def disable_hotspot(self): except: logging.debug('Wifi hotspot disabling failed') - def configure_wifi(self, ssid, password): - # This changes updates the config (ssid and psk) in the /etc/NetworkManager/system-connections file - # Bringing the connection up is expected to be done elsewhere (eg. disable_hotspot) - # - # TODO check credentials without connecting - problem is reusing wlan0 for hotspot, would prob need separate dev - # Can be done by making a test conn with a different profile, try to conn, disconnect if success, error if not. - # nmcli connection add type wifi ifname wlan0 con-name temp-test ssid "SSID" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "Password" - # nmcli connection up temp-test - # nmcli connection delete temp-test + def list_connections(self): + """Return list of dicts {name, ssid} for all wifi profiles, excluding the hotspot.""" + try: + result = subprocess.run( + ['nmcli', '-t', '-f', 'NAME,TYPE,802-11-WIRELESS.SSID', 'connection', 'show'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + connections = [] + for line in result.stdout.strip().split('\n'): + # nmcli terse mode escapes literal colons as \: — split on unescaped colons only + parts = [p.replace('\\:', ':') for p in re.split(r'(?= 2 and parts[1] == '802-11-wireless': + name = parts[0] + ssid = parts[2] if len(parts) > 2 and parts[2] else name + if name != self.HOTSPOT_PROFILE: + connections.append({'name': name, 'ssid': ssid}) + return connections + except Exception as e: + logging.error("Failed to list wifi connections: " + str(e)) + return [] + + def add_connection(self, ssid, psk): + """Add a new wifi profile. Profile name is the SSID, suffixed if a duplicate exists.""" + existing = {c['name'] for c in self.list_connections()} + name = ssid + counter = 2 + while name in existing: + name = '%s (%d)' % (ssid, counter) + counter += 1 + try: + subprocess.check_output([ + 'sudo', 'nmcli', 'connection', 'add', + 'type', 'wifi', 'ifname', self.iface_name, + 'con-name', name, + 'ssid', ssid, + 'wifi-sec.key-mgmt', 'wpa-psk', + 'wifi-sec.psk', psk, + 'connection.autoconnect', 'yes' + ], stderr=subprocess.STDOUT) + return None + except subprocess.CalledProcessError as exc: + return exc.output + + def delete_connection(self, name): + """Delete a wifi profile by its NM connection name.""" try: - result = subprocess.check_output([ - 'sudo', 'nmcli', 'connection', 'modify', self.connection_name, + subprocess.check_output( + ['sudo', 'nmcli', 'connection', 'delete', name], + stderr=subprocess.STDOUT + ) + return None + except subprocess.CalledProcessError as exc: + return exc.output + + def configure_wifi(self, name, ssid, password): + """Update the SSID and PSK for an existing wifi profile.""" + try: + subprocess.check_output([ + 'sudo', 'nmcli', 'connection', 'modify', name, '802-11-wireless.ssid', ssid, '802-11-wireless-security.psk', password ], stderr=subprocess.STDOUT) @@ -170,19 +221,28 @@ def configure_wifi(self, ssid, password): except subprocess.CalledProcessError as exc: return exc.output - def _acquire_creds(self): + def get_psk_for(self, name): + """Fetch the stored PSK for a specific wifi profile.""" + try: + result = subprocess.run( + ['sudo', 'nmcli', '-s', '-g', '802-11-wireless-security.psk', 'connection', 'show', name], + stdout=subprocess.PIPE, text=True + ) + return result.stdout.strip() or None + except Exception: + return None + + def _acquire_creds(self, connection_name): try: - # Run the nmcli command and capture the output result = subprocess.run( ['sudo', 'nmcli', '-s', '-g', '802-11-wireless.ssid,802-11-wireless-security.psk', 'connection', - 'show', self.connection_name], + 'show', connection_name], stdout=subprocess.PIPE, text=True ) fields = result.stdout.split('\n') if len(fields) == 3: return fields[:2] - except: logging.debug('Failure running nmcli to get wifi name') diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 5d325aa3..0cd40f9a 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -93,6 +93,8 @@ def __init__(self, cwd, handler=None, flip=False): self.w_wifi = None self.w_wifi_ssid = None self.w_wifi_pw = None + self._wifi_networks_menu = None + self._wifi_edit_profile = None self.w_eq = None self.w_power = None self.w_wrench = None @@ -211,33 +213,43 @@ def toggle_hotspot(self, arg1): self.main_panel.refresh() def configure_wifi(self, event, button): - result = self.handler.configure_wifi_credentials(self.w_wifi_ssid.text, self.w_wifi_pw.text) + ssid = self.w_wifi_ssid.text + psk = self.w_wifi_pw.text + if self._wifi_edit_profile is None: + result = self.handler.wifi_manager.add_connection(ssid, psk) + else: + result = self.handler.wifi_manager.configure_wifi(self._wifi_edit_profile, ssid, psk) - # Show Error dialog if configure was not successful if result is not None: d = MessageDialog(self.pstack, result.decode("utf-8"), title="Error") self.pstack.push_panel(d) else: self.pstack.pop_panel(button.parent) - - def draw_wifi_dialog(self, event): - ssid = self.handler.wifi_manager.get_ssid() - ssid = ssid if ssid else "None" - psk = self.handler.wifi_manager.get_psk() - psk = psk if psk else "None" + if self._wifi_edit_profile is None: + self.pstack.pop_panel(self._wifi_networks_menu) + self.draw_wifi_menu(None, None) + + def _draw_wifi_dialog(self, conn): + # conn is None for "Add Network", or a dict {name, ssid} for "Edit" + if conn is None: + self._wifi_edit_profile = None + ssid = '' + psk = '' + else: + self._wifi_edit_profile = conn['name'] + ssid = conn['ssid'] + psk = self.handler.wifi_manager.get_psk_for(conn['name']) or '' d = Dialog(width=240, height=120, auto_destroy=True, title='Configure WiFi') self.w_wifi_ssid = TextWidget(box=Box.xywh(0, 0, 190, 0), text=ssid, prompt='SSID :', parent=d, - outline=1, sel_width=3, - outline_radius=5, - align=WidgetAlign.NONE, name='cancel_btn', + outline=1, sel_width=3, outline_radius=5, + align=WidgetAlign.NONE, name='ssid_field', edit_message='WiFi SSID') d.add_sel_widget(self.w_wifi_ssid) self.w_wifi_pw = TextWidget(box=Box.xywh(0, 30, 169, 0), text=psk, prompt='Passwd :', parent=d, - outline=1, - sel_width=3, outline_radius=5, - align=WidgetAlign.NONE, name='cancel_btn', + outline=1, sel_width=3, outline_radius=5, + align=WidgetAlign.NONE, name='pw_field', edit_message='Password') d.add_sel_widget(self.w_wifi_pw) @@ -251,6 +263,21 @@ def draw_wifi_dialog(self, event): self.pstack.push_panel(d) d.refresh() + def _draw_wifi_network_menu(self, conn): + items = [("Edit", self._draw_wifi_dialog, conn), + ("Forget", self._forget_wifi_network, conn)] + self.draw_selection_menu(items, conn['name'], dismiss_option=True) + + def _forget_wifi_network(self, conn): + result = self.handler.wifi_manager.delete_connection(conn['name']) + if result is not None: + d = MessageDialog(self.pstack, result.decode("utf-8"), title="Error") + self.pstack.push_panel(d) + return + self.pstack.pop_panel(None) # pop network submenu + self.pstack.pop_panel(self._wifi_networks_menu) # pop stale list + self.draw_wifi_menu(None, None) # redraw fresh + # # Title (Pedalboard and Preset) # @@ -573,10 +600,17 @@ def draw_bank_menu(self, event): self.draw_selection_menu(items, "Bank Select", auto_dismiss=True) def draw_wifi_menu(self, event, widget): - label = "Switch to Wifi" if util.DICT_GET(self.handler.wifi_status, 'hotspot_active') else "Switch to Hotspot" - items = [("Configure WiFi", self.draw_wifi_dialog, None), - (label, self.toggle_hotspot, None)] - self.draw_selection_menu(items, "WiFi Menu", dismiss_option = True) + connections = self.handler.wifi_manager.list_connections() + active = util.DICT_GET(self.handler.wifi_status, 'connection') + hotspot_active = util.DICT_GET(self.handler.wifi_status, 'hotspot_active') + label = "Switch to Wifi" if hotspot_active else "Switch to Hotspot" + items = [] + for conn in connections: + is_active = conn['name'] == active + items.append((conn['name'], self._draw_wifi_network_menu, conn, is_active)) + items.append(("Add Network...", self._draw_wifi_dialog, None)) + items.append((label, self.toggle_hotspot, None)) + self._wifi_networks_menu = self.draw_selection_menu(items, "WiFi Networks", dismiss_option=True) def draw_audio_menu(self, event, widget): items = [("Output Volume", self.handler.system_menu_headphone_volume, None), From ea573b1f10fb25042db548f994821e0cfedcce93 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 3 May 2026 11:19:16 -0400 Subject: [PATCH 2/9] chore: migrate dev deps to dependency-groups Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 13 +++++++++---- uv.lock | 30 ++++++++++++++++-------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c1f537f0..0e02f63d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,6 @@ hardware = [ "adafruit-circuitpython-mcp3xxx>=1.4", ] -# Development dependencies -dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.1.0", "pyright>=1.1.408", "numpy>=2.4.1"] - [project.urls] Homepage = "https://treefallsound.com" Documentation = "https://www.treefallsound.com/wiki/doku.php" @@ -73,4 +70,12 @@ stubPath = "typings" pythonVersion = "3.11" [dependency-groups] -dev = ["pyright>=1.1.408"] +dev = [ + "numpy>=2.4.1", + "pillow>=12.0.0", + "pyright>=1.1.408", + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "ruff>=0.1.0", + "typing-extensions>=4.15.0", +] diff --git a/uv.lock b/uv.lock index 68db36a1..44f1a83f 100644 --- a/uv.lock +++ b/uv.lock @@ -819,13 +819,6 @@ dependencies = [ ] [package.optional-dependencies] -dev = [ - { name = "numpy" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] hardware = [ { name = "adafruit-circuitpython-mcp3xxx" }, { name = "adafruit-circuitpython-neopixel" }, @@ -838,7 +831,13 @@ hardware = [ [package.dev-dependencies] dev = [ + { name = "numpy" }, + { name = "pillow" }, { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "typing-extensions" }, ] [package.metadata] @@ -849,22 +848,25 @@ requires-dist = [ { name = "gfxhat", marker = "sys_platform == 'linux' and extra == 'hardware'", specifier = ">=0.0.1" }, { name = "jsonschema", specifier = ">=4.0" }, { name = "matplotlib", marker = "extra == 'hardware'", specifier = ">=3.5" }, - { name = "numpy", marker = "extra == 'dev'", specifier = ">=2.4.1" }, { name = "pyalsaaudio", marker = "sys_platform == 'linux'", specifier = ">=0.9" }, - { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.408" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "python-rtmidi", specifier = ">=1.4" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.28" }, { name = "rpi-ws281x", marker = "sys_platform == 'linux' and extra == 'hardware'", specifier = ">=5.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "spidev", marker = "sys_platform == 'linux' and extra == 'hardware'", specifier = ">=3.0" }, ] -provides-extras = ["hardware", "dev"] +provides-extras = ["hardware"] [package.metadata.requires-dev] -dev = [{ name = "pyright", specifier = ">=1.1.408" }] +dev = [ + { name = "numpy", specifier = ">=2.4.1" }, + { name = "pillow", specifier = ">=12.0.0" }, + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.1.0" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, +] [[package]] name = "pillow" From 340ea4b1bf41c5caad5eda8af71c01550599fcf5 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 4 May 2026 23:47:18 -0400 Subject: [PATCH 3/9] Better wifi implementation --- modalapi/modhandler.py | 3 - modalapi/wifi.py | 169 +++++++++++++++++++-- pistomp/handler.py | 3 - pistomp/lcd320x240.py | 322 +++++++++++++++++++++++++++++++++-------- 4 files changed, 418 insertions(+), 79 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 475d5692..41d25526 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -766,9 +766,6 @@ def system_toggle_hotspot(self, **kwargs): else: self.wifi_manager.enable_hotspot() - def configure_wifi_credentials(self, ssid, password): - return self.wifi_manager.configure_wifi(ssid, password) - def audio_parameter_change(self, direction, name, symbol, value, min, max, commit_callback): if symbol is not None: d = self.lcd.draw_audio_parameter_dialog(name, symbol, value, min, max, commit_callback) diff --git a/modalapi/wifi.py b/modalapi/wifi.py index 5aa50814..30a7bbf2 100644 --- a/modalapi/wifi.py +++ b/modalapi/wifi.py @@ -23,6 +23,36 @@ import subprocess import logging + +def parse_nmcli_error(stderr): + """Map a chunk of nmcli stderr to a short user-facing reason.""" + if stderr is None: + return "unknown error" + text = stderr.decode('utf-8', errors='replace') if isinstance(stderr, (bytes, bytearray)) else str(stderr) + lower = text.lower() + if 'secrets were required' in lower or '802-11-wireless-security.psk' in lower or '(7)' in lower: + return "auth failed (wrong password)" + if 'no network with ssid' in lower or 'no suitable' in lower or 'ssid not found' in lower: + return "network not found" + if 'ip-config-unavailable' in lower or 'dhcp' in lower: + return "couldn't get an IP (DHCP timeout)" + if 'timeout' in lower or 'timed out' in lower: + return "timed out" + if 'not authorized' in lower or 'permission denied' in lower: + return "permission denied" + # Fall back to first non-empty line, truncated. + for line in text.splitlines(): + line = line.strip() + if line: + return line[:80] + return "unknown error" + + +def _split_terse(line): + """Split an nmcli -t terse line, honouring backslash-escaped colons.""" + return [p.replace('\\:', ':') for p in re.split(r'(?= 2 and parts[1] == '802-11-wireless': name = parts[0] - ssid = parts[2] if len(parts) > 2 and parts[2] else name - if name != self.HOTSPOT_PROFILE: - connections.append({'name': name, 'ssid': ssid}) + if name == self.HOTSPOT_PROFILE: + continue + try: + timestamp = int(parts[2]) if len(parts) > 2 and parts[2] else 0 + except ValueError: + timestamp = 0 + ssid = parts[3] if len(parts) > 3 and parts[3] else name + connections.append({'name': name, 'ssid': ssid, 'timestamp': timestamp}) return connections except Exception as e: logging.error("Failed to list wifi connections: " + str(e)) return [] - def add_connection(self, ssid, psk): - """Add a new wifi profile. Profile name is the SSID, suffixed if a duplicate exists.""" + def scan_networks(self): + """Return a list of nearby networks as {ssid, signal, security, in_use} dicts. + + Deduplicated by SSID (strongest signal wins), sorted by signal desc, hidden SSIDs filtered.""" + try: + result = subprocess.run( + ['nmcli', '-t', '-f', 'IN-USE,SSID,SIGNAL,SECURITY', 'dev', 'wifi', 'list', + '--rescan', 'auto', 'ifname', self.iface_name], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=15 + ) + except Exception as e: + logging.error("wifi scan failed: " + str(e)) + return [] + + best = {} + for line in result.stdout.strip().split('\n'): + if not line: + continue + parts = _split_terse(line) + if len(parts) < 4: + continue + in_use = parts[0] == '*' + ssid = parts[1] + if not ssid: + continue # hidden network + try: + signal = int(parts[2]) + except ValueError: + signal = 0 + security = parts[3] + existing = best.get(ssid) + if existing is None or signal > existing['signal']: + best[ssid] = {'ssid': ssid, 'signal': signal, + 'security': security, 'in_use': in_use} + elif in_use: + existing['in_use'] = True + return sorted(best.values(), key=lambda n: n['signal'], reverse=True) + + def _resolve_unique_name(self, desired, exclude=None): + """Pick a profile name based on `desired`, suffixing (2)/(3)/... if it collides. + + `exclude` is the existing name of a profile being modified (so it doesn't collide with itself).""" existing = {c['name'] for c in self.list_connections()} - name = ssid + if exclude is not None: + existing.discard(exclude) + name = desired counter = 2 while name in existing: - name = '%s (%d)' % (ssid, counter) + name = '%s (%d)' % (desired, counter) counter += 1 + return name + + def add_connection(self, ssid, psk): + """Add a new wifi profile. Profile name is the SSID, suffixed if a duplicate exists.""" + name = self._resolve_unique_name(ssid) try: subprocess.check_output([ 'sudo', 'nmcli', 'connection', 'add', @@ -210,10 +295,15 @@ def delete_connection(self, name): return exc.output def configure_wifi(self, name, ssid, password): - """Update the SSID and PSK for an existing wifi profile.""" + """Update the SSID and PSK for an existing wifi profile. + + Auto-syncs connection.id to the new SSID (with collision suffix), so the display + label can never drift from the SSID.""" + new_name = self._resolve_unique_name(ssid, exclude=name) try: subprocess.check_output([ 'sudo', 'nmcli', 'connection', 'modify', name, + 'connection.id', new_name, '802-11-wireless.ssid', ssid, '802-11-wireless-security.psk', password ], stderr=subprocess.STDOUT) @@ -221,6 +311,61 @@ def configure_wifi(self, name, ssid, password): except subprocess.CalledProcessError as exc: return exc.output + def connect_scanned(self, ssid, psk=None): + """Join a network discovered via scan. Creates a profile and activates it atomically. + + On failure nmcli cleans up the partial profile, so this doubles as a credential test.""" + cmd = ['sudo', 'nmcli', 'dev', 'wifi', 'connect', ssid, 'ifname', self.iface_name] + if psk: + cmd += ['password', psk] + try: + subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=45) + return None + except subprocess.CalledProcessError as exc: + return exc.output + except subprocess.TimeoutExpired: + return b'connection timed out' + + def connect_saved(self, name): + """Activate an existing saved profile.""" + try: + subprocess.check_output( + ['sudo', 'nmcli', 'connection', 'up', name], + stderr=subprocess.STDOUT, timeout=45 + ) + return None + except subprocess.CalledProcessError as exc: + return exc.output + except subprocess.TimeoutExpired: + return b'connection timed out' + + def replace_psk(self, name, psk): + """Update the PSK on a saved profile and validate by activating it. + + On failure the previous PSK is restored so the saved profile keeps working.""" + old_psk = self.get_psk_for(name) + try: + subprocess.check_output( + ['sudo', 'nmcli', 'connection', 'modify', name, + '802-11-wireless-security.psk', psk], + stderr=subprocess.STDOUT + ) + except subprocess.CalledProcessError as exc: + return exc.output + + err = self.connect_saved(name) + if err is not None and old_psk is not None: + try: + subprocess.check_output( + ['sudo', 'nmcli', 'connection', 'modify', name, + '802-11-wireless-security.psk', old_psk], + stderr=subprocess.STDOUT + ) + logging.info("rolled back PSK on %s after failed connect" % name) + except subprocess.CalledProcessError as rollback_exc: + logging.error("PSK rollback failed: " + str(rollback_exc.output)) + return err + def get_psk_for(self, name): """Fetch the stored PSK for a specific wifi profile.""" try: diff --git a/pistomp/handler.py b/pistomp/handler.py index 695dd325..74728f80 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -89,6 +89,3 @@ def poll_wifi(self): def system_toggle_hotspot(self, **kw): raise NotImplementedError() - - def configure_wifi_credentials(self, ssid, password): - raise NotImplementedError() diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 0cd40f9a..83cb5c41 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -17,8 +17,10 @@ import digitalio import logging import os +import time import common.token as Token import modalapi.parameter as Parameter +from modalapi.wifi import parse_nmcli_error import pistomp.category as Category import pistomp.lcd as abstract_lcd import pistomp.switchstate as switchstate @@ -91,10 +93,7 @@ def __init__(self, cwd, handler=None, flip=False): # widgets self.w_wifi = None - self.w_wifi_ssid = None - self.w_wifi_pw = None self._wifi_networks_menu = None - self._wifi_edit_profile = None self.w_eq = None self.w_power = None self.w_wrench = None @@ -204,79 +203,280 @@ def draw_bypass_preference(self): pref == Token.LEFT_RIGHT or pref == None)] self.draw_selection_menu(items, "Bypass Preference", auto_dismiss=True) + # ----- WiFi menu ----- + + _SIGNAL_FILLED = '\u25ae' # ▮ + _SIGNAL_EMPTY = '\u25af' # ▯ + _ACTIVE_GLYPH = '\u2714' # ✔ + _IN_RANGE_NEARBY_CAP = 5 + _OUT_OF_RANGE_CAP = 2 + + @staticmethod + def _signal_bars(signal): + levels = max(1, min(4, (signal + 12) // 25)) + return Lcd._SIGNAL_FILLED * levels + Lcd._SIGNAL_EMPTY * (4 - levels) + + @staticmethod + def _format_age(ts): + if not ts: + return None + age = max(0, int(time.time()) - int(ts)) + if age < 60: + return "now" + if age < 3600: + return "%dm ago" % (age // 60) + if age < 86400: + return "%dh ago" % (age // 3600) + return "%dd ago" % (age // 86400) + + def _network_label(self, row): + ssid = row['ssid'] + active_marker = (self._ACTIVE_GLYPH + ' ') if row.get('active') else '' + if row.get('signal') is not None: + suffix = ' ' + self._signal_bars(row['signal']) + elif row.get('saved'): + suffix = ' (saved)' + else: + suffix = '' + if row.get('disambiguator'): + suffix += ' ' + row['disambiguator'] + return active_marker + ssid + suffix + def toggle_hotspot(self, arg1): self.pstack.pop_panel(None) - self.draw_info_message("connecting...") - self.main_panel.refresh() + self.draw_info_message("connecting...", refresh=True) self.handler.system_toggle_hotspot() - self.draw_info_message("") - self.main_panel.refresh() + self.draw_info_message("", refresh=True) - def configure_wifi(self, event, button): - ssid = self.w_wifi_ssid.text - psk = self.w_wifi_pw.text - if self._wifi_edit_profile is None: - result = self.handler.wifi_manager.add_connection(ssid, psk) + def draw_wifi_menu(self, event, widget): + wifi_status = self.handler.wifi_status or {} + supported = util.DICT_GET(wifi_status, 'wifi_supported') + hotspot_active = util.DICT_GET(wifi_status, 'hotspot_active') + active_name = util.DICT_GET(wifi_status, 'connection') + + saved = self.handler.wifi_manager.list_connections() + saved_by_ssid = {} + for c in saved: + saved_by_ssid.setdefault(c['ssid'], []).append(c) + + scanned = [] + if supported and not hotspot_active: + self.draw_info_message("scanning...", refresh=True) + scanned = self.handler.wifi_manager.scan_networks() + self.draw_info_message("", refresh=True) + scanned_ssids = {n['ssid'] for n in scanned} + + title = self._wifi_menu_title(wifi_status, active_name, saved_by_ssid, scanned) + + rows = [] + # In-range networks first (scanned), promoting active to top. + in_range = [] + for net in scanned: + profiles = saved_by_ssid.get(net['ssid'], []) + saved_profile = self._pick_profile(profiles, active_name) + row = { + 'ssid': net['ssid'], + 'signal': net['signal'], + 'security': net['security'], + 'saved': bool(saved_profile), + 'profile': saved_profile, + 'active': bool(saved_profile) and saved_profile['name'] == active_name, + } + self._maybe_disambiguate(row, profiles) + in_range.append(row) + in_range.sort(key=lambda r: (not r['active'], -r['signal'])) + in_range = in_range[:self._IN_RANGE_NEARBY_CAP + (1 if any(r['active'] for r in in_range) else 0)] + rows.extend(in_range) + + # Saved profiles not visible in scan. + out_of_range = [] + for ssid, profiles in saved_by_ssid.items(): + if ssid in scanned_ssids: + continue + for profile in profiles: + row = { + 'ssid': ssid, + 'signal': None, + 'security': None, + 'saved': True, + 'profile': profile, + 'active': profile['name'] == active_name, + } + self._maybe_disambiguate(row, profiles) + out_of_range.append(row) + out_of_range.sort(key=lambda r: -(r['profile']['timestamp'] or 0)) + if len(out_of_range) > self._OUT_OF_RANGE_CAP: + shown = out_of_range[:self._OUT_OF_RANGE_CAP] + extras = out_of_range[self._OUT_OF_RANGE_CAP:] else: - result = self.handler.wifi_manager.configure_wifi(self._wifi_edit_profile, ssid, psk) + shown = out_of_range + extras = [] + rows.extend(shown) - if result is not None: - d = MessageDialog(self.pstack, result.decode("utf-8"), title="Error") - self.pstack.push_panel(d) - else: - self.pstack.pop_panel(button.parent) - if self._wifi_edit_profile is None: - self.pstack.pop_panel(self._wifi_networks_menu) - self.draw_wifi_menu(None, None) - - def _draw_wifi_dialog(self, conn): - # conn is None for "Add Network", or a dict {name, ssid} for "Edit" - if conn is None: - self._wifi_edit_profile = None - ssid = '' - psk = '' + items = [(self._network_label(row), self._on_network_tap, row) for row in rows] + if extras: + items.append(("More saved...", self._draw_more_saved, extras)) + items.append(("Join other network...", self._draw_join_dialog, None)) + hotspot_label = ("Hotspot Mode \u25cf" if hotspot_active else "Hotspot Mode \u25cb") + items.append((hotspot_label, self.toggle_hotspot, None)) + + self._wifi_networks_menu = self.draw_selection_menu(items, title, dismiss_option=True) + + def _wifi_menu_title(self, wifi_status, active_name, saved_by_ssid, scanned): + if util.DICT_GET(wifi_status, 'hotspot_active'): + return "WiFi \u00b7 Hotspot" + if active_name: + ssid = util.DICT_GET(wifi_status, 'ssid') or active_name + for net in scanned: + if net['in_use'] or net['ssid'] == ssid: + return "WiFi \u00b7 %s %s" % (ssid, self._signal_bars(net['signal'])) + return "WiFi \u00b7 %s" % ssid + return "WiFi \u00b7 Disconnected" + + def _pick_profile(self, profiles, active_name): + if not profiles: + return None + for p in profiles: + if p['name'] == active_name: + return p + return max(profiles, key=lambda p: p['timestamp'] or 0) + + def _maybe_disambiguate(self, row, profiles): + if row.get('saved') and len(profiles) > 1 and row.get('profile'): + age = self._format_age(row['profile'].get('timestamp')) + if age: + row['disambiguator'] = "\u00b7 " + age + + def _on_network_tap(self, row): + if row.get('saved'): + self._draw_saved_network_menu(row) + elif self._is_open_network(row.get('security')): + self._connect_with_feedback( + lambda: self.handler.wifi_manager.connect_scanned(row['ssid']), + row['ssid']) else: - self._wifi_edit_profile = conn['name'] - ssid = conn['ssid'] - psk = self.handler.wifi_manager.get_psk_for(conn['name']) or '' - - d = Dialog(width=240, height=120, auto_destroy=True, title='Configure WiFi') - - self.w_wifi_ssid = TextWidget(box=Box.xywh(0, 0, 190, 0), text=ssid, prompt='SSID :', parent=d, - outline=1, sel_width=3, outline_radius=5, - align=WidgetAlign.NONE, name='ssid_field', - edit_message='WiFi SSID') - d.add_sel_widget(self.w_wifi_ssid) - self.w_wifi_pw = TextWidget(box=Box.xywh(0, 30, 169, 0), text=psk, prompt='Passwd :', parent=d, - outline=1, sel_width=3, outline_radius=5, - align=WidgetAlign.NONE, name='pw_field', - edit_message='Password') - d.add_sel_widget(self.w_wifi_pw) - - b = TextWidget(box=Box.xywh(0, 90, 0, 0), text='Cancel', parent=d, outline=1, sel_width=3, outline_radius=5, - action=lambda x, y: self.pstack.pop_panel(d), align=WidgetAlign.NONE, name='cancel_btn') - d.add_sel_widget(b) - b = TextWidget(box=Box.xywh(80, 90, 0, 0), text='Ok', parent=d, outline=1, sel_width=3, outline_radius=5, - action=self.configure_wifi, align=WidgetAlign.NONE, name='ok_btn') - d.add_sel_widget(b) + self._prompt_password(row['ssid'], + lambda psk: self._connect_with_feedback( + lambda: self.handler.wifi_manager.connect_scanned(row['ssid'], psk), + row['ssid'])) + + @staticmethod + def _is_open_network(security): + if not security or security == '--': + return True + return False + + def _draw_saved_network_menu(self, row): + profile = row['profile'] + items = [] + if row.get('signal') is not None and not row.get('active'): + items.append(("Connect", self._connect_saved, row)) + items.append(("Replace password", self._draw_replace_psk_dialog, row)) + items.append(("Forget", self._forget_wifi_network, row)) + self.draw_selection_menu(items, row['ssid'], dismiss_option=True) + + def _draw_more_saved(self, extras): + items = [(self._network_label(row), self._on_network_tap, row) for row in extras] + self.draw_selection_menu(items, "Saved Networks", dismiss_option=True) + + def _connect_saved(self, row): + profile = row['profile'] + self._connect_with_feedback( + lambda: self.handler.wifi_manager.connect_saved(profile['name']), + row['ssid']) + + def _connect_with_feedback(self, connect_fn, ssid): + self.pstack.pop_panel(None) + self.draw_info_message("connecting to %s..." % ssid, refresh=True) + err = connect_fn() + self.draw_info_message("", refresh=True) + if err is None: + self.draw_info_message("connected: %s" % ssid, refresh=True) + return + d = MessageDialog(self.pstack, parse_nmcli_error(err), title="Couldn't connect") + self.pstack.push_panel(d) + def _prompt_password(self, ssid, on_submit): + d = Dialog(width=240, height=110, auto_destroy=True, title='Password for %s' % ssid) + pw = TextWidget(box=Box.xywh(0, 0, 200, 0), text='', prompt='Passwd :', parent=d, + outline=1, sel_width=3, outline_radius=5, + align=WidgetAlign.NONE, name='pw_field', + edit_message='Password') + d.add_sel_widget(pw) + cancel = TextWidget(box=Box.xywh(0, 60, 0, 0), text='Cancel', parent=d, + outline=1, sel_width=3, outline_radius=5, + action=lambda x, y: self.pstack.pop_panel(d), + align=WidgetAlign.NONE, name='cancel_btn') + d.add_sel_widget(cancel) + + def submit(event, button): + psk = pw.text + if not psk: + return + self.pstack.pop_panel(d) + on_submit(psk) + + ok = TextWidget(box=Box.xywh(80, 60, 0, 0), text='Ok', parent=d, + outline=1, sel_width=3, outline_radius=5, + action=submit, align=WidgetAlign.NONE, name='ok_btn') + d.add_sel_widget(ok) self.pstack.push_panel(d) d.refresh() - def _draw_wifi_network_menu(self, conn): - items = [("Edit", self._draw_wifi_dialog, conn), - ("Forget", self._forget_wifi_network, conn)] - self.draw_selection_menu(items, conn['name'], dismiss_option=True) + def _draw_replace_psk_dialog(self, row): + profile = row['profile'] + self._prompt_password(row['ssid'], + lambda psk: self._connect_with_feedback( + lambda: self.handler.wifi_manager.replace_psk(profile['name'], psk), + row['ssid'])) + + def _draw_join_dialog(self, _): + d = Dialog(width=240, height=120, auto_destroy=True, title='Join other network') + ssid_widget = TextWidget(box=Box.xywh(0, 0, 190, 0), text='', prompt='SSID :', parent=d, + outline=1, sel_width=3, outline_radius=5, + align=WidgetAlign.NONE, name='ssid_field', + edit_message='WiFi SSID') + d.add_sel_widget(ssid_widget) + pw_widget = TextWidget(box=Box.xywh(0, 30, 169, 0), text='', prompt='Passwd :', parent=d, + outline=1, sel_width=3, outline_radius=5, + align=WidgetAlign.NONE, name='pw_field', + edit_message='Password') + d.add_sel_widget(pw_widget) + + cancel = TextWidget(box=Box.xywh(0, 90, 0, 0), text='Cancel', parent=d, + outline=1, sel_width=3, outline_radius=5, + action=lambda x, y: self.pstack.pop_panel(d), + align=WidgetAlign.NONE, name='cancel_btn') + d.add_sel_widget(cancel) + + def submit(event, button): + ssid = ssid_widget.text + psk = pw_widget.text + if not ssid: + return + self.pstack.pop_panel(d) + self._connect_with_feedback( + lambda: self.handler.wifi_manager.connect_scanned(ssid, psk or None), + ssid) + + ok = TextWidget(box=Box.xywh(80, 90, 0, 0), text='Ok', parent=d, + outline=1, sel_width=3, outline_radius=5, + action=submit, align=WidgetAlign.NONE, name='ok_btn') + d.add_sel_widget(ok) + self.pstack.push_panel(d) + d.refresh() - def _forget_wifi_network(self, conn): - result = self.handler.wifi_manager.delete_connection(conn['name']) + def _forget_wifi_network(self, row): + profile = row['profile'] + result = self.handler.wifi_manager.delete_connection(profile['name']) if result is not None: - d = MessageDialog(self.pstack, result.decode("utf-8"), title="Error") + d = MessageDialog(self.pstack, parse_nmcli_error(result), title="Error") self.pstack.push_panel(d) return - self.pstack.pop_panel(None) # pop network submenu - self.pstack.pop_panel(self._wifi_networks_menu) # pop stale list - self.draw_wifi_menu(None, None) # redraw fresh + self.pstack.pop_panel(None) # pop submenu + if self._wifi_networks_menu is not None: + self.pstack.pop_panel(self._wifi_networks_menu) + self.draw_wifi_menu(None, None) # # Title (Pedalboard and Preset) From 3d9f5ddbe0179992b5380785ae9a24fa58f9a965 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Tue, 5 May 2026 00:29:47 -0400 Subject: [PATCH 4/9] Better wifi menu --- modalapi/wifi.py | 178 +++++++++++-------- pistomp/lcd320x240.py | 296 +------------------------------ pyproject.toml | 2 +- ui/__init__.py | 0 ui/wifi_menu.py | 404 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 513 insertions(+), 367 deletions(-) create mode 100644 ui/__init__.py create mode 100644 ui/wifi_menu.py diff --git a/modalapi/wifi.py b/modalapi/wifi.py index 30a7bbf2..149575e7 100644 --- a/modalapi/wifi.py +++ b/modalapi/wifi.py @@ -22,9 +22,33 @@ import threading import subprocess import logging +from typing import Optional, TypedDict -def parse_nmcli_error(stderr): +class SavedConnection(TypedDict): + name: str + ssid: str + timestamp: int + + +class ScannedNetwork(TypedDict): + ssid: str + signal: int + security: str + in_use: bool + + +class WifiStatus(TypedDict, total=False): + wifi_supported: bool + wifi_connected: bool + hotspot_active: bool + state: str + connection: str + ip4_address: str + ssid: str + + +def parse_nmcli_error(stderr: Optional[bytes | str]) -> str: """Map a chunk of nmcli stderr to a short user-facing reason.""" if stderr is None: return "unknown error" @@ -48,7 +72,7 @@ def parse_nmcli_error(stderr): return "unknown error" -def _split_terse(line): +def _split_terse(line: str) -> list[str]: """Split an nmcli -t terse line, honouring backslash-escaped colons.""" return [p.replace('\\:', ':') for p in re.split(r'(? None: + self.iface_name: str = ifname + self.ssid: Optional[str] = None + self.psk: Optional[str] = None + self.lock: threading.Lock = threading.Lock() + self.last_status: WifiStatus = {} + self.changed: bool = False + self.stop: threading.Event = threading.Event() + self.wireless_supported: bool = False + self.wireless_file: str = os.path.join(os.sep, 'sys', 'class', 'net', self.iface_name, 'wireless') + self.operstate_file: str = os.path.join(os.sep, 'sys', 'class', 'net', self.iface_name, 'operstate') self.thread = threading.Thread(target=self._polling_thread, daemon=True).start() - def __del__(self): + def __del__(self) -> None: logging.info("Wifi monitor cleanup") self.stop.set() - self.thread.join() + if self.thread is not None: + self.thread.join() - def _is_wifi_supported(self): + def _is_wifi_supported(self) -> bool: # Once we know it's supported, no need to check the file again if self.wireless_supported: return True self.wireless_supported = os.path.exists(self.wireless_file) return self.wireless_supported - - def _is_wifi_connected(self): + + def _is_wifi_connected(self) -> bool: try: with open(self.operstate_file) as f: line = f.readline() - f.close() return line.startswith('up') - except Exception as e: + except Exception: return False - def _is_hotspot_active(self): + def _is_hotspot_active(self) -> bool: try: result = subprocess.run(['systemctl', 'is-active', 'wifi-hotspot'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if result.stdout.strip() == 'active': - return True - else: - return False - except: + return result.stdout.strip() == 'active' + except Exception: return False - return True - def _get_wpa_status(self, status): + def _get_wpa_status(self, status: WifiStatus) -> None: try: result = subprocess.run( ['nmcli', '-t', '-f', 'GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,802-11-WIRELESS.SSID', @@ -120,34 +139,44 @@ def _get_wpa_status(self, status): stderr=subprocess.PIPE, text=True ) - if result.returncode == 0: - for line in result.stdout.strip().split('\n'): - if ':' in line: - key, value = line.split(':', 1) - # Map nmcli fields to wpa_cli-like keys for compatibility - key = key.strip().replace('GENERAL.', '').replace('802-11-WIRELESS.', '').replace('.', '_').lower() - status[key] = value.strip() except Exception as e: logging.error("NetworkManager status fail:" + str(e)) - - def _polling_thread(self): + return + if result.returncode != 0: + return + for line in result.stdout.strip().split('\n'): + if ':' not in line: + continue + key, value = line.split(':', 1) + key = key.strip() + value = value.strip() + if key == 'GENERAL.STATE': + status['state'] = value + elif key == 'GENERAL.CONNECTION': + status['connection'] = value + elif key == 'IP4.ADDRESS' or key.startswith('IP4.ADDRESS['): + status['ip4_address'] = value + elif key == '802-11-WIRELESS.SSID': + status['ssid'] = value + + def _polling_thread(self) -> None: while True: - new_status = {} - new_status['wifi_supported'] = supported = self._is_wifi_supported() - new_status['wifi_connected'] = connected = self._is_wifi_connected() - new_status['hotspot_active'] = hp_active = self._is_hotspot_active() + new_status: WifiStatus = {} + supported = new_status['wifi_supported'] = self._is_wifi_supported() + connected = new_status['wifi_connected'] = self._is_wifi_connected() + hp_active = new_status['hotspot_active'] = self._is_hotspot_active() if supported and (connected or hp_active): self._get_wpa_status(new_status) if new_status != self.last_status: logging.debug("Wifi status changed:" + str(new_status)) - creds=() + creds: Optional[list[str]] = None if supported and connected: active_conn = new_status.get('connection') if active_conn: creds = self._acquire_creds(active_conn) self.lock.acquire() - if supported and connected and creds and len(creds)==2: + if supported and connected and creds is not None and len(creds) == 2: self.ssid = creds[0] self.psk = creds[1] self.last_status = new_status @@ -159,7 +188,7 @@ def _polling_thread(self): break # External API - def poll(self): + def poll(self) -> Optional[WifiStatus]: if self.changed: logging.debug("wifi poll changed detect !") # We don't need to do a deep copy because that dictionary content @@ -173,28 +202,28 @@ def poll(self): return update return None - def enable_hotspot(self): + def enable_hotspot(self) -> None: try: subprocess.check_output(['sudo', 'systemctl', 'enable', '--now', 'wifi-hotspot']).strip().decode('utf-8') - except: + except Exception: logging.debug('Wifi hotspot enabling failed') - def disable_hotspot(self): + def disable_hotspot(self) -> None: try: subprocess.check_output(['sudo', 'systemctl', 'disable', '--now', 'wifi-hotspot']).strip().decode('utf-8') - except: + except Exception: logging.debug('Wifi hotspot disabling failed') - def list_connections(self): - """Return list of dicts {name, ssid, timestamp} for all wifi profiles, excluding the hotspot. + def list_connections(self) -> list[SavedConnection]: + """Return all saved wifi profiles, excluding the hotspot. - timestamp is the unix-seconds of last successful activation (int, 0 if never).""" + timestamp is the unix-seconds of last successful activation (0 if never).""" try: result = subprocess.run( ['nmcli', '-t', '-f', 'NAME,TYPE,TIMESTAMP,802-11-WIRELESS.SSID', 'connection', 'show'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - connections = [] + connections: list[SavedConnection] = [] for line in result.stdout.strip().split('\n'): if not line: continue @@ -208,16 +237,16 @@ def list_connections(self): except ValueError: timestamp = 0 ssid = parts[3] if len(parts) > 3 and parts[3] else name - connections.append({'name': name, 'ssid': ssid, 'timestamp': timestamp}) + connections.append(SavedConnection(name=name, ssid=ssid, timestamp=timestamp)) return connections except Exception as e: logging.error("Failed to list wifi connections: " + str(e)) return [] - def scan_networks(self): - """Return a list of nearby networks as {ssid, signal, security, in_use} dicts. + def scan_networks(self) -> list[ScannedNetwork]: + """Return nearby networks, deduplicated by SSID (strongest wins), sorted by signal desc. - Deduplicated by SSID (strongest signal wins), sorted by signal desc, hidden SSIDs filtered.""" + Hidden SSIDs are filtered out.""" try: result = subprocess.run( ['nmcli', '-t', '-f', 'IN-USE,SSID,SIGNAL,SECURITY', 'dev', 'wifi', 'list', @@ -228,7 +257,7 @@ def scan_networks(self): logging.error("wifi scan failed: " + str(e)) return [] - best = {} + best: dict[str, ScannedNetwork] = {} for line in result.stdout.strip().split('\n'): if not line: continue @@ -246,13 +275,13 @@ def scan_networks(self): security = parts[3] existing = best.get(ssid) if existing is None or signal > existing['signal']: - best[ssid] = {'ssid': ssid, 'signal': signal, - 'security': security, 'in_use': in_use} + best[ssid] = ScannedNetwork(ssid=ssid, signal=signal, + security=security, in_use=in_use) elif in_use: existing['in_use'] = True return sorted(best.values(), key=lambda n: n['signal'], reverse=True) - def _resolve_unique_name(self, desired, exclude=None): + def _resolve_unique_name(self, desired: str, exclude: Optional[str] = None) -> str: """Pick a profile name based on `desired`, suffixing (2)/(3)/... if it collides. `exclude` is the existing name of a profile being modified (so it doesn't collide with itself).""" @@ -266,7 +295,7 @@ def _resolve_unique_name(self, desired, exclude=None): counter += 1 return name - def add_connection(self, ssid, psk): + def add_connection(self, ssid: str, psk: str) -> Optional[bytes]: """Add a new wifi profile. Profile name is the SSID, suffixed if a duplicate exists.""" name = self._resolve_unique_name(ssid) try: @@ -283,7 +312,7 @@ def add_connection(self, ssid, psk): except subprocess.CalledProcessError as exc: return exc.output - def delete_connection(self, name): + def delete_connection(self, name: str) -> Optional[bytes]: """Delete a wifi profile by its NM connection name.""" try: subprocess.check_output( @@ -294,7 +323,7 @@ def delete_connection(self, name): except subprocess.CalledProcessError as exc: return exc.output - def configure_wifi(self, name, ssid, password): + def configure_wifi(self, name: str, ssid: str, password: str) -> Optional[bytes]: """Update the SSID and PSK for an existing wifi profile. Auto-syncs connection.id to the new SSID (with collision suffix), so the display @@ -311,7 +340,7 @@ def configure_wifi(self, name, ssid, password): except subprocess.CalledProcessError as exc: return exc.output - def connect_scanned(self, ssid, psk=None): + def connect_scanned(self, ssid: str, psk: Optional[str] = None) -> Optional[bytes]: """Join a network discovered via scan. Creates a profile and activates it atomically. On failure nmcli cleans up the partial profile, so this doubles as a credential test.""" @@ -326,7 +355,7 @@ def connect_scanned(self, ssid, psk=None): except subprocess.TimeoutExpired: return b'connection timed out' - def connect_saved(self, name): + def connect_saved(self, name: str) -> Optional[bytes]: """Activate an existing saved profile.""" try: subprocess.check_output( @@ -339,7 +368,7 @@ def connect_saved(self, name): except subprocess.TimeoutExpired: return b'connection timed out' - def replace_psk(self, name, psk): + def replace_psk(self, name: str, psk: str) -> Optional[bytes]: """Update the PSK on a saved profile and validate by activating it. On failure the previous PSK is restored so the saved profile keeps working.""" @@ -366,7 +395,7 @@ def replace_psk(self, name, psk): logging.error("PSK rollback failed: " + str(rollback_exc.output)) return err - def get_psk_for(self, name): + def get_psk_for(self, name: str) -> Optional[str]: """Fetch the stored PSK for a specific wifi profile.""" try: result = subprocess.run( @@ -377,7 +406,7 @@ def get_psk_for(self, name): except Exception: return None - def _acquire_creds(self, connection_name): + def _acquire_creds(self, connection_name: str) -> Optional[list[str]]: try: result = subprocess.run( ['sudo', 'nmcli', '-s', '-g', '802-11-wireless.ssid,802-11-wireless-security.psk', 'connection', @@ -388,11 +417,12 @@ def _acquire_creds(self, connection_name): fields = result.stdout.split('\n') if len(fields) == 3: return fields[:2] - except: + except Exception: logging.debug('Failure running nmcli to get wifi name') + return None - def get_ssid(self): + def get_ssid(self) -> Optional[str]: return self.ssid - def get_psk(self): + def get_psk(self) -> Optional[str]: return self.psk diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 83cb5c41..83fc0057 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -17,10 +17,9 @@ import digitalio import logging import os -import time import common.token as Token import modalapi.parameter as Parameter -from modalapi.wifi import parse_nmcli_error +from ui.wifi_menu import WifiMenu import pistomp.category as Category import pistomp.lcd as abstract_lcd import pistomp.switchstate as switchstate @@ -93,7 +92,6 @@ def __init__(self, cwd, handler=None, flip=False): # widgets self.w_wifi = None - self._wifi_networks_menu = None self.w_eq = None self.w_power = None self.w_wrench = None @@ -117,6 +115,8 @@ def __init__(self, cwd, handler=None, flip=False): self.pedalboards = {} + self.wifi_menu = WifiMenu(self) + self.splash_show(True) # @@ -175,7 +175,7 @@ def draw_tools(self, wifi_type=None, eq_type=None, bypass_type=None, system_type if self.w_wifi is not None: return self.w_wifi = ImageWidget(box=Box.xywh(210, 0, 20, 20), image_path=os.path.join(self.imagedir, - 'wifi_gray.png'), parent=self.main_panel, action=self.draw_wifi_menu) + 'wifi_gray.png'), parent=self.main_panel, action=self.wifi_menu.open) self.main_panel.add_sel_widget(self.w_wifi) if self.w_eq is not None: return @@ -203,281 +203,6 @@ def draw_bypass_preference(self): pref == Token.LEFT_RIGHT or pref == None)] self.draw_selection_menu(items, "Bypass Preference", auto_dismiss=True) - # ----- WiFi menu ----- - - _SIGNAL_FILLED = '\u25ae' # ▮ - _SIGNAL_EMPTY = '\u25af' # ▯ - _ACTIVE_GLYPH = '\u2714' # ✔ - _IN_RANGE_NEARBY_CAP = 5 - _OUT_OF_RANGE_CAP = 2 - - @staticmethod - def _signal_bars(signal): - levels = max(1, min(4, (signal + 12) // 25)) - return Lcd._SIGNAL_FILLED * levels + Lcd._SIGNAL_EMPTY * (4 - levels) - - @staticmethod - def _format_age(ts): - if not ts: - return None - age = max(0, int(time.time()) - int(ts)) - if age < 60: - return "now" - if age < 3600: - return "%dm ago" % (age // 60) - if age < 86400: - return "%dh ago" % (age // 3600) - return "%dd ago" % (age // 86400) - - def _network_label(self, row): - ssid = row['ssid'] - active_marker = (self._ACTIVE_GLYPH + ' ') if row.get('active') else '' - if row.get('signal') is not None: - suffix = ' ' + self._signal_bars(row['signal']) - elif row.get('saved'): - suffix = ' (saved)' - else: - suffix = '' - if row.get('disambiguator'): - suffix += ' ' + row['disambiguator'] - return active_marker + ssid + suffix - - def toggle_hotspot(self, arg1): - self.pstack.pop_panel(None) - self.draw_info_message("connecting...", refresh=True) - self.handler.system_toggle_hotspot() - self.draw_info_message("", refresh=True) - - def draw_wifi_menu(self, event, widget): - wifi_status = self.handler.wifi_status or {} - supported = util.DICT_GET(wifi_status, 'wifi_supported') - hotspot_active = util.DICT_GET(wifi_status, 'hotspot_active') - active_name = util.DICT_GET(wifi_status, 'connection') - - saved = self.handler.wifi_manager.list_connections() - saved_by_ssid = {} - for c in saved: - saved_by_ssid.setdefault(c['ssid'], []).append(c) - - scanned = [] - if supported and not hotspot_active: - self.draw_info_message("scanning...", refresh=True) - scanned = self.handler.wifi_manager.scan_networks() - self.draw_info_message("", refresh=True) - scanned_ssids = {n['ssid'] for n in scanned} - - title = self._wifi_menu_title(wifi_status, active_name, saved_by_ssid, scanned) - - rows = [] - # In-range networks first (scanned), promoting active to top. - in_range = [] - for net in scanned: - profiles = saved_by_ssid.get(net['ssid'], []) - saved_profile = self._pick_profile(profiles, active_name) - row = { - 'ssid': net['ssid'], - 'signal': net['signal'], - 'security': net['security'], - 'saved': bool(saved_profile), - 'profile': saved_profile, - 'active': bool(saved_profile) and saved_profile['name'] == active_name, - } - self._maybe_disambiguate(row, profiles) - in_range.append(row) - in_range.sort(key=lambda r: (not r['active'], -r['signal'])) - in_range = in_range[:self._IN_RANGE_NEARBY_CAP + (1 if any(r['active'] for r in in_range) else 0)] - rows.extend(in_range) - - # Saved profiles not visible in scan. - out_of_range = [] - for ssid, profiles in saved_by_ssid.items(): - if ssid in scanned_ssids: - continue - for profile in profiles: - row = { - 'ssid': ssid, - 'signal': None, - 'security': None, - 'saved': True, - 'profile': profile, - 'active': profile['name'] == active_name, - } - self._maybe_disambiguate(row, profiles) - out_of_range.append(row) - out_of_range.sort(key=lambda r: -(r['profile']['timestamp'] or 0)) - if len(out_of_range) > self._OUT_OF_RANGE_CAP: - shown = out_of_range[:self._OUT_OF_RANGE_CAP] - extras = out_of_range[self._OUT_OF_RANGE_CAP:] - else: - shown = out_of_range - extras = [] - rows.extend(shown) - - items = [(self._network_label(row), self._on_network_tap, row) for row in rows] - if extras: - items.append(("More saved...", self._draw_more_saved, extras)) - items.append(("Join other network...", self._draw_join_dialog, None)) - hotspot_label = ("Hotspot Mode \u25cf" if hotspot_active else "Hotspot Mode \u25cb") - items.append((hotspot_label, self.toggle_hotspot, None)) - - self._wifi_networks_menu = self.draw_selection_menu(items, title, dismiss_option=True) - - def _wifi_menu_title(self, wifi_status, active_name, saved_by_ssid, scanned): - if util.DICT_GET(wifi_status, 'hotspot_active'): - return "WiFi \u00b7 Hotspot" - if active_name: - ssid = util.DICT_GET(wifi_status, 'ssid') or active_name - for net in scanned: - if net['in_use'] or net['ssid'] == ssid: - return "WiFi \u00b7 %s %s" % (ssid, self._signal_bars(net['signal'])) - return "WiFi \u00b7 %s" % ssid - return "WiFi \u00b7 Disconnected" - - def _pick_profile(self, profiles, active_name): - if not profiles: - return None - for p in profiles: - if p['name'] == active_name: - return p - return max(profiles, key=lambda p: p['timestamp'] or 0) - - def _maybe_disambiguate(self, row, profiles): - if row.get('saved') and len(profiles) > 1 and row.get('profile'): - age = self._format_age(row['profile'].get('timestamp')) - if age: - row['disambiguator'] = "\u00b7 " + age - - def _on_network_tap(self, row): - if row.get('saved'): - self._draw_saved_network_menu(row) - elif self._is_open_network(row.get('security')): - self._connect_with_feedback( - lambda: self.handler.wifi_manager.connect_scanned(row['ssid']), - row['ssid']) - else: - self._prompt_password(row['ssid'], - lambda psk: self._connect_with_feedback( - lambda: self.handler.wifi_manager.connect_scanned(row['ssid'], psk), - row['ssid'])) - - @staticmethod - def _is_open_network(security): - if not security or security == '--': - return True - return False - - def _draw_saved_network_menu(self, row): - profile = row['profile'] - items = [] - if row.get('signal') is not None and not row.get('active'): - items.append(("Connect", self._connect_saved, row)) - items.append(("Replace password", self._draw_replace_psk_dialog, row)) - items.append(("Forget", self._forget_wifi_network, row)) - self.draw_selection_menu(items, row['ssid'], dismiss_option=True) - - def _draw_more_saved(self, extras): - items = [(self._network_label(row), self._on_network_tap, row) for row in extras] - self.draw_selection_menu(items, "Saved Networks", dismiss_option=True) - - def _connect_saved(self, row): - profile = row['profile'] - self._connect_with_feedback( - lambda: self.handler.wifi_manager.connect_saved(profile['name']), - row['ssid']) - - def _connect_with_feedback(self, connect_fn, ssid): - self.pstack.pop_panel(None) - self.draw_info_message("connecting to %s..." % ssid, refresh=True) - err = connect_fn() - self.draw_info_message("", refresh=True) - if err is None: - self.draw_info_message("connected: %s" % ssid, refresh=True) - return - d = MessageDialog(self.pstack, parse_nmcli_error(err), title="Couldn't connect") - self.pstack.push_panel(d) - - def _prompt_password(self, ssid, on_submit): - d = Dialog(width=240, height=110, auto_destroy=True, title='Password for %s' % ssid) - pw = TextWidget(box=Box.xywh(0, 0, 200, 0), text='', prompt='Passwd :', parent=d, - outline=1, sel_width=3, outline_radius=5, - align=WidgetAlign.NONE, name='pw_field', - edit_message='Password') - d.add_sel_widget(pw) - cancel = TextWidget(box=Box.xywh(0, 60, 0, 0), text='Cancel', parent=d, - outline=1, sel_width=3, outline_radius=5, - action=lambda x, y: self.pstack.pop_panel(d), - align=WidgetAlign.NONE, name='cancel_btn') - d.add_sel_widget(cancel) - - def submit(event, button): - psk = pw.text - if not psk: - return - self.pstack.pop_panel(d) - on_submit(psk) - - ok = TextWidget(box=Box.xywh(80, 60, 0, 0), text='Ok', parent=d, - outline=1, sel_width=3, outline_radius=5, - action=submit, align=WidgetAlign.NONE, name='ok_btn') - d.add_sel_widget(ok) - self.pstack.push_panel(d) - d.refresh() - - def _draw_replace_psk_dialog(self, row): - profile = row['profile'] - self._prompt_password(row['ssid'], - lambda psk: self._connect_with_feedback( - lambda: self.handler.wifi_manager.replace_psk(profile['name'], psk), - row['ssid'])) - - def _draw_join_dialog(self, _): - d = Dialog(width=240, height=120, auto_destroy=True, title='Join other network') - ssid_widget = TextWidget(box=Box.xywh(0, 0, 190, 0), text='', prompt='SSID :', parent=d, - outline=1, sel_width=3, outline_radius=5, - align=WidgetAlign.NONE, name='ssid_field', - edit_message='WiFi SSID') - d.add_sel_widget(ssid_widget) - pw_widget = TextWidget(box=Box.xywh(0, 30, 169, 0), text='', prompt='Passwd :', parent=d, - outline=1, sel_width=3, outline_radius=5, - align=WidgetAlign.NONE, name='pw_field', - edit_message='Password') - d.add_sel_widget(pw_widget) - - cancel = TextWidget(box=Box.xywh(0, 90, 0, 0), text='Cancel', parent=d, - outline=1, sel_width=3, outline_radius=5, - action=lambda x, y: self.pstack.pop_panel(d), - align=WidgetAlign.NONE, name='cancel_btn') - d.add_sel_widget(cancel) - - def submit(event, button): - ssid = ssid_widget.text - psk = pw_widget.text - if not ssid: - return - self.pstack.pop_panel(d) - self._connect_with_feedback( - lambda: self.handler.wifi_manager.connect_scanned(ssid, psk or None), - ssid) - - ok = TextWidget(box=Box.xywh(80, 90, 0, 0), text='Ok', parent=d, - outline=1, sel_width=3, outline_radius=5, - action=submit, align=WidgetAlign.NONE, name='ok_btn') - d.add_sel_widget(ok) - self.pstack.push_panel(d) - d.refresh() - - def _forget_wifi_network(self, row): - profile = row['profile'] - result = self.handler.wifi_manager.delete_connection(profile['name']) - if result is not None: - d = MessageDialog(self.pstack, parse_nmcli_error(result), title="Error") - self.pstack.push_panel(d) - return - self.pstack.pop_panel(None) # pop submenu - if self._wifi_networks_menu is not None: - self.pstack.pop_panel(self._wifi_networks_menu) - self.draw_wifi_menu(None, None) - # # Title (Pedalboard and Preset) # @@ -799,19 +524,6 @@ def draw_bank_menu(self, event): items.append((k, self.handler.set_bank, k, k==current_bank)) self.draw_selection_menu(items, "Bank Select", auto_dismiss=True) - def draw_wifi_menu(self, event, widget): - connections = self.handler.wifi_manager.list_connections() - active = util.DICT_GET(self.handler.wifi_status, 'connection') - hotspot_active = util.DICT_GET(self.handler.wifi_status, 'hotspot_active') - label = "Switch to Wifi" if hotspot_active else "Switch to Hotspot" - items = [] - for conn in connections: - is_active = conn['name'] == active - items.append((conn['name'], self._draw_wifi_network_menu, conn, is_active)) - items.append(("Add Network...", self._draw_wifi_dialog, None)) - items.append((label, self.toggle_hotspot, None)) - self._wifi_networks_menu = self.draw_selection_menu(items, "WiFi Networks", dismiss_option=True) - def draw_audio_menu(self, event, widget): items = [("Output Volume", self.handler.system_menu_headphone_volume, None), ("Input Gain", self.handler.system_menu_input_gain, None), diff --git a/pyproject.toml b/pyproject.toml index 0e02f63d..92902487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["modalapi", "pistomp", "uilib", "common"] +packages = ["modalapi", "pistomp", "uilib", "ui", "common"] [tool.ruff] line-length = 120 diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py new file mode 100644 index 00000000..f60cb7b2 --- /dev/null +++ b/ui/wifi_menu.py @@ -0,0 +1,404 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pi-stomp. If not, see . + +import time +from typing import TYPE_CHECKING, Callable, NotRequired, Optional, Protocol, TypedDict, cast + +import common.util as util +from modalapi.wifi import ( + SavedConnection, + ScannedNetwork, + WifiManager, + WifiStatus, + parse_nmcli_error, +) +from uilib import ( + Box, + Dialog, + MessageDialog, + TextWidget, + WidgetAlign, +) + +if TYPE_CHECKING: + from pistomp.lcd320x240 import Lcd + from uilib.menu import Menu + + +class _WifiHost(Protocol): + """The handler-side surface WifiMenu needs to do its job. + + The concrete handler (`modalapi.modhandler.ModHandler`) satisfies this structurally. + """ + wifi_manager: WifiManager + wifi_status: Optional[WifiStatus] + + def system_toggle_hotspot(self) -> None: ... + + +SIGNAL_FILLED = '\u25ae' # ▮ +SIGNAL_EMPTY = '\u25af' # ▯ +ACTIVE_GLYPH = '\u2714' # ✔ +HOTSPOT_ON = '\u25cf' # ● +HOTSPOT_OFF = '\u25cb' # ○ +SEP = '\u00b7' # · + +IN_RANGE_NEARBY_CAP = 5 +OUT_OF_RANGE_CAP = 2 + + +class Row(TypedDict): + """A single network line in the wifi menu — saved profile, in-range network, or both. + + `signal`, `security`, and `profile` are present but may be None (e.g. saved-but-out-of-range + has no signal/security; in-range-but-unsaved has no profile).""" + ssid: str + signal: Optional[int] + security: Optional[str] + saved: bool + profile: Optional[SavedConnection] + active: bool + disambiguator: NotRequired[str] + + +ConnectFn = Callable[[], Optional[bytes]] +PasswordCallback = Callable[[str], None] +MenuItem = tuple # (label, callback, arg) or (label, callback, arg, is_active) + + +def signal_bars(signal: int) -> str: + levels = max(1, min(4, (signal + 12) // 25)) + return SIGNAL_FILLED * levels + SIGNAL_EMPTY * (4 - levels) + + +def format_age(ts: Optional[int]) -> Optional[str]: + if not ts: + return None + age = max(0, int(time.time()) - int(ts)) + if age < 60: + return "now" + if age < 3600: + return "%dm ago" % (age // 60) + if age < 86400: + return "%dh ago" % (age // 3600) + return "%dd ago" % (age // 86400) + + +def is_open_network(security: Optional[str]) -> bool: + return not security or security == '--' + + +class WifiMenu: + """The wifi panel: scan, join, edit and forget networks; toggle hotspot. + + Owned by Lcd, which exposes the panel stack and a few shared affordances + (`draw_selection_menu`, `draw_info_message`). The menu is opened via `open()`, + typically wired to the toolbar wifi icon. + """ + + def __init__(self, lcd: 'Lcd') -> None: + self.lcd: 'Lcd' = lcd + self._root_menu: Optional['Menu'] = None + + @property + def _host(self) -> _WifiHost: + h = self.lcd.handler + assert h is not None, "WifiMenu requires lcd.handler to be set" + return cast(_WifiHost, h) + + @property + def _wifi_manager(self) -> WifiManager: + return self._host.wifi_manager + + @property + def _wifi_status(self) -> WifiStatus: + return self._host.wifi_status or {} + + @property + def _pstack(self): + return self.lcd.pstack + + # ----- entry points ----- + + def open(self, event: object = None, widget: object = None) -> None: + wifi_status = self._wifi_status + supported = bool(util.DICT_GET(wifi_status, 'wifi_supported')) + hotspot_active = bool(util.DICT_GET(wifi_status, 'hotspot_active')) + active_name = util.DICT_GET(wifi_status, 'connection') + + saved_by_ssid: dict[str, list[SavedConnection]] = {} + for c in self._wifi_manager.list_connections(): + saved_by_ssid.setdefault(c['ssid'], []).append(c) + + scanned: list[ScannedNetwork] = [] + if supported and not hotspot_active: + self.lcd.draw_info_message("scanning...", refresh=True) + scanned = self._wifi_manager.scan_networks() + self.lcd.draw_info_message("", refresh=True) + scanned_ssids = {n['ssid'] for n in scanned} + + rows, extras = self._build_rows(scanned, saved_by_ssid, scanned_ssids, active_name) + title = self._title(wifi_status, active_name, scanned) + items = self._build_items(rows, extras, hotspot_active) + self._root_menu = self.lcd.draw_selection_menu(items, title, dismiss_option=True) + + def toggle_hotspot(self, _: object = None) -> None: + self._pstack.pop_panel(None) + self.lcd.draw_info_message("connecting...", refresh=True) + self._host.system_toggle_hotspot() + self.lcd.draw_info_message("", refresh=True) + + # ----- list assembly ----- + + def _build_rows(self, + scanned: list[ScannedNetwork], + saved_by_ssid: dict[str, list[SavedConnection]], + scanned_ssids: set[str], + active_name: Optional[str]) -> tuple[list[Row], list[Row]]: + """Returns (visible_rows, extras_for_more_submenu).""" + rows: list[Row] = [] + + # In-range networks: scan results, with active connection promoted to top. + in_range: list[Row] = [] + for net in scanned: + profiles = saved_by_ssid.get(net['ssid'], []) + saved_profile = self._pick_profile(profiles, active_name) + row: Row = { + 'ssid': net['ssid'], + 'signal': net['signal'], + 'security': net['security'], + 'saved': saved_profile is not None, + 'profile': saved_profile, + 'active': saved_profile is not None and saved_profile['name'] == active_name, + } + self._maybe_disambiguate(row, profiles) + in_range.append(row) + in_range.sort(key=lambda r: (not r['active'], -(r['signal'] or 0))) + cap = IN_RANGE_NEARBY_CAP + (1 if any(r['active'] for r in in_range) else 0) + rows.extend(in_range[:cap]) + + # Saved profiles not visible in scan, sorted by recency. + out_of_range: list[Row] = [] + for ssid, profiles in saved_by_ssid.items(): + if ssid in scanned_ssids: + continue + for profile in profiles: + ooo_row: Row = { + 'ssid': ssid, + 'signal': None, + 'security': None, + 'saved': True, + 'profile': profile, + 'active': profile['name'] == active_name, + } + self._maybe_disambiguate(ooo_row, profiles) + out_of_range.append(ooo_row) + out_of_range.sort(key=lambda r: -(r['profile']['timestamp'] if r['profile'] else 0)) + + extras: list[Row] = [] + if len(out_of_range) > OUT_OF_RANGE_CAP: + rows.extend(out_of_range[:OUT_OF_RANGE_CAP]) + extras = out_of_range[OUT_OF_RANGE_CAP:] + else: + rows.extend(out_of_range) + return rows, extras + + def _build_items(self, rows: list[Row], extras: list[Row], + hotspot_active: bool) -> list[MenuItem]: + items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r) for r in rows] + if extras: + items.append(("More saved...", self._open_more_saved, extras)) + items.append(("Join other network...", self._open_join_dialog, None)) + items.append(( + "Hotspot Mode " + (HOTSPOT_ON if hotspot_active else HOTSPOT_OFF), + self.toggle_hotspot, None)) + return items + + def _title(self, wifi_status: WifiStatus, + active_name: Optional[str], + scanned: list[ScannedNetwork]) -> str: + if util.DICT_GET(wifi_status, 'hotspot_active'): + return "WiFi " + SEP + " Hotspot" + if active_name: + ssid = util.DICT_GET(wifi_status, 'ssid') or active_name + for net in scanned: + if net['in_use'] or net['ssid'] == ssid: + return "WiFi %s %s %s" % (SEP, ssid, signal_bars(net['signal'])) + return "WiFi %s %s" % (SEP, ssid) + return "WiFi " + SEP + " Disconnected" + + def _row_label(self, row: Row) -> str: + ssid = row['ssid'] + active_marker = (ACTIVE_GLYPH + ' ') if row.get('active') else '' + signal = row.get('signal') + if signal is not None: + suffix = ' ' + signal_bars(signal) + elif row.get('saved'): + suffix = ' (saved)' + else: + suffix = '' + disambiguator = row.get('disambiguator') + if disambiguator: + suffix += ' ' + disambiguator + return active_marker + ssid + suffix + + @staticmethod + def _pick_profile(profiles: list[SavedConnection], + active_name: Optional[str]) -> Optional[SavedConnection]: + if not profiles: + return None + for p in profiles: + if p['name'] == active_name: + return p + return max(profiles, key=lambda p: p['timestamp'] or 0) + + @staticmethod + def _maybe_disambiguate(row: Row, profiles: list[SavedConnection]) -> None: + profile = row.get('profile') + if row.get('saved') and len(profiles) > 1 and profile is not None: + age = format_age(profile.get('timestamp')) + if age: + row['disambiguator'] = SEP + ' ' + age + + # ----- per-network actions ----- + + def _on_network_tap(self, row: Row) -> None: + if row.get('saved'): + self._open_saved_submenu(row) + elif is_open_network(row.get('security')): + self._connect_with_feedback( + lambda: self._wifi_manager.connect_scanned(row['ssid']), + row['ssid']) + else: + self._open_password_prompt(row['ssid'], + lambda psk: self._connect_with_feedback( + lambda: self._wifi_manager.connect_scanned(row['ssid'], psk), + row['ssid'])) + + def _open_saved_submenu(self, row: Row) -> None: + items: list[MenuItem] = [] + if row.get('signal') is not None and not row.get('active'): + items.append(("Connect", self._connect_saved, row)) + items.append(("Replace password", self._open_replace_psk_dialog, row)) + items.append(("Forget", self._forget, row)) + self.lcd.draw_selection_menu(items, row['ssid'], dismiss_option=True) + + def _open_more_saved(self, extras: list[Row]) -> None: + items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r) for r in extras] + self.lcd.draw_selection_menu(items, "Saved Networks", dismiss_option=True) + + def _connect_saved(self, row: Row) -> None: + profile = row['profile'] + assert profile is not None + self._connect_with_feedback( + lambda: self._wifi_manager.connect_saved(profile['name']), + row['ssid']) + + def _connect_with_feedback(self, connect_fn: ConnectFn, ssid: str) -> None: + self._pstack.pop_panel(None) + self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) + err = connect_fn() + self.lcd.draw_info_message("", refresh=True) + if err is None: + self.lcd.draw_info_message("connected: %s" % ssid, refresh=True) + return + self._pstack.push_panel( + MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't connect")) + + def _open_replace_psk_dialog(self, row: Row) -> None: + profile = row['profile'] + assert profile is not None + self._open_password_prompt(row['ssid'], + lambda psk: self._connect_with_feedback( + lambda: self._wifi_manager.replace_psk(profile['name'], psk), + row['ssid'])) + + def _forget(self, row: Row) -> None: + profile = row['profile'] + assert profile is not None + result = self._wifi_manager.delete_connection(profile['name']) + if result is not None: + self._pstack.push_panel( + MessageDialog(self._pstack, parse_nmcli_error(result), title="Error")) + return + self._pstack.pop_panel(None) + if self._root_menu is not None: + self._pstack.pop_panel(self._root_menu) + self.open() + + # ----- dialogs ----- + + def _open_password_prompt(self, ssid: str, on_submit: PasswordCallback) -> None: + d = Dialog(width=240, height=110, auto_destroy=True, title='Password for %s' % ssid) + pw = TextWidget(box=Box.xywh(0, 0, 200, 0), text='', prompt='Passwd :', parent=d, + outline=1, sel_width=3, outline_radius=5, + align=WidgetAlign.NONE, name='pw_field', + edit_message='Password') + d.add_sel_widget(pw) + cancel = TextWidget(box=Box.xywh(0, 60, 0, 0), text='Cancel', parent=d, + outline=1, sel_width=3, outline_radius=5, + action=lambda x, y: self._pstack.pop_panel(d), + align=WidgetAlign.NONE, name='cancel_btn') + d.add_sel_widget(cancel) + + def submit(_event, _button): + psk = pw.text + if not psk: + return + self._pstack.pop_panel(d) + on_submit(psk) + + ok = TextWidget(box=Box.xywh(80, 60, 0, 0), text='Ok', parent=d, + outline=1, sel_width=3, outline_radius=5, + action=submit, align=WidgetAlign.NONE, name='ok_btn') + d.add_sel_widget(ok) + self._pstack.push_panel(d) + d.refresh() + + def _open_join_dialog(self, _: object = None) -> None: + d = Dialog(width=240, height=120, auto_destroy=True, title='Join other network') + ssid_w = TextWidget(box=Box.xywh(0, 0, 190, 0), text='', prompt='SSID :', parent=d, + outline=1, sel_width=3, outline_radius=5, + align=WidgetAlign.NONE, name='ssid_field', + edit_message='WiFi SSID') + d.add_sel_widget(ssid_w) + pw_w = TextWidget(box=Box.xywh(0, 30, 169, 0), text='', prompt='Passwd :', parent=d, + outline=1, sel_width=3, outline_radius=5, + align=WidgetAlign.NONE, name='pw_field', + edit_message='Password') + d.add_sel_widget(pw_w) + + cancel = TextWidget(box=Box.xywh(0, 90, 0, 0), text='Cancel', parent=d, + outline=1, sel_width=3, outline_radius=5, + action=lambda x, y: self._pstack.pop_panel(d), + align=WidgetAlign.NONE, name='cancel_btn') + d.add_sel_widget(cancel) + + def submit(_event, _button): + ssid = ssid_w.text + psk = pw_w.text + if not ssid: + return + self._pstack.pop_panel(d) + self._connect_with_feedback( + lambda: self._wifi_manager.connect_scanned(ssid, psk or None), + ssid) + + ok = TextWidget(box=Box.xywh(80, 90, 0, 0), text='Ok', parent=d, + outline=1, sel_width=3, outline_radius=5, + action=submit, align=WidgetAlign.NONE, name='ok_btn') + d.add_sel_widget(ok) + self._pstack.push_panel(d) + d.refresh() From 3f796d3842a77287c2736cbec5bed89bc95ed120 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Tue, 5 May 2026 16:50:45 -0400 Subject: [PATCH 5/9] Better wifi menu --- modalapi/modhandler.py | 1 + modalapi/wifi.py | 13 ++++++ pistomp/lcd320x240.py | 13 ++++-- ui/wifi_menu.py | 102 ++++++++++++++++++++++++++++++----------- uilib/container.py | 2 +- uilib/menu.py | 3 +- uilib/panel.py | 4 +- uilib/text.py | 25 +++++++++- 8 files changed, 127 insertions(+), 36 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 41d25526..64bfeeb8 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -374,6 +374,7 @@ def set_current_pedalboard(self, pedalboard): self.load_current_presets() self.lcd.link_data(self.pedalboard_list, self.current, self.hardware.footswitches) self.lcd.draw_main_panel() + self.lcd.update_wifi(self.wifi_status) def bind_current_pedalboard(self): # "current" being the pedalboard mod-host says is current diff --git a/modalapi/wifi.py b/modalapi/wifi.py index 149575e7..0e3ab0f6 100644 --- a/modalapi/wifi.py +++ b/modalapi/wifi.py @@ -355,6 +355,19 @@ def connect_scanned(self, ssid: str, psk: Optional[str] = None) -> Optional[byte except subprocess.TimeoutExpired: return b'connection timed out' + def disconnect(self, name: str) -> Optional[bytes]: + """Bring down a saved profile without forgetting it.""" + try: + subprocess.check_output( + ['sudo', 'nmcli', 'connection', 'down', name], + stderr=subprocess.STDOUT, timeout=20 + ) + return None + except subprocess.CalledProcessError as exc: + return exc.output + except subprocess.TimeoutExpired: + return b'disconnect timed out' + def connect_saved(self, name: str) -> Optional[bytes]: """Activate an existing saved profile.""" try: diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 83fc0057..2f0cf457 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -258,12 +258,17 @@ def draw_preset_menu(self, event, widget): self.draw_selection_menu(items, "Snapshots", auto_dismiss=True, dismiss_option=True) def draw_selection_menu(self, items, title="", auto_dismiss=False, dismiss_option=False): - # items is list of touples: (item_label, callback_method, callback_arg) - # The below assumes that the callback takes the menu item label as an argument + # items is a list of tuples: (label, callback, arg) or (label, callback, arg, is_active) + # or (label, callback, arg, is_active, long_callback) where long_callback is called + # instead of callback on a long press. def menu_action(event, params): + if event == InputEvent.LONG_CLICK and len(params) >= 5 and params[4] is not None: + params[4](params[2]) + return callback = params[1] - if callback is not None: - callback(params[2]) + if callback is None: + return + callback(params[2]) m = Menu(title=title, items=items, auto_destroy=True, default_item=None, max_width=180, max_height=200, auto_dismiss=auto_dismiss, dismiss_option=dismiss_option, action=menu_action) diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index f60cb7b2..45097f46 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -31,10 +31,10 @@ TextWidget, WidgetAlign, ) +from uilib.menu import Menu if TYPE_CHECKING: from pistomp.lcd320x240 import Lcd - from uilib.menu import Menu class _WifiHost(Protocol): @@ -54,6 +54,7 @@ def system_toggle_hotspot(self) -> None: ... HOTSPOT_ON = '\u25cf' # ● HOTSPOT_OFF = '\u25cb' # ○ SEP = '\u00b7' # · +SPLIT = TextWidget.SPLIT_SEP # left/right alignment marker for menu rows IN_RANGE_NEARBY_CAP = 5 OUT_OF_RANGE_CAP = 2 @@ -134,7 +135,12 @@ def _pstack(self): def open(self, event: object = None, widget: object = None) -> None: wifi_status = self._wifi_status - supported = bool(util.DICT_GET(wifi_status, 'wifi_supported')) + # If wifi_supported is missing the host hasn't seen a poll yet — assume + # supported so we don't render a stale "Disconnected" surface during + # the cold-start window. + supported = util.DICT_GET(wifi_status, 'wifi_supported') + if supported is None: + supported = True hotspot_active = bool(util.DICT_GET(wifi_status, 'hotspot_active')) active_name = util.DICT_GET(wifi_status, 'connection') @@ -217,12 +223,12 @@ def _build_rows(self, def _build_items(self, rows: list[Row], extras: list[Row], hotspot_active: bool) -> list[MenuItem]: - items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r) for r in rows] + items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r, None, self._on_network_long_tap) for r in rows] if extras: items.append(("More saved...", self._open_more_saved, extras)) items.append(("Join other network...", self._open_join_dialog, None)) items.append(( - "Hotspot Mode " + (HOTSPOT_ON if hotspot_active else HOTSPOT_OFF), + "Hotspot Mode" + SPLIT + (HOTSPOT_ON if hotspot_active else HOTSPOT_OFF), self.toggle_hotspot, None)) return items @@ -242,17 +248,16 @@ def _title(self, wifi_status: WifiStatus, def _row_label(self, row: Row) -> str: ssid = row['ssid'] active_marker = (ACTIVE_GLYPH + ' ') if row.get('active') else '' + disambiguator = row.get('disambiguator') + left = active_marker + ssid + ((' ' + disambiguator) if disambiguator else '') signal = row.get('signal') if signal is not None: - suffix = ' ' + signal_bars(signal) + right = signal_bars(signal) elif row.get('saved'): - suffix = ' (saved)' + right = '(saved)' else: - suffix = '' - disambiguator = row.get('disambiguator') - if disambiguator: - suffix += ' ' + disambiguator - return active_marker + ssid + suffix + right = '' + return left + SPLIT + right @staticmethod def _pick_profile(profiles: list[SavedConnection], @@ -275,36 +280,78 @@ def _maybe_disambiguate(row: Row, profiles: list[SavedConnection]) -> None: # ----- per-network actions ----- def _on_network_tap(self, row: Row) -> None: - if row.get('saved'): - self._open_saved_submenu(row) - elif is_open_network(row.get('security')): + """Root-menu row tap. Routes by row state: + + tap on saved+active → Disconnect/Forget/Replace pw submenu + tap on saved+non-active → connect_saved (no prompt — we have the PSK) + tap on unsaved+open → connect_scanned + tap on unsaved+secured → password prompt → connect_scanned + """ + saved = row.get('saved') + if saved: + if row.get('active'): + self._open_saved_submenu(row, include_disconnect=True) + return + self._connect_saved(row) + return + if is_open_network(row.get('security')): self._connect_with_feedback( lambda: self._wifi_manager.connect_scanned(row['ssid']), row['ssid']) - else: - self._open_password_prompt(row['ssid'], - lambda psk: self._connect_with_feedback( - lambda: self._wifi_manager.connect_scanned(row['ssid'], psk), - row['ssid'])) + return + self._open_password_prompt(row['ssid'], + lambda psk: self._connect_with_feedback( + lambda: self._wifi_manager.connect_scanned(row['ssid'], psk), + row['ssid'])) - def _open_saved_submenu(self, row: Row) -> None: + def _on_network_long_tap(self, row: Row) -> None: + """Long-press on a network row → saved-network submenu.""" + if row.get('saved'): + self._open_saved_submenu(row, include_disconnect=bool(row.get('active'))) + + def _open_saved_submenu(self, row: Row, include_disconnect: bool = False) -> None: items: list[MenuItem] = [] - if row.get('signal') is not None and not row.get('active'): - items.append(("Connect", self._connect_saved, row)) + if include_disconnect: + items.append(("Disconnect", self._disconnect, row)) items.append(("Replace password", self._open_replace_psk_dialog, row)) items.append(("Forget", self._forget, row)) self.lcd.draw_selection_menu(items, row['ssid'], dismiss_option=True) def _open_more_saved(self, extras: list[Row]) -> None: - items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r) for r in extras] + items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r, None, self._on_network_long_tap) for r in extras] self.lcd.draw_selection_menu(items, "Saved Networks", dismiss_option=True) def _connect_saved(self, row: Row) -> None: profile = row['profile'] assert profile is not None - self._connect_with_feedback( - lambda: self._wifi_manager.connect_saved(profile['name']), - row['ssid']) + name = profile['name'] + ssid = row['ssid'] + self._pstack.pop_panel(None) + self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) + err = self._wifi_manager.connect_saved(name) + self.lcd.draw_info_message("", refresh=True) + if err is None: + return + # macOS-style: if the saved PSK is wrong, prompt for a new one and + # retry via replace_psk (which validates by reactivating). + reason = parse_nmcli_error(err) + if 'auth failed' in reason or 'wrong password' in reason: + self._open_password_prompt(ssid, + lambda psk: self._connect_with_feedback( + lambda: self._wifi_manager.replace_psk(name, psk), + ssid)) + return + self._pstack.push_panel( + MessageDialog(self._pstack, reason, title="Couldn't connect")) + + def _disconnect(self, row: Row) -> None: + profile = row['profile'] + assert profile is not None + self._pstack.pop_panel(None) + err = self._wifi_manager.disconnect(profile['name']) + if err is not None: + self._pstack.push_panel( + MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't disconnect")) def _connect_with_feedback(self, connect_fn: ConnectFn, ssid: str) -> None: self._pstack.pop_panel(None) @@ -312,7 +359,6 @@ def _connect_with_feedback(self, connect_fn: ConnectFn, ssid: str) -> None: err = connect_fn() self.lcd.draw_info_message("", refresh=True) if err is None: - self.lcd.draw_info_message("connected: %s" % ssid, refresh=True) return self._pstack.push_panel( MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't connect")) @@ -342,7 +388,7 @@ def _forget(self, row: Row) -> None: def _open_password_prompt(self, ssid: str, on_submit: PasswordCallback) -> None: d = Dialog(width=240, height=110, auto_destroy=True, title='Password for %s' % ssid) - pw = TextWidget(box=Box.xywh(0, 0, 200, 0), text='', prompt='Passwd :', parent=d, + pw = TextWidget(box=Box.xywh(0, 0, 169, 0), text='', prompt='Passwd :', parent=d, outline=1, sel_width=3, outline_radius=5, align=WidgetAlign.NONE, name='pw_field', edit_message='Password') diff --git a/uilib/container.py b/uilib/container.py index 443be4bd..5afaeb66 100644 --- a/uilib/container.py +++ b/uilib/container.py @@ -87,7 +87,7 @@ def _unfocus(self, box): def _compose(self, widget, orig_box, real_box): assert isinstance(widget, ContainerWidget) - real_box.deoffset(self.offset) + real_box.deoffset(self.offset) # XXX: result is discarded # Crop real box to this image box. This avoids trying to copy pixels # that are outside of it diff --git a/uilib/menu.py b/uilib/menu.py index bd5bb5db..daa57813 100644 --- a/uilib/menu.py +++ b/uilib/menu.py @@ -16,6 +16,7 @@ from uilib.dialog import * from uilib.config import * + class Menu(Dialog): """A pop-up menu panel with lines of text to select items : iterable of tuples whose first element is the text to display @@ -45,7 +46,7 @@ def __init__(self, items, font = None, max_width = None, max_height = None, for i in items: # item structure: 0:name, 1:action, 2:object, 3:selected item t = i[0] - if len(i) == 4 and i[3]: + if len(i) >= 4 and i[3]: t = '\u2714 ' + t # Add checkmark to selected item b = Box.xywh(0,h,self.box.width,self.item_h) w = TextWidget(box = b, text_halign = self.text_halign, font = self.font, diff --git a/uilib/panel.py b/uilib/panel.py index aff3bf76..e6028069 100644 --- a/uilib/panel.py +++ b/uilib/panel.py @@ -204,7 +204,9 @@ def poll_updates(self): def _compose(self, widget, orig_box, real_box): # This always called with widget = a Panel which is a direct # child of the stack, so we can drop orig_box - self._do_refresh(widget, real_box) + real_box = real_box.intersection(self.box.norm()) + if not real_box.is_empty(): + self._do_refresh(widget, real_box) def refresh(self): self._do_refresh(None, self.box) diff --git a/uilib/text.py b/uilib/text.py index 31501fe8..f5e561d1 100644 --- a/uilib/text.py +++ b/uilib/text.py @@ -218,6 +218,10 @@ def _adjust_box(self): return h_margin, v_margin = self._get_margins() tw, th = self._get_text_size() + # For height, always use at least a full line height so empty-text + # widgets don't collapse to near-zero. + ascent, descent = self.font_metrics + th = max(th, ascent + descent) # Add outline to account for PIL rectangles being "inset" extra = self.outline trace(self, "margins=", h_margin, v_margin, "text_size=", tw, th) @@ -242,6 +246,8 @@ def set_font(self, font): self.text_size_valid = False self.refresh() + SPLIT_SEP = '\u001F' # if present in text exactly once, render as left + right halves + def _draw(self, image, draw, real_box): # Draw text # @@ -250,12 +256,29 @@ def _draw(self, image, draw, real_box): # ContainerWidget subclass ? For now assume it fits ... # h_margin, v_margin = self._get_margins() - tw, th = self._get_text_size() extra = self.outline hroom = real_box.width - h_margin - extra vroom = real_box.height - v_margin - extra if hroom < 0 or vroom < 0: return + + if self.SPLIT_SEP in self.text: + parts = self.text.split(self.SPLIT_SEP) + if len(parts) != 2: + raise ValueError("TextWidget split text must contain exactly one separator") + left, right = parts + lw, lh = get_text_size(left, self.font, self.font_metrics) + rw, rh = get_text_size(right, self.font, self.font_metrics) + th = max(lh, rh) + if th > vroom: + th = vroom + y = real_box.y0 + v_margin + draw.text((real_box.x0 + h_margin, y), left, fill=self.fgnd_color, font=self.font) + draw.text((real_box.x0 + real_box.width - h_margin - extra - rw, y), + right, fill=self.fgnd_color, font=self.font) + return + + tw, th = self._get_text_size() if tw > hroom: tw = hroom if th > vroom: From 36864c1186aa1f2e37e7e985ff76e656d006206b Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Tue, 5 May 2026 17:00:15 -0400 Subject: [PATCH 6/9] Nearby networks... --- ui/wifi_menu.py | 50 ++++++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index 45097f46..0f547149 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -51,12 +51,12 @@ def system_toggle_hotspot(self) -> None: ... SIGNAL_FILLED = '\u25ae' # ▮ SIGNAL_EMPTY = '\u25af' # ▯ ACTIVE_GLYPH = '\u2714' # ✔ +SAVED_GLYPH = '\u2022' # • HOTSPOT_ON = '\u25cf' # ● HOTSPOT_OFF = '\u25cb' # ○ SEP = '\u00b7' # · SPLIT = TextWidget.SPLIT_SEP # left/right alignment marker for menu rows -IN_RANGE_NEARBY_CAP = 5 OUT_OF_RANGE_CAP = 2 @@ -155,9 +155,9 @@ def open(self, event: object = None, widget: object = None) -> None: self.lcd.draw_info_message("", refresh=True) scanned_ssids = {n['ssid'] for n in scanned} - rows, extras = self._build_rows(scanned, saved_by_ssid, scanned_ssids, active_name) + rows, extras, nearby = self._build_rows(scanned, saved_by_ssid, scanned_ssids, active_name) title = self._title(wifi_status, active_name, scanned) - items = self._build_items(rows, extras, hotspot_active) + items = self._build_items(rows, extras, nearby, hotspot_active) self._root_menu = self.lcd.draw_selection_menu(items, title, dismiss_option=True) def toggle_hotspot(self, _: object = None) -> None: @@ -172,12 +172,13 @@ def _build_rows(self, scanned: list[ScannedNetwork], saved_by_ssid: dict[str, list[SavedConnection]], scanned_ssids: set[str], - active_name: Optional[str]) -> tuple[list[Row], list[Row]]: - """Returns (visible_rows, extras_for_more_submenu).""" + active_name: Optional[str]) -> tuple[list[Row], list[Row], list[Row]]: + """Returns (visible_rows, extras_for_more_submenu, nearby_unsaved).""" rows: list[Row] = [] + nearby: list[Row] = [] - # In-range networks: scan results, with active connection promoted to top. - in_range: list[Row] = [] + # Split in-range scan results into saved (shown in main list) and + # unsaved (shown in "Other networks nearby..." submenu). for net in scanned: profiles = saved_by_ssid.get(net['ssid'], []) saved_profile = self._pick_profile(profiles, active_name) @@ -190,10 +191,12 @@ def _build_rows(self, 'active': saved_profile is not None and saved_profile['name'] == active_name, } self._maybe_disambiguate(row, profiles) - in_range.append(row) - in_range.sort(key=lambda r: (not r['active'], -(r['signal'] or 0))) - cap = IN_RANGE_NEARBY_CAP + (1 if any(r['active'] for r in in_range) else 0) - rows.extend(in_range[:cap]) + if saved_profile is not None: + rows.append(row) + else: + nearby.append(row) + rows.sort(key=lambda r: (not r['active'], -(r['signal'] or 0))) + nearby.sort(key=lambda r: -(r['signal'] or 0)) # Saved profiles not visible in scan, sorted by recency. out_of_range: list[Row] = [] @@ -219,13 +222,15 @@ def _build_rows(self, extras = out_of_range[OUT_OF_RANGE_CAP:] else: rows.extend(out_of_range) - return rows, extras + return rows, extras, nearby - def _build_items(self, rows: list[Row], extras: list[Row], + def _build_items(self, rows: list[Row], extras: list[Row], nearby: list[Row], hotspot_active: bool) -> list[MenuItem]: items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r, None, self._on_network_long_tap) for r in rows] if extras: items.append(("More saved...", self._open_more_saved, extras)) + if nearby: + items.append(("Nearby networks...", self._open_nearby_menu, nearby)) items.append(("Join other network...", self._open_join_dialog, None)) items.append(( "Hotspot Mode" + SPLIT + (HOTSPOT_ON if hotspot_active else HOTSPOT_OFF), @@ -247,16 +252,15 @@ def _title(self, wifi_status: WifiStatus, def _row_label(self, row: Row) -> str: ssid = row['ssid'] - active_marker = (ACTIVE_GLYPH + ' ') if row.get('active') else '' - disambiguator = row.get('disambiguator') - left = active_marker + ssid + ((' ' + disambiguator) if disambiguator else '') - signal = row.get('signal') - if signal is not None: - right = signal_bars(signal) + if row.get('active'): + prefix = ACTIVE_GLYPH + ' ' elif row.get('saved'): - right = '(saved)' + prefix = SAVED_GLYPH + ' ' else: - right = '' + prefix = '' + disambiguator = row.get('disambiguator') + left = prefix + ssid + ((' ' + disambiguator) if disambiguator else '') + right = signal_bars(row['signal']) if row.get('signal') is not None else '' return left + SPLIT + right @staticmethod @@ -317,6 +321,10 @@ def _open_saved_submenu(self, row: Row, include_disconnect: bool = False) -> Non items.append(("Forget", self._forget, row)) self.lcd.draw_selection_menu(items, row['ssid'], dismiss_option=True) + def _open_nearby_menu(self, nearby: list[Row]) -> None: + items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r) for r in nearby] + self.lcd.draw_selection_menu(items, "Nearby Networks", dismiss_option=True) + def _open_more_saved(self, extras: list[Row]) -> None: items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r, None, self._on_network_long_tap) for r in extras] self.lcd.draw_selection_menu(items, "Saved Networks", dismiss_option=True) From 7a9cca132b752d7fb1a9ff6d91b6818649ecf0b3 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Tue, 5 May 2026 22:31:46 -0400 Subject: [PATCH 7/9] Wifi menu upgrades --- ui/wifi_menu.py | 191 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 127 insertions(+), 64 deletions(-) diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index 0f547149..8f70cc6c 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -16,6 +16,8 @@ import time from typing import TYPE_CHECKING, Callable, NotRequired, Optional, Protocol, TypedDict, cast +from PIL import ImageFont + import common.util as util from modalapi.wifi import ( SavedConnection, @@ -27,7 +29,10 @@ from uilib import ( Box, Dialog, + InputEvent, + LetterSelector, MessageDialog, + RoundedPanel, TextWidget, WidgetAlign, ) @@ -52,14 +57,12 @@ def system_toggle_hotspot(self) -> None: ... SIGNAL_EMPTY = '\u25af' # ▯ ACTIVE_GLYPH = '\u2714' # ✔ SAVED_GLYPH = '\u2022' # • +PUBLIC_GLYPH = '\u24de' # Ⓟ — open/public network badge HOTSPOT_ON = '\u25cf' # ● HOTSPOT_OFF = '\u25cb' # ○ SEP = '\u00b7' # · SPLIT = TextWidget.SPLIT_SEP # left/right alignment marker for menu rows -OUT_OF_RANGE_CAP = 2 - - class Row(TypedDict): """A single network line in the wifi menu — saved profile, in-range network, or both. @@ -79,6 +82,53 @@ class Row(TypedDict): MenuItem = tuple # (label, callback, arg) or (label, callback, arg, is_active) +class _PassphraseEditor(RoundedPanel): + """Single-panel passphrase entry that opens directly with the letter selector. + + Skips the intermediate form dialog: Cancel dismisses cleanly; OK with a + non-empty passphrase dismisses and calls on_submit. + """ + + def __init__(self, ssid: str, pstack, on_submit: PasswordCallback) -> None: + self._pstack = pstack + self._on_submit = on_submit + self._curline = '' + + font = ImageFont.truetype("DejaVuSans.ttf", 18) + box = Box(0, 0, 300, 80) + box = box.centre(pstack.box) + super().__init__(box=box, parent=pstack, auto_destroy=True) + self.set_outline(2, (255, 255, 255)) + + TextWidget(box=Box.xywh(10, 8, 280, 0), text='Password for ' + ssid, + font=font, parent=self) + self._edit = TextWidget(box=Box.xywh(10, 30, 280, 20), + text='\u2588', font=font, parent=self) + self._edit.set_background((64, 64, 64)) + selector = LetterSelector(box=Box.xywh(10, 52, 280, 22), font=font, + parent=self, action=self._on_letter) + self.add_sel_widget(selector) + pstack.push_panel(self) + self.refresh() + + def _on_letter(self, event: InputEvent, data: object) -> None: + if event == InputEvent.CANCEL: + self._pstack.pop_panel(self) + return + if event == InputEvent.OK: + if self._curline: + self._pstack.pop_panel(self) + self._on_submit(self._curline) + return + if event == InputEvent.CLEAR: + self._curline = '' + elif event == InputEvent.BACKSPACE: + self._curline = self._curline[:-1] + elif event == InputEvent.LETTER: + self._curline += str(data) + self._edit.set_text(self._curline + '\u2588') + + def signal_bars(signal: int) -> str: levels = max(1, min(4, (signal + 12) // 25)) return SIGNAL_FILLED * levels + SIGNAL_EMPTY * (4 - levels) @@ -155,9 +205,9 @@ def open(self, event: object = None, widget: object = None) -> None: self.lcd.draw_info_message("", refresh=True) scanned_ssids = {n['ssid'] for n in scanned} - rows, extras, nearby = self._build_rows(scanned, saved_by_ssid, scanned_ssids, active_name) + rows, nearby = self._build_rows(scanned, saved_by_ssid, scanned_ssids, active_name) title = self._title(wifi_status, active_name, scanned) - items = self._build_items(rows, extras, nearby, hotspot_active) + items = self._build_items(rows, nearby, hotspot_active) self._root_menu = self.lcd.draw_selection_menu(items, title, dismiss_option=True) def toggle_hotspot(self, _: object = None) -> None: @@ -172,13 +222,13 @@ def _build_rows(self, scanned: list[ScannedNetwork], saved_by_ssid: dict[str, list[SavedConnection]], scanned_ssids: set[str], - active_name: Optional[str]) -> tuple[list[Row], list[Row], list[Row]]: - """Returns (visible_rows, extras_for_more_submenu, nearby_unsaved).""" + active_name: Optional[str]) -> tuple[list[Row], list[Row]]: + """Returns (visible_rows, nearby_unsaved).""" rows: list[Row] = [] nearby: list[Row] = [] # Split in-range scan results into saved (shown in main list) and - # unsaved (shown in "Other networks nearby..." submenu). + # unsaved (shown in "Nearby networks..." submenu). for net in scanned: profiles = saved_by_ssid.get(net['ssid'], []) saved_profile = self._pick_profile(profiles, active_name) @@ -198,7 +248,7 @@ def _build_rows(self, rows.sort(key=lambda r: (not r['active'], -(r['signal'] or 0))) nearby.sort(key=lambda r: -(r['signal'] or 0)) - # Saved profiles not visible in scan, sorted by recency. + # All saved profiles not visible in scan, sorted by recency. out_of_range: list[Row] = [] for ssid, profiles in saved_by_ssid.items(): if ssid in scanned_ssids: @@ -215,20 +265,12 @@ def _build_rows(self, self._maybe_disambiguate(ooo_row, profiles) out_of_range.append(ooo_row) out_of_range.sort(key=lambda r: -(r['profile']['timestamp'] if r['profile'] else 0)) + rows.extend(out_of_range) + return rows, nearby - extras: list[Row] = [] - if len(out_of_range) > OUT_OF_RANGE_CAP: - rows.extend(out_of_range[:OUT_OF_RANGE_CAP]) - extras = out_of_range[OUT_OF_RANGE_CAP:] - else: - rows.extend(out_of_range) - return rows, extras, nearby - - def _build_items(self, rows: list[Row], extras: list[Row], nearby: list[Row], + def _build_items(self, rows: list[Row], nearby: list[Row], hotspot_active: bool) -> list[MenuItem]: items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r, None, self._on_network_long_tap) for r in rows] - if extras: - items.append(("More saved...", self._open_more_saved, extras)) if nearby: items.append(("Nearby networks...", self._open_nearby_menu, nearby)) items.append(("Join other network...", self._open_join_dialog, None)) @@ -260,7 +302,12 @@ def _row_label(self, row: Row) -> str: prefix = '' disambiguator = row.get('disambiguator') left = prefix + ssid + ((' ' + disambiguator) if disambiguator else '') - right = signal_bars(row['signal']) if row.get('signal') is not None else '' + right_parts = [] + if not row.get('saved') and is_open_network(row.get('security')): + right_parts.append(PUBLIC_GLYPH) + if row.get('signal') is not None: + right_parts.append(signal_bars(row['signal'])) + right = ' '.join(right_parts) return left + SPLIT + right @staticmethod @@ -288,8 +335,7 @@ def _on_network_tap(self, row: Row) -> None: tap on saved+active → Disconnect/Forget/Replace pw submenu tap on saved+non-active → connect_saved (no prompt — we have the PSK) - tap on unsaved+open → connect_scanned - tap on unsaved+secured → password prompt → connect_scanned + tap on unsaved → _connect_scanned_flow (stays in nearby on failure) """ saved = row.get('saved') if saved: @@ -298,15 +344,36 @@ def _on_network_tap(self, row: Row) -> None: return self._connect_saved(row) return + self._connect_scanned_flow(row) + + def _connect_scanned_flow(self, row: Row) -> None: + """Connect to a scanned (unsaved) network, keeping the nearby submenu open. + + On auth failure for a secured network, re-opens the passphrase prompt so + the user can retry without being ejected back to the root menu. + On success, dismisses the nearby submenu. + """ + ssid = row['ssid'] + + def attempt(psk: Optional[str]) -> None: + self._mark_disconnected() + self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) + err = self._wifi_manager.connect_scanned(ssid, psk) + self.lcd.draw_info_message("", refresh=True) + if err is None: + self._pstack.pop_panel(None) # dismiss nearby submenu on success + return + reason = parse_nmcli_error(err) + if psk is not None and ('auth failed' in reason or 'wrong password' in reason): + self._open_password_prompt(ssid, attempt) # re-prompt; stay in nearby + else: + self._pstack.push_panel( + MessageDialog(self._pstack, reason, title="Couldn't connect")) + if is_open_network(row.get('security')): - self._connect_with_feedback( - lambda: self._wifi_manager.connect_scanned(row['ssid']), - row['ssid']) - return - self._open_password_prompt(row['ssid'], - lambda psk: self._connect_with_feedback( - lambda: self._wifi_manager.connect_scanned(row['ssid'], psk), - row['ssid'])) + attempt(None) + else: + self._open_password_prompt(ssid, attempt) def _on_network_long_tap(self, row: Row) -> None: """Long-press on a network row → saved-network submenu.""" @@ -325,16 +392,13 @@ def _open_nearby_menu(self, nearby: list[Row]) -> None: items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r) for r in nearby] self.lcd.draw_selection_menu(items, "Nearby Networks", dismiss_option=True) - def _open_more_saved(self, extras: list[Row]) -> None: - items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r, None, self._on_network_long_tap) for r in extras] - self.lcd.draw_selection_menu(items, "Saved Networks", dismiss_option=True) - def _connect_saved(self, row: Row) -> None: profile = row['profile'] assert profile is not None name = profile['name'] ssid = row['ssid'] self._pstack.pop_panel(None) + self._mark_disconnected() self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) err = self._wifi_manager.connect_saved(name) self.lcd.draw_info_message("", refresh=True) @@ -344,14 +408,34 @@ def _connect_saved(self, row: Row) -> None: # retry via replace_psk (which validates by reactivating). reason = parse_nmcli_error(err) if 'auth failed' in reason or 'wrong password' in reason: - self._open_password_prompt(ssid, - lambda psk: self._connect_with_feedback( - lambda: self._wifi_manager.replace_psk(name, psk), - ssid)) + def _on_new_psk(psk: str) -> None: + # Passphrase editor already popped itself; don't pop again. + self._mark_disconnected() + self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) + err2 = self._wifi_manager.replace_psk(name, psk) + self.lcd.draw_info_message("", refresh=True) + if err2 is None: + return + self._pstack.push_panel(MessageDialog( + self._pstack, + parse_nmcli_error(err2) + "\n(saved password unchanged)", + title="Couldn't connect")) + self._open_password_prompt(ssid, _on_new_psk) return self._pstack.push_panel( MessageDialog(self._pstack, reason, title="Couldn't connect")) + def _mark_disconnected(self) -> None: + """Immediately dim the WiFi toolbar icon to show we are no longer connected. + + The background polling thread will eventually push the authoritative status, + but callers should not leave the icon silver while a blocking operation runs. + """ + status: WifiStatus = {**self._wifi_status, + 'wifi_connected': False, 'connection': None, 'ssid': None} + self._host.wifi_status = status + self.lcd.update_wifi(status) + def _disconnect(self, row: Row) -> None: profile = row['profile'] assert profile is not None @@ -360,9 +444,12 @@ def _disconnect(self, row: Row) -> None: if err is not None: self._pstack.push_panel( MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't disconnect")) + return + self._mark_disconnected() def _connect_with_feedback(self, connect_fn: ConnectFn, ssid: str) -> None: self._pstack.pop_panel(None) + self._mark_disconnected() self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) err = connect_fn() self.lcd.draw_info_message("", refresh=True) @@ -395,31 +482,7 @@ def _forget(self, row: Row) -> None: # ----- dialogs ----- def _open_password_prompt(self, ssid: str, on_submit: PasswordCallback) -> None: - d = Dialog(width=240, height=110, auto_destroy=True, title='Password for %s' % ssid) - pw = TextWidget(box=Box.xywh(0, 0, 169, 0), text='', prompt='Passwd :', parent=d, - outline=1, sel_width=3, outline_radius=5, - align=WidgetAlign.NONE, name='pw_field', - edit_message='Password') - d.add_sel_widget(pw) - cancel = TextWidget(box=Box.xywh(0, 60, 0, 0), text='Cancel', parent=d, - outline=1, sel_width=3, outline_radius=5, - action=lambda x, y: self._pstack.pop_panel(d), - align=WidgetAlign.NONE, name='cancel_btn') - d.add_sel_widget(cancel) - - def submit(_event, _button): - psk = pw.text - if not psk: - return - self._pstack.pop_panel(d) - on_submit(psk) - - ok = TextWidget(box=Box.xywh(80, 60, 0, 0), text='Ok', parent=d, - outline=1, sel_width=3, outline_radius=5, - action=submit, align=WidgetAlign.NONE, name='ok_btn') - d.add_sel_widget(ok) - self._pstack.push_panel(d) - d.refresh() + _PassphraseEditor(ssid, self._pstack, on_submit) def _open_join_dialog(self, _: object = None) -> None: d = Dialog(width=240, height=120, auto_destroy=True, title='Join other network') From dcea2a37f23da9ffc9f6d9ade9a14caa5ff5de8c Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Wed, 6 May 2026 00:29:54 -0400 Subject: [PATCH 8/9] Font with glyphs, public glyph, proper auth fail flow --- pistomp/lcd320x240.py | 6 +- ui/wifi_menu.py | 32 +++++---- uilib/__init__.py | 1 + uilib/font_with_glyphs.py | 142 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 uilib/font_with_glyphs.py diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 2f0cf457..d58a1ed0 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -257,7 +257,8 @@ def draw_preset_menu(self, event, widget): items.append((name, self.handler.preset_change, i)) self.draw_selection_menu(items, "Snapshots", auto_dismiss=True, dismiss_option=True) - def draw_selection_menu(self, items, title="", auto_dismiss=False, dismiss_option=False): + def draw_selection_menu(self, items, title="", auto_dismiss=False, dismiss_option=False, + font=None): # items is a list of tuples: (label, callback, arg) or (label, callback, arg, is_active) # or (label, callback, arg, is_active, long_callback) where long_callback is called # instead of callback on a long press. @@ -270,8 +271,9 @@ def menu_action(event, params): return callback(params[2]) + extra = {} if font is None else {'font': font} m = Menu(title=title, items=items, auto_destroy=True, default_item=None, max_width=180, max_height=200, - auto_dismiss=auto_dismiss, dismiss_option=dismiss_option, action=menu_action) + auto_dismiss=auto_dismiss, dismiss_option=dismiss_option, action=menu_action, **extra) self.pstack.push_panel(m) return m diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index 8f70cc6c..fbe12322 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -16,7 +16,7 @@ import time from typing import TYPE_CHECKING, Callable, NotRequired, Optional, Protocol, TypedDict, cast -from PIL import ImageFont +from PIL import Image as _Image, ImageDraw as _ImageDraw, ImageFont import common.util as util from modalapi.wifi import ( @@ -28,10 +28,13 @@ ) from uilib import ( Box, + Config, Dialog, + FontWithGlyphs, InputEvent, LetterSelector, MessageDialog, + PillGlyph, RoundedPanel, TextWidget, WidgetAlign, @@ -57,7 +60,7 @@ def system_toggle_hotspot(self) -> None: ... SIGNAL_EMPTY = '\u25af' # ▯ ACTIVE_GLYPH = '\u2714' # ✔ SAVED_GLYPH = '\u2022' # • -PUBLIC_GLYPH = '\u24de' # Ⓟ — open/public network badge +PUBLIC_GLYPH = '\ue001' # PUA sentinel — rendered as pill badge by FontWithGlyphs HOTSPOT_ON = '\u25cf' # ● HOTSPOT_OFF = '\u25cb' # ○ SEP = '\u00b7' # · @@ -151,6 +154,12 @@ def is_open_network(security: Optional[str]) -> bool: return not security or security == '--' +def _make_badge_font() -> FontWithGlyphs: + base = Config().get_font('default') + assert base is not None, "default font not configured" + return FontWithGlyphs(base, {PUBLIC_GLYPH: PillGlyph('P')}) + + class WifiMenu: """The wifi panel: scan, join, edit and forget networks; toggle hotspot. @@ -301,13 +310,9 @@ def _row_label(self, row: Row) -> str: else: prefix = '' disambiguator = row.get('disambiguator') - left = prefix + ssid + ((' ' + disambiguator) if disambiguator else '') - right_parts = [] - if not row.get('saved') and is_open_network(row.get('security')): - right_parts.append(PUBLIC_GLYPH) - if row.get('signal') is not None: - right_parts.append(signal_bars(row['signal'])) - right = ' '.join(right_parts) + badge = (' ' + PUBLIC_GLYPH) if (not row.get('saved') and is_open_network(row.get('security'))) else '' + left = prefix + ssid + ((' ' + disambiguator) if disambiguator else '') + badge + right = signal_bars(row['signal']) if row.get('signal') is not None else '' return left + SPLIT + right @staticmethod @@ -355,7 +360,7 @@ def _connect_scanned_flow(self, row: Row) -> None: """ ssid = row['ssid'] - def attempt(psk: Optional[str]) -> None: + def attempt(psk: Optional[str], is_retry: bool = False) -> None: self._mark_disconnected() self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) err = self._wifi_manager.connect_scanned(ssid, psk) @@ -364,8 +369,8 @@ def attempt(psk: Optional[str]) -> None: self._pstack.pop_panel(None) # dismiss nearby submenu on success return reason = parse_nmcli_error(err) - if psk is not None and ('auth failed' in reason or 'wrong password' in reason): - self._open_password_prompt(ssid, attempt) # re-prompt; stay in nearby + if psk is not None and not is_retry and ('auth failed' in reason or 'wrong password' in reason): + self._open_password_prompt(ssid, lambda psk: attempt(psk, is_retry=True)) else: self._pstack.push_panel( MessageDialog(self._pstack, reason, title="Couldn't connect")) @@ -390,7 +395,8 @@ def _open_saved_submenu(self, row: Row, include_disconnect: bool = False) -> Non def _open_nearby_menu(self, nearby: list[Row]) -> None: items: list[MenuItem] = [(self._row_label(r), self._on_network_tap, r) for r in nearby] - self.lcd.draw_selection_menu(items, "Nearby Networks", dismiss_option=True) + self.lcd.draw_selection_menu(items, "Nearby Networks", dismiss_option=True, + font=_make_badge_font()) def _connect_saved(self, row: Row) -> None: profile = row['profile'] diff --git a/uilib/__init__.py b/uilib/__init__.py index 4b2a8540..ac7f6501 100644 --- a/uilib/__init__.py +++ b/uilib/__init__.py @@ -14,6 +14,7 @@ # along with pi-stomp. If not, see . from uilib.misc import * +from uilib.font_with_glyphs import * from uilib.box import * from uilib.widget import * from uilib.container import * diff --git a/uilib/font_with_glyphs.py b/uilib/font_with_glyphs.py new file mode 100644 index 00000000..f1cccda7 --- /dev/null +++ b/uilib/font_with_glyphs.py @@ -0,0 +1,142 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pi-stomp. If not, see . + +from typing import Generator, Protocol, runtime_checkable + +from PIL import Image, ImageDraw, ImageFont + + +@runtime_checkable +class Glyph(Protocol): + def width(self, font_height: int) -> int: ... + def draw(self, draw: ImageDraw.ImageDraw, x: int, y: int, font_height: int, color) -> None: ... + + +class PillGlyph: + """Rounded-rectangle badge with a short label rendered in a small font.""" + + def __init__(self, label: str, font_size: int = 9) -> None: + self._label = label + self._font = ImageFont.truetype("DejaVuSans.ttf", font_size) + bb = self._font.getbbox(label) + self._text_w = int(bb[2] - bb[0]) + self._text_h = int(bb[3] - bb[1]) + self._text_bb = bb + + def width(self, font_height: int) -> int: + return self._text_w + 8 + 2 # +2 for 1px breathing on each side + + def draw(self, draw: ImageDraw.ImageDraw, x: int, y: int, font_height: int, color) -> None: + bw = self.width(font_height) - 2 # pill body excludes breathing pixels + bx = x + 1 # 1px left breathing + bh = self._text_h + 4 + by = y + max(0, (font_height - bh) // 2) + draw.rounded_rectangle([bx, by, bx + bw - 1, by + bh - 1], + radius=2, fill=color, outline=None) + tx = bx + (bw - self._text_w) // 2 - int(self._text_bb[0]) + ty = by + 2 - int(self._text_bb[1]) + draw.text((tx, ty), self._label, fill=0, font=self._font) + + +class FontWithGlyphs: + """Font wrapper that renders custom glyphs for specified sentinel characters. + + All other rendering delegates transparently to the wrapped base font, so + regular text draws exactly as it would with the base font alone. + + Usage:: + + font = FontWithGlyphs(base_font, {MY_CHAR: PillGlyph('P')}) + draw_selection_menu(items, font=font) + """ + + def __init__(self, base: ImageFont.FreeTypeFont, glyphs: dict[str, Glyph]) -> None: + self._base = base + self._glyphs = glyphs + self._ascent, self._descent = base.getmetrics() + self._font_height = self._ascent + self._descent + + # --- PIL font interface --- + + def getmetrics(self) -> tuple[int, int]: + return self._base.getmetrics() + + def getbbox(self, text: str, *args, **kwargs) -> tuple[int, int, int, int]: + if not any(c in text for c in self._glyphs): + bb = self._base.getbbox(text, *args, **kwargs) + return (int(bb[0]), int(bb[1]), int(bb[2]), int(bb[3])) + + total_w = 0 + min_top = 0 + max_bottom = 0 + for segment, glyph in self._iter_segments(text): + if glyph is not None: + total_w += glyph.width(self._font_height) + max_bottom = max(max_bottom, self._font_height) + elif segment: + bb = self._base.getbbox(segment, *args, **kwargs) + total_w += int(bb[2]) - int(bb[0]) + min_top = min(min_top, int(bb[1])) + max_bottom = max(max_bottom, int(bb[3])) + return (0, min_top, total_w, max_bottom) + + def getmask2(self, text: str, mode: str = '', **kwargs) -> tuple: + if not any(c in text for c in self._glyphs): + return self._base.getmask2(text, mode, **kwargs) + + img = Image.new('L', (max(self._measure_width(text), 1), self._font_height), 0) + draw = ImageDraw.Draw(img) + x = 0 + for segment, glyph in self._iter_segments(text): + if glyph is not None: + glyph.draw(draw, x, 0, self._font_height, 255) + x += glyph.width(self._font_height) + elif segment: + # draw.text applies the font's internal offset automatically, + # so vertical positioning matches normal single-string rendering. + draw.text((x, 0), segment, fill=255, font=self._base) + bb = self._base.getbbox(segment) + x += int(bb[2]) - int(bb[0]) + return img.im, (0, 0) + + def __getattr__(self, name: str): + return getattr(self._base, name) + + # --- helpers --- + + def _iter_segments(self, text: str) -> Generator[tuple[str, 'Glyph | None'], None, None]: + """Yield (segment_str, glyph_or_None) pairs for each run in text.""" + buf = '' + for ch in text: + glyph = self._glyphs.get(ch) + if glyph is not None: + if buf: + yield buf, None + buf = '' + yield ch, glyph + else: + buf += ch + if buf: + yield buf, None + + def _measure_width(self, text: str) -> int: + total = 0 + for segment, glyph in self._iter_segments(text): + if glyph is not None: + total += glyph.width(self._font_height) + elif segment: + bb = self._base.getbbox(segment) + total += int(bb[2]) - int(bb[0]) + return total From f3d6b1337c1864ce89edeb481fd7c397c8f2b5e6 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Wed, 6 May 2026 01:55:13 -0400 Subject: [PATCH 9/9] Live refresh, hotspot dim, one-attempt, no toasts --- modalapi/modhandler.py | 3 ++ ui/wifi_menu.py | 97 +++++++++++++++++++----------------------- 2 files changed, 47 insertions(+), 53 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 64bfeeb8..b33f7937 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -167,6 +167,9 @@ def poll_wifi(self): if wifi_update is not None: self.wifi_status = wifi_update self.lcd.update_wifi(self.wifi_status) + wifi_menu = getattr(self.lcd, 'wifi_menu', None) + if wifi_menu is not None: + wifi_menu.notify_status_change() def poll_system_info(self): # Get the system state from the systemd service diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index fbe12322..0d79e580 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -163,14 +163,16 @@ def _make_badge_font() -> FontWithGlyphs: class WifiMenu: """The wifi panel: scan, join, edit and forget networks; toggle hotspot. - Owned by Lcd, which exposes the panel stack and a few shared affordances - (`draw_selection_menu`, `draw_info_message`). The menu is opened via `open()`, + Owned by Lcd, which exposes the panel stack and `draw_selection_menu`. + The menu is opened via `open()`, typically wired to the toolbar wifi icon. """ def __init__(self, lcd: 'Lcd') -> None: self.lcd: 'Lcd' = lcd self._root_menu: Optional['Menu'] = None + self._cached_scanned: list[ScannedNetwork] = [] + self._cached_saved_by_ssid: dict[str, list[SavedConnection]] = {} @property def _host(self) -> _WifiHost: @@ -193,15 +195,13 @@ def _pstack(self): # ----- entry points ----- def open(self, event: object = None, widget: object = None) -> None: - wifi_status = self._wifi_status # If wifi_supported is missing the host hasn't seen a poll yet — assume # supported so we don't render a stale "Disconnected" surface during # the cold-start window. - supported = util.DICT_GET(wifi_status, 'wifi_supported') + supported = util.DICT_GET(self._wifi_status, 'wifi_supported') if supported is None: supported = True - hotspot_active = bool(util.DICT_GET(wifi_status, 'hotspot_active')) - active_name = util.DICT_GET(wifi_status, 'connection') + hotspot_active = bool(util.DICT_GET(self._wifi_status, 'hotspot_active')) saved_by_ssid: dict[str, list[SavedConnection]] = {} for c in self._wifi_manager.list_connections(): @@ -209,21 +209,42 @@ def open(self, event: object = None, widget: object = None) -> None: scanned: list[ScannedNetwork] = [] if supported and not hotspot_active: - self.lcd.draw_info_message("scanning...", refresh=True) scanned = self._wifi_manager.scan_networks() - self.lcd.draw_info_message("", refresh=True) - scanned_ssids = {n['ssid'] for n in scanned} + self._cached_scanned = scanned + self._cached_saved_by_ssid = saved_by_ssid + self._render_root_menu() + + def _render_root_menu(self) -> None: + wifi_status = self._wifi_status + hotspot_active = bool(util.DICT_GET(wifi_status, 'hotspot_active')) + active_name = util.DICT_GET(wifi_status, 'connection') + scanned = self._cached_scanned + saved_by_ssid = self._cached_saved_by_ssid + scanned_ssids = {n['ssid'] for n in scanned} rows, nearby = self._build_rows(scanned, saved_by_ssid, scanned_ssids, active_name) title = self._title(wifi_status, active_name, scanned) items = self._build_items(rows, nearby, hotspot_active) self._root_menu = self.lcd.draw_selection_menu(items, title, dismiss_option=True) + def notify_status_change(self) -> None: + """Called by the handler after wifi_status is updated. + + Rebuilds the root menu in place so hotspot/active indicators stay + current. No-op if the wifi menu isn't the top panel (don't disrupt + password prompts, submenus, or unrelated UI). + """ + if self._root_menu is None or self._pstack.current is not self._root_menu: + return + old = self._root_menu + self._root_menu = None + self._pstack.pop_panel(old) + self._render_root_menu() + def toggle_hotspot(self, _: object = None) -> None: self._pstack.pop_panel(None) - self.lcd.draw_info_message("connecting...", refresh=True) + self._mark_disconnected(also_hotspot=True) self._host.system_toggle_hotspot() - self.lcd.draw_info_message("", refresh=True) # ----- list assembly ----- @@ -352,28 +373,18 @@ def _on_network_tap(self, row: Row) -> None: self._connect_scanned_flow(row) def _connect_scanned_flow(self, row: Row) -> None: - """Connect to a scanned (unsaved) network, keeping the nearby submenu open. - - On auth failure for a secured network, re-opens the passphrase prompt so - the user can retry without being ejected back to the root menu. - On success, dismisses the nearby submenu. - """ + """Connect to a scanned (unsaved) network. One attempt — if it fails, + show the error. The user can re-try via the menu.""" ssid = row['ssid'] - def attempt(psk: Optional[str], is_retry: bool = False) -> None: + def attempt(psk: Optional[str]) -> None: self._mark_disconnected() - self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) err = self._wifi_manager.connect_scanned(ssid, psk) - self.lcd.draw_info_message("", refresh=True) if err is None: self._pstack.pop_panel(None) # dismiss nearby submenu on success return - reason = parse_nmcli_error(err) - if psk is not None and not is_retry and ('auth failed' in reason or 'wrong password' in reason): - self._open_password_prompt(ssid, lambda psk: attempt(psk, is_retry=True)) - else: - self._pstack.push_panel( - MessageDialog(self._pstack, reason, title="Couldn't connect")) + self._pstack.push_panel( + MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't connect")) if is_open_network(row.get('security')): attempt(None) @@ -401,44 +412,26 @@ def _open_nearby_menu(self, nearby: list[Row]) -> None: def _connect_saved(self, row: Row) -> None: profile = row['profile'] assert profile is not None - name = profile['name'] - ssid = row['ssid'] self._pstack.pop_panel(None) self._mark_disconnected() - self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) - err = self._wifi_manager.connect_saved(name) - self.lcd.draw_info_message("", refresh=True) + err = self._wifi_manager.connect_saved(profile['name']) if err is None: return - # macOS-style: if the saved PSK is wrong, prompt for a new one and - # retry via replace_psk (which validates by reactivating). - reason = parse_nmcli_error(err) - if 'auth failed' in reason or 'wrong password' in reason: - def _on_new_psk(psk: str) -> None: - # Passphrase editor already popped itself; don't pop again. - self._mark_disconnected() - self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) - err2 = self._wifi_manager.replace_psk(name, psk) - self.lcd.draw_info_message("", refresh=True) - if err2 is None: - return - self._pstack.push_panel(MessageDialog( - self._pstack, - parse_nmcli_error(err2) + "\n(saved password unchanged)", - title="Couldn't connect")) - self._open_password_prompt(ssid, _on_new_psk) - return self._pstack.push_panel( - MessageDialog(self._pstack, reason, title="Couldn't connect")) + MessageDialog(self._pstack, parse_nmcli_error(err), title="Couldn't connect")) - def _mark_disconnected(self) -> None: + def _mark_disconnected(self, also_hotspot: bool = False) -> None: """Immediately dim the WiFi toolbar icon to show we are no longer connected. The background polling thread will eventually push the authoritative status, but callers should not leave the icon silver while a blocking operation runs. + Pass also_hotspot=True when toggling hotspot mode so the icon goes gray + rather than staying orange. """ status: WifiStatus = {**self._wifi_status, 'wifi_connected': False, 'connection': None, 'ssid': None} + if also_hotspot: + status['hotspot_active'] = False self._host.wifi_status = status self.lcd.update_wifi(status) @@ -456,9 +449,7 @@ def _disconnect(self, row: Row) -> None: def _connect_with_feedback(self, connect_fn: ConnectFn, ssid: str) -> None: self._pstack.pop_panel(None) self._mark_disconnected() - self.lcd.draw_info_message("connecting to %s..." % ssid, refresh=True) err = connect_fn() - self.lcd.draw_info_message("", refresh=True) if err is None: return self._pstack.push_panel(