From b1c5d4da740077beb7af54cb0f15606b2b011ae2 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 11 Jul 2026 12:29:41 -0400 Subject: [PATCH 1/4] Support WPA3 --- ui/wifi_menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index e6a689dbe..382d301c8 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -591,7 +591,7 @@ def submit(_event, _button): # Join dialog has no scan context: infer security from psk presence. # "WPA2" → wpa-psk; "" → open. Users on WPA3-only / enterprise networks # need to use the scan flow instead. - security = "WPA2" if psk else "" + security = "WPA2 WPA3" if psk else "" self._wifi_manager.queue.submit( ConnectScannedCmd(ssid=ssid, security=security, psk=psk or None), self._on_op_done ) From 333b94b1fe19ede849a5577e1afaed8c6e1eec9e Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 11 Jul 2026 13:15:54 -0400 Subject: [PATCH 2/4] Optimistic wifi menu --- modalapi/wifi/commands.py | 8 ++ modalapi/wifi/manager.py | 44 +++++++- plugins/layouts/mode_selector.py | 2 +- .../root_after_disconnect.png | Bin 10958 -> 11915 bytes .../root_before_forget.png | Bin 10454 -> 11161 bytes .../nearby_none_found.png | Bin 0 -> 13113 bytes .../root_after_replace.png | Bin 10958 -> 11459 bytes .../root_active_first.png | Bin 10454 -> 11161 bytes tests/test_command_queue.py | 35 ++++++ tests/test_wifi_manager.py | 64 ++++++++--- tests/v3/conftest.py | 4 + tests/v3/test_wifi_menu.py | 31 +++++- ui/wifi_menu.py | 104 ++++++++++++++---- uilib/menu.py | 12 +- 14 files changed, 260 insertions(+), 44 deletions(-) create mode 100644 tests/snapshots/v3/test_wifi_menu/test_nearby_empty_when_every_network_is_saved/nearby_none_found.png diff --git a/modalapi/wifi/commands.py b/modalapi/wifi/commands.py index eb98a80bf..5d089bfa6 100644 --- a/modalapi/wifi/commands.py +++ b/modalapi/wifi/commands.py @@ -180,6 +180,14 @@ def _drain(self) -> None: self._pending_keys.discard(cmd.key()) if bumps_pending: self._pending_op_count -= 1 + if bumps_pending: + # A write op just changed (or tried to change) connectivity — + # don't make the UI wait out the status poll's 5s tick. Guarded: + # an escape here would kill the worker and wedge every later op. + try: + self._wm.request_refresh() + except Exception: + logging.exception("Status refresh request failed") self._result_queue.put((on_done, result)) def poll(self) -> None: diff --git a/modalapi/wifi/manager.py b/modalapi/wifi/manager.py index b587979e2..eeb9f16bf 100644 --- a/modalapi/wifi/manager.py +++ b/modalapi/wifi/manager.py @@ -35,6 +35,8 @@ def __init__(self, ifname: str = "wlan0", on_status_change: Optional[Callable[[W self.changed: bool = False self.on_status_change: Optional[Callable[[WifiStatus], None]] = on_status_change self.stop: threading.Event = threading.Event() + self._wake: threading.Event = threading.Event() + self._force_publish: bool = False 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") @@ -46,8 +48,33 @@ def __del__(self) -> None: logging.info("Wifi monitor cleanup") self.shutdown() + def set_status(self, status: WifiStatus) -> None: + """Optimistically overwrite cached status and publish it now, on the + calling (main) thread, through the same callback the poller uses. + + For UI actions whose outcome we can predict — a disconnect drops the + active connection — so the screen doesn't sit on stale state for a poll + cycle. The next poll observes reality and overwrites this, so a wrong + prediction is corrected rather than compounded.""" + with self.lock: + self.last_status = status + self.changed = False + if self.on_status_change is not None: + self.on_status_change(status) + self.request_refresh() + + def request_refresh(self) -> None: + """Poll status now rather than waiting out the 5s tick, and publish the + result even if it's unchanged. Write ops need a definitive repaint, not + just a diff — a failed op leaves status identical but the UI may have + painted optimistically and must be reconciled back.""" + with self.lock: + self._force_publish = True + self._wake.set() + def shutdown(self) -> None: self.stop.set() + self._wake.set() try: self.queue.shutdown() except Exception: @@ -96,6 +123,12 @@ def _get_wpa_status(self, status: WifiStatus) -> None: def _polling_thread(self) -> None: while True: + # Claimed up front: a refresh requested *while* we're mid-poll refers + # to state we haven't read yet, so it must survive into the next pass. + with self.lock: + forced = self._force_publish + self._force_publish = False + new_status: WifiStatus = {} supported = new_status["wifi_supported"] = self._is_wifi_supported() connected = new_status["wifi_connected"] = self._is_wifi_connected() @@ -110,14 +143,21 @@ def _polling_thread(self) -> None: with self.lock: self._cached_saved = saved - if new_status != self.last_status: + if forced or new_status != self.last_status: logging.debug("Wifi status changed:" + str(new_status)) self.last_status = new_status self.changed = True - if self.stop.wait(5.0): + if self._wait_next_poll(5.0): break + def _wait_next_poll(self, timeout: float) -> bool: + """Sleep out the poll interval, cut short by request_refresh() or + shutdown(). Returns True when we're shutting down.""" + self._wake.wait(timeout) + self._wake.clear() + return self.stop.is_set() + def poll(self) -> None: """Main-thread tick. Drains write-op callbacks and fires on_status_change when the polling thread has new status.""" diff --git a/plugins/layouts/mode_selector.py b/plugins/layouts/mode_selector.py index a75d34152..eee54698c 100644 --- a/plugins/layouts/mode_selector.py +++ b/plugins/layouts/mode_selector.py @@ -84,7 +84,7 @@ def _open_dialog(self) -> None: for label, value in self._param.get_enum_value_list(): selected = value == current_value if selected: - default_item = f"\u2714 {label}" + default_item = label items.append((label, self._commit_value, value, selected)) title = f"{self._param.instance_id}:{self._param.name}" lcd.draw_selection_menu(items, title, auto_dismiss=True, default_item=default_item) diff --git a/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png b/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png index 4603f5cfe6d67e748688bd9808757dcab1a22bea..44979543eb4a10823d5d5a53132ae14b636c5497 100644 GIT binary patch literal 11915 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 literal 10958 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 E7bZAA+W-In diff --git a/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png b/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png index adf03bf76864e0e4d789a5a11f3cc023d783e3c1..ff3ae22e7b293ca2a639e34a9f317c1b96e340df 100644 GIT binary patch literal 11161 zcmZ{KWmHvByY42XyBpkuNGpxf-QC^Y-BKbYASGSWCEbl8N;lHoAl-NJ-TUL*GtS`$ zgIIg5HRFAsnvqHhlISQTC=dt)U0O;^1pb^_Xm6|1*=6_qk9kmkMR&2cv9 zI=pR61%dcv;L;xma#ODcF8pSZMu$K!x`)_P5FnD>?8*KlI0F_z{D^wz1xgj`%6eD6H ztme;G&e!bh*wkiu22nvlFp_KA+k`|!JBNq%IKw72JY))02JL8Jg%YZonwo}&iU|d0 zUs@K$)YR}aSVeYr#_}=WQNW||AhM*8Qcx%)=|iPU)fi(W@+WqygZ%ycn)Fygx>uZd zsHv%8U|>9tm(z8g&Kj?b$TzIT+TRo}QBPJ>L@& z82GfZVnjDF8ZkdVucN6+pE9&7(^aj-l&dt`>gk9Pv3__+j*YFXuC8uqczohZhlkSJ z(?dc+A~#+>vHLYW9sGtRa%rta6JK|w{2{mBZXO8{1`eZZc{zK~Z13V0JTgsgddV1G zc#;%5s$W=GSdMODd3kvi_VV996m)cZJG(D3g#3wAd;dj@G@v?{Sb5=ytaC$9Y{YE&_+h2?8&yrlGNB zZ)mtYI;uoTNqMHg6fZS7K2DP)qt2M5Q}Io~nMowKx~3-Hz}vyzero?>l0_KHr8#3{ z+f%LUr9?ClkCTyab{3Db$)n$d5ZDe<*M0%Z9sa~FJy#=oG2lg*9<;ieh47 z-rnA-s;V=FS*D7|ZzFoGt*mk)CMPFT$tJ)f@lg2j4vUrZvwV+u^rpmTf zsf@bfi>GX2B-S@K-Jc%)!Av;9a1fz^<;%&T%2e1E$Q)Vh16zfVMNg_Eh3eySz@0oz zJX_lDPoF*|(kmKt_|2_FKHp`>t!1Inu~v&o?_``>T7@o=lamu$BE#~avA%w)pvMs- zWr%Y`**BVm^Osn=wzf97VfCh7Dk?e;k-#pBz9l!BZjoJG^PK!vX2j6 z;nLdL8U`)md{+=Ib&`zpskw8kSOjU;#|C5n))0B~FcD>CW$JjTa5UmPB?c$T^td?8 zBk#)M;_8}5xppMsgpKZy7yPCFd9keT-dMs`-b^!hz>#}5Mv-Dm4}+T_uGRj0#`4LN?T7PsAlx!U~vd^k9`inc-o$gq&Q z`qbu7imQu@$Km|uPTl#>A+%bHKR$=`GsTNxsDvu2Qz(Nw1rmn+aJB%1cXK8X6m6f-s``Q7|wl z-dOyuv!2My%kwz;%L_iD-|B%+KtL0R!&bON&hKGnY01w>3^DpK)RmN!G&eWry7gPT z*@ZSBnw-|?o9R@m&xMT=>q;KD4h)Qh`mN(qTcK~^VSj+B$?mjpRaPZJ?RxvYF`1J%@;x%7>5#I^LUM}M2az{>TST@L5#w_=R>p%bFC zZ0Uaj&i-&W+AnY!_rS+XZESAR;zX{kuM0k2ud|yDXbEcOkHI4%8Z^7~PYo>@zNWAf zBTepM?=s2DwKq`c?WMFk|0nzai%#0P$X!h6^yYhQZIxa#4jEaSN&hE@1i6{U;0RK7 z%xxzg+v#%e%Q?$ki3_^(qeW8cFX`z~dKuq!CjXj@TDlRLuwhB(1^Z*An3nDt(QV zf9tstn@^wCUVBp7xz)-1rB%0DAjUB%w!_5aR;$Lh={;818Ywy&6f^G#PT`z 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 literal 10454 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 E7bZAA+W-In diff --git a/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png b/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png index adf03bf76864e0e4d789a5a11f3cc023d783e3c1..ff3ae22e7b293ca2a639e34a9f317c1b96e340df 100644 GIT binary patch literal 11161 zcmZ{KWmHvByY42XyBpkuNGpxf-QC^Y-BKbYASGSWCEbl8N;lHoAl-NJ-TUL*GtS`$ zgIIg5HRFAsnvqHhlISQTC=dt)U0O;^1pb^_Xm6|1*=6_qk9kmkMR&2cv9 zI=pR61%dcv;L;xma#ODcF8pSZMu$K!x`)_P5FnD>?8*KlI0F_z{D^wz1xgj`%6eD6H ztme;G&e!bh*wkiu22nvlFp_KA+k`|!JBNq%IKw72JY))02JL8Jg%YZonwo}&iU|d0 zUs@K$)YR}aSVeYr#_}=WQNW||AhM*8Qcx%)=|iPU)fi(W@+WqygZ%ycn)Fygx>uZd zsHv%8U|>9tm(z8g&Kj?b$TzIT+TRo}QBPJ>L@& z82GfZVnjDF8ZkdVucN6+pE9&7(^aj-l&dt`>gk9Pv3__+j*YFXuC8uqczohZhlkSJ z(?dc+A~#+>vHLYW9sGtRa%rta6JK|w{2{mBZXO8{1`eZZc{zK~Z13V0JTgsgddV1G zc#;%5s$W=GSdMODd3kvi_VV996m)cZJG(D3g#3wAd;dj@G@v?{Sb5=ytaC$9Y{YE&_+h2?8&yrlGNB zZ)mtYI;uoTNqMHg6fZS7K2DP)qt2M5Q}Io~nMowKx~3-Hz}vyzero?>l0_KHr8#3{ z+f%LUr9?ClkCTyab{3Db$)n$d5ZDe<*M0%Z9sa~FJy#=oG2lg*9<;ieh47 z-rnA-s;V=FS*D7|ZzFoGt*mk)CMPFT$tJ)f@lg2j4vUrZvwV+u^rpmTf zsf@bfi>GX2B-S@K-Jc%)!Av;9a1fz^<;%&T%2e1E$Q)Vh16zfVMNg_Eh3eySz@0oz zJX_lDPoF*|(kmKt_|2_FKHp`>t!1Inu~v&o?_``>T7@o=lamu$BE#~avA%w)pvMs- zWr%Y`**BVm^Osn=wzf97VfCh7Dk?e;k-#pBz9l!BZjoJG^PK!vX2j6 z;nLdL8U`)md{+=Ib&`zpskw8kSOjU;#|C5n))0B~FcD>CW$JjTa5UmPB?c$T^td?8 zBk#)M;_8}5xppMsgpKZy7yPCFd9keT-dMs`-b^!hz>#}5Mv-Dm4}+T_uGRj0#`4LN?T7PsAlx!U~vd^k9`inc-o$gq&Q z`qbu7imQu@$Km|uPTl#>A+%bHKR$=`GsTNxsDvu2Qz(Nw1rmn+aJB%1cXK8X6m6f-s``Q7|wl z-dOyuv!2My%kwz;%L_iD-|B%+KtL0R!&bON&hKGnY01w>3^DpK)RmN!G&eWry7gPT z*@ZSBnw-|?o9R@m&xMT=>q;KD4h)Qh`mN(qTcK~^VSj+B$?mjpRaPZJ?RxvYF`1J%@;x%7>5#I^LUM}M2az{>TST@L5#w_=R>p%bFC zZ0Uaj&i-&W+AnY!_rS+XZESAR;zX{kuM0k2ud|yDXbEcOkHI4%8Z^7~PYo>@zNWAf zBTepM?=s2DwKq`c?WMFk|0nzai%#0P$X!h6^yYhQZIxa#4jEaSN&hE@1i6{U;0RK7 z%xxzg+v#%e%Q?$ki3_^(qeW8cFX`z~dKuq!CjXj@TDlRLuwhB(1^Z*An3nDt(QV zf9tstn@^wCUVBp7xz)-1rB%0DAjUB%w!_5aR;$Lh={;818Ywy&6f^G#PT`z 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 literal 10454 zcmZ{~byQVR*FAhe1tbIk0Z~#)KtMoHx}>F~I~0(T?v!o;=@JkSY3T+50cnu#Zt1Sy z;`zScH^%#p=evL0aql^2oU_kfYtJ?3Tsu%sMhpv`7#)E?U`dFJC?F6>0`PkW4H>?# zWa)Vj2y%pkh@hfl($7(uUyo<*+NqLs(ZFhj7`ZCMVC1wlvh# zo<@l0X_&jZy4J+_BTzVIx3|e2KbFp1Z<9+*Of)q$rN+Jc3fGdIIJDak6$NEr%@!Lw zoX_nnKqS4onk(_z(2Rqdkuk5ne)@YCacEX?@pDE-Op13}nEQ^k< zC>NKPIf_4IvUBtD@Pk{;pJff1Y+$~8d@r^$Qz7T->WTwfmy@%iudmO~4<$`*^h_&F zPCQLcNLYBu`qgxTacp_@-N3-Wg|=`QrTtu!H)>N;lYViWF>Z+}BTdxW!h$fSe#eq^ z4wBIK@^WRSSk&9M14Jk_Vo6RuV(VbY$ zjE;U+$l1YTb2Q&vTN}%kP2JnGj+Y)jP0ZF{<9d@lxUsR(Ve{k1kC2!TCMNnNV_WK5 zvrXPQ?x*i-kp|SYwWqriIW>A=7RtXV(kszNCnjpx*_FX093!6M2Q@VC$94`GvmddY zOup7PFj(nLl@%#e&UM?FMG9}rP{?`k;DJ$ZN}c;n*{_1#Qa?_}pp;YED?)l2pj~(rs8VG~CL)!$^QZ=I)X{I;v{jsg^xR^3t;L*Qzf9<%pV! z%EtnSm^f%tDS3`ZSzKIP17p^g)5bY3zgi`G@V$|dPQB~c-Y)?xOwSj>t0!fTMGsj* zMKQ3kjfz!~y&5C^giTD=3$$ujSy=;CpV8BssOWvg#@q~$qie}!jv5g!t+^E<&fL{t zVWgr)AFZsYxZ_xhbPGj1Q^7T)dvFks%&Fk}_oAXp=}9y%z0ovb|M5q4kfZhN92_xU zzL1NNM@vdcN4>C>mK$;>;Ryf}d(BZWG>9UWuE2LAb-1J5|wzJ2>vdpNAovUqX2 zA1E~x%cNCp*qtytJ8MCQf=H$uC4c_hsypEYna4%3dGn~D!;?@FZu_-?Og5t)5wxjF zt0$bC$LmA6lo9RI|E5tqLqs#~prT?wWPLaAO`^ZQf1=_e8Mpmr7zsBzI{Ig+J1qC> zG%75X_D{>{Q4q?Ln2b1RC$3ke9|o24RK3W4m!s?cFwby+SHY=k6x=<%P|8)N@Gpt0+Q;p6p^AB|86<4hUd|d8j@0TmCMcgXIbA{jX3kDgM z>JbnVW~5#Z+@s19E|eA67fn>vZQQUS3{kX_J$hi?e@B zKL;e3senu@f!0G!y)(7?dcZIA0*{yoePnJ06!p#|D* zll8W$M1B@LB2OrBqP(1~4|YRP&HJ_3OR?wgkn4Q>OMab8U%y+l^x7q{C3(Q7e9{xg z&A>&jvfk^N()#uGyDL9Q(z4nbD-Pn^ZA4nQhfox1Mu6_Ofv?w^!q?1Xb?hkAe3{qb z_F_KesRGC1;S8%I!q@)>dG7~%y?e77j(X;yggt(_NX~6HWXW>8Hb~3DQUq)43B{nB zw<@>8zl*JknS{i|?WvmMrC6QF8A?gRER=HpY(=}t!?x!S4FU#M+mJ%Y5*&H(l%5wC z@je>63E;WPV^n>>7 zm`B7zlN)o}-=10MZvUBGb^k$UcyNF3!_nm^c9M{GOBN^~Jb>j|_gRy5ZgFyOq`b26 zi(~&oA^vWOBhc~Uv9+s6uA|(`KUD(?W7-}8LYIejGSWo!jOJ?(M#SacH{%VLTP9tF z7dbavd3aIdrV4Ewc%K;`KegQvIxiOz%}9@ad-&W>&v-C=RVa|pV#ZCI->4Oz{h00Y z<>NW^JL8r(SZf(N2r}#mH_2=2JP+;b9P;w3iArmoh90HC&)itN_!-C8X+i3joFz-| z-CkevNA+EAzLpU%eHjN-z`uD7(&7T)ez1DKs$+WcqN3^HMN@adY&p({(z69_8ceIP zv$Jf8(0|J5IQ(C@GcGeS29V=Btimpn~(uxqJ$Eh9@lL9IAG0J7<537$l zOSZ%cY~|Zkb#6t`=5Kg#Bk^cH5k6G<7x%qEh`2ER95sWYZld4a!W1XhCY~qMXMf+q zNnZGmiRQEC?u4|gJYNgdrdZX+zJ_bN*&UpyT4uzb;6LsK)JV1K6RpQpucn+M z1zguFG*+j)ljXz(hDpq|LVpKdwUrBr&`j&sYVTH@o=DfGmwRMVDw;Z?Zx8bJp9r3< zTWMlC|LH3(j8?0{a3C#YV^Z)~3l7hACwC^t&ZJF$|1??Co$xQoiz1P|%KOV9?dArT zfthXC5e?d2G(3D;4jQ9|ciyX%a$T0`koEM3^UMjqT|6D$`lRg7cd6DO<4T3uV2`Du zJMT5rc!hj&v^AlgKdkog;6dj>U6U^V<;I>m)!PJPHtv?YRFl5Am+d>X{bxb39evw9 zhyG{#jTKxwdFj6t4tjZ4TVGzTQR^^YTE-ULyR@&dNw{Px;6|=YOY`bo$VRBL zDb3@25dsM_2|h6q8fGu%wB5Q;zPYi+CItTIl=li0Kw0xvN~5OYYXK*+z0t zBuia;{6?u5#wHd5uZZ2!9u|zw%r^p zK09-UqjSG}S5jPh_97WI$~SlBnt^mDa+q^D9~fubgo7)TGPyurCm4NppV{>zJe0+RFeiBrXKYy}jDo9*0u6yn- zw5=>JyI-BIlo)pJ?e31%xg4MFuNW8@Fwm?2tIw39gMpK|?lg5WH(cztk=>MUUqbD; zS2oMvB%+8ge+S~x+HZ}&o*X?n7?e%r_ekko!6xA*SrND2ksR9*uM(?W@OOGTAjY zEBE9*Ij0?kbBBJ8JyT1uwz2s*UAwk?G~!VVL@U+(EGNIvS27c7DINv8^%DnIeBAerztQk2$^WN9^RYPfPjGM zI+xs`^+e8(=(oe!T0YMX{ZynMGAR>Elgm-GuF|~7PvLd>v$ThbP5OOk9jatxBrbil z`i()IwIcjzuv!bK&oVBW;*m4f@-vxsQP}2y>Op(x$CXEPKCopQe!x_?cc_pRS?u#KHc(Ox2nwjPO_`xc< z6&TwFbZu(NE!wMpx=aaiAz}0S^=n1~sZ52;tSqj!eQJS2|Q%4|&{;FO;wRDEjV= zO65fN)&JtHkgx3G;!<%qX8*z1xNpr?v~CsNzgX3b=3*ROJWwQk$YeL0Q|+f+y!25U znMcd--#=B)l5#s^nH^S*GLRGH=}6><0*`?k){)xOPXDrocfJRtxycZR$>tU zhH?p~`(Qj4?=zzx%Do`Q^3z*nqP|jW4ZHnJ?d{vQ^78VMl7G$)H+Elvezvl*n(?@< zvoqy(Iodo}8@xU&^iVrVitXy^0tM#N*AYeAa59g*A2b(VQ( zDWOuJnbvRoHcMKMg$TR`__5aB)Vs3p{pr(kZ))$7H4FlN{|2e#c6Pv2n=D5eq1oWh z%fsV3TBHXco;d)}gwgiF;UQ5sKH6>350ZPB(gkzRZtXqJpo$}8)@E0q*qW$-Su&N_ zML1Fhiv0XIQ~&gBRtWKnAL;4yp!!RUdPRqE(R~4=))qUWW?c8*fc;WkT@3#sY`097~`_> z8)N15YbQrXaHA;t$#_`m9YklPGoob7471sOd)oZe`RsT*T#v|ItiA(b z@e!VpheBvw>WX1l{{%u(W;!xmX(db(rAMT!pzsOj=7t@<6NxVf5PfBDR$&?dglTAL z;eP18xL|!`4{)g6vZ>OHdNtqujS#ufMDA7wNc0fUon7R^lT|kS*BASEv@+sUxR}j5 z7n|lyCBN&Br(G7=ck$7ECCh5w&LC8~!@|Ig;4D$itC(@0FunpHZVP=RmL^wQTid=? zrtP}>TI%QT0pn6823ABto3z0~H|NY>lQIzb;UOX42G{iT^tNmF?^#i_MLZGv)7c3% zW^-fX{P&}ekNI3jrSXW#e8CPH8yf?x!9+1=^hAP^&lY8ii^#1yqhr@wY>$B1az0oE zpmsjl(SvlTb=u#NdV1^Dt&)-wu(De9cL{Y|_3E)hfOdU^^93Y6)iD=en|w6~+( zy}Nd%b(;y*8-dbjrDw^{f@t(YEk+==?%br?%~3Bw#Q!CNIE{#xENH+KiEgv}e_5F) z>l>)2s-zTMQNg)ecd$N$(OlFTOz6;moB~={O)XHg%`r&K0baacoK_}9OmP< z@0OB~+_nscX#&4uY)8HWp^AiD*@{Z$I=i)1r{5Y>UjLNsp-J~*4YhhWuk*o6`^`9M znu#(~!dETZg~-Qoe;r&2x`=olA?0<;ih~%ASnV z9~q^(l>-&vi6!^bL-x}B4?f&~)jSRB8M7G%K#r`ft>t5c7aCn(T>=f(D;djGDg4c1_V>Gd4F1ITyKgcTAiA7pqn@(h zDl4YRX;xYyrAo`nju^8`)8K_`hl-Yf@df7&Hb$74nPnLX@E(SZkL#pk*E%1Xz!NR! zgj|0X741N+f(zfX5DEKXcKrD>Z(Y^iuWN6wSfh{d_wfOuAKM@jw ziP@Ybn<}H7b=U+5`%2XD*Nw-y$>asKr5)I07{)~muwu(Eo-o8#$;sM3_VX!~I5!N9=q z6#q}T`2;?CD;Qoor@%zHk}aA(-Dz)vWG(83cQb~X2lC|k52Td1w4=aNi|3fDsEQDyOcv=Z?Rk03gVHczAgskSS47!mg74p?bikrZ28wmFMN< zm6nzQ-QPQPb6H!8rwMu@V~hi@>*bm2D^9=vEPJP{)YR0Pn)4q@ zx$SEoX55aoJUl#Hy+T4lKn@hBmj$&g$mb}MhWn2^Yas@z)UkGAi5n=Asc=7PXlMv6 zt*=kcM-Em2@ckW8L+~vf9ZxuCTnC3`=?H>5DCna)cG4JOBX9y2^10KnIxG?f@?M-a7^U@t23YyYaA~UfB_`(ILUtGRQNypExti*o#0*}Fd{PJ^TP(nf?islWME#-W2Ha@aVUoF&vG&5JWu6Ei9a=w%ce4#Oq)K zb~6S-1=!*6LzCSrZD(h$h;5FSFGnK_>$9?!qgAv6gM$r9#z5&vOG!f2}Popv}7?3$?QcpFvof{jew_RxgidbZYFMJ$DY7sBvHxTJ1{{#tm$#)){dv z4kzJ00{@Zpm95vn4ZO z7!1tJ6-A1KOqy{ot>!DO7VeSpX{xE64yowWfniTgO^xhD{_L6gaGu(tIZ}@bRf_eeH+Tp-rTmb8~Owjk%o0 zY25-F#hShXBItVWPn)$sI{D{Ek>l1 z27)81aVX$)C;?M9x1>2idSU(Dn9&v)3MZQ_zZB~?^~lCD@7yg%d)259n%q{G9KC!P z*&G(Od{B(2Pq&-n{}hqAIVDZhJdCnt&~ zCJNHz5VWLd@X;I2|8d{*A6$BLiwWibWj53Vh_7{;7Wl*nBjo=-YLksV3%wo4*&jdX z14R@R6%U3rK0+xdGsRL~iJ)HjR4ier31F-@3N4Gz3ylttoqm3Pkt<-XEMzNnXA7__ zKrLO=M3(7tLOvn8K52tez@$^pW4qe-`SWKeqwmIkzTd7pP9e#9TjrEL{O_r!uLhu0 z0=EN`Q_l@?bHVk(xU&=VfC0Knr6xo4UIOH?o#BsPvBE9DpeQ$`{w!904sD)TZRfza zgT1|k=LWxv4cg4)@KG0ekO0ORa0A{wH6czFLUK#wxydV|Da6siW9nOQ+&eP~W!Gz4efB#NzV2k=fINGU{ z!GE#i4es9Qglfv&=t)$IDdfk{($QqP2GcAfJv~l(+2<}ExIhN42gZg?Ia0lbg7_?5 z_Qweb2vYdms-b!S1#bUL0b*8@DR#6FmhBB)9~p7zPk#++2EK>QTNlHi*$8vxelq8O z(v_5y1P}|F?(=!$GAFzJrfRax@ti*y_+zW9t8i8-mg&Bm?!JT#H^yXFpFHSM@R^AG zrt00!LDFax=_0-INbrr9>qg!2xY|!07*L1`Oc;;09e#Ha*U@OQSIHTOHfz6BX6N8A zvSy1xz-VmR(B7eQygBv+Dj#!N-I&dre`w)t8V&B3;3e$Pgh*yeN=Q6M*WG`_<#NQB z5`Y2rB8!Dm|LVlx#%NsbS_9qk`gJQz z%}#kSHO$HW;h~+44Vz6t`>$^$$ou5p#});6%~n40*h&(svn2V z1u~3BzzUhQYfmDZpj3taR7#ln3|@o3zkh#C%eMy-HYC9%cUaPZS!_?&nQ^#4Tav&~ z=tW^#nqWphSV0;2ck=#?YQ)|=OI1OE_5B}we*Olan`v^6P|_&VsK8dq{szrcA+)=` zzj~h88yYS{NX0^lqvPYp3Z`;$b4@KQEG`R%Zk`pji6XR`5zfadDM*Zq`{~-}=3%Ka zWmQ$DBRU?*uWSt4q8>cTuNo|wP){CFPLoUGvNM4kg~lYAm;-^oa zf+zR*o)OU6lR0t59ngNdMBwqLG7>yz57iq58$9rRySFN+93sDn!5rL-J}_IPX>7nh zzwsk9RP-a-eutE5+vw|qUqiuKw7k3wIvp}_a9!SbpMjlT0PNA!-c%MnAprqIREK7* zW3fy&u)oIR-0JExe=_%g|4TK1d;yzNHp|Q>ncH>DpJ63#VQokQ29YdNVYMK7&mZr> zgVvS(P|>vatZMf5_Jq_+txa^cVpv2hx{xD4Eki>?(S7H~%S^W$uIpoC35x{)9>6&J z$^{ax|Im36k)@CWtRO!(_ett+*R!?kQ&)HICa}&~-?if~AdfC4;Nju*rt%jm(q}5H zK^q~Y;)2`yX=eO`awY-r+u%wC4Go3}adZp}qn;#*X=c*!*u+HOZKQmzmDWp=d-fA$ z4{~pv1kUtsj+FpvO#S_93k`Uv7sOhUpv2;nleM(8@)hZC_MSvUcvbXp-M`IJBsS#~ zS|Qv_PD_Ajw7XHkQ%%0k7>)0B`+QIkrG)Hh&Sm@|Ka&kbw=M#?V=?162V(StWvlAQS zMmi7C_L2K4xbZ%ThT@4QO=(HLF+234hBFaUs3yAE3hL^_WMm$YjI*=4UIMzT%?#h# zkw}m^4;;#_F306#7?K(J*3{K^Pko<9F{+$D2?%W44aSWqoGPt(%w18Xef-j&5|sGeeYh@;F>~?B2%nsnL2Uk zGHfo>FNRHB;#6?3J+SfW*LHByQ%~@HB{CI2wMI))JMnop0a*FVWCJS|lev9Fh}uxd zcHjjqk?`YaR6vw%ott2xQ=%atC zGA=UedUU|KO#}>to%jt?Y@(VdQ|zEIJ6CepW3tVUraR|cmWTy|o@KGoU5CK_5B@0U z6T}zw_Z~OKTsX!&J_tiTs(5kYM)J}V+-Bw6!=odPl8}mu3guk8FgA&zhWw`OjZtsa z6QT#+O)RHO-c5~SZl6m$FR8s}DmHB2roK&+W5EJ%?gjCijeQ^DP*Eye3L~U*DbJY- zDZ81k5ehj6;Op)0+c-F6%h9nD!!Dme*tx;Nf)lR2vWmdd^_V+;t%Y6S`%m5S8M`LN zmQOfgYws7X?(ai*O;=o;)xjja|2=5` None: + with self.lock: + self.refreshes += 1 @dataclass @@ -145,6 +151,35 @@ def call_poll(): assert len(err) == 1 +def test_write_op_requests_status_refresh_but_scan_does_not(queue, ctx): + """A write op may have changed connectivity, so the poller is nudged for + fresh status. A scan changes nothing — nudging on every 2s scan would spin + the poller for no reason.""" + results: list = [] + queue.submit(SleepCmd("write", delay=0.0), results.append) + _drain(queue, results, 1) + assert ctx.refreshes == 1 + + queue.submit_scan(SleepCmd("scan", delay=0.0), results.append) + _drain(queue, results, 2) + assert ctx.refreshes == 1 + + +def test_worker_survives_a_failing_refresh(queue, ctx): + """A blowup in the post-op refresh must not kill the worker — that would + silently wedge every later wifi command.""" + with patch.object(_Ctx, "request_refresh", side_effect=RuntimeError("nope")): + results: list = [] + queue.submit(SleepCmd("A", delay=0.0), results.append) + _drain(queue, results, 1) + assert results == ["A"] + + results2: list = [] + queue.submit(SleepCmd("B", delay=0.0), results2.append) + _drain(queue, results2, 1) + assert results2 == ["B"] + + def test_shutdown_joins_worker(ctx): """shutdown() returns promptly even with no work pending.""" q = CommandQueue(ctx) diff --git a/tests/test_wifi_manager.py b/tests/test_wifi_manager.py index 49d45eae1..20c158c29 100644 --- a/tests/test_wifi_manager.py +++ b/tests/test_wifi_manager.py @@ -10,7 +10,7 @@ import pytest -from modalapi.wifi import KeyMgmt, WifiManager +from modalapi.wifi import KeyMgmt, WifiManager, WifiStatus from modalapi.wifi import ops @@ -284,13 +284,7 @@ def test_polling_thread_iteration_refreshes_status_and_saved(): with patch.object(WifiManager, "_polling_thread", lambda self: None): wm = WifiManager(ifname="wlan0") - # Stop the loop after a single pass. - original_wait = wm.stop.wait - - def stop_after_first(_timeout): - wm.stop.set() - return original_wait(0) - + # Stop the loop after a single pass: the inter-poll wait reports shutdown. with ( patch.object(wm, "_is_wifi_supported", return_value=True), patch.object(wm, "_is_wifi_connected", return_value=True), @@ -300,7 +294,7 @@ def stop_after_first(_timeout): side_effect=lambda s: s.update(ssid="Home", ip4_address="10.0.0.2", hotspot_active=False), ), patch.object(ops, "list_connections", return_value=saved), - patch.object(wm.stop, "wait", side_effect=stop_after_first), + patch.object(wm, "_wait_next_poll", return_value=True), ): WifiManager._polling_thread(wm) @@ -312,21 +306,63 @@ def stop_after_first(_timeout): assert wm.changed is True +def test_set_status_publishes_immediately_and_requests_refresh(): + """An optimistic status write reaches on_status_change on the calling thread + (no waiting out a poll) and asks the poller for confirming truth.""" + seen: list[WifiStatus] = [] + + def record(status: WifiStatus) -> None: + seen.append(status) + + with patch.object(WifiManager, "_polling_thread", lambda self: None): + wm = WifiManager(ifname="wlan0", on_status_change=record) + + wm.set_status({"wifi_supported": True, "wifi_connected": False, "hotspot_active": False}) + + assert seen == [{"wifi_supported": True, "wifi_connected": False, "hotspot_active": False}] + assert wm.last_status.get("wifi_connected") is False + assert wm._force_publish is True + assert wm._wake.is_set() + + +def test_forced_publish_survives_a_refresh_requested_mid_poll(): + """A refresh requested while the poller is mid-pass refers to state that pass + hasn't read, so it must not be consumed by it — it carries to the next pass.""" + with patch.object(WifiManager, "_polling_thread", lambda self: None): + wm = WifiManager(ifname="wlan0") + wm.last_status = {"wifi_supported": True, "wifi_connected": True, "hotspot_active": False} + + # Fire the request from inside the poll, after _force_publish was claimed. + def racing_probe() -> bool: + wm.request_refresh() + return True + + with ( + patch.object(wm, "_is_wifi_supported", return_value=True), + patch.object(wm, "_is_wifi_connected", side_effect=racing_probe), + patch.object(wm, "_get_wpa_status", side_effect=lambda s: None), + patch.object(ops, "list_connections", return_value=[]), + patch.object(wm, "_wait_next_poll", return_value=True), + ): + WifiManager._polling_thread(wm) + + # Status is unchanged, so this pass published nothing — but the request is + # still armed for the pass that can actually observe the new state. + assert wm.changed is False + assert wm._force_publish is True + + def test_polling_thread_skips_saved_when_wifi_unsupported(): """When wifi isn't supported, the polling thread must not call list_connections (no nmcli on the box) and _cached_saved stays empty.""" with patch.object(WifiManager, "_polling_thread", lambda self: None): wm = WifiManager(ifname="wlan0") - def stop_after_first(_timeout): - wm.stop.set() - return True - with ( patch.object(wm, "_is_wifi_supported", return_value=False), patch.object(wm, "_is_wifi_connected", return_value=False), patch.object(ops, "list_connections") as list_conns, - patch.object(wm.stop, "wait", side_effect=stop_after_first), + patch.object(wm, "_wait_next_poll", return_value=True), ): WifiManager._polling_thread(wm) list_conns.assert_not_called() diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py index 5dc25633a..554a9daf8 100644 --- a/tests/v3/conftest.py +++ b/tests/v3/conftest.py @@ -327,6 +327,10 @@ def _run_inline(cmd, on_done): wm.queue.submit_scan.side_effect = _run_inline wm.queue.pending_op_count.return_value = 0 + # The real WifiManager.set_status publishes through on_status_change, + # which is the handler's own hook. Mirror that wiring. + wm.set_status.side_effect = v3_system.handler._on_wifi_status_change + status = { "wifi_supported": supported, "wifi_connected": active is not None, diff --git a/tests/v3/test_wifi_menu.py b/tests/v3/test_wifi_menu.py index b777d0a45..f5ae5d74e 100644 --- a/tests/v3/test_wifi_menu.py +++ b/tests/v3/test_wifi_menu.py @@ -22,13 +22,17 @@ def _open(v3_system) -> tuple[WifiMenu, Lcd]: - """Create a fresh WifiMenu, open it, and return (menu, lcd). + """Open the LCD's WifiMenu and return (menu, lcd). + + Must be *the LCD's own* instance, not a fresh one: the handler's status + callback re-renders `lcd.wifi_menu`, so a private instance would never see + the repaints that a status change drives. The wifi_state fixture installs an inline CommandQueue shim, so submit/submit_scan callbacks fire synchronously. """ lcd = v3_system.handler._lcd - wm = WifiMenu(lcd) + wm = lcd.wifi_menu wm.open() return wm, lcd @@ -96,6 +100,21 @@ def _defer(cmd, on_done): snapshot("nearby_populated") +def test_nearby_empty_when_every_network_is_saved(v3_system, wifi_state, nav_lcd, snapshot): + """The only in-range SSID is the saved+connected one, so nearby has nothing + to list — it must say so, not sit on 'Scanning...' forever.""" + wifi_state( + scanned=[make_scanned("Home", signal=80, in_use=True)], + saved=[make_saved("Home")], + active="Home", + ) + _wm, lcd = _open(v3_system) + + nav_lcd(1) # Home → Nearby networks... + _click(lcd) + snapshot("nearby_none_found") + + # --------------------------------------------------------------------------- # 5.2 test_signal_bar_levels # --------------------------------------------------------------------------- @@ -626,9 +645,15 @@ def test_disconnect_active(v3_system, wifi_state, snapshot): wm_mock = v3_system.handler.wifi_manager wm_mock.disconnect.return_value = None - _wm, lcd = _open(v3_system) + wm, lcd = _open(v3_system) _click(lcd) # tap active Home → [Disconnect, Replace password, Forget, ↩] _click(lcd) # tap Disconnect + + wm_mock.disconnect.assert_called_once_with("Home") + # The ✔ must be gone before the status poll catches up — not still painted + # from the pre-disconnect status. + rows, _ = wm._current_rows() + assert [r["active"] for r in rows] == [False] snapshot("root_after_disconnect") diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index 382d301c8..e8d8ab91d 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -46,7 +46,7 @@ WidgetAlign, ) from uilib.glyphs import PillGlyph, SignalBarsGlyph, EthernetCableGlyph -from uilib.menu import Menu, MenuItem +from uilib.menu import Menu, MenuItem, label_key from uilib.rich_text import IconSeg, Segment, Spacer, TextSeg if TYPE_CHECKING: @@ -138,6 +138,24 @@ def signal_bars_level(signal: int) -> int: return max(1, min(4, (signal + 12) // 25)) +# What a rendered row actually shows. Signal is bucketed to the drawn bar count, +# so ordinary dBm jitter doesn't count as a change. +RowSig = tuple[str, Optional[str], bool, bool, Optional[int]] + + +def _rows_sig(rows: list["Row"]) -> tuple[RowSig, ...]: + return tuple( + ( + r["ssid"], + r.get("display_name"), + r["saved"], + r["active"], + None if r["signal"] is None else signal_bars_level(r["signal"]), + ) + for r in rows + ) + + def is_open_network(security: Optional[str]) -> bool: return not security or security == "--" @@ -162,6 +180,11 @@ def __init__(self, lcd: "Lcd") -> None: self._root_menu: Optional["Menu"] = None self._nearby_menu: Optional["Menu"] = None self._cached_scanned: list[ScannedNetwork] = [] + # False until the first scan result lands: distinguishes "still scanning" + # from "scan done, nothing to list". + self._scan_completed: bool = False + self._root_sig: tuple[RowSig, ...] = () + self._nearby_sig: tuple[RowSig, ...] = () @property def _host(self) -> _WifiHost: @@ -209,36 +232,68 @@ def _on_scan(self, networks: list[ScannedNetwork]) -> None: if not networks and self._cached_scanned: return self._cached_scanned = networks - if self._nearby_menu is not None and self._pstack.current is self._nearby_menu: - keep = self._current_label(self._nearby_menu) - old = self._nearby_menu - self._nearby_menu = None - self._pstack.pop_panel(old) - self._render_nearby_menu(default_label=keep) + first_result = not self._scan_completed + self._scan_completed = True + + # Rebuild whichever menu the user is looking at, but only when the scan + # actually changed its contents — tick() rescans every 2s, and a blind + # pop/push would reset the cursor and scroll under the user. + root_rows, nearby_rows = self._current_rows() + current = self._pstack.current + if self._nearby_menu is not None and current is self._nearby_menu: + if first_result or _rows_sig(nearby_rows) != self._nearby_sig: + self._rerender_nearby() + elif self._root_menu is not None and current is self._root_menu: + if _rows_sig(root_rows) != self._root_sig: + self._rerender_root() + + def _current_rows(self) -> tuple[list[Row], list[Row]]: + scanned = self._cached_scanned + return self._build_rows( + scanned, + self._saved_by_ssid, + {n["ssid"] for n in scanned}, + util.DICT_GET(self._wifi_status, "connection"), + ) + + def _rerender_root(self) -> None: + assert self._root_menu is not None + keep = self._current_label(self._root_menu) + old = self._root_menu + self._root_menu = None + self._pstack.pop_panel(old) + self._render_root_menu(default_label=keep) + + def _rerender_nearby(self) -> None: + assert self._nearby_menu is not None + keep = self._current_label(self._nearby_menu) + old = self._nearby_menu + self._nearby_menu = None + self._pstack.pop_panel(old) + self._render_nearby_menu(default_label=keep) def _render_root_menu(self, default_label: Optional[str] = None) -> None: wifi_status = self._wifi_status hotspot_active = bool(util.DICT_GET(wifi_status, "hotspot_active")) supported = util.DICT_GET(wifi_status, "wifi_supported") is not False active_name = util.DICT_GET(wifi_status, "connection") - scanned = self._cached_scanned - saved_by_ssid = self._saved_by_ssid - scanned_ssids = {n["ssid"] for n in scanned} - rows, _ = self._build_rows(scanned, saved_by_ssid, scanned_ssids, active_name) + rows, _ = self._current_rows() title = self._title(wifi_status, active_name) items = self._build_items(rows, hotspot_active, supported) + self._root_sig = _rows_sig(rows) self._root_menu = self.lcd.draw_selection_menu(items, title, dismiss_option=True, default_item=default_label) def _render_nearby_menu(self, default_label: Optional[str] = None) -> None: - scanned = self._cached_scanned - saved_by_ssid = self._saved_by_ssid - scanned_ssids = {n["ssid"] for n in scanned} - active_name = util.DICT_GET(self._wifi_status, "connection") - _, nearby = self._build_rows(scanned, saved_by_ssid, scanned_ssids, active_name) + _, nearby = self._current_rows() if nearby: items: list[MenuItem] = [(self._row_segments(r), self._on_network_tap, r) for r in nearby] + elif self._scan_completed: + # Saved networks live on the root menu, so an in-range-but-saved + # SSID legitimately leaves this list empty. + items = [("No other networks found", None, None)] else: items = [("Scanning...", None, None)] + self._nearby_sig = _rows_sig(nearby) self._nearby_menu = self.lcd.draw_selection_menu( items, "Nearby Networks", dismiss_option=True, default_item=default_label ) @@ -248,11 +303,7 @@ def notify_status_change(self) -> None: in place, preserving cursor; no-op if root isn't the top panel.""" if self._root_menu is None or self._pstack.current is not self._root_menu: return - keep = self._current_label(self._root_menu) - old = self._root_menu - self._root_menu = None - self._pstack.pop_panel(old) - self._render_root_menu(default_label=keep) + self._rerender_root() @staticmethod def _current_label(menu: "Menu") -> Optional[str]: @@ -260,7 +311,7 @@ def _current_label(menu: "Menu") -> Optional[str]: if sel_ref is None: return None try: - return sel_ref.data[0] + return label_key(sel_ref.data[0]) except (IndexError, AttributeError): return None @@ -460,6 +511,15 @@ def _disconnect(self, row: Row) -> None: profile = row["profile"] assert profile is not None self._pstack.pop_panel(None) + # Predict the outcome so the ✔ clears now; the status poll reconciles. + status = self._wifi_status + self._wifi_manager.set_status( + { + "wifi_supported": util.DICT_GET(status, "wifi_supported") is not False, + "wifi_connected": False, + "hotspot_active": False, + } + ) self._wifi_manager.queue.submit(DisconnectCmd(name=profile["name"], ssid=row["ssid"]), self._on_disconnect_done) def _on_disconnect_done(self, err: Optional[bytes]) -> None: diff --git a/uilib/menu.py b/uilib/menu.py index d4f13612c..cc8bc3b29 100644 --- a/uilib/menu.py +++ b/uilib/menu.py @@ -19,7 +19,7 @@ from uilib.config import Config from uilib.dialog import Dialog from uilib.misc import InputEvent, TextHAlign, get_text_size, trace -from uilib.rich_text import RichTextWidget, Segment +from uilib.rich_text import RichTextWidget, Segment, TextSeg from uilib.text import TextWidget # A menu row label. Either a plain string (rendered as a `TextWidget`) or a @@ -51,6 +51,14 @@ def _item_label(i: MenuItem) -> Label: return i[0] +def label_key(label: Label) -> str: + """Stable identity for a label. Rich labels reduce to their text; their + segment lists are rebuilt per render and never compare equal.""" + if isinstance(label, str): + return label + return "".join(s.text for s in label if isinstance(s, TextSeg)) + + def _item_selected(i: MenuItem) -> bool: return len(i) >= 4 and bool(i[3]) @@ -100,7 +108,7 @@ def __init__(self, items: list[MenuItem], font=None, # Stash the source item on the widget for `_item_action` to recover. setattr(w, 'data', i) self.add_sel_widget(w) - if t == self.default_item: + if self.default_item is not None and label_key(_item_label(i)) == self.default_item: self.sel_widget(w) h = h + self.item_h From 9b8fdeab5bbc37cf8072a0bce7dc05fe80afaedd Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 11 Jul 2026 13:22:58 -0400 Subject: [PATCH 3/4] Fix stale scanning --- modalapi/wifi/__init__.py | 2 ++ modalapi/wifi/commands.py | 14 ++++++++++++++ modalapi/wifi/manager.py | 3 +++ modalapi/wifi/ops.py | 10 ++++++++-- tests/test_wifi_manager.py | 24 ++++++++++++++++++++++++ tests/v3/conftest.py | 1 + tests/v3/test_wifi_menu.py | 25 ++++++++++++++++++++++++- ui/wifi_menu.py | 36 ++++++++++++++++++++++++++++++++---- 8 files changed, 108 insertions(+), 7 deletions(-) diff --git a/modalapi/wifi/__init__.py b/modalapi/wifi/__init__.py index fe5a0e2c7..ef4f6feae 100644 --- a/modalapi/wifi/__init__.py +++ b/modalapi/wifi/__init__.py @@ -21,6 +21,7 @@ DisconnectCmd, ForgetCmd, ReplacePskCmd, + RescanCmd, ScanCmd, ToggleHotspotCmd, ) @@ -37,6 +38,7 @@ "KeyMgmt", "ReplacePskCmd", "SavedConnection", + "RescanCmd", "ScanCmd", "ScannedNetwork", "ToggleHotspotCmd", diff --git a/modalapi/wifi/commands.py b/modalapi/wifi/commands.py index 5d089bfa6..e704db184 100644 --- a/modalapi/wifi/commands.py +++ b/modalapi/wifi/commands.py @@ -119,6 +119,8 @@ def key(self) -> str: @dataclass class ScanCmd(Command[list]): + """Read NM's cached AP list. Cheap; safe to run every UI tick.""" + def run(self, wm: "WifiManager") -> list: return wm.scan_networks() @@ -126,6 +128,18 @@ def key(self) -> str: return "scan" +@dataclass +class RescanCmd(Command[Optional[bytes]]): + """Ask NM to actually go and scan. Blocks for seconds — pace it (see + WifiMenu.RESCAN_INTERVAL_S); NM rate-limits to one per 8s while connected.""" + + def run(self, wm: "WifiManager") -> Optional[bytes]: + return wm.request_rescan() + + def key(self) -> str: + return "rescan" + + _SHUTDOWN_SENTINEL = object() diff --git a/modalapi/wifi/manager.py b/modalapi/wifi/manager.py index eeb9f16bf..83734b1a0 100644 --- a/modalapi/wifi/manager.py +++ b/modalapi/wifi/manager.py @@ -180,6 +180,9 @@ def list_connections(self) -> list[SavedConnection]: def scan_networks(self) -> list[ScannedNetwork]: return ops.scan_networks(self.iface_name) + def request_rescan(self) -> Optional[bytes]: + return ops.request_rescan(self.iface_name) + def connect_scanned(self, ssid: str, security: str, psk: Optional[str] = None) -> Optional[bytes]: return ops.connect_scanned(self.iface_name, ssid, security, psk) diff --git a/modalapi/wifi/ops.py b/modalapi/wifi/ops.py index 557a9ad6d..c32dfb37f 100644 --- a/modalapi/wifi/ops.py +++ b/modalapi/wifi/ops.py @@ -66,10 +66,16 @@ def wifi_profile_ssid_mode(uuid: str) -> tuple[str, str]: return kv.get("802-11-wireless.ssid", ""), kv.get("802-11-wireless.mode", "") +def request_rescan(iface_name: str) -> Optional[bytes]: + """Ask NetworkManager for a fresh broadcast scan. Sudo necessary to get real results.""" + _, err = nmcli(["device", "wifi", "rescan", "ifname", iface_name], sudo=True, timeout=30) + return err + + def scan_networks(iface_name: str) -> list[ScannedNetwork]: - """Return visible nearby networks, deduplicated by SSID (strongest wins), sorted by signal desc.""" + """Return NM's cached AP list, deduplicated by SSID (strongest wins), sorted by signal desc.""" stdout, err = nmcli( - ["dev", "wifi", "list", "--rescan", "yes", "ifname", iface_name], + ["dev", "wifi", "list", "--rescan", "no", "ifname", iface_name], terse_fields=["IN-USE", "SSID", "SIGNAL", "SECURITY"], timeout=15, ) diff --git a/tests/test_wifi_manager.py b/tests/test_wifi_manager.py index 20c158c29..a85a366bd 100644 --- a/tests/test_wifi_manager.py +++ b/tests/test_wifi_manager.py @@ -446,6 +446,30 @@ def _eff(cmd, **kw): return _eff +def test_request_rescan_runs_privileged(wm): + """The wifi.scan polkit action denies our session-less daemon, so a rescan + must go through sudo — unprivileged, nmcli exits 0 with a stale cache.""" + calls: list[list[str]] = [] + with patch("subprocess.run", side_effect=_ok_run(calls)): + err = wm.request_rescan() + + assert err is None + assert calls == [["sudo", "nmcli", "device", "wifi", "rescan", "ifname", "wlan0"]] + + +def test_scan_networks_reads_cache_without_rescanning(wm): + """The 2s UI read must not trigger a scan: --rescan yes would block for the + scan plus NM's 8s rate limit.""" + calls: list[list[str]] = [] + with patch("subprocess.run", side_effect=_ok_run(calls)): + wm.scan_networks() + + assert len(calls) == 1 + argv = calls[0] + assert "sudo" not in argv + assert argv[argv.index("--rescan") + 1] == "no" + + def test_connect_scanned_wpa2_passes_explicit_key_mgmt(wm): calls: list[list[str]] = [] with ( diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py index 554a9daf8..16029b7ce 100644 --- a/tests/v3/conftest.py +++ b/tests/v3/conftest.py @@ -312,6 +312,7 @@ def wifi_state(v3_system): def _set(scanned=(), saved=(), active=None, hotspot=False, supported=True): wm = v3_system.handler.wifi_manager wm.scan_networks.return_value = list(scanned) + wm.request_rescan.return_value = None wm.list_connections.return_value = list(saved) wm.get_cached_saved.return_value = list(saved) diff --git a/tests/v3/test_wifi_menu.py b/tests/v3/test_wifi_menu.py index f5ae5d74e..425d34fcf 100644 --- a/tests/v3/test_wifi_menu.py +++ b/tests/v3/test_wifi_menu.py @@ -5,11 +5,13 @@ """ import time +from typing import cast +from unittest.mock import MagicMock, patch import pytest from tests.v3.conftest import make_saved, make_scanned -from ui.wifi_menu import Row, WifiMenu, _PassphraseEditor +from ui.wifi_menu import RESCAN_INTERVAL_S, Row, WifiMenu, _PassphraseEditor from uilib.dialog import Dialog, MessageDialog from uilib.misc import InputEvent from uilib.text import LetterSelector, TextEditor, TextWidget @@ -100,6 +102,27 @@ def _defer(cmd, on_done): snapshot("nearby_populated") +def test_rescan_is_paced_but_cache_is_read_every_tick(v3_system, wifi_state): + """Every tick reads NM's cache; the scan NM actually has to run is paced, + since NM only honours one per 8s while connected.""" + wifi_state(scanned=[make_scanned("Home", signal=80)], saved=[]) + wm, _lcd = _open(v3_system) + + def _cmds() -> list[str]: + submit_scan = cast(MagicMock, wm._wifi_manager.queue.submit_scan) + return [type(c.args[0]).__name__ for c in submit_scan.call_args_list] + + assert _cmds() == ["ScanCmd", "RescanCmd"] # open() kicks one immediately + + with patch("ui.wifi_menu.time.monotonic", return_value=wm._last_rescan + 2.0): + wm.tick() + assert _cmds() == ["ScanCmd", "RescanCmd", "ScanCmd"] # too soon to rescan + + with patch("ui.wifi_menu.time.monotonic", return_value=wm._last_rescan + RESCAN_INTERVAL_S): + wm.tick() + assert _cmds() == ["ScanCmd", "RescanCmd", "ScanCmd", "ScanCmd", "RescanCmd"] + + def test_nearby_empty_when_every_network_is_saved(v3_system, wifi_state, nav_lcd, snapshot): """The only in-range SSID is the saved+connected one, so nearby has nothing to list — it must say so, not sit on 'Scanning...' forever.""" diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index e8d8ab91d..fff3ed025 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -13,6 +13,8 @@ # You should have received a copy of the GNU General Public License # along with pi-stomp. If not, see . +import logging +import time from typing import TYPE_CHECKING, Callable, NotRequired, Optional, Protocol, TypedDict, cast from common.fonts import font_path @@ -26,6 +28,7 @@ DisconnectCmd, ForgetCmd, ReplacePskCmd, + RescanCmd, SavedConnection, ScanCmd, ScannedNetwork, @@ -69,6 +72,10 @@ class _WifiHost(Protocol): SEP = "\u00b7" # · +# NM rate-limits explicit scans to one per 8s while connected; leave headroom. +RESCAN_INTERVAL_S = 10.0 + + class Row(TypedDict): """A single network line in the wifi menu — saved profile, in-range network, or both. @@ -185,6 +192,7 @@ def __init__(self, lcd: "Lcd") -> None: self._scan_completed: bool = False self._root_sig: tuple[RowSig, ...] = () self._nearby_sig: tuple[RowSig, ...] = () + self._last_rescan: float = 0.0 @property def _host(self) -> _WifiHost: @@ -215,14 +223,34 @@ def _pstack(self): def open(self, event: object = None, widget: object = None) -> None: self._render_root_menu() - self._wifi_manager.queue.submit_scan(ScanCmd(), self._on_scan) + self._poll_networks(rescan=True) def tick(self) -> None: - """Handler poll hook. Kicks a rescan while any wifi menu is open - so the nearby cache stays warm. Deduped if a scan is in flight.""" + """Handler poll hook (2s). Keeps the list warm while a wifi menu is open.""" current = self._pstack.current if current is self._root_menu or current is self._nearby_menu: - self._wifi_manager.queue.submit_scan(ScanCmd(), self._on_scan) + self._poll_networks() + + def _poll_networks(self, rescan: bool = False) -> None: + """Read NM's AP cache, and periodically make NM go refill it. + + The read is cheap and drives the UI. The rescan is the expensive part + (seconds, and NM rate-limits it to one per 8s while connected), but + without it the cache decays to just the connected SSID — see + ops.request_rescan. Both dedupe if one is already in flight.""" + self._wifi_manager.queue.submit_scan(ScanCmd(), self._on_scan) + now = time.monotonic() + if rescan or now - self._last_rescan >= RESCAN_INTERVAL_S: + self._last_rescan = now + self._wifi_manager.queue.submit_scan(RescanCmd(), self._on_rescan) + + def _on_rescan(self, err: Optional[bytes]) -> None: + """A failed rescan just means a stale list, not a failed user action — + log it, don't interrupt with a dialog.""" + if isinstance(err, Exception): + err = str(err).encode("utf-8") + if err is not None: + logging.warning("wifi rescan failed: %s", parse_nmcli_error(err)) def _on_scan(self, networks: list[ScannedNetwork]) -> None: if isinstance(networks, Exception): From d8dfb2244333da49def6f9718800dacc78bea7f5 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 11 Jul 2026 13:59:27 -0400 Subject: [PATCH 4/4] Simplify wifi --- modalapi/wifi/__init__.py | 2 - modalapi/wifi/commands.py | 35 ++-------- modalapi/wifi/manager.py | 33 ++-------- modalapi/wifi/ops.py | 48 ++++---------- tests/test_wifi_manager.py | 24 ++----- tests/v3/conftest.py | 1 - tests/v3/test_wifi_menu.py | 26 +++++--- ui/wifi_menu.py | 128 ++++++++++--------------------------- 8 files changed, 81 insertions(+), 216 deletions(-) diff --git a/modalapi/wifi/__init__.py b/modalapi/wifi/__init__.py index ef4f6feae..fe5a0e2c7 100644 --- a/modalapi/wifi/__init__.py +++ b/modalapi/wifi/__init__.py @@ -21,7 +21,6 @@ DisconnectCmd, ForgetCmd, ReplacePskCmd, - RescanCmd, ScanCmd, ToggleHotspotCmd, ) @@ -38,7 +37,6 @@ "KeyMgmt", "ReplacePskCmd", "SavedConnection", - "RescanCmd", "ScanCmd", "ScannedNetwork", "ToggleHotspotCmd", diff --git a/modalapi/wifi/commands.py b/modalapi/wifi/commands.py index e704db184..ae5547821 100644 --- a/modalapi/wifi/commands.py +++ b/modalapi/wifi/commands.py @@ -27,12 +27,8 @@ class Command(ABC, Generic[T]): - """A unit of serialized work. Subclasses carry their args as fields. - - `run(wm)` does the blocking work on a worker thread. `key()` is the dedup - identity — if a command with the same key is already pending or in-flight, - a fresh submission is silently dropped. - """ + """A unit of serialized work. Deduped by key() — if a command with the + same key is pending or in-flight, a fresh submission is dropped.""" @abstractmethod def run(self, wm: Any) -> T: ... @@ -119,7 +115,7 @@ def key(self) -> str: @dataclass class ScanCmd(Command[list]): - """Read NM's cached AP list. Cheap; safe to run every UI tick.""" + """Trigger a fresh scan and return the results. Blocks for seconds.""" def run(self, wm: "WifiManager") -> list: return wm.scan_networks() @@ -128,29 +124,12 @@ def key(self) -> str: return "scan" -@dataclass -class RescanCmd(Command[Optional[bytes]]): - """Ask NM to actually go and scan. Blocks for seconds — pace it (see - WifiMenu.RESCAN_INTERVAL_S); NM rate-limits to one per 8s while connected.""" - - def run(self, wm: "WifiManager") -> Optional[bytes]: - return wm.request_rescan() - - def key(self) -> str: - return "rescan" - - _SHUTDOWN_SENTINEL = object() class CommandQueue: - """Serialized executor over a WifiManager. Drains submitted Commands on a - single daemon worker; delivers results on the main thread via poll(). - - Dedupes by Command.key(): a submission whose key matches a queued or - in-flight item is silently dropped. State-changing submissions bump - pending_op_count; scan submissions do not. - """ + """Serialized executor over a WifiManager. Worker thread runs Commands; + results are delivered on the main thread via poll(). Dedupes by key().""" def __init__(self, wm: "WifiManager") -> None: self._wm = wm @@ -195,9 +174,7 @@ def _drain(self) -> None: if bumps_pending: self._pending_op_count -= 1 if bumps_pending: - # A write op just changed (or tried to change) connectivity — - # don't make the UI wait out the status poll's 5s tick. Guarded: - # an escape here would kill the worker and wedge every later op. + # Nudge the poller for fresh status — don't wait out the 5s tick. try: self._wm.request_refresh() except Exception: diff --git a/modalapi/wifi/manager.py b/modalapi/wifi/manager.py index 83734b1a0..5b56becbf 100644 --- a/modalapi/wifi/manager.py +++ b/modalapi/wifi/manager.py @@ -25,8 +25,6 @@ class WifiManager: - # Hard-wired wifi interface to avoid scrubbing sysfs. - # Hotspot state is read from / mutates via NetworkManager directly. def __init__(self, ifname: str = "wlan0", on_status_change: Optional[Callable[[WifiStatus], None]] = None) -> None: self.iface_name: str = ifname self.lock: threading.Lock = threading.Lock() @@ -49,13 +47,8 @@ def __del__(self) -> None: self.shutdown() def set_status(self, status: WifiStatus) -> None: - """Optimistically overwrite cached status and publish it now, on the - calling (main) thread, through the same callback the poller uses. - - For UI actions whose outcome we can predict — a disconnect drops the - active connection — so the screen doesn't sit on stale state for a poll - cycle. The next poll observes reality and overwrites this, so a wrong - prediction is corrected rather than compounded.""" + """Optimistically overwrite cached status and publish it now. The next + poll reconciles against truth.""" with self.lock: self.last_status = status self.changed = False @@ -64,10 +57,7 @@ def set_status(self, status: WifiStatus) -> None: self.request_refresh() def request_refresh(self) -> None: - """Poll status now rather than waiting out the 5s tick, and publish the - result even if it's unchanged. Write ops need a definitive repaint, not - just a diff — a failed op leaves status identical but the UI may have - painted optimistically and must be reconciled back.""" + """Poll status now and publish even if unchanged.""" with self.lock: self._force_publish = True self._wake.set() @@ -96,7 +86,6 @@ def _is_wifi_connected(self) -> bool: return False def _get_wpa_status(self, status: WifiStatus) -> None: - # `device show` rejects per-setting fields; fetch SSID/mode via `connection show` below. stdout, err = nmcli( ["device", "show", self.iface_name], terse_fields=["GENERAL.STATE", "GENERAL.CONNECTION", "IP4.ADDRESS"], @@ -123,8 +112,8 @@ def _get_wpa_status(self, status: WifiStatus) -> None: def _polling_thread(self) -> None: while True: - # Claimed up front: a refresh requested *while* we're mid-poll refers - # to state we haven't read yet, so it must survive into the next pass. + # Claimed up front: a refresh requested mid-poll refers to state we + # haven't read yet, so it survives into the next pass. with self.lock: forced = self._force_publish self._force_publish = False @@ -132,9 +121,6 @@ def _polling_thread(self) -> None: new_status: WifiStatus = {} supported = new_status["wifi_supported"] = self._is_wifi_supported() connected = new_status["wifi_connected"] = self._is_wifi_connected() - # Default false; _get_wpa_status flips it when the active wlan0 - # connection has mode=ap. operstate is "up" in both client and AP - # modes, so `connected` covers both cases. new_status["hotspot_active"] = False if supported and connected: self._get_wpa_status(new_status) @@ -152,15 +138,13 @@ def _polling_thread(self) -> None: break def _wait_next_poll(self, timeout: float) -> bool: - """Sleep out the poll interval, cut short by request_refresh() or - shutdown(). Returns True when we're shutting down.""" + """Sleep out the poll interval, cut short by request_refresh().""" self._wake.wait(timeout) self._wake.clear() return self.stop.is_set() def poll(self) -> None: - """Main-thread tick. Drains write-op callbacks and fires - on_status_change when the polling thread has new status.""" + """Main-thread tick: drain callbacks and publish status changes.""" self.queue.poll() update: Optional[WifiStatus] = None with self.lock: @@ -180,9 +164,6 @@ def list_connections(self) -> list[SavedConnection]: def scan_networks(self) -> list[ScannedNetwork]: return ops.scan_networks(self.iface_name) - def request_rescan(self) -> Optional[bytes]: - return ops.request_rescan(self.iface_name) - def connect_scanned(self, ssid: str, security: str, psk: Optional[str] = None) -> Optional[bytes]: return ops.connect_scanned(self.iface_name, ssid, security, psk) diff --git a/modalapi/wifi/ops.py b/modalapi/wifi/ops.py index c32dfb37f..73225ae9f 100644 --- a/modalapi/wifi/ops.py +++ b/modalapi/wifi/ops.py @@ -66,18 +66,13 @@ def wifi_profile_ssid_mode(uuid: str) -> tuple[str, str]: return kv.get("802-11-wireless.ssid", ""), kv.get("802-11-wireless.mode", "") -def request_rescan(iface_name: str) -> Optional[bytes]: - """Ask NetworkManager for a fresh broadcast scan. Sudo necessary to get real results.""" - _, err = nmcli(["device", "wifi", "rescan", "ifname", iface_name], sudo=True, timeout=30) - return err - - def scan_networks(iface_name: str) -> list[ScannedNetwork]: - """Return NM's cached AP list, deduplicated by SSID (strongest wins), sorted by signal desc.""" + """Scan and return visible networks, deduplicated by SSID (strongest wins).""" stdout, err = nmcli( - ["dev", "wifi", "list", "--rescan", "no", "ifname", iface_name], + ["dev", "wifi", "list", "--rescan", "yes", "ifname", iface_name], + sudo=True, terse_fields=["IN-USE", "SSID", "SIGNAL", "SECURITY"], - timeout=15, + timeout=30, ) if err is not None or stdout is None: logging.error("nmcli dev wifi list failed: " + (err or b"").decode("utf-8", "replace")) @@ -124,11 +119,7 @@ def delete_connection(name: str) -> Optional[bytes]: def connect_scanned(iface_name: str, ssid: str, security: str, psk: Optional[str] = None) -> Optional[bytes]: - """Create a profile for an SSID and activate it; on failure the new profile is deleted. - - Activating a client profile via `nmcli connection up` on wlan0 implicitly - deactivates whatever is currently active on the device — including a - running AP — so no explicit hotspot teardown is needed first.""" + """Create a profile for an SSID and activate it; on failure the new profile is deleted.""" try: km = KeyMgmt.from_scan_security(security) except ValueError as e: @@ -153,10 +144,11 @@ def connect_scanned(iface_name: str, ssid: str, security: str, psk: Optional[str "connection.autoconnect", "yes", ] - # nmcli treats wifi-sec.key-mgmt=none as WEP. For a genuinely open AP, - # the wifi-sec section must be omitted entirely. + # nmcli treats wifi-sec.key-mgmt=none as WEP; omit the section for open APs. if km != KeyMgmt.NONE: add_args += ["wifi-sec.key-mgmt", km] + if km == KeyMgmt.WPA_PSK: + add_args += ["wifi-sec.pmf", "optional"] if km in (KeyMgmt.WPA_PSK, KeyMgmt.SAE) and psk: add_args += ["wifi-sec.psk", psk] _, err = nmcli(add_args, sudo=True, timeout=20) @@ -190,11 +182,7 @@ def is_profile_activated(name: str) -> bool: def connect_saved(name: str, wait: bool = True, reconnect: bool = False) -> Optional[bytes]: - """Activate a saved profile; with wait=False, NM keeps retrying in the background. - - `nmcli connection up` on a wifi profile implicitly deactivates whatever is - currently active on wlan0 — including an AP-mode hotspot — so no explicit - teardown is needed first.""" + """Activate a saved profile; with wait=False, NM keeps retrying in the background.""" if not reconnect and is_profile_activated(name): return None args = ["--wait", "0", "connection", "up", name] if not wait else ["connection", "up", name] @@ -242,11 +230,7 @@ def replace_psk(name: str, psk: str) -> Optional[bytes]: def find_hotspot_profile() -> Optional[str]: - """Return the name of an existing AP-mode wifi profile, or None. - - The boot-fallback `wifi-hotspot.service` (shipped by both pi-gen-pistomp - and pistomp-arch) typically creates `pistomp-hotspot` on first run. Older - images may name it `Hotspot`. We accept whichever AP-mode profile exists.""" + """Return the name of an existing AP-mode wifi profile, or None.""" stdout, err = nmcli( ["connection", "show"], terse_fields=["NAME", "UUID", "TYPE"], @@ -300,11 +284,7 @@ def _create_default_hotspot_profile(iface_name: str) -> Optional[bytes]: def enable_hotspot(iface_name: str) -> Optional[bytes]: - """Activate the AP-mode profile on wlan0, creating it if missing. - - Drives NetworkManager directly — does not touch the systemd unit. NM will - deactivate any currently-active client connection on the device as a side - effect of bringing the AP up.""" + """Activate the AP-mode profile on wlan0, creating it if missing.""" name = find_hotspot_profile() if name is None: err = _create_default_hotspot_profile(iface_name) @@ -316,11 +296,7 @@ def enable_hotspot(iface_name: str) -> Optional[bytes]: def disable_hotspot(iface_name: str) -> Optional[bytes]: - """Tear down the AP and reactivate the most-recent saved client profile. - - NM doesn't autoconnect a client profile after AP teardown, so we drive - the reconnect explicitly. Returns None on success or when no saved - profile is available; nmcli stderr bytes otherwise.""" + """Tear down the AP and reactivate the most-recent saved client profile.""" name = find_hotspot_profile() if name is not None: _, err = nmcli(["connection", "down", name], sudo=True, timeout=20) diff --git a/tests/test_wifi_manager.py b/tests/test_wifi_manager.py index a85a366bd..efb8569c9 100644 --- a/tests/test_wifi_manager.py +++ b/tests/test_wifi_manager.py @@ -446,28 +446,17 @@ def _eff(cmd, **kw): return _eff -def test_request_rescan_runs_privileged(wm): - """The wifi.scan polkit action denies our session-less daemon, so a rescan - must go through sudo — unprivileged, nmcli exits 0 with a stale cache.""" - calls: list[list[str]] = [] - with patch("subprocess.run", side_effect=_ok_run(calls)): - err = wm.request_rescan() - - assert err is None - assert calls == [["sudo", "nmcli", "device", "wifi", "rescan", "ifname", "wlan0"]] - - -def test_scan_networks_reads_cache_without_rescanning(wm): - """The 2s UI read must not trigger a scan: --rescan yes would block for the - scan plus NM's 8s rate limit.""" +def test_scan_networks_runs_privileged_rescan(wm): + """scan_networks triggers a real scan via sudo. Unprivileged --rescan yes + silently exits 0 with a stale cache (polkit denies our session-less daemon).""" calls: list[list[str]] = [] with patch("subprocess.run", side_effect=_ok_run(calls)): wm.scan_networks() assert len(calls) == 1 argv = calls[0] - assert "sudo" not in argv - assert argv[argv.index("--rescan") + 1] == "no" + assert argv[0] == "sudo" + assert argv[argv.index("--rescan") + 1] == "yes" def test_connect_scanned_wpa2_passes_explicit_key_mgmt(wm): @@ -481,6 +470,7 @@ def test_connect_scanned_wpa2_passes_explicit_key_mgmt(wm): assert err is None add = next(c for c in calls if "add" in c) assert "wifi-sec.key-mgmt" in add and "wpa-psk" in add + assert "wifi-sec.pmf" in add and "optional" in add assert "wifi-sec.psk" in add and "secret" in add @@ -494,8 +484,6 @@ def test_connect_scanned_open_omits_security_fields(wm): assert err is None add = next(c for c in calls if "add" in c) - # nmcli treats wifi-sec.key-mgmt=none as WEP, breaking association with - # genuinely open APs. The wifi-sec section must be omitted entirely. assert "wifi-sec.psk" not in add assert "wifi-sec.key-mgmt" not in add diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py index 16029b7ce..554a9daf8 100644 --- a/tests/v3/conftest.py +++ b/tests/v3/conftest.py @@ -312,7 +312,6 @@ def wifi_state(v3_system): def _set(scanned=(), saved=(), active=None, hotspot=False, supported=True): wm = v3_system.handler.wifi_manager wm.scan_networks.return_value = list(scanned) - wm.request_rescan.return_value = None wm.list_connections.return_value = list(saved) wm.get_cached_saved.return_value = list(saved) diff --git a/tests/v3/test_wifi_menu.py b/tests/v3/test_wifi_menu.py index 425d34fcf..4e59f48d7 100644 --- a/tests/v3/test_wifi_menu.py +++ b/tests/v3/test_wifi_menu.py @@ -102,25 +102,25 @@ def _defer(cmd, on_done): snapshot("nearby_populated") -def test_rescan_is_paced_but_cache_is_read_every_tick(v3_system, wifi_state): - """Every tick reads NM's cache; the scan NM actually has to run is paced, - since NM only honours one per 8s while connected.""" +def test_scan_is_paced_at_rescan_interval(v3_system, wifi_state): + """tick() paces scans at RESCAN_INTERVAL_S; the CommandQueue dedupes if + one is already in flight.""" wifi_state(scanned=[make_scanned("Home", signal=80)], saved=[]) wm, _lcd = _open(v3_system) - def _cmds() -> list[str]: + def _scan_cmds() -> list[str]: submit_scan = cast(MagicMock, wm._wifi_manager.queue.submit_scan) return [type(c.args[0]).__name__ for c in submit_scan.call_args_list] - assert _cmds() == ["ScanCmd", "RescanCmd"] # open() kicks one immediately + assert _scan_cmds() == ["ScanCmd"] # open() kicks one immediately - with patch("ui.wifi_menu.time.monotonic", return_value=wm._last_rescan + 2.0): + with patch("ui.wifi_menu.time.monotonic", return_value=wm._last_scan + 2.0): wm.tick() - assert _cmds() == ["ScanCmd", "RescanCmd", "ScanCmd"] # too soon to rescan + assert _scan_cmds() == ["ScanCmd"] # too soon — no new scan - with patch("ui.wifi_menu.time.monotonic", return_value=wm._last_rescan + RESCAN_INTERVAL_S): + with patch("ui.wifi_menu.time.monotonic", return_value=wm._last_scan + RESCAN_INTERVAL_S): wm.tick() - assert _cmds() == ["ScanCmd", "RescanCmd", "ScanCmd", "ScanCmd", "RescanCmd"] + assert _scan_cmds() == ["ScanCmd", "ScanCmd"] # interval elapsed — new scan def test_nearby_empty_when_every_network_is_saved(v3_system, wifi_state, nav_lcd, snapshot): @@ -614,14 +614,20 @@ def test_notify_status_change_leaves_error_dialog_open(v3_system, wifi_state, na def test_tick_rescans_while_root_open(v3_system, wifi_state): - """tick() submits a scan when the wifi root menu is the top panel.""" + """tick() submits a scan when the wifi root menu is the top panel and the + rescan interval has elapsed.""" wm_mock = v3_system.handler.wifi_manager wifi_state(scanned=[], saved=[make_saved("Home")], hotspot=False) wm, _lcd = _open(v3_system) scan_calls_after_open = wm_mock.scan_networks.call_count + # Immediately after open() — too soon, should skip. wm.tick() + assert wm_mock.scan_networks.call_count == scan_calls_after_open + # After the interval elapses — should scan. + with patch("ui.wifi_menu.time.monotonic", return_value=wm._last_scan + RESCAN_INTERVAL_S): + wm.tick() assert wm_mock.scan_networks.call_count > scan_calls_after_open, "tick must trigger a fresh scan" diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index fff3ed025..d192cc7a7 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -13,7 +13,6 @@ # You should have received a copy of the GNU General Public License # along with pi-stomp. If not, see . -import logging import time from typing import TYPE_CHECKING, Callable, NotRequired, Optional, Protocol, TypedDict, cast @@ -28,7 +27,6 @@ DisconnectCmd, ForgetCmd, ReplacePskCmd, - RescanCmd, SavedConnection, ScanCmd, ScannedNetwork, @@ -57,30 +55,21 @@ class _WifiHost(Protocol): - """The handler-side surface WifiMenu needs to do its job. - - The concrete handler (`modalapi.modhandler.ModHandler`) satisfies this structurally. - """ + """The handler-side surface WifiMenu needs.""" wifi_manager: WifiManager wifi_status: Optional[WifiStatus] - # v3 sets this; v1/v2 (mod.py) leave it None — Wired Connection row stays hidden. ethernet_manager: Optional[EthernetManager] ACTIVE_GLYPH = "\u2714" # ✔ SEP = "\u00b7" # · - -# NM rate-limits explicit scans to one per 8s while connected; leave headroom. -RESCAN_INTERVAL_S = 10.0 +RESCAN_INTERVAL_S = 10.0 # NM rate-limits scans to one per 8s while connected 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).""" + """A network line: saved profile, in-range network, or both.""" ssid: str signal: Optional[int] @@ -88,20 +77,15 @@ class Row(TypedDict): saved: bool profile: Optional[SavedConnection] active: bool - # Override label when this SSID has multiple saved profiles (e.g. OEM - # "preconfigured" alongside a user-created profile). Otherwise the SSID is shown. - display_name: NotRequired[str] + display_name: NotRequired[str] # set when multiple saved profiles share an SSID PasswordCallback = Callable[[str], None] 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. - """ + """Single-panel passphrase entry. Cancel dismisses; OK with a non-empty + passphrase dismisses and calls on_submit.""" def __init__(self, ssid: str, pstack, on_submit: PasswordCallback) -> None: self._pstack = pstack @@ -145,8 +129,7 @@ def signal_bars_level(signal: int) -> int: return max(1, min(4, (signal + 12) // 25)) -# What a rendered row actually shows. Signal is bucketed to the drawn bar count, -# so ordinary dBm jitter doesn't count as a change. +# Signal is bucketed to the drawn bar count, so dBm jitter doesn't count as a change. RowSig = tuple[str, Optional[str], bool, bool, Optional[int]] @@ -168,31 +151,23 @@ def is_open_network(security: Optional[str]) -> bool: def _glyph_height() -> int: - """Row height to size glyphs at — the default font's line height. Picked - once at construction (emoji model). RichTextWidget centers glyphs shorter - than the row inside the row band; this picks a height that matches text.""" + """Row height matching the default font's line height.""" font = Config().get_font("default") return int(font.get_sized_ascender()) + abs(int(font.get_sized_descender())) class WifiMenu: - """The wifi panel: scan, join, edit and forget networks; toggle hotspot. - - Owned by Lcd, which exposes the panel stack and `draw_selection_menu`. - Reads cached state from WifiManager; submits writes to its CommandQueue. - """ + """The wifi panel: scan, join, edit, forget networks; toggle hotspot.""" def __init__(self, lcd: "Lcd") -> None: self.lcd: "Lcd" = lcd self._root_menu: Optional["Menu"] = None self._nearby_menu: Optional["Menu"] = None self._cached_scanned: list[ScannedNetwork] = [] - # False until the first scan result lands: distinguishes "still scanning" - # from "scan done, nothing to list". - self._scan_completed: bool = False + self._scan_completed: bool = False # False until first scan result lands self._root_sig: tuple[RowSig, ...] = () self._nearby_sig: tuple[RowSig, ...] = () - self._last_rescan: float = 0.0 + self._last_scan: float = 0.0 @property def _host(self) -> _WifiHost: @@ -223,49 +198,32 @@ def _pstack(self): def open(self, event: object = None, widget: object = None) -> None: self._render_root_menu() - self._poll_networks(rescan=True) + self._submit_scan() def tick(self) -> None: - """Handler poll hook (2s). Keeps the list warm while a wifi menu is open.""" + """Handler poll hook (2s). Paces a fresh scan at RESCAN_INTERVAL_S.""" current = self._pstack.current - if current is self._root_menu or current is self._nearby_menu: - self._poll_networks() - - def _poll_networks(self, rescan: bool = False) -> None: - """Read NM's AP cache, and periodically make NM go refill it. - - The read is cheap and drives the UI. The rescan is the expensive part - (seconds, and NM rate-limits it to one per 8s while connected), but - without it the cache decays to just the connected SSID — see - ops.request_rescan. Both dedupe if one is already in flight.""" - self._wifi_manager.queue.submit_scan(ScanCmd(), self._on_scan) + if current is not self._root_menu and current is not self._nearby_menu: + return now = time.monotonic() - if rescan or now - self._last_rescan >= RESCAN_INTERVAL_S: - self._last_rescan = now - self._wifi_manager.queue.submit_scan(RescanCmd(), self._on_rescan) + if now - self._last_scan >= RESCAN_INTERVAL_S: + self._submit_scan() - def _on_rescan(self, err: Optional[bytes]) -> None: - """A failed rescan just means a stale list, not a failed user action — - log it, don't interrupt with a dialog.""" - if isinstance(err, Exception): - err = str(err).encode("utf-8") - if err is not None: - logging.warning("wifi rescan failed: %s", parse_nmcli_error(err)) + def _submit_scan(self) -> None: + self._last_scan = time.monotonic() + self._wifi_manager.queue.submit_scan(ScanCmd(), self._on_scan) def _on_scan(self, networks: list[ScannedNetwork]) -> None: if isinstance(networks, Exception): return - # nmcli intermittently returns [] during transient states. Don't - # clobber a populated cache with empty — flash-empties the UI. + # nmcli intermittently returns [] during transient states — don't clobber. if not networks and self._cached_scanned: return self._cached_scanned = networks first_result = not self._scan_completed self._scan_completed = True - # Rebuild whichever menu the user is looking at, but only when the scan - # actually changed its contents — tick() rescans every 2s, and a blind - # pop/push would reset the cursor and scroll under the user. + # Rebuild only when contents actually changed — avoids cursor resets. root_rows, nearby_rows = self._current_rows() current = self._pstack.current if self._nearby_menu is not None and current is self._nearby_menu: @@ -316,8 +274,6 @@ def _render_nearby_menu(self, default_label: Optional[str] = None) -> None: if nearby: items: list[MenuItem] = [(self._row_segments(r), self._on_network_tap, r) for r in nearby] elif self._scan_completed: - # Saved networks live on the root menu, so an in-range-but-saved - # SSID legitimately leaves this list empty. items = [("No other networks found", None, None)] else: items = [("Scanning...", None, None)] @@ -327,8 +283,7 @@ def _render_nearby_menu(self, default_label: Optional[str] = None) -> None: ) def notify_status_change(self) -> None: - """Handler hook after wifi_status changes. Rebuilds the root menu - in place, preserving cursor; no-op if root isn't the top panel.""" + """Rebuild the root menu in place, preserving cursor.""" if self._root_menu is None or self._pstack.current is not self._root_menu: return self._rerender_root() @@ -363,10 +318,7 @@ def _build_rows( scanned_ssids: set[str], active_name: Optional[str], ) -> tuple[list[Row], list[Row]]: - """Returns (saved_rows_for_root, nearby_unsaved_for_submenu). - - Saved-in-range pick up signal/security from scan results; saved-out-of-range - stay bar-less. Nearby submenu excludes any saved+active network.""" + """Returns (saved_rows_for_root, nearby_unsaved_for_submenu).""" rows: list[Row] = [] nearby: list[Row] = [] for net in scanned: @@ -456,9 +408,7 @@ def _pick_profile(profiles: list[SavedConnection], active_name: Optional[str]) - @staticmethod def _maybe_disambiguate(row: Row, profiles: list[SavedConnection]) -> None: - """When several saved profiles share an SSID (e.g. OEM `preconfigured` - plus a user-added entry), surface the profile name so the rows are - distinguishable. If the name doesn't already contain the SSID, suffix it.""" + """When several saved profiles share an SSID, surface the profile name.""" profile = row.get("profile") if not (row.get("saved") and len(profiles) > 1 and profile is not None): return @@ -472,12 +422,7 @@ def _maybe_disambiguate(row: Row, profiles: list[SavedConnection]) -> None: # ----- per-network actions ----- def _on_network_tap(self, row: Row) -> None: - """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 → _connect_scanned_flow (stays in nearby on failure) - """ + """Routes by row state: saved+active → submenu, saved → connect, unsaved → join flow.""" saved = row.get("saved") if saved: if row.get("active"): @@ -488,8 +433,7 @@ 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. One attempt — if it fails, - show the error. The user can re-try via the menu.""" + """Connect to a scanned (unsaved) network. One attempt; show error on failure.""" ssid = row["ssid"] def attempt(psk: Optional[str]) -> None: @@ -504,7 +448,7 @@ def attempt(psk: Optional[str]) -> None: self._open_password_prompt(ssid, attempt) def _on_network_long_tap(self, row: Row) -> None: - """Long-press on a network row → saved-network submenu.""" + """Long-press → saved-network submenu.""" if row.get("saved"): self._open_saved_submenu(row, include_disconnect=bool(row.get("active"))) @@ -521,7 +465,7 @@ def _open_ethernet_menu(self, _: object = None) -> None: def _open_nearby_menu(self, _: object = None) -> None: self._render_nearby_menu() - self._wifi_manager.queue.submit_scan(ScanCmd(), self._on_scan) + self._submit_scan() def _on_op_done(self, err: Optional[bytes]) -> None: if isinstance(err, Exception): @@ -539,7 +483,7 @@ def _disconnect(self, row: Row) -> None: profile = row["profile"] assert profile is not None self._pstack.pop_panel(None) - # Predict the outcome so the ✔ clears now; the status poll reconciles. + # Optimistic: clear the ✔ now; the status poll reconciles. status = self._wifi_status self._wifi_manager.set_status( { @@ -596,10 +540,7 @@ def _on_forget_done(self, err: Optional[bytes], was_active: bool, forgotten_ssid self.open() def _pick_fallback_saved(self, exclude_ssid: str) -> Optional[Row]: - """Strongest saved+in-range network other than `exclude_ssid`. - - Uses the cached scan as the reachability check — out-of-range saved - profiles have no signal entry and are skipped.""" + """Strongest saved+in-range network other than `exclude_ssid`.""" saved_by_ssid = self._saved_by_ssid scanned_ssids = {n["ssid"] for n in self._cached_scanned} rows, _ = self._build_rows(self._cached_scanned, saved_by_ssid, scanned_ssids, active_name=None) @@ -676,10 +617,9 @@ def submit(_event, _button): if not ssid: return self._pstack.pop_panel(d) - # Join dialog has no scan context: infer security from psk presence. - # "WPA2" → wpa-psk; "" → open. Users on WPA3-only / enterprise networks - # need to use the scan flow instead. - security = "WPA2 WPA3" if psk else "" + # "WPA2" → wpa-psk (WPA2 + WPA3-transition); "" → open. + # WPA3-only networks need the scan flow (detects SAE). + security = "WPA2" if psk else "" self._wifi_manager.queue.submit( ConnectScannedCmd(ssid=ssid, security=security, psk=psk or None), self._on_op_done )