diff --git a/lib/page/_shared/components/wifi_ui.dart b/lib/page/_shared/components/wifi_ui.dart index a106294c7..7db7f0337 100644 --- a/lib/page/_shared/components/wifi_ui.dart +++ b/lib/page/_shared/components/wifi_ui.dart @@ -105,6 +105,11 @@ String wifiDisplayValue(BuildContext context, String value) { return loc(context).none; case 'Mixed': return loc(context).mixed; + case 'OWE': + // Firmware reports Enhanced Open as the TR-181 token 'OWE'. Show the + // Wi-Fi standard label instead of the raw token; like WPA2/WPA3-Personal + // it is a technical term and is not localized. + return 'Enhanced Open'; default: return value; } diff --git a/lib/page/wifi_settings/models/wifi_network_ui_model.dart b/lib/page/wifi_settings/models/wifi_network_ui_model.dart index e9e06fb40..3ab9b0051 100644 --- a/lib/page/wifi_settings/models/wifi_network_ui_model.dart +++ b/lib/page/wifi_settings/models/wifi_network_ui_model.dart @@ -101,11 +101,10 @@ class WifiNetworkUIModel extends Equatable { return band.isNotEmpty ? band : 'Unknown'; } - /// True if this network uses an open (no password) security mode + /// True if this network uses an open (no password) security mode. + /// 'OWE' is the TR-181 token for Enhanced Open (firmware only accepts 'OWE'). bool get isOpenSecurity => - securityMode == 'None' || - securityMode.isEmpty || - securityMode == 'Enhanced-Open'; + securityMode == 'None' || securityMode.isEmpty || securityMode == 'OWE'; /// Channel display string ("Auto" if autoChannelEnable, else the channel number) String get channelDisplay => autoChannelEnable ? 'Auto' : channel.toString(); diff --git a/lib/page/wifi_settings/models/wifi_settings_settings.dart b/lib/page/wifi_settings/models/wifi_settings_settings.dart index 7ed6bacc7..1a0f634fb 100644 --- a/lib/page/wifi_settings/models/wifi_settings_settings.dart +++ b/lib/page/wifi_settings/models/wifi_settings_settings.dart @@ -52,7 +52,8 @@ class WifiQuickSetupSettings extends Equatable { final passwordChanged = original == null || password != original.password; final modeChanged = original == null || securityMode != original.securityMode; - final isOpen = securityMode == 'None' || securityMode == 'Enhanced-Open'; + // 'OWE' is the TR-181 token for Enhanced Open (firmware only accepts 'OWE'). + final isOpen = securityMode == 'None' || securityMode == 'OWE'; return (passwordChanged || modeChanged) && !isOpen; } diff --git a/lib/page/wifi_settings/services/usp_wifi_settings_service.dart b/lib/page/wifi_settings/services/usp_wifi_settings_service.dart index 83fe62bce..40373131a 100644 --- a/lib/page/wifi_settings/services/usp_wifi_settings_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_settings_service.dart @@ -421,6 +421,16 @@ class UspWifiSettingsService { orig.ssidAdvertisementEnabled != curr.ssidAdvertisementEnabled; if (ap != null && (enabledChanged || securityChanged || broadcastChanged)) { + // Apply the 6 GHz security override (Wi-Fi 6E mandates WPA3), the + // same way saveQuickSetup does, so both save paths write a + // firmware-valid mode on 6 GHz. Skip the override for enable-only + // toggles (securityChanged false) so the mode is never re-written. + final securityMode = securityChanged && curr.securityMode.isNotEmpty + ? _securityModeFor6GHz( + band: curr.band, + selectedMode: curr.securityMode, + ) + : null; final result = await WiFiAccessPoints.update( _usp, [ @@ -430,10 +440,7 @@ class UspWifiSettingsService { keyPassphrase: securityChanged && curr.keyPassphrase.isNotEmpty ? curr.keyPassphrase : null, - securityModeEnabled: - securityChanged && curr.securityMode.isNotEmpty - ? curr.securityMode - : null, + securityModeEnabled: securityMode, ssidAdvertisementEnabled: broadcastChanged ? curr.ssidAdvertisementEnabled : null, ) @@ -627,17 +634,18 @@ class UspWifiSettingsService { /// Returns the effective security mode to apply to a given band. /// /// 6 GHz (Wi-Fi 6E) mandates WPA3: - /// - Open / Enhanced-Open selected → send "Enhanced-Open" - /// - Any other mode → send "WPA3-Personal" + /// - Open / OWE (Enhanced Open) selected → send "OWE" + /// - Any other mode → send "WPA3-Personal" /// + /// 'OWE' is the TR-181 token firmware accepts for Enhanced Open. /// All other bands: return [selectedMode] unchanged. String _securityModeFor6GHz({ required String band, required String selectedMode, }) { if (!band.contains('6')) return selectedMode; - const openModes = {'None', 'Enhanced-Open', ''}; - return openModes.contains(selectedMode) ? 'Enhanced-Open' : 'WPA3-Personal'; + const openModes = {'None', 'OWE', ''}; + return openModes.contains(selectedMode) ? 'OWE' : 'WPA3-Personal'; } } diff --git a/test/page/wifi_settings/providers/usp_wifi_settings_state_test.dart b/test/page/wifi_settings/providers/usp_wifi_settings_state_test.dart index 63121bdbe..202fb038b 100644 --- a/test/page/wifi_settings/providers/usp_wifi_settings_state_test.dart +++ b/test/page/wifi_settings/providers/usp_wifi_settings_state_test.dart @@ -534,8 +534,8 @@ void main() { enabled: true, ssid: 'OpenNet', password: '', - securityMode: 'Enhanced-Open', - supportedSecurityModes: ['None', 'Enhanced-Open'], + securityMode: 'OWE', + supportedSecurityModes: ['None', 'OWE'], ); expect(openPending.isPasswordRequired(null), isFalse); }); @@ -555,4 +555,41 @@ void main() { expect(updated.status.isSaving, isTrue); }); }); + + // ------------------------------------------------------------------------- + // WifiNetworkUIModel.isOpenSecurity + // + // 'OWE' is the TR-181 token for Enhanced Open — firmware advertises and + // accepts only 'OWE' (see issue #1073). It must be treated as an open + // (no-password) mode; 'Enhanced-Open' is not a valid firmware token. + // ------------------------------------------------------------------------- + + group('WifiNetworkUIModel.isOpenSecurity', () { + test('true for None', () { + final n = WifiSettingsTestData.createNetworkUIModel(securityMode: 'None'); + expect(n.isOpenSecurity, isTrue); + }); + + test('true for empty string', () { + final n = WifiSettingsTestData.createNetworkUIModel(securityMode: ''); + expect(n.isOpenSecurity, isTrue); + }); + + test('true for OWE (Enhanced Open)', () { + final n = WifiSettingsTestData.createNetworkUIModel(securityMode: 'OWE'); + expect(n.isOpenSecurity, isTrue); + }); + + test('false for WPA2-Personal', () { + final n = WifiSettingsTestData.createNetworkUIModel( + securityMode: 'WPA2-Personal'); + expect(n.isOpenSecurity, isFalse); + }); + + test('false for WPA3-Personal-Transition', () { + final n = WifiSettingsTestData.createNetworkUIModel( + securityMode: 'WPA3-Personal-Transition'); + expect(n.isOpenSecurity, isFalse); + }); + }); } diff --git a/test/page/wifi_settings/services/usp_wifi_settings_service_test.dart b/test/page/wifi_settings/services/usp_wifi_settings_service_test.dart index eb166cffa..783005950 100644 --- a/test/page/wifi_settings/services/usp_wifi_settings_service_test.dart +++ b/test/page/wifi_settings/services/usp_wifi_settings_service_test.dart @@ -1110,6 +1110,67 @@ void main() { isNot( contains('Device.WiFi.AccessPoint.1.SSIDAdvertisementEnabled'))); }); + + // ----------------------------------------------------------------------- + // 6 GHz security override (#1073, #1142) — saveAdvanced must apply the + // same _securityModeFor6GHz coercion as saveQuickSetup, so both save + // paths write a firmware-valid mode on 6 GHz. + // ----------------------------------------------------------------------- + + /// Returns the value written to AccessPoint.1 Security.ModeEnabled after a + /// mode change on the given band, or null if it was never sent. + Future capturedModeEnabled({ + required String band, + required String selectedMode, + }) async { + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => uspSuccess()); + // Baseline mode differs from every selectedMode under test so the + // security diff always fires (WPA2-in == WPA2-baseline would send nothing). + final original = [ + makeNetwork(band: band, securityMode: 'WPA3-Personal-Transition') + ]; + final current = [makeNetwork(band: band, securityMode: selectedMode)]; + + await writeSvc.saveAdvanced(original: original, current: current); + + final captured = verify(() => mockUsp.set(captureAny(), + allowPartial: any(named: 'allowPartial'))).captured; + const key = 'Device.WiFi.AccessPoint.1.Security.ModeEnabled'; + for (final arg in captured) { + if (arg is Map && arg.containsKey(key)) return arg[key] as String?; + } + return null; + } + + test('6 GHz + OWE selected → sends OWE verbatim', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'OWE'), + 'OWE', + ); + }); + + test('6 GHz + None (open) selected → normalized to OWE', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'None'), + 'OWE', + ); + }); + + test('6 GHz + WPA2-Personal selected → forced to WPA3-Personal', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'WPA2-Personal'), + 'WPA3-Personal', + ); + }); + + test('5 GHz + None selected → written verbatim (no 6 GHz override)', + () async { + expect( + await capturedModeEnabled(band: '5GHz', selectedMode: 'None'), + 'None', + ); + }); }); // ------------------------------------------------------------------------- @@ -1321,4 +1382,163 @@ void main() { expect(keys.any((k) => k.contains('Security.KeyPassphrase')), isFalse); }); }); + + // ------------------------------------------------------------------------- + // 6 GHz security override (_securityModeFor6GHz) — issue #1073 + // + // Wi-Fi 6E mandates WPA3, so on 6 GHz the service overrides the selected + // mode: open modes ('None' / 'OWE' / '') → 'OWE' (the TR-181 token firmware + // accepts for Enhanced Open), everything else → 'WPA3-Personal'. On other + // bands the selected mode is written verbatim. These tests assert the exact + // ModeEnabled value sent to firmware, guarding against a regression to the + // old invalid 'Enhanced-Open' token. + // ------------------------------------------------------------------------- + + group('saveQuickSetup — 6 GHz security override (#1073)', () { + late MockUspClient mockUsp; + late UspWifiSettingsService writeSvc; + + setUp(() { + mockUsp = MockUspClient(); + writeSvc = UspWifiSettingsService(mockUsp); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => uspSuccess()); + }); + + WifiNetworkUIModel makeNetwork({ + required String band, + String ssidInstancePath = 'Device.WiFi.SSID.1.', + String accessPointInstancePath = 'Device.WiFi.AccessPoint.1.', + }) => + WifiNetworkUIModel( + ssidInstancePath: ssidInstancePath, + accessPointInstancePath: accessPointInstancePath, + radioInstancePath: 'Device.WiFi.Radio.1.', + ssid: 'Home', + enabled: true, + ssidAdvertisementEnabled: true, + supportedSecurityModes: const [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal', + 'OWE' + ], + securityMode: 'WPA2-Personal', + keyPassphrase: '', + isGuest: false, + band: band, + channel: 6, + channelBandwidth: '20MHz', + autoChannelEnable: true, + possibleChannels: const [1, 6, 11], + operatingStandards: 'ax', + supportedStandards: 'ax', + ); + + /// Returns the value written to `Security.ModeEnabled`, or null if the + /// param was never sent. + Future capturedModeEnabled({ + required String band, + required String selectedMode, + String ap = 'Device.WiFi.AccessPoint.1.', + }) async { + final agg = WifiQuickSetupNetwork( + isGuest: false, + ssid: 'Home', + securityMode: 'WPA2-Personal', + keyPassphrase: '', + supportedSecurityModes: const [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal', + 'OWE' + ], + ssidInstancePaths: const ['Device.WiFi.SSID.1.'], + apInstancePaths: [ap], + ); + // Baseline mode differs from every selectedMode under test so the + // security diff always fires (otherwise WPA2-in == WPA2-baseline would + // be a no-op and nothing would be sent). + const orig = WifiQuickSetupSettings( + isGuest: false, + enabled: true, + ssid: 'Home', + password: '', + securityMode: 'WPA3-Personal-Transition', + supportedSecurityModes: [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal', + 'OWE' + ], + ); + + final original = WifiSettingsSettings( + networks: [ + makeNetwork(band: band, accessPointInstancePath: ap) + .copyWith(securityMode: 'WPA3-Personal-Transition') + ], + quickSetupEnabled: true, + quickSetupMain: orig, + ); + final current = original.copyWith( + quickSetupMain: orig.copyWith(securityMode: selectedMode), + ); + final status = WifiSettingsStatus(quickSetupMainAggregate: agg); + + await writeSvc.saveQuickSetup( + original: original, + current: current, + status: status, + ); + + final captured = verify(() => mockUsp.set(captureAny(), + allowPartial: any(named: 'allowPartial'))).captured; + for (final arg in captured) { + if (arg is Map && arg.containsKey('${ap}Security.ModeEnabled')) { + return arg['${ap}Security.ModeEnabled'] as String?; + } + } + return null; + } + + test('6 GHz + OWE selected → sends OWE verbatim', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'OWE'), + 'OWE', + ); + }); + + test('6 GHz + None (open) selected → normalized to OWE', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'None'), + 'OWE', + ); + }); + + test('6 GHz + WPA2-Personal selected → forced to WPA3-Personal', () async { + // Pass a non-WPA3 mode so this genuinely exercises the coercion branch + // (WPA3 input would pass even if the override were removed). + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'WPA2-Personal'), + 'WPA3-Personal', + ); + }); + + test('2.4 GHz + OWE selected → written verbatim (no 6 GHz override)', + () async { + expect( + await capturedModeEnabled(band: '2.4GHz', selectedMode: 'OWE'), + 'OWE', + ); + }); + + test('5 GHz + None selected → written verbatim (no 6 GHz override)', + () async { + expect( + await capturedModeEnabled(band: '5GHz', selectedMode: 'None'), + 'None', + ); + }); + }); }