From 8ff3aeebe42ef3865cd4c4166b1ebc3d0500f212 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Mon, 16 Mar 2026 20:55:40 +0800 Subject: [PATCH 1/2] feat: USP WiFi settings, network diagnostics, codegen updates & tests - WiFi settings: channel bonding service, network card UI improvements, quick setup model, preservable notifier dirty-check integration - Network diagnostics: YAML definition + codegen output - WiFi radios: added SupportedOperatingChannelBandwidths field - WAN operations: updated codegen output - Dashboard notifier: bridge port map fetch, firmware images - SSE operation awaiter & UspService: minor refinements - Test console: updated for new client API - Preservable notifier mixin: added generic save callback support - Added unit tests for WiFi settings service and channel bonding Co-Authored-By: Claude Opus 4.6 --- definitions/network/network_diagnostics.yaml | 33 + definitions/wifi/wi_fi_radios.yaml | 5 + doc/usp/integration/roadmap_m2.md | 191 +++--- lib/generated/index.dart | 1 + lib/generated/network_diagnostics.g.dart | 34 ++ lib/generated/wan_operations.g.dart | 9 +- lib/generated/wi_fi_radios.g.dart | 9 +- lib/page/usp_test/usp_test_page.dart | 6 +- lib/providers/preservable_notifier_mixin.dart | 8 + lib/usp/models/usp_response.dart | 20 - lib/usp/services/sse_operation_awaiter.dart | 2 +- lib/usp/services/usp_service.dart | 12 +- lib/usp/stub/usp_client_stub.dart | 4 +- lib/usp/web/usp_client_wasm.dart | 25 +- .../providers/usp_dashboard_notifier.dart | 22 +- .../views/usp_test_console_view.dart | 6 +- .../models/wifi_network_ui_model.dart | 29 + .../providers/usp_wifi_settings_provider.dart | 16 +- .../services/usp_wifi_settings_service.dart | 30 +- .../services/wifi_channel_bonding.dart | 203 ++++++ .../views/components/wifi_network_card.dart | 22 +- .../views/tabs/wifi_list_tab.dart | 46 +- .../usp_wifi_settings_service_test.dart | 577 ++++++++++++++++++ .../services/wifi_channel_bonding_test.dart | 268 ++++++++ 24 files changed, 1396 insertions(+), 182 deletions(-) create mode 100644 definitions/network/network_diagnostics.yaml create mode 100644 lib/generated/network_diagnostics.g.dart delete mode 100644 lib/usp/models/usp_response.dart create mode 100644 lib/usp_page/wifi_settings/services/wifi_channel_bonding.dart create mode 100644 test/usp_page/wifi_settings/services/usp_wifi_settings_service_test.dart create mode 100644 test/usp_page/wifi_settings/services/wifi_channel_bonding_test.dart diff --git a/definitions/network/network_diagnostics.yaml b/definitions/network/network_diagnostics.yaml new file mode 100644 index 000000000..8b06e59a9 --- /dev/null +++ b/definitions/network/network_diagnostics.yaml @@ -0,0 +1,33 @@ +name: NetworkDiagnostics +description: Network diagnostic operations (Ping, Traceroute) +type: operate +category: network + +operations: + - name: ping + path: Device.IP.Diagnostics.IPPing() + description: Run ICMP ping diagnostic + inputs: + - path: Host + field: host + type: string + required: true + - path: NumberOfRepetitions + field: numberOfRepetitions + type: string + required: false + default: "3" + + - name: traceRoute + path: Device.IP.Diagnostics.TraceRoute() + description: Run traceroute diagnostic + inputs: + - path: Host + field: host + type: string + required: true + - path: MaxHopCount + field: maxHopCount + type: string + required: false + default: "30" diff --git a/definitions/wifi/wi_fi_radios.yaml b/definitions/wifi/wi_fi_radios.yaml index 9ab44b036..0e6c716eb 100644 --- a/definitions/wifi/wi_fi_radios.yaml +++ b/definitions/wifi/wi_fi_radios.yaml @@ -74,3 +74,8 @@ parameters: type: boolean writable: true description: Enables IEEE 802.11h on this radio, which activates both Dynamic Frequency Selection (DFS) and Transmit Power Control (TPC) + + - field_name: supportedOperatingChannelBandwidths + path: .SupportedOperatingChannelBandwidths + type: string + description: Comma-separated list of supported channel bandwidths (e.g. "Auto,20MHz,40MHz,80MHz") diff --git a/doc/usp/integration/roadmap_m2.md b/doc/usp/integration/roadmap_m2.md index 1a0842380..eb20a1db8 100644 --- a/doc/usp/integration/roadmap_m2.md +++ b/doc/usp/integration/roadmap_m2.md @@ -28,17 +28,17 @@ All features still dependent on JNAP are considered **Migration Gaps**, requirin ## Status Overview -| Category | Total | โœ… USP Ready | ๐Ÿ”ง Needs Code Fix | ๐Ÿญ Needs FW Team | ๐Ÿ”ด USP Gap (JNAP dependency) | -|----------|-------|-------------|-------------------|-----------------|-------------------------------| -| M1 Pending Features | 8 | 6 | โ€” | โ€” | โ€” | -| Internet Settings | 10 | โ€” | 5 | 4 | 1 | -| WiFi Settings | 4 | โ€” | 2 | 2 | โ€” | -| WiFi Advanced / Security | 3 | 1 | 1 | โ€” | 1 | -| DDNS | 1 | โ€” | โ€” | 1 | โ€” | -| QoS | 4 | โ€” | โ€” | 1 | 3 | -| Parental Control | 3 | โ€” | โ€” | โ€” | 2 | -| Remaining JNAP Dependencies | 7 | โ€” | โ€” | โ€” | 7 | -| **Total** | **40** | **7** | **8** | **8** | **14** | +| Category | Total | โœ… M2 Done | โœ… USP Ready | ๐Ÿ”ง Needs Code Fix | ๐Ÿญ Needs FW Team | ๐Ÿ”ด USP Gap (JNAP dependency) | +|----------|-------|-----------|-------------|-------------------|-----------------|-------------------------------| +| M1 Pending Features | 8 | 3 | 4 | โ€” | โ€” | 1 | +| Internet Settings | 10 | โ€” | โ€” | 5 | 4 | 1 | +| WiFi Settings | 4 | 2 | โ€” | โ€” | 2 | โ€” | +| WiFi Advanced / Security | 3 | โ€” | 1 | 1 | โ€” | 1 | +| DDNS | 1 | โ€” | โ€” | โ€” | 1 | โ€” | +| QoS | 4 | โ€” | โ€” | โ€” | 1 | 3 | +| Parental Control | 3 | โ€” | โ€” | โ€” | โ€” | 2 | +| Remaining JNAP Dependencies | 7 | โ€” | โ€” | โ€” | โ€” | 7 | +| **Total** | **40** | **5** | **5** | **6** | **8** | **15** | > ๐Ÿ”ด **USP Gap** = Feature currently only supported by JNAP, no corresponding USP path. Requires firmware team to provide vendor extension or bbfdm plugin to complete migration. @@ -50,24 +50,21 @@ These features have verified TR-181 support and are ready for implementation. ### F-001: WiFi SSID / Password / Security Management -**Priority:** P0 | **Effort:** Small | **Feasibility:** โœ… USP Ready (SSH-verified 2026-03-16) +**Priority:** P0 | **Effort:** Small | **Status:** โœ… **Implemented (M2-A)** -- `Device.WiFi.AccessPoint.{i}.Security.KeyPassphrase` โ€” validated SET-able -- `Device.WiFi.AccessPoint.{i}.Security.SAEPassphrase` โ€” WPA3 password (writable) -- `Device.WiFi.AccessPoint.{i}.Security.ModeEnabled` โ€” writable, supports: `None, WPA2-Personal, WPA3-Personal, WPA3-Personal-Transition, Enhanced-Open` -- `Device.WiFi.AccessPoint.{i}.Security.MFPConfig` โ€” Management Frame Protection (writable) -- `Device.WiFi.AccessPoint.{i}.Security.Reset()` โ€” Operate command -- Add `writable: true` to `wi_fi_access_points.yaml`, re-run codegen -- New dialog: WiFi password edit per AP/SSID + security mode selector +- Password edit per AP/SSID, security mode selector (WPA2/WPA3/Enhanced-Open) +- 6GHz auto-override: forces WPA3-Personal + SAEPassphrase when band is 6GHz +- `wi_fi_access_points.yaml` updated with `writable: true`, codegen re-run +- `KeyPassphrase`, `SAEPassphrase`, `ModeEnabled`, `MFPConfig` โ€” all SET-able - **JNAP `setWPSServerSessionStatus` can be fully migrated to USP** ### F-004: WiFi Channel Width Edit -**Priority:** P1 | **Effort:** Small | **Feasibility:** โœ… USP Ready +**Priority:** P1 | **Effort:** Small | **Status:** โœ… **Implemented (M2-A)** -- `Device.WiFi.Radio.{i}.OperatingChannelBandwidth` โ€” available in codegen -- Mark as `writable` in `wi_fi_radios.yaml` -- Expand `wifi_channel_dialog.dart` with bandwidth selector +- Channel Width selector in WiFi network card, reads `SupportedOperatingChannelBandwidths` dynamically +- `wi_fi_radios.yaml` updated with `writable: true` for `OperatingChannelBandwidth` +- Fallback to hardcoded band-based options when firmware doesn't return supported bandwidths ### F-007: Guest Network Management @@ -79,11 +76,12 @@ These features have verified TR-181 support and are ready for implementation. ### F-011: Network Diagnostics (Ping / Traceroute) -**Priority:** P1 | **Effort:** Medium | **Feasibility:** โœ… SSE Infrastructure Ready +**Priority:** P1 | **Effort:** Medium | **Status:** โœ… **Implemented (M2-A)** -- Backend complete: `SseOperationAwaiter`, `PingResult`, `TracerouteResult` -- Remaining: UI page only (models, notifier, view, routing) -- Plan file: `noble-tickling-pumpkin.md` +- Full UI: `UspNetworkDiagnosticsView` with Ping + Traceroute tabs +- `SseOperationAwaiter` for async Operate + OperationComplete via SSE +- Route: `/uspAdvancedSettings/uspNetworkDiagnostics` +- YAML definitions: `network_diagnostics.yaml` + `wan_operations.yaml` (codegen) ### F-025: Historical Trend Analysis @@ -110,28 +108,22 @@ These features have verified TR-181 support and are ready for implementation. - Verified: AP.1 โœ… AP.2 โœ… AP.3 (guest, disabled) โœ… AP.4 โœ… - **JNAP `setWPSServerSessionStatus` can be fully migrated to USP** -### F-028: WiFi Advanced Radio Settings +### F-028: WiFi Advanced Settings -**Priority:** P2 | **Effort:** Medium | **Feasibility:** โœ… USP Ready (SSH-verified 2026-03-16) +**Priority:** P2 | **Effort:** Small | **Feasibility:** โš ๏ธ Mostly USP Gap (1/4 USP Ready) -All 12 Radio parameters below are writable (schema `data:"1"`), partially replacing JNAP `setAdvancedRadioSettings`: +**Scope correction (2026-03-16):** The original assessment listed 12 low-level Radio parameters (TransmitPower, GuardInterval, BeaconPeriod, etc.) as F-028 scope. These TR-181 paths are writable but were **never part of the JNAP Advanced WiFi UI**. The actual JNAP implementation (`wifi_advanced_settings_view.dart`) has 4 toggles (IPTV excluded โ€” not needed): -| Parameter | Path | Current Value | Notes | -|-----------|------|--------------|-------| -| Transmit Power | `Radio.{i}.TransmitPower` | (supported: -1,25,50,75,100) | % or auto(-1) | -| Guard Interval | `Radio.{i}.GuardInterval` | `"Auto"` | Auto/Short/Long | -| Beacon Period | `Radio.{i}.BeaconPeriod` | 100 | ms | -| DTIM Period | `Radio.{i}.DTIMPeriod` | 2 | beacon count | -| RTS Threshold | `Radio.{i}.RTSThreshold` | 2347 | bytes | -| Fragmentation | `Radio.{i}.FragmentationThreshold` | 2346 | bytes | -| Preamble Type | `Radio.{i}.PreambleType` | `"long"` | long/short | -| MCS Index | `Radio.{i}.MCS` | 0 | modulation scheme | -| 802.11h (DFS) | `Radio.{i}.IEEE80211hEnabled` | | radar detection | -| Extension Channel | `Radio.{i}.ExtensionChannel` | | HT40 secondary | -| Operating Standards | `Radio.{i}.OperatingStandards` | | 802.11 a/b/g/n/ac/ax | -| Auto Channel Refresh | `Radio.{i}.AutoChannelRefreshPeriod` | | seconds | +| Toggle | JNAP Action | USP Path | Status | +|--------|-------------|----------|--------| +| DFS (802.11h) | `setAdvancedRadioSettings` | `Radio.{i}.IEEE80211hEnabled` | โœ… USP Ready โ€” writable, SSH verified | +| Client Steering | `setSmartConnectSettings` | โ€” | ๐Ÿ”ด USP Gap โ€” = Smart Connect, requires `X_LINKSYS_SmartConnect.*` (ยง7) | +| Node Steering | `setSmartConnectSettings` | โ€” | ๐Ÿ”ด USP Gap โ€” Mesh node steering, requires `X_LINKSYS_SmartConnect.*` (ยง7) | +| MLO | `setMLOSettings` | โ€” | ๐Ÿ”ด USP Gap โ€” = WiFi 6E/7 MLO, requires `X_LINKSYS_MLO.*` (ยง7) | -**Most of JNAP `setAdvancedRadioSettings` can be migrated to USP.** Remaining JNAP-only items in ยง7. +**Only DFS can be migrated immediately.** Client Steering, Node Steering, and MLO depend on firmware team vendor extensions already tracked in ยง7 (Smart Connect, MLO). + +> **Note:** The 12 low-level Radio parameters (TransmitPower, GuardInterval, etc.) are available as writable TR-181 paths for future use, but are NOT in the current JNAP migration scope since they were never exposed in the JNAP UI. --- @@ -204,10 +196,11 @@ Phase 3 โ€” Re-test: **Reference:** [wifi-settings-tr181-limitations.md](../issues/wifi-settings-tr181-limitations.md) **SSH Re-verified:** 2026-03-16 -### ๐Ÿ”ง ISS-2: Channel Width โ€” โœ… Standard Path Exists (Code Fix Only) +### โœ… ISS-2: Channel Width โ€” Implemented (M2-A) **Original Assessment:** Vendor extension needed (`X_LINKSYS_PossibleChannelBandwidths`) **SSH Verification:** `Device.WiFi.Radio.{i}.SupportedOperatingChannelBandwidths` path exists and returns correct data +**Implementation:** Completed โ€” dynamic bandwidth from `SupportedOperatingChannelBandwidths` with hardcoded fallback ``` Radio.1 (2.4 GHz): "Auto,20MHz" @@ -216,29 +209,25 @@ Radio.2 (5 GHz): "Auto,20MHz,40MHz,80MHz" โ† SupportedBandwidths max 80MHz > โš ๏ธ **Data Inconsistency (2026-03-16):** Radio.2's `CurrentOperatingChannelBandwidth` reports `160MHz`, but `SupportedOperatingChannelBandwidths` only lists up to `80MHz`. This contradiction needs FW team clarification โ€” `SupportedOperatingChannelBandwidths` return value may be incomplete, or `CurrentOperatingChannelBandwidth` is reporting incorrectly. -**Fix:** Remove UI hardcoded values, read `SupportedOperatingChannelBandwidths` for dynamic rendering. Update `wifi-settings-tr181-limitations.md` accordingly. +**Changes:** +- YAML: Added `supportedOperatingChannelBandwidths` field to `wi_fi_radios.yaml`, re-ran codegen +- Model: `WifiNetworkUIModel.supportedBandwidths` field populated from TR-181 +- UI: `wifi_network_card.dart` `_editChannelWidth()` reads `supportedBandwidths` dynamically, falls back to band-based hardcoded list if empty -### ๐Ÿ”ง ISS-1: Channel-per-Width โ€” Client-Side Computable (Code Fix) +### โœ… ISS-1: Channel-per-Width โ€” Implemented (M2-A) **Original Assessment:** Vendor extension needed (`X_LINKSYS_AvailableChannels`) -**Revised Assessment:** **Client-side computable** โ€” downgraded from FW Team dependency to Code Fix - -IEEE 802.11 channel bonding rules are spec-defined constants. The app can derive `Map>` by combining: -1. `Device.WiFi.Radio.{i}.PossibleChannels` โ€” regulatory-filtered flat channel list from TR-181 -2. `Device.WiFi.Radio.{i}.SupportedOperatingChannelBandwidths` โ€” supported widths per radio -3. IEEE 802.11 bonding rules โ€” deterministic primary channel selection per width - -**Existing App Code:** - -| Component | Location | Description | -|-----------|----------|-------------| -| Channel/frequency/DFS/UNII data | `lib/page/wifi_settings/models/channel_constants.dart` | 1302-line static table: 2.4GHz (14 ch), 5GHz (~60 ch), 6GHz (~90 ch) | -| Mode โ†’ max width mapping | `lib/page/wifi_settings/models/wifi_enums.dart` `WifiWirelessMode.maxSupportedWidth` | e.g., acโ†’80MHz, axโ†’160MHz, beโ†’320MHz | -| JNAP channel-per-width mapping | `lib/page/wifi_settings/services/wifi_settings_mapper.dart:32-39` | Maps `supportedChannelsForChannelWidths` โ†’ `Map>` | -| Target data structure | `lib/page/wifi_settings/providers/wifi_item.dart:22` | `Map> availableChannels` | -| Channel selection by width | `lib/page/wifi_settings/providers/wifi_bundle_provider.dart:262` | `setChannelWidth` uses `availableChannels[channelWidth]` | - -**USP Migration Fix:** Implement a utility that parses `PossibleChannels` string โ†’ applies IEEE 802.11 bonding rules per width โ†’ produces `Map>` to populate `WiFiItem.availableChannels`. This replaces the JNAP `supportedChannelsForChannelWidths` without FW dependency. +**Revised Assessment:** Client-side computable using IEEE 802.11 bonding rules +**Implementation:** Completed โ€” bonding utility + UI channel filtering + auto-reset + +**Changes:** +- New utility: `lib/usp_page/wifi_settings/services/wifi_channel_bonding.dart` โ€” `computeChannelsPerBandwidth()` pure function implementing IEEE 802.11 bonding rules for 2.4GHz/5GHz/6GHz +- Model: `WifiNetworkUIModel.availableChannelsPerBandwidth` (`Map>`) computed at fetch time +- Service: `usp_wifi_settings_service.dart` calls bonding utility with `PossibleChannels` + `SupportedOperatingChannelBandwidths` +- UI: `wifi_network_card.dart` `_editChannel()` filters channel list by current bandwidth +- Provider: `updateNetworkField()` auto-resets to `autoChannelEnable: true` when bandwidth change invalidates current channel +- Tests: `wifi_channel_bonding_test.dart` covers 2.4G/5G/6G bonding groups, empty input, edge cases +- Regional filtering: Firmware handles via `PossibleChannels` (SSH verified: EU Radio.2 only returns ch 36-140, no ch 149-165) ### ๐Ÿญ Needs FW Team (1 item) @@ -246,6 +235,26 @@ IEEE 802.11 channel bonding rules are spec-defined constants. The app can derive |----|-------|--------|-----------------| | ISS-4 | Guest network detection โ€” no field distinguishes Guest/Primary | SSH verified 4 APs: SSIDReference/IsolationEnable/MultiAPMode identical across all, only Enable and Security.ModeEnabled differ (AP.3/4 disabled + None) | `X_LINKSYS_NetworkType` on `Device.WiFi.AccessPoint.{i}` | +**ISS-4 Additional SSH Findings (2026-03-16):** + +**Bridge/VLAN approach NOT viable through TR-181:** + +| Layer | Linux Reality | TR-181 Exposure | +|-------|-------------|-----------------| +| WiFi โ†’ Bridge | `ath0` โ†’ `br-lan` (main), guest โ†’ `br-guest` | SSID.LowerLayers only points to **Radio**, not Bridge | +| Bridge Ports | br-lan contains ath0, ath10, eth1 | Bridge.{i}.Port only lists **Ethernet**, WiFi not included | +| AccessPoint | โ€” | No bridge-related field (no LowerLayers to Bridge) | + +Bridge structure exists at the OS level (`brctl show`), but bbfdm does not map WiFi interfaces as Bridge Ports in TR-181. This means Guest detection via bridge membership is impossible through USP. + +**Dynamic SSID Add NOT functional:** +- `Device.WiFi.SSID.` Add succeeds but creates dmmap stub only (no `__section_name__`, no real UCI wireless config) +- Set on the new instance is silently ignored (no backend to write to) +- No new WiFi interface appears at the OS level +- Conclusion: Cannot dynamically create/delete SSIDs through TR-181 on current firmware + +**Current mitigation:** Case-insensitive `"guest"` substring match on SSID name. Reliable detection requires `X_LINKSYS_IsGuest` or `X_LINKSYS_NetworkType` vendor extension from FW team. + ### ๐Ÿญ ISS-3: MAC Filtering โ€” Issue More Severe Than Documented **Original Assessment:** TR-181 only supports allow-list, vendor extension needed for deny-list @@ -261,10 +270,10 @@ IEEE 802.11 channel bonding rules are spec-defined constants. The app can derive **Revised Assessment:** Not only is the deny-list missing, but the allow-list multi-instance table is also not implemented. The entire MAC address list mechanism is broken at the bbfdm level. FW team needs to fix `AllowedMACAddress.{i}` multi-instance support **and** add deny-list vendor extension. **Current Mitigations:** -- ISS-1: ~~Hardcoded channel lists~~ โ†’ **Client-side computable** using `PossibleChannels` + `SupportedOperatingChannelBandwidths` + IEEE 802.11 bonding rules (see ISS-1 section above) -- ISS-2: ~~Hardcoded width lists~~ โ†’ **Can use `SupportedOperatingChannelBandwidths` instead** +- ISS-1: โœ… **Implemented** โ€” Client-side bonding computation using `PossibleChannels` + `SupportedOperatingChannelBandwidths` + IEEE 802.11 bonding rules +- ISS-2: โœ… **Implemented** โ€” Dynamic bandwidth from `SupportedOperatingChannelBandwidths` with hardcoded fallback - ISS-3: MAC Filtering tab removed from WiFi Settings page -- ISS-4: Case-insensitive `"guest"` substring match on SSID name +- ISS-4: Case-insensitive `"guest"` substring match on SSID name (Bridge/VLAN approach confirmed NOT viable via TR-181) --- @@ -277,7 +286,7 @@ IEEE 802.11 channel bonding rules are spec-defined constants. The app can derive | Feature | JNAP Action | USP Path | Status | |---------|-------------|----------|--------| | **WPS** | `setWPSServerSessionStatus` | `AP.{i}.WPS.Enable` + `InitiateWPSPBC()` | โœ… Full support (see ยง1 F-027) | -| **Advanced Radio (partial)** | `setAdvancedRadioSettings` | 12 writable Radio parameters | โœ… Mostly migratable (see ยง1 F-028) | +| **Advanced Radio** | `setAdvancedRadioSettings` | DFS toggle only (`IEEE80211hEnabled`) | โš ๏ธ DFS only USP Ready โ€” 3/4 toggles are USP Gap (see ยง1 F-028) | | **Security Mode** | `setWirelessNetworkSettings` | `AP.{i}.Security.ModeEnabled` + WPA3 | โœ… Includes WPA3 + MFP (see ยง1 F-001) | ### ๐Ÿ”ง Code Fix (YAML/codegen update needed) @@ -432,7 +441,7 @@ QoS is implemented via Qualcomm hardware layer (SAL) + ebtables bridging rules. The following features currently only have JNAP implementations, with no standard TR-181 paths. **Each requires firmware team vendor extension to complete migration.** > ~~WPS~~ โ€” โœ… Confirmed USP Ready (2026-03-16), moved to ยง1 F-027 -> ~~WiFi Advanced Settings~~ โ€” โœ… Mostly confirmed USP Ready, moved to ยง1 F-028 / ยง3b +> ~~WiFi Advanced Settings~~ โ€” โš ๏ธ Reclassified: only DFS is USP Ready (ยง1 F-028); Client Steering/Node Steering (= Smart Connect) and MLO remain in ยง7 | Feature | JNAP Action | Category | Required Vendor Extension | Priority | |---------|-------------|----------|--------------------------|----------| @@ -467,7 +476,7 @@ The following features currently only have JNAP implementations, with no standar โ”‚ โ”‚ โ”‚ F-001 WiFi PW/Security F-004 Channel Width โ”‚ โ”‚ F-011 Ping UI F-026 Prefetch Cache โ”‚ -โ”‚ F-027 WPS F-028 Advanced Radio โ”‚ +โ”‚ F-027 WPS F-028 DFS toggle โ”‚ โ”‚ WiFi ISS-1 (channel-per-width client computation) โ”‚ โ”‚ WiFi ISS-2 (use SupportedBandwidths) โ”‚ โ”‚ Internet ISS-3/4 Vendor Ext ISS-6 YAML Fix โ”‚ @@ -524,19 +533,19 @@ The following features currently only have JNAP implementations, with no standar ### Phase M2-A: Immediate (no external dependency) -| # | Feature | Effort | Rationale | -|---|---------|--------|-----------| -| 1 | F-001 WiFi Password/Security | Small | P0 daily WiFi management + WPA3 security modes | -| 2 | F-004 Channel Width | Small | P1 paired with WiFi, switch to `SupportedOperatingChannelBandwidths` | -| 3 | WiFi ISS-1 fix | Medium | Channel-per-width client-side computation (IEEE 802.11 bonding rules + `PossibleChannels`) | -| 4 | WiFi ISS-2 fix | Trivial | Remove hardcoded bandwidth, read from TR-181 | -| 5 | F-027 WPS | Small | P2 โœ… SSH verified full support | -| 6 | F-028 Advanced Radio | Medium | P2 12 writable parameters, partially replaces JNAP | -| 7 | F-011 Ping/Traceroute UI | Medium | SSE infrastructure ready, UI only | -| 8 | Internet ISS-3/4 | Small | Vendor extension path swap | -| 9 | Internet ISS-6 | Trivial | YAML writable flag removal | -| 10 | Internet ISS-2/8 | Medium | PPP/VLAN Add/Delete lifecycle | -| 11 | F-026 Prefetch Cache | Medium | Dashboard performance optimization | +| # | Feature | Effort | Status | Rationale | +|---|---------|--------|--------|-----------| +| 1 | F-001 WiFi Password/Security | Small | โœ… Done | P0 daily WiFi management + WPA3 security modes | +| 2 | F-004 Channel Width | Small | โœ… Done | P1 paired with WiFi, switch to `SupportedOperatingChannelBandwidths` | +| 3 | WiFi ISS-1 fix | Medium | โœ… Done | Channel-per-width client-side computation (IEEE 802.11 bonding rules + `PossibleChannels`) | +| 4 | WiFi ISS-2 fix | Trivial | โœ… Done | Remove hardcoded bandwidth, read from TR-181 | +| 5 | F-011 Ping/Traceroute UI | Medium | โœ… Done | SSE infrastructure ready, UI + notifier + routing | +| 6 | F-027 WPS | Small | Pending | P2 โœ… SSH verified full support | +| 7 | F-028 DFS Toggle | Small | Pending | P2 only DFS (`IEEE80211hEnabled`) is USP Ready; Client Steering/Node Steering/MLO need vendor ext (ยง7) | +| 8 | Internet ISS-3/4 | Small | Pending | Vendor extension path swap | +| 9 | Internet ISS-6 | Trivial | Pending | YAML writable flag removal | +| 10 | Internet ISS-2/8 | Medium | Pending | PPP/VLAN Add/Delete lifecycle | +| 11 | F-026 Prefetch Cache | Medium | Pending | Dashboard performance optimization | ### Phase M2-B: FW Team Coordination @@ -583,9 +592,10 @@ Based on USP Features Matrix (72 features total): | Status | Count | Percentage | Description | |--------|-------|-----------|-------------| -| โœ… USP Complete | 45 | 63% | M1 migration completed | -| โœ… USP Ready (verified) | 3 | 4% | SSH verified available: WPS, Advanced Radio, Security Mode | -| ๐Ÿ”ง Code Fix Only | 8 | 11% | Vendor extension path swap, YAML fix, bandwidth fix, channel-per-width computation, scheduling workaround | +| โœ… USP Complete (M1) | 45 | 63% | M1 migration completed | +| โœ… USP Complete (M2-A) | 5 | 7% | F-001, F-004, F-011, WiFi ISS-1, WiFi ISS-2 | +| โœ… USP Ready (verified) | 2 | 3% | SSH verified available: WPS, Security Mode (F-028 reclassified: only DFS toggle is USP Ready) | +| ๐Ÿ”ง Code Fix Only | 3 | 4% | Vendor extension path swap (ISS-3/4), YAML fix (ISS-6), scheduling workaround | | ๐Ÿญ FW Bug Fix | 5 | 7% | Existing TR-181 paths need repair (ISS-1/5/7/9/10) | | ๐Ÿญ FW bbfdm Plugin | 2 | 3% | New bbfdm plugins needed (DDNS, QoS Basic) | | ๐Ÿ”ด FW Vendor Extension | 9 | 13% | FW team must provide new vendor extensions (JNAP migration blockers) | @@ -595,7 +605,8 @@ Based on USP Features Matrix (72 features total): | Milestone | Scope | USP Coverage | JNAP Dependencies | |-----------|-------|-------------|-------------------| | **M1 Done** | 45 features implemented | 45/72 (63%) | 27 remaining | -| **M2-A** (code fix) | F-001/004/011/026/027/028 + WiFi ISS-1/2 + Internet ISS-3/4/6/2/8 | 56/72 (78%) | 16 remaining | +| **M2-A partial** โœ… | F-001, F-004, F-011, WiFi ISS-1, WiFi ISS-2 | **50/72 (69%)** | 22 remaining | +| **M2-A full** (remaining code fix) | F-026/027 + F-028 DFS + Internet ISS-3/4/6/2/8 | 55/72 (76%) | 17 remaining | | **M2-B** (FW bug fix) | ISS-1/5/7/9/10 + F-007 | 61/72 (85%) | 11 remaining | | **M2-C** (bbfdm plugins) | DDNS, QoS Basic | 63/72 (88%) | 9 remaining | | **M2-D** (vendor extensions) | Smart Connect, Parental Control, PPTP/L2TP, FW Update, etc. | **72/72 (100%)** | **0 โ€” JNAP migration complete** | @@ -628,5 +639,5 @@ Completing 100% migration requires the firmware team to provide: --- -**Last Updated:** 2026-03-16 +**Last Updated:** 2026-03-16 (M2-A partial: 5/11 items completed) **Next Review:** After FW team response on ISS-1 / bbfdm plugin feasibility diff --git a/lib/generated/index.dart b/lib/generated/index.dart index 661a1336f..6ba469aaf 100644 --- a/lib/generated/index.dart +++ b/lib/generated/index.dart @@ -18,6 +18,7 @@ export 'lan_network_info.g.dart'; export 'ipv6settings.g.dart'; export 'dhcp_clients.g.dart'; export 'wan_traffic_stats.g.dart'; +export 'network_diagnostics.g.dart'; export 'dhcp_reservations.g.dart'; export 'wan_operations.g.dart'; export 'multi_interface_traffic_stats.g.dart'; diff --git a/lib/generated/network_diagnostics.g.dart b/lib/generated/network_diagnostics.g.dart new file mode 100644 index 000000000..dda01a0bc --- /dev/null +++ b/lib/generated/network_diagnostics.g.dart @@ -0,0 +1,34 @@ +// AUTO-GENERATED CODE - DO NOT EDIT +// This file was generated by usp-codegen +// Any modifications will be overwritten on next generation + +import 'package:privacy_gui/usp/services/usp_service.dart'; + +/// Network diagnostic operations (Ping, Traceroute) +class NetworkDiagnostics { + /// Run ICMP ping diagnostic + static Future> ping( + UspService client, { + required String host, + String? numberOfRepetitions, + }) async { + final inputs = {}; + inputs['Host'] = host; + if (numberOfRepetitions != null) + inputs['NumberOfRepetitions'] = numberOfRepetitions; + return await client.operate('Device.IP.Diagnostics.IPPing()', args: inputs); + } + + /// Run traceroute diagnostic + static Future> traceRoute( + UspService client, { + required String host, + String? maxHopCount, + }) async { + final inputs = {}; + inputs['Host'] = host; + if (maxHopCount != null) inputs['MaxHopCount'] = maxHopCount; + return await client.operate('Device.IP.Diagnostics.TraceRoute()', + args: inputs); + } +} diff --git a/lib/generated/wan_operations.g.dart b/lib/generated/wan_operations.g.dart index 59e4f7662..205e95b4c 100644 --- a/lib/generated/wan_operations.g.dart +++ b/lib/generated/wan_operations.g.dart @@ -7,12 +7,13 @@ import 'package:privacy_gui/usp/services/usp_service.dart'; /// WAN DHCP lease renewal operations class WanOperations { /// Renew DHCPv4 WAN lease - static Future renewDhcpLease(UspService client) async { - await client.operate('Device.DHCPv4.Client.1.Renew()'); + static Future> renewDhcpLease(UspService client) async { + return await client.operate('Device.DHCPv4.Client.1.Renew()'); } /// Renew DHCPv6 WAN lease - static Future renewDhcpv6Lease(UspService client) async { - await client.operate('Device.DHCPv6.Client.1.Renew()'); + static Future> renewDhcpv6Lease( + UspService client) async { + return await client.operate('Device.DHCPv6.Client.1.Renew()'); } } diff --git a/lib/generated/wi_fi_radios.g.dart b/lib/generated/wi_fi_radios.g.dart index 7add014fd..3a4d2fa40 100644 --- a/lib/generated/wi_fi_radios.g.dart +++ b/lib/generated/wi_fi_radios.g.dart @@ -19,6 +19,7 @@ class WiFiRadio { final int maxBitRate; final bool autoChannelEnable; final bool ieee80211hEnabled; + final String supportedOperatingChannelBandwidths; const WiFiRadio({ required this.instancePath, @@ -34,6 +35,7 @@ class WiFiRadio { required this.maxBitRate, required this.autoChannelEnable, required this.ieee80211hEnabled, + required this.supportedOperatingChannelBandwidths, }); } @@ -77,6 +79,7 @@ class WiFiRadios { 'Device.WiFi.Radio.*.MaxBitRate', 'Device.WiFi.Radio.*.AutoChannelEnable', 'Device.WiFi.Radio.*.IEEE80211hEnabled', + 'Device.WiFi.Radio.*.SupportedOperatingChannelBandwidths', ]; /// Fetch all instances via USP Get message @@ -112,7 +115,8 @@ class WiFiRadios { response['${p}TransmitPower'], response['${p}MaxBitRate'], response['${p}AutoChannelEnable'], - response['${p}IEEE80211hEnabled'] + response['${p}IEEE80211hEnabled'], + response['${p}SupportedOperatingChannelBandwidths'] ].every((v) => v == null || v == '' || @@ -146,6 +150,9 @@ class WiFiRadios { ieee80211hEnabled: response['${p}IEEE80211hEnabled'] == true || response['${p}IEEE80211hEnabled'] == 'true' || response['${p}IEEE80211hEnabled'] == '1', + supportedOperatingChannelBandwidths: + (response['${p}SupportedOperatingChannelBandwidths'] ?? '') + as String, )); } return WiFiRadios(items: items); diff --git a/lib/page/usp_test/usp_test_page.dart b/lib/page/usp_test/usp_test_page.dart index 993054537..3e8f153ef 100644 --- a/lib/page/usp_test/usp_test_page.dart +++ b/lib/page/usp_test/usp_test_page.dart @@ -193,11 +193,11 @@ class _UspTestPageState extends State { try { final args = Map.from(jsonDecode(argsJson) as Map? ?? {}); final response = await _service!.operate(command, args: args); - _log(' commandKey = ${response.commandKey}'); - if (response.data.isEmpty) { + _log(' commandKey = ${response['commandKey']}'); + if (response.isEmpty) { _log('OPERATE OK (no output)'); } else { - for (final entry in response.data.entries) { + for (final entry in response.entries) { _log(' ${entry.key} = ${entry.value}'); } } diff --git a/lib/providers/preservable_notifier_mixin.dart b/lib/providers/preservable_notifier_mixin.dart index 540f247df..7baf7e931 100644 --- a/lib/providers/preservable_notifier_mixin.dart +++ b/lib/providers/preservable_notifier_mixin.dart @@ -36,6 +36,10 @@ mixin PreservableNotifierMixin< settings: Preservable(original: newSettings, current: newSettings), status: newStatus ?? state.status, ) as TState; + } else if (newStatus != null) { + // Settings unavailable but status returned (e.g. error) โ€” apply status + // so the UI can exit the loading state and display the error. + state = state.copyWith(status: newStatus) as TState; } } return state; @@ -104,6 +108,10 @@ mixin PreservableAutoDisposeNotifierMixin< settings: Preservable(original: newSettings, current: newSettings), status: newStatus ?? state.status, ) as TState; + } else if (newStatus != null) { + // Settings unavailable but status returned (e.g. error) โ€” apply status + // so the UI can exit the loading state and display the error. + state = state.copyWith(status: newStatus) as TState; } } return state; diff --git a/lib/usp/models/usp_response.dart b/lib/usp/models/usp_response.dart deleted file mode 100644 index dda43b0a4..000000000 --- a/lib/usp/models/usp_response.dart +++ /dev/null @@ -1,20 +0,0 @@ -/// Unified response wrapper for all USP operations. -/// -/// Wraps the operation-specific [data] result with optional protocol-level -/// metadata. Currently only [commandKey] is populated (for Operate responses), -/// but additional fields can be added as USP integration evolves. -class UspResponse { - /// The operation result data. - final T data; - - /// UUID correlator assigned by the USP agent. - /// - /// Only present for Operate responses โ€” used to correlate with - /// OperationComplete SSE events. `null` for GET/SET/ADD/DELETE. - final String? commandKey; - - const UspResponse({required this.data, this.commandKey}); - - @override - String toString() => 'UspResponse(commandKey=$commandKey, data=$data)'; -} diff --git a/lib/usp/services/sse_operation_awaiter.dart b/lib/usp/services/sse_operation_awaiter.dart index 2a1193fc3..ecd5b026c 100644 --- a/lib/usp/services/sse_operation_awaiter.dart +++ b/lib/usp/services/sse_operation_awaiter.dart @@ -87,7 +87,7 @@ class SseOperationAwaiter { // Step 2: Fire the operate command and capture commandKey for correlation final operateResponse = await _usp.operate(operateCommand, args: args); - final expectedKey = operateResponse.commandKey; + final expectedKey = operateResponse['commandKey'] as String?; logger.d('[SSE Operate] Starting $operateCommand ' '(sub=$subscriptionId, commandKey=$expectedKey)'); diff --git a/lib/usp/services/usp_service.dart b/lib/usp/services/usp_service.dart index 54ab6e730..84111af45 100644 --- a/lib/usp/services/usp_service.dart +++ b/lib/usp/services/usp_service.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:privacy_gui/core/utils/logger.dart'; -import 'package:privacy_gui/usp/models/usp_response.dart'; // Conditional import: use WASM client on Web, stub on other platforms (VM/tests). import '../stub/usp_client_stub.dart' @@ -341,8 +340,9 @@ class UspService { /// [command] is the command path (e.g., "Device.Reboot()" or /// "Device.IP.Diagnostics.Ping()"). /// [args] are the input arguments for the command. - /// Returns [UspResponse] with commandKey (for SSE correlation) and output arguments. - Future>> operate(String command, + /// Returns a flat map containing `commandKey` (for SSE correlation) and + /// all output arguments from the Operate response. + Future> operate(String command, {Map args = const {}}) async { final id = ++_reqId; final sw = Stopwatch()..start(); @@ -351,10 +351,10 @@ class UspService { sw.stop(); logger.d('[UspService]:#$id OPERATE $command' '${args.isNotEmpty ? ' โ€” ${args.length} args' : ''}' - ' โ†’ key=${response.commandKey}, ${response.data.length} output keys' + ' โ†’ key=${response['commandKey']}, ${response.length} output keys' ' (${sw.elapsedMilliseconds}ms)'); - if (response.data.isNotEmpty) { - logger.d('[UspService]:#$id โ† ${_mapSummary(response.data)}'); + if (response.isNotEmpty) { + logger.d('[UspService]:#$id โ† ${_mapSummary(response)}'); } return response; } diff --git a/lib/usp/stub/usp_client_stub.dart b/lib/usp/stub/usp_client_stub.dart index a3b0de0b8..359f355fa 100644 --- a/lib/usp/stub/usp_client_stub.dart +++ b/lib/usp/stub/usp_client_stub.dart @@ -1,5 +1,3 @@ -import 'package:privacy_gui/usp/models/usp_response.dart'; - /// Stub implementation of UspClientWeb for non-Web platforms (Dart VM / tests). /// /// This file is selected by conditional import when dart.library.js_interop @@ -54,7 +52,7 @@ class UspClientWeb { {bool allowPartial = false}) => throw UnsupportedError('USP is only available on Web'); - Future>> operate(String command, + Future> operate(String command, {Map args = const {}}) => throw UnsupportedError('USP is only available on Web'); diff --git a/lib/usp/web/usp_client_wasm.dart b/lib/usp/web/usp_client_wasm.dart index 0865bf284..3e3290014 100644 --- a/lib/usp/web/usp_client_wasm.dart +++ b/lib/usp/web/usp_client_wasm.dart @@ -3,8 +3,6 @@ library usp_client; import 'dart:js_interop'; -import 'package:privacy_gui/usp/models/usp_response.dart'; - // Bind to the UspClient class exported in usp_client.js @JS('UspClient') extension type UspClientJS._(JSObject _) implements JSObject { @@ -157,26 +155,29 @@ class UspClientWeb { } /// Executes a USP Operate command. - /// Returns [UspResponse] with commandKey and output arguments. - Future>> operate(String command, + /// + /// Returns a flat map containing: + /// - `commandKey`: UUID correlator from the USP agent (may be absent) + /// - all output arguments from the Operate response + Future> operate(String command, {Map args = const {}}) async { final result = await _client.operate(command, args.jsify()!).toDart; - if (result == null || result.isUndefinedOrNull) { - return UspResponse(data: {}); - } + if (result == null || result.isUndefinedOrNull) return {}; final map = result.dartify() as Map?; - if (map == null) return UspResponse(data: {}); + if (map == null) return {}; + final output = {}; final commandKey = map['commandKey']?.toString(); + if (commandKey != null && commandKey.isNotEmpty) { + output['commandKey'] = commandKey; + } final rawOutputArgs = map['outputArgs']; - final outputArgs = {}; if (rawOutputArgs is Map) { for (final entry in rawOutputArgs.entries) { - outputArgs[entry.key.toString()] = entry.value.toString(); + output[entry.key.toString()] = entry.value.toString(); } } - - return UspResponse(data: outputArgs, commandKey: commandKey); + return output; } /// Lists all active OBUSPA subscriptions on the router. diff --git a/lib/usp_page/dashboard/providers/usp_dashboard_notifier.dart b/lib/usp_page/dashboard/providers/usp_dashboard_notifier.dart index 0d40e4603..f82b95430 100644 --- a/lib/usp_page/dashboard/providers/usp_dashboard_notifier.dart +++ b/lib/usp_page/dashboard/providers/usp_dashboard_notifier.dart @@ -10,6 +10,7 @@ import 'package:privacy_gui/generated/ethernet_interfaces.g.dart'; import 'package:privacy_gui/generated/firewall_chain_rules.g.dart'; import 'package:privacy_gui/generated/firmware_images.g.dart'; import 'package:privacy_gui/generated/lan_network_info.g.dart'; +import 'package:privacy_gui/generated/wan_operations.g.dart'; import 'package:privacy_gui/generated/wan_status.g.dart'; import 'package:privacy_gui/generated/port_forwarding.g.dart'; import 'package:privacy_gui/generated/port_triggering.g.dart'; @@ -368,7 +369,7 @@ class UspDashboardNotifier extends AsyncNotifier { 'Device.Routing.Router.1.IPv4Forwarding.*.DestIPAddress', 'Device.Routing.Router.1.IPv4Forwarding.*.GatewayIPAddress', 'Device.Routing.Router.1.IPv4Forwarding.*.Interface', - ]); + ]).timeout(const Duration(seconds: 10)); // Extract instance IDs from response keys const basePath = 'Device.Routing.Router.1.IPv4Forwarding.'; @@ -393,7 +394,7 @@ class UspDashboardNotifier extends AsyncNotifier { } return ''; } catch (e) { - logger.w('[USP] Failed to fetch default gateway: $e'); + logger.e('[USP] Failed to fetch default gateway: $e'); return ''; } } @@ -418,7 +419,7 @@ class UspDashboardNotifier extends AsyncNotifier { 'Device.IP.Interface.2.IPv6Enable', 'Device.IP.Interface.1.IPv6Address.', 'Device.IP.Interface.2.IPv6Address.', - ]); + ]).timeout(const Duration(seconds: 10)); final lanEnabled = resp['Device.IP.Interface.1.IPv6Enable'] == true; final wanEnabled = resp['Device.IP.Interface.2.IPv6Enable'] == true; @@ -449,7 +450,7 @@ class UspDashboardNotifier extends AsyncNotifier { wanAddresses: wanAddresses, ); } catch (e) { - logger.w('[USP] IPv6 fetch failed (router may not support IPv6): $e'); + logger.e('[USP] IPv6 fetch failed (router may not support IPv6): $e'); return ( lanEnabled: false, lanAddresses: [], @@ -474,7 +475,7 @@ class UspDashboardNotifier extends AsyncNotifier { 'Device.DeviceInfo.ActiveFirmwareImage', 'Device.DeviceInfo.BootFirmwareImage', ]), - ]); + ]).timeout(const Duration(seconds: 10)); final images = results[0] as FirmwareImages; final refs = results[1] as Map; final activeRef = @@ -485,7 +486,7 @@ class UspDashboardNotifier extends AsyncNotifier { 'active=$activeRef, boot=$bootRef'); return (images: images, activeRef: activeRef, bootRef: bootRef); } catch (e) { - logger.w('[USP] Firmware images fetch failed: $e'); + logger.e('[USP] Firmware images fetch failed: $e'); return ( images: FirmwareImages(items: const []), activeRef: '', @@ -508,7 +509,7 @@ class UspDashboardNotifier extends AsyncNotifier { try { final resp = await usp.get([ 'Device.Bridging.Bridge.*.Port.*.LowerLayers', - ]); + ]).timeout(const Duration(seconds: 10)); final map = {}; for (final entry in resp.entries) { if (!entry.key.endsWith('.LowerLayers')) continue; @@ -532,7 +533,7 @@ class UspDashboardNotifier extends AsyncNotifier { } return map; } catch (e) { - logger.w('[USP] Bridge port map fetch failed: $e'); + logger.e('[USP] Bridge port map fetch failed: $e'); return {}; } } @@ -737,7 +738,10 @@ class UspDashboardNotifier extends AsyncNotifier { Future renewWanLease() async { await _withLock(() async { - await _usp.operate('Device.DHCPv4.Client.1.Renew()'); + // DHCP Renew is fire-and-forget โ€” firmware does NOT send + // OperationComplete for this operation (unlike Ping/Traceroute). + await WanOperations.renewDhcpLease(_usp); + await Future.delayed(const Duration(seconds: 2)); final wan = await WanStatus.fetch(_usp); final s = state.requireValue; state = AsyncData(s.copyWith( diff --git a/lib/usp_page/test_console/views/usp_test_console_view.dart b/lib/usp_page/test_console/views/usp_test_console_view.dart index eef346f7e..0db72b9cd 100644 --- a/lib/usp_page/test_console/views/usp_test_console_view.dart +++ b/lib/usp_page/test_console/views/usp_test_console_view.dart @@ -270,11 +270,11 @@ class _UspTestConsoleViewState extends ConsumerState { try { final args = Map.from(jsonDecode(argsJson) as Map? ?? {}); final response = await _service!.operate(command, args: args); - _log(' commandKey = ${response.commandKey}'); - if (response.data.isEmpty) { + _log(' commandKey = ${response['commandKey']}'); + if (response.isEmpty) { _log('OPERATE OK (no output)'); } else { - for (final entry in response.data.entries) { + for (final entry in response.entries) { _log(' ${entry.key} = ${entry.value}'); } } diff --git a/lib/usp_page/wifi_settings/models/wifi_network_ui_model.dart b/lib/usp_page/wifi_settings/models/wifi_network_ui_model.dart index 09c856abe..24101d712 100644 --- a/lib/usp_page/wifi_settings/models/wifi_network_ui_model.dart +++ b/lib/usp_page/wifi_settings/models/wifi_network_ui_model.dart @@ -61,6 +61,17 @@ class WifiNetworkUIModel extends Equatable { /// e.g. "a,n,ac,ax" โ€” read-only, used to derive available options. final String supportedStandards; + /// Supported channel bandwidths from Device.WiFi.Radio.{i}.SupportedOperatingChannelBandwidths. + /// e.g. ['Auto', '20MHz', '40MHz', '80MHz']. Empty list = firmware didn't provide data. + final List supportedBandwidths; + + /// Channels available for each bandwidth, computed from possibleChannels + /// using IEEE 802.11 bonding rules. + /// Key = bandwidth string ("Auto", "20MHz", "40MHz", etc.) + /// Value = sorted list of valid primary channel numbers. + /// Empty map = bonding data not computed (fallback to possibleChannels). + final Map> availableChannelsPerBandwidth; + const WifiNetworkUIModel({ required this.ssidInstancePath, this.accessPointInstancePath, @@ -79,6 +90,8 @@ class WifiNetworkUIModel extends Equatable { required this.possibleChannels, required this.operatingStandards, required this.supportedStandards, + this.supportedBandwidths = const [], + this.availableChannelsPerBandwidth = const {}, }); /// Display name for the band tab/header @@ -116,6 +129,8 @@ class WifiNetworkUIModel extends Equatable { List? possibleChannels, String? operatingStandards, String? supportedStandards, + List? supportedBandwidths, + Map>? availableChannelsPerBandwidth, }) { return WifiNetworkUIModel( ssidInstancePath: ssidInstancePath ?? this.ssidInstancePath, @@ -138,6 +153,9 @@ class WifiNetworkUIModel extends Equatable { possibleChannels: possibleChannels ?? this.possibleChannels, operatingStandards: operatingStandards ?? this.operatingStandards, supportedStandards: supportedStandards ?? this.supportedStandards, + supportedBandwidths: supportedBandwidths ?? this.supportedBandwidths, + availableChannelsPerBandwidth: + availableChannelsPerBandwidth ?? this.availableChannelsPerBandwidth, ); } @@ -160,6 +178,8 @@ class WifiNetworkUIModel extends Equatable { possibleChannels, operatingStandards, supportedStandards, + supportedBandwidths, + availableChannelsPerBandwidth, ]; Map toMap() => { @@ -180,6 +200,8 @@ class WifiNetworkUIModel extends Equatable { 'possibleChannels': possibleChannels, 'operatingStandards': operatingStandards, 'supportedStandards': supportedStandards, + 'supportedBandwidths': supportedBandwidths, + 'availableChannelsPerBandwidth': availableChannelsPerBandwidth, }; Map toJson() => toMap(); @@ -205,6 +227,13 @@ class WifiNetworkUIModel extends Equatable { possibleChannels: (map['possibleChannels'] as List?)?.cast() ?? [], operatingStandards: map['operatingStandards'] as String? ?? '', supportedStandards: map['supportedStandards'] as String? ?? '', + supportedBandwidths: + (map['supportedBandwidths'] as List?)?.cast() ?? [], + availableChannelsPerBandwidth: + (map['availableChannelsPerBandwidth'] as Map?)?.map( + (k, v) => MapEntry(k as String, (v as List).cast()), + ) ?? + {}, ); factory WifiNetworkUIModel.fromJson(Map json) => diff --git a/lib/usp_page/wifi_settings/providers/usp_wifi_settings_provider.dart b/lib/usp_page/wifi_settings/providers/usp_wifi_settings_provider.dart index 686a0f87d..c5b602d68 100644 --- a/lib/usp_page/wifi_settings/providers/usp_wifi_settings_provider.dart +++ b/lib/usp_page/wifi_settings/providers/usp_wifi_settings_provider.dart @@ -326,7 +326,7 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier final current = state.settings.current; final updatedNetworks = current.networks.map((n) { if (n.ssidInstancePath != ssidInstancePath) return n; - return n.copyWith( + var updated = n.copyWith( enabled: enabled, ssid: ssid, keyPassphrase: password, @@ -337,6 +337,20 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier channel: autoChannel == true ? n.channel : (channel ?? n.channel), autoChannelEnable: autoChannel, ); + + // Auto-reset channel to Auto when bandwidth changes and the current + // manual channel is no longer valid for the new bandwidth. + if (channelBandwidth != null && !updated.autoChannelEnable) { + final validChannels = + updated.availableChannelsPerBandwidth[channelBandwidth]; + if (validChannels != null && + validChannels.isNotEmpty && + !validChannels.contains(updated.channel)) { + updated = updated.copyWith(autoChannelEnable: true); + } + } + + return updated; }).toList(); state = state.copyWith( diff --git a/lib/usp_page/wifi_settings/services/usp_wifi_settings_service.dart b/lib/usp_page/wifi_settings/services/usp_wifi_settings_service.dart index bd1dd66c6..f9a32f7f8 100644 --- a/lib/usp_page/wifi_settings/services/usp_wifi_settings_service.dart +++ b/lib/usp_page/wifi_settings/services/usp_wifi_settings_service.dart @@ -5,6 +5,7 @@ import 'package:privacy_gui/generated/wi_fi_radios.g.dart'; import 'package:privacy_gui/generated/wi_fi_ssids.g.dart'; import 'package:privacy_gui/usp_page/wifi_settings/models/wifi_network_ui_model.dart'; import 'package:privacy_gui/usp_page/wifi_settings/models/wifi_quick_setup_network.dart'; +import 'package:privacy_gui/usp_page/wifi_settings/services/wifi_channel_bonding.dart'; final uspWifiSettingsServiceProvider = Provider((_) => UspWifiSettingsService()); @@ -69,6 +70,18 @@ class UspWifiSettingsService { // e.g. "None, WPA2-Personal, WPA3-Personal" โ†’ ['None', 'WPA2-Personal', 'WPA3-Personal'] final supportedModes = _parseModesSupported(ap?.modesSupported ?? ''); + final band = _normalizeBand(radio?.operatingFrequencyBand ?? ''); + final possibleChannels = + _parsePossibleChannels(radio?.possibleChannels ?? ''); + final supportedBandwidths = _parseSupportedBandwidths( + radio?.supportedOperatingChannelBandwidths ?? ''); + + final channelsPerBw = computeChannelsPerBandwidth( + band: band, + possibleChannels: possibleChannels, + supportedBandwidths: supportedBandwidths, + ); + networks.add(WifiNetworkUIModel( ssidInstancePath: ssid.instancePath, accessPointInstancePath: ap?.instancePath, @@ -80,13 +93,15 @@ class UspWifiSettingsService { securityMode: ap?.securityModeEnabled ?? '', keyPassphrase: ap?.keyPassphrase ?? '', isGuest: isGuest, - band: _normalizeBand(radio?.operatingFrequencyBand ?? ''), + band: band, channel: radio?.channel ?? 0, channelBandwidth: radio?.operatingChannelBandwidth ?? '', autoChannelEnable: radio?.autoChannelEnable ?? true, - possibleChannels: _parsePossibleChannels(radio?.possibleChannels ?? ''), + possibleChannels: possibleChannels, operatingStandards: radio?.operatingStandards ?? '', supportedStandards: radio?.supportedStandards ?? '', + supportedBandwidths: supportedBandwidths, + availableChannelsPerBandwidth: channelsPerBw, )); } @@ -203,6 +218,17 @@ String _ensureTrailingDot(String path) { return path.endsWith('.') ? path : '$path.'; } +/// Parses a TR-181 SupportedOperatingChannelBandwidths string. +/// e.g. "Auto,20MHz,40MHz,80MHz" โ†’ ['Auto', '20MHz', '40MHz', '80MHz'] +List _parseSupportedBandwidths(String raw) { + if (raw.isEmpty) return []; + return raw + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); +} + /// Normalizes TR-181 OperatingFrequencyBand to display string. String _normalizeBand(String rawBand) { final lower = rawBand.toLowerCase(); diff --git a/lib/usp_page/wifi_settings/services/wifi_channel_bonding.dart b/lib/usp_page/wifi_settings/services/wifi_channel_bonding.dart new file mode 100644 index 000000000..08909331c --- /dev/null +++ b/lib/usp_page/wifi_settings/services/wifi_channel_bonding.dart @@ -0,0 +1,203 @@ +/// IEEE 802.11 channel bonding rules for computing valid primary channels +/// per bandwidth. +/// +/// This is a pure utility with no Flutter or provider dependencies. +/// All group tables are IEEE 802.11 standard constants. + +/// Computes a map of bandwidth -> valid primary channels, filtered by +/// what the router actually supports ([possibleChannels]). +/// +/// [band]: normalized band string ("2.4GHz", "5GHz", "6GHz") +/// [possibleChannels]: from Device.WiFi.Radio.{i}.PossibleChannels (parsed) +/// [supportedBandwidths]: from Device.WiFi.Radio.{i}.SupportedOperatingChannelBandwidths (parsed) +/// +/// Returns: { "20MHz": [1,2,...], "40MHz": [1,5,9], "80MHz": [...], ... } +/// The "Auto" key always maps to all possibleChannels. +Map> computeChannelsPerBandwidth({ + required String band, + required List possibleChannels, + required List supportedBandwidths, +}) { + if (possibleChannels.isEmpty) return {}; + + final result = >{}; + final possibleSet = possibleChannels.toSet(); + + // "Auto" always includes all possible channels. + result['Auto'] = List.of(possibleChannels); + + // Determine which bandwidths to compute. + // If supportedBandwidths is provided, use it; otherwise compute all known widths for the band. + final widths = supportedBandwidths.isNotEmpty + ? supportedBandwidths.where((bw) => bw != 'Auto').toList() + : _defaultWidthsForBand(band); + + for (final bw in widths) { + final channels = _channelsForBandwidth(band, bw, possibleSet); + if (channels.isNotEmpty) { + result[bw] = channels; + } + } + + return result; +} + +List _defaultWidthsForBand(String band) { + return switch (band) { + '2.4GHz' => ['20MHz', '40MHz'], + '5GHz' => ['20MHz', '40MHz', '80MHz', '160MHz'], + '6GHz' => ['20MHz', '40MHz', '80MHz', '160MHz'], + _ => ['20MHz'], + }; +} + +List _channelsForBandwidth(String band, String bw, Set possible) { + return switch (band) { + '2.4GHz' => _channels24(bw, possible), + '5GHz' => _channels5(bw, possible), + '6GHz' => _channels6(bw, possible), + _ => possible.toList()..sort(), + }; +} + +// --------------------------------------------------------------------------- +// 2.4 GHz bonding rules +// --------------------------------------------------------------------------- + +List _channels24(String bw, Set possible) { + return switch (bw) { + '20MHz' => possible.toList()..sort(), + '40MHz' => _filterByGroups(possible, _bondingGroups24_40), + _ => possible.toList()..sort(), + }; +} + +/// 2.4 GHz 40 MHz: HT40+ pairs. +/// A channel is a valid primary if its bonding partner (primary + 4) exists. +/// Standard pairs: (1,5), (2,6), (3,7), (4,8), (5,9), (6,10), (7,11), (8,12), (9,13). +const _bondingGroups24_40 = [ + [1, 5], + [2, 6], + [3, 7], + [4, 8], + [5, 9], + [6, 10], + [7, 11], + [8, 12], + [9, 13], +]; + +// --------------------------------------------------------------------------- +// 5 GHz bonding rules +// --------------------------------------------------------------------------- + +List _channels5(String bw, Set possible) { + return switch (bw) { + '20MHz' => possible.toList()..sort(), + '40MHz' => _filterByGroups(possible, _bondingGroups5_40), + '80MHz' => _filterByGroups(possible, _bondingGroups5_80), + '160MHz' => _filterByGroups(possible, _bondingGroups5_160), + _ => possible.toList()..sort(), + }; +} + +/// 5 GHz 40 MHz bonding pairs (IEEE 802.11n/ac/ax). +const _bondingGroups5_40 = [ + [36, 40], + [44, 48], + [52, 56], + [60, 64], + [100, 104], + [108, 112], + [116, 120], + [124, 128], + [132, 136], + [140, 144], + [149, 153], + [157, 161], + [165, 169], + [173, 177], +]; + +/// 5 GHz 80 MHz bonding groups (IEEE 802.11ac/ax). +const _bondingGroups5_80 = [ + [36, 40, 44, 48], + [52, 56, 60, 64], + [100, 104, 108, 112], + [116, 120, 124, 128], + [132, 136, 140, 144], + [149, 153, 157, 161], + [165, 169, 173, 177], +]; + +/// 5 GHz 160 MHz bonding groups (IEEE 802.11ac Wave 2 / ax). +const _bondingGroups5_160 = [ + [36, 40, 44, 48, 52, 56, 60, 64], + [100, 104, 108, 112, 116, 120, 124, 128], +]; + +// --------------------------------------------------------------------------- +// 6 GHz bonding rules +// --------------------------------------------------------------------------- + +List _channels6(String bw, Set possible) { + return switch (bw) { + '20MHz' => possible.toList()..sort(), + '40MHz' => _filterByGroups(possible, _build6GhzGroups(2)), + '80MHz' => _filterByGroups(possible, _build6GhzGroups(4)), + '160MHz' => _filterByGroups(possible, _build6GhzGroups(8)), + '320MHz' => _filterByGroups(possible, _build6GhzGroups(16)), + _ => possible.toList()..sort(), + }; +} + +/// Builds 6 GHz bonding groups dynamically. +/// +/// 6 GHz channels: 1, 5, 9, 13, 17, 21, ..., 229, 233 (step 4). +/// 20 MHz channels use all of them as primary. +/// 40 MHz pairs: [1,5], [9,13], [17,21], ... (groups of 2, step 8) +/// 80 MHz groups: [1,5,9,13], [17,21,25,29], ... (groups of 4, step 16) +/// 160 MHz groups: [1,5,9,13,17,21,25,29], ... (groups of 8, step 32) +/// 320 MHz groups: groups of 16, step 64 +List> _build6GhzGroups(int channelsPerGroup) { + const firstChannel = 1; + const channelStep = 4; + const lastChannel = 233; + final groupStep = channelsPerGroup * channelStep; + + final groups = >[]; + for (var start = firstChannel; start <= lastChannel; start += groupStep) { + final group = []; + for (var i = 0; i < channelsPerGroup; i++) { + final ch = start + i * channelStep; + if (ch <= lastChannel) group.add(ch); + } + if (group.length == channelsPerGroup) { + groups.add(group); + } + } + return groups; +} + +// --------------------------------------------------------------------------- +// Shared group filter +// --------------------------------------------------------------------------- + +/// Given a set of available channels and bonding group definitions, +/// returns all channels from [possible] that belong to a group where +/// at least 2 members (for pairs) or all members (for wider groups) are present. +/// +/// For 40 MHz (2-member groups): both channels in the pair must exist. +/// For 80/160/320 MHz (4+): all channels in the group must exist. +List _filterByGroups(Set possible, List> groups) { + final result = {}; + for (final group in groups) { + final presentInGroup = group.where(possible.contains).toList(); + // For a bonding group to be valid, all members must be present. + if (presentInGroup.length == group.length) { + result.addAll(presentInGroup); + } + } + final sorted = result.toList()..sort(); + return sorted; +} diff --git a/lib/usp_page/wifi_settings/views/components/wifi_network_card.dart b/lib/usp_page/wifi_settings/views/components/wifi_network_card.dart index 8bbd2eb16..fd35d8b72 100644 --- a/lib/usp_page/wifi_settings/views/components/wifi_network_card.dart +++ b/lib/usp_page/wifi_settings/views/components/wifi_network_card.dart @@ -371,11 +371,13 @@ class WifiNetworkCard extends ConsumerWidget { Future _editChannelWidth( BuildContext context, WidgetRef ref, WifiNetworkUIModel n) async { - final options = switch (n.band) { - '2.4GHz' => ['Auto', '20MHz', '40MHz'], - '6GHz' => ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], - _ => ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], - }; + final options = n.supportedBandwidths.isNotEmpty + ? n.supportedBandwidths + : switch (n.band) { + '2.4GHz' => ['Auto', '20MHz', '40MHz'], + '6GHz' => ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], + _ => ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], + }; final current = n.channelBandwidth.isNotEmpty ? n.channelBandwidth : 'Auto'; String selected = options.contains(current) ? current : options.first; @@ -411,9 +413,17 @@ class WifiNetworkCard extends ConsumerWidget { const autoLabel = 'Auto'; final currentLabel = n.autoChannelEnable ? autoLabel : n.channel.toString(); + // Use per-bandwidth filtered channels when available; fall back to all. + final channelsForCurrentBw = + n.availableChannelsPerBandwidth[n.channelBandwidth]; + final effectiveChannels = + (channelsForCurrentBw != null && channelsForCurrentBw.isNotEmpty) + ? channelsForCurrentBw + : n.possibleChannels; + final channelItems = [ AppRadioListItem(title: autoLabel, value: autoLabel), - ...n.possibleChannels.map( + ...effectiveChannels.map( (ch) => AppRadioListItem( title: ch.toString(), value: ch.toString(), diff --git a/lib/usp_page/wifi_settings/views/tabs/wifi_list_tab.dart b/lib/usp_page/wifi_settings/views/tabs/wifi_list_tab.dart index 4794779fd..76adc5532 100644 --- a/lib/usp_page/wifi_settings/views/tabs/wifi_list_tab.dart +++ b/lib/usp_page/wifi_settings/views/tabs/wifi_list_tab.dart @@ -21,27 +21,8 @@ class UspWifiListTab extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(uspWifiSettingsProvider); - // Loading / error states - if (state.status.isLoading || state.settings.current.networks.isEmpty) { - if (state.status.errorMessage != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - color: Theme.of(context).colorScheme.error), - AppGap.md(), - AppText.bodyMedium( - 'Failed to load WiFi settings.', - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ); - } + // Loading state + if (state.status.isLoading) { return const Center( child: Padding( padding: EdgeInsets.all(AppSpacing.xxxl), @@ -50,6 +31,29 @@ class UspWifiListTab extends ConsumerWidget { ); } + // Error or empty state + if (state.status.errorMessage != null || + state.settings.current.networks.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon.font(Icons.error_outline, + color: Theme.of(context).colorScheme.error), + AppGap.md(), + AppText.bodyMedium( + state.status.errorMessage ?? + 'No WiFi networks found. Check router connection.', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } + final quickSetupEnabled = state.settings.current.quickSetupEnabled; final canSave = state.canSave; final isSaving = state.status.isSaving; diff --git a/test/usp_page/wifi_settings/services/usp_wifi_settings_service_test.dart b/test/usp_page/wifi_settings/services/usp_wifi_settings_service_test.dart new file mode 100644 index 000000000..00ab65289 --- /dev/null +++ b/test/usp_page/wifi_settings/services/usp_wifi_settings_service_test.dart @@ -0,0 +1,577 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/generated/wi_fi_access_points.g.dart'; +import 'package:privacy_gui/generated/wi_fi_radios.g.dart'; +import 'package:privacy_gui/generated/wi_fi_ssids.g.dart'; +import 'package:privacy_gui/usp_page/wifi_settings/services/usp_wifi_settings_service.dart'; + +void main() { + late UspWifiSettingsService svc; + + setUp(() { + svc = UspWifiSettingsService(); + }); + + // ------------------------------------------------------------------------- + // buildWifiNetworks โ€” supportedBandwidths & availableChannelsPerBandwidth + // ------------------------------------------------------------------------- + + group('buildWifiNetworks', () { + test('populates supportedBandwidths from radio field', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'MyNetwork', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'None,WPA2-Personal,WPA3-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'test1234', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48,52,56,60,64', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + expect(networks, hasLength(1)); + final n = networks.first; + + // supportedBandwidths parsed from comma-separated string + expect(n.supportedBandwidths, ['Auto', '20MHz', '40MHz', '80MHz']); + }); + + test('populates availableChannelsPerBandwidth with bonding rules', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'TestNet', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48,52,56,60,64', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: false, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz,160MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + final n = networks.first; + final bwMap = n.availableChannelsPerBandwidth; + + // Auto โ†’ all channels + expect(bwMap['Auto'], [36, 40, 44, 48, 52, 56, 60, 64]); + + // 20MHz โ†’ all channels + expect(bwMap['20MHz'], [36, 40, 44, 48, 52, 56, 60, 64]); + + // 40MHz โ†’ valid pairs: (36,40), (44,48), (52,56), (60,64) + expect(bwMap['40MHz'], [36, 40, 44, 48, 52, 56, 60, 64]); + + // 80MHz โ†’ valid groups: [36,40,44,48], [52,56,60,64] + expect(bwMap['80MHz'], [36, 40, 44, 48, 52, 56, 60, 64]); + + // 160MHz โ†’ valid group: [36..64] + expect(bwMap['160MHz'], [36, 40, 44, 48, 52, 56, 60, 64]); + }); + + test('empty supportedOperatingChannelBandwidths falls back to defaults', + () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'FallbackNet', + enable: true, + status: 'Up', + bssid: '11:22:33:44:55:66', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,2,3,4,5,6,7,8,9,10,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: '', // empty โ†’ fallback + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + final n = networks.first; + + // supportedBandwidths should be empty (raw value was empty) + expect(n.supportedBandwidths, isEmpty); + + // But availableChannelsPerBandwidth should still be computed with defaults + final bwMap = n.availableChannelsPerBandwidth; + expect(bwMap.containsKey('Auto'), isTrue); + expect(bwMap.containsKey('20MHz'), isTrue); + expect(bwMap.containsKey('40MHz'), isTrue); + }); + + test('multi-band networks each get correct bonding', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:01', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + WiFiSsid( + instancePath: 'Device.WiFi.SSID.2.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:02', + lowerLayers: 'Device.WiFi.Radio.2.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.2.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal,WPA3-Personal', + securityModeEnabled: 'WPA3-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.2.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,2,3,4,5,6,7,8,9,10,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz', + ), + WiFiRadio( + instancePath: 'Device.WiFi.Radio.2.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: false, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + expect(networks, hasLength(2)); + + // 2.4 GHz network + final n24 = networks[0]; + expect(n24.band, '2.4GHz'); + expect(n24.supportedBandwidths, ['Auto', '20MHz', '40MHz']); + expect(n24.availableChannelsPerBandwidth['20MHz'], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + // 40MHz bonding: all channels 1-11 should be present + // because pairs (1,5),(2,6),(3,7),...,(7,11) cover 1-11 + expect(n24.availableChannelsPerBandwidth['40MHz'], isNotEmpty); + + // 5 GHz network + final n5 = networks[1]; + expect(n5.band, '5GHz'); + expect(n5.supportedBandwidths, ['Auto', '20MHz', '40MHz', '80MHz']); + expect(n5.availableChannelsPerBandwidth['20MHz'], [36, 40, 44, 48]); + expect(n5.availableChannelsPerBandwidth['40MHz'], [36, 40, 44, 48]); + expect(n5.availableChannelsPerBandwidth['80MHz'], [36, 40, 44, 48]); + }); + + test('normalizes band strings correctly', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Test6GHz', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA3-Personal', + securityModeEnabled: 'WPA3-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 1, + operatingFrequencyBand: '6GHz', // already normalized + operatingChannelBandwidth: '80MHz', + possibleChannels: '1,5,9,13,17,21,25,29', + operatingStandards: 'ax', + supportedStandards: 'ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz,160MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + final n = networks.first; + expect(n.band, '6GHz'); + + // 6GHz bonding: [1,5,9,13,17,21,25,29] is a valid 160MHz group + expect(n.availableChannelsPerBandwidth['160MHz'], + [1, 5, 9, 13, 17, 21, 25, 29]); + + // 80MHz: [1,5,9,13] and [17,21,25,29] + expect(n.availableChannelsPerBandwidth['80MHz'], + [1, 5, 9, 13, 17, 21, 25, 29]); + + // 40MHz: [1,5],[9,13],[17,21],[25,29] + expect(n.availableChannelsPerBandwidth['40MHz'], + [1, 5, 9, 13, 17, 21, 25, 29]); + }); + + test('SSID without matching radio still builds network', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'NoRadio', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.99.', // no matching radio + ), + ]); + + final accessPoints = WiFiAccessPoints(items: []); + final radios = WiFiRadios(items: []); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + expect(networks, hasLength(1)); + final n = networks.first; + expect(n.supportedBandwidths, isEmpty); + expect(n.availableChannelsPerBandwidth, isEmpty); + expect(n.possibleChannels, isEmpty); + }); + + test('trailing dot normalization matches AP to SSID', () { + // AP ssidReference without trailing dot, SSID path with trailing dot + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', // with trailing dot + ssid: 'DotTest', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.1', // without trailing dot + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1', // without trailing dot + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1', // without trailing dot + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,6,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + expect(networks, hasLength(1)); + final n = networks.first; + // AP should be matched despite dot mismatch + expect(n.accessPointInstancePath, 'Device.WiFi.AccessPoint.1.'); + expect(n.securityMode, 'WPA2-Personal'); + // Radio should be matched + expect(n.band, '2.4GHz'); + expect(n.possibleChannels, [1, 6, 11]); + }); + }); + + // ------------------------------------------------------------------------- + // buildQuickSetupNetworks + // ------------------------------------------------------------------------- + + group('buildQuickSetupNetworks', () { + test('isQuickSetup true when all main networks share ssid and enabled', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: '01:01:01:01:01:01', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + WiFiSsid( + instancePath: 'Device.WiFi.SSID.2.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: '02:02:02:02:02:02', + lowerLayers: 'Device.WiFi.Radio.2.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal,WPA3-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.2.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal,WPA3-Personal', + securityModeEnabled: 'WPA3-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.2.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,6,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz', + ), + WiFiRadio( + instancePath: 'Device.WiFi.Radio.2.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: false, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + final qs = svc.buildQuickSetupNetworks(networks); + expect(qs.isQuickSetup, isTrue); + expect(qs.main, isNotNull); + expect(qs.guest, isNull); + expect(qs.main!.ssid, 'Home'); + // Intersection of security modes + expect( + qs.main!.supportedSecurityModes, ['WPA2-Personal', 'WPA3-Personal']); + }); + }); +} diff --git a/test/usp_page/wifi_settings/services/wifi_channel_bonding_test.dart b/test/usp_page/wifi_settings/services/wifi_channel_bonding_test.dart new file mode 100644 index 000000000..435704288 --- /dev/null +++ b/test/usp_page/wifi_settings/services/wifi_channel_bonding_test.dart @@ -0,0 +1,268 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/usp_page/wifi_settings/services/wifi_channel_bonding.dart'; + +void main() { + group('computeChannelsPerBandwidth', () { + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + test('returns empty map when possibleChannels is empty', () { + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: [], + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + expect(result, isEmpty); + }); + + test('always includes Auto key with all possibleChannels', () { + final channels = [36, 40, 44, 48]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz'], + ); + expect(result['Auto'], channels); + }); + + // ----------------------------------------------------------------------- + // 2.4 GHz + // ----------------------------------------------------------------------- + + group('2.4 GHz', () { + test('20MHz returns all possible channels', () { + final channels = List.generate(13, (i) => i + 1); // 1-13 + final result = computeChannelsPerBandwidth( + band: '2.4GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + expect(result['20MHz'], channels); + }); + + test('40MHz returns valid HT40 primary channels', () { + final channels = List.generate(13, (i) => i + 1); // 1-13 + final result = computeChannelsPerBandwidth( + band: '2.4GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + // All channels 1-13 should be present since every pair (c, c+4) + // has both members in the 1-13 set. + final fortyMhz = result['40MHz']!; + expect(fortyMhz, isNotEmpty); + // Channels 1-9 can bond with +4 partner, channels 5-13 can bond with -4. + // Union of all valid pairs covers 1-13. + expect(fortyMhz, channels); + }); + + test('40MHz with limited channels filters correctly', () { + // Only channels 1 and 5 available โ€” valid pair (1,5) + final result = computeChannelsPerBandwidth( + band: '2.4GHz', + possibleChannels: [1, 5], + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + expect(result['40MHz'], [1, 5]); + }); + + test('40MHz with no valid pairs returns empty', () { + // Only channels 1 and 3 โ€” not a valid 40MHz pair + final result = computeChannelsPerBandwidth( + band: '2.4GHz', + possibleChannels: [1, 3], + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + expect(result.containsKey('40MHz'), isFalse); + }); + }); + + // ----------------------------------------------------------------------- + // 5 GHz + // ----------------------------------------------------------------------- + + group('5 GHz', () { + test('20MHz returns all possible channels', () { + final channels = [36, 40, 44, 48, 52, 56, 60, 64]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], + ); + expect(result['20MHz'], channels); + }); + + test('40MHz returns channels with valid bonding partner', () { + final channels = [36, 40, 44, 48]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + // Two pairs: (36,40), (44,48) โ€” all channels present + expect(result['40MHz'], [36, 40, 44, 48]); + }); + + test('40MHz with incomplete pair filters out orphan', () { + // Channel 36 without its partner 40 + final channels = [36, 44, 48]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + // Only pair (44,48) is complete + expect(result['40MHz'], [44, 48]); + }); + + test('80MHz with full UNII-1 group', () { + final channels = [36, 40, 44, 48]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz', '80MHz'], + ); + expect(result['80MHz'], [36, 40, 44, 48]); + }); + + test('80MHz with incomplete group returns empty', () { + // Only 3 of 4 channels in the group + final channels = [36, 40, 44]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '80MHz'], + ); + expect(result.containsKey('80MHz'), isFalse); + }); + + test('160MHz with full 36-64 group', () { + final channels = [36, 40, 44, 48, 52, 56, 60, 64]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '160MHz'], + ); + expect(result['160MHz'], channels); + }); + + test('160MHz with partial 36-64 group returns empty', () { + // Missing channel 64 + final channels = [36, 40, 44, 48, 52, 56, 60]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '160MHz'], + ); + expect(result.containsKey('160MHz'), isFalse); + }); + + test('multiple 80MHz groups coexist', () { + final channels = [ + 36, 40, 44, 48, // UNII-1 + 100, 104, 108, 112, // UNII-2 extended + 149, 153, 157, 161, // UNII-3 + ]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '80MHz'], + ); + expect(result['80MHz'], channels); + }); + }); + + // ----------------------------------------------------------------------- + // 6 GHz + // ----------------------------------------------------------------------- + + group('6 GHz', () { + test('20MHz returns all possible channels', () { + final channels = [1, 5, 9, 13, 17, 21, 25, 29]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz'], + ); + expect(result['20MHz'], channels); + }); + + test('40MHz returns valid pairs', () { + final channels = [1, 5, 9, 13, 17, 21, 25, 29]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '40MHz'], + ); + // Pairs: [1,5], [9,13], [17,21], [25,29] + expect(result['40MHz'], channels); + }); + + test('80MHz returns valid groups of 4', () { + final channels = [1, 5, 9, 13, 17, 21, 25, 29]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '80MHz'], + ); + // Groups: [1,5,9,13], [17,21,25,29] + expect(result['80MHz'], channels); + }); + + test('160MHz returns valid groups of 8', () { + final channels = [1, 5, 9, 13, 17, 21, 25, 29]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '160MHz'], + ); + // Group: [1,5,9,13,17,21,25,29] + expect(result['160MHz'], channels); + }); + + test('160MHz with partial group returns empty', () { + // Only 6 of 8 channels + final channels = [1, 5, 9, 13, 17, 21]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '160MHz'], + ); + expect(result.containsKey('160MHz'), isFalse); + }); + }); + + // ----------------------------------------------------------------------- + // Default bandwidths (when supportedBandwidths is empty) + // ----------------------------------------------------------------------- + + group('default bandwidths fallback', () { + test('computes all known widths when supportedBandwidths is empty', () { + final channels = [36, 40, 44, 48, 52, 56, 60, 64]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: [], + ); + expect(result.containsKey('Auto'), isTrue); + expect(result.containsKey('20MHz'), isTrue); + expect(result.containsKey('40MHz'), isTrue); + expect(result.containsKey('80MHz'), isTrue); + expect(result.containsKey('160MHz'), isTrue); + }); + + test('unknown band returns 20MHz only', () { + final channels = [1, 2, 3]; + final result = computeChannelsPerBandwidth( + band: 'unknown', + possibleChannels: channels, + supportedBandwidths: [], + ); + expect(result['Auto'], channels); + expect(result['20MHz'], channels); + expect(result.length, 2); // Only Auto + 20MHz + }); + }); + }); +} From 79e25bc7ac8cba0a5aa86a5dc0b2576a0db6c7c7 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 17 Mar 2026 11:42:17 +0800 Subject: [PATCH 2/2] fix: update test to match operate() return type change Remove obsolete UspResponse import and use Map to match the current UspService.operate() signature. Co-Authored-By: Claude Opus 4.6 --- .../services/usp_internet_settings_service_test.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/usp_page/internet_settings/services/usp_internet_settings_service_test.dart b/test/usp_page/internet_settings/services/usp_internet_settings_service_test.dart index b20ff4b05..919584263 100644 --- a/test/usp_page/internet_settings/services/usp_internet_settings_service_test.dart +++ b/test/usp_page/internet_settings/services/usp_internet_settings_service_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:privacy_gui/usp/models/usp_response.dart'; import 'package:privacy_gui/usp/services/usp_service.dart'; import 'package:privacy_gui/usp_page/internet_settings/models/usp_internet_settings_form.dart'; import 'package:privacy_gui/usp_page/internet_settings/models/usp_wan_connection_type.dart'; @@ -114,7 +113,7 @@ void main() { group('renewDhcpLease', () { test('calls WanOperations.renewDhcpLease', () async { when(() => mockUsp.operate(any())) - .thenAnswer((_) async => UspResponse(data: {})); + .thenAnswer((_) async => {}); await service.renewDhcpLease(); @@ -125,7 +124,7 @@ void main() { group('renewDhcpv6Lease', () { test('calls WanOperations.renewDhcpv6Lease', () async { when(() => mockUsp.operate(any())) - .thenAnswer((_) async => UspResponse(data: {})); + .thenAnswer((_) async => {}); await service.renewDhcpv6Lease();