Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/core/utils/tr181_path.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Shared helpers for TR-181 object path manipulation.

/// Ensures a TR-181 instance path ends with a dot so paths from different
/// sources (codegen `instancePath`, `lowerLayers`, `ssidReference`, provider
/// maps) compare and look up consistently. Returns [path] unchanged when empty
/// or already dot-terminated.
String ensureTrailingDot(String path) {
if (path.isEmpty) return path;
return path.endsWith('.') ? path : '$path.';
}
67 changes: 67 additions & 0 deletions lib/core/utils/wifi_channel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Shared WiFi channel helpers: TR-181 PossibleChannels parsing and DFS
// (IEEE 802.11h) channel classification/filtering. Pure functions with no
// Flutter or provider dependencies — safe to import from services and widgets.

/// Parses a TR-181 `PossibleChannels` string into a sorted list of channel
/// numbers. Handles comma-separated values and range notation.
/// e.g. "1-13,36,40,44,48" → [1,2,3,4,5,6,7,8,9,10,11,12,13,36,40,44,48]
List<int> parsePossibleChannels(String raw) {
if (raw.isEmpty) return const [];
final result = <int>[];
for (final part in raw.split(',')) {
final trimmed = part.trim();
if (trimmed.contains('-')) {
final bounds = trimmed.split('-');
// Skip malformed range tokens (e.g. "1-2-3").
if (bounds.length != 2) continue;
final start = int.tryParse(bounds[0].trim());
final end = int.tryParse(bounds[1].trim());
if (start != null && end != null) {
// Inverted ranges (start > end) naturally yield nothing.
for (var i = start; i <= end; i++) {
result.add(i);
}
}
} else {
final ch = int.tryParse(trimmed);
if (ch != null) result.add(ch);
}
}
// Drop non-positive channels: TR-181 PossibleChannels "0" is an auto/any
// sentinel, not a real channel, and channel 0 must never reach the dropdown
// or be sent to firmware.
result.removeWhere((ch) => ch <= 0);
result.sort();
return result;
}

/// 5 GHz DFS channels (IEEE 802.11h): UNII-2A (52–64) + UNII-2C (100–144).
/// These are the only channels subject to Dynamic Frequency Selection; 2.4 GHz
/// and 6 GHz channels are never DFS.
///
/// Regulatory scope: this is the US/FCC (UNII-2A/2C) set. Other domains differ
/// (e.g. ETSI weather-radar restrictions, MIC/Japan assignments) — extend or
/// parameterize per regulatory domain if multi-market support is required.
const Set<int> dfsChannels5GHz = {
52, 56, 60, 64, //
100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
};

/// True only for 5 GHz DFS channels. [band] is the normalized band string
/// ("2.4GHz" / "5GHz" / "6GHz").
bool isDfsChannel(int channel, {required String band}) =>
band == '5GHz' && dfsChannels5GHz.contains(channel);

/// Removes DFS channels when DFS is disabled. Returns [channels] unchanged when
/// DFS is enabled or when the band is not 5 GHz (DFS applies only to 5 GHz).
///
/// The firmware does not trim `PossibleChannels` by DFS state, and TR-181
/// exposes no DFS-vs-non-DFS channel field, so the client filters here.
List<int> filterDfsChannels(
List<int> channels, {
required String band,
required bool dfsEnabled,
}) =>
(dfsEnabled || band != '5GHz')
? channels
: channels.where((ch) => !dfsChannels5GHz.contains(ch)).toList();
15 changes: 15 additions & 0 deletions lib/page/_shared/models/wifi_radio_ui_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,21 @@ class WifiRadioUIModel extends Equatable with DiagnosticLoggable {
/// dashboard data fetch ([UspWifiDataService.fetch]), so the edit-channel
/// dialog can render its dropdown synchronously with no per-dialog fetch.
/// Empty when the band exposes no manual channels.
///
/// NOTE: this list is RAW — it is NOT DFS-filtered. On the dashboard path,
/// DFS (IEEE 802.11h) channels are hidden at display time in
/// [WifiChannelDialog] via `filterDfsChannels` keyed off [isDfsEnabled]. (The
/// WiFi Settings path pre-filters instead, in
/// `UspWifiSettingsService.buildWifiNetworks`, before building bandwidth
/// maps.) Any new consumer needing DFS-off filtering must call
/// `filterDfsChannels` itself.
final List<int> possibleChannels;

/// Per-radio DFS (IEEE 802.11h) enabled state, from
/// `Device.WiFi.Radio.{i}.IEEE80211hEnabled`. When false, 5 GHz DFS channels
/// must be hidden from the channel dropdown.
final bool isDfsEnabled;

/// Access points grouped under this radio.
final List<WifiAccessPointUIModel> accessPoints;

Expand All @@ -35,6 +48,7 @@ class WifiRadioUIModel extends Equatable with DiagnosticLoggable {
required this.channelBandwidth,
required this.supportedStandards,
this.possibleChannels = const [],
this.isDfsEnabled = false,
this.accessPoints = const [],
});

Expand Down Expand Up @@ -72,6 +86,7 @@ class WifiRadioUIModel extends Equatable with DiagnosticLoggable {
'channelBandwidth': channelBandwidth,
'supportedStandards': supportedStandards,
'possibleChannels': possibleChannels,
'isDfsEnabled': isDfsEnabled,
'accessPoints': accessPoints,
};
}
Expand Down
45 changes: 27 additions & 18 deletions lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:privacy_gui/core/utils/wifi_channel.dart';
import 'package:privacy_gui/localization/localization_hook.dart';
import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart';
import 'package:ui_kit_library/ui_kit.dart';
Expand Down Expand Up @@ -27,6 +28,13 @@ class _WifiChannelDialogState extends State<WifiChannelDialog> {
/// Currently-selected dropdown value; [_autoValue] means Auto.
late int _selected;

/// The value [_selected] held when the dialog opened. Apply is a no-op unless
/// the user moves away from this — compared against the initial *dropdown*
/// selection, not the radio's stored channel, so a stored channel that was
/// filtered out of the list (e.g. a DFS channel while DFS is off, which the
/// firmware leaves in place) is not mistaken for a user change.
late final int _initialSelected;

/// Manual channels available for this radio's band, sorted ascending.
late final List<int> _channels;

Expand All @@ -37,22 +45,22 @@ class _WifiChannelDialogState extends State<WifiChannelDialog> {
@override
void initState() {
super.initState();
_channels = widget.radio.possibleChannels;
// Hide 5 GHz DFS channels when DFS (IEEE 802.11h) is disabled — the
// firmware leaves them in PossibleChannels regardless of DFS state.
_channels = filterDfsChannels(
widget.radio.possibleChannels,
band: widget.radio.band,
dfsEnabled: widget.radio.isDfsEnabled,
);
// AC5: a stored channel that is no longer selectable defaults to Auto
// (no ghost value is ever shown).
final storedChannelSelectable = !widget.radio.autoChannelEnable &&
_channels.contains(widget.radio.channel);
_selected = storedChannelSelectable ? widget.radio.channel : _autoValue;
_initialSelected = _selected;
}

/// 5 GHz DFS channels (IEEE 802.11h). Used to annotate options with "· DFS".
static const _dfsChannels5 = {
52, 56, 60, 64, //
100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
};

bool _isDfs(int channel) =>
widget.radio.band == '5GHz' && _dfsChannels5.contains(channel);
bool _isDfs(int channel) => isDfsChannel(channel, band: widget.radio.band);

String _labelFor(int value) {
if (value == _autoValue) return loc(context).channelAutoRecommended;
Expand Down Expand Up @@ -138,20 +146,21 @@ class _WifiChannelDialogState extends State<WifiChannelDialog> {
}

void _onApply() {
// AC4: if the user did not move the dropdown from where it opened, Apply is
// a no-op — return null so the caller issues no mutation. Comparing against
// the initial dropdown selection (not the radio's stored channel) means an
// unselectable stored channel — e.g. a DFS channel the firmware left set
// while DFS is off — does not read as a user change and trigger a write.
if (_selected == _initialSelected) {
Navigator.of(context).pop();
return;
}

final autoChannel = _autoChannel;
// When Auto is selected the concrete channel is irrelevant to firmware;
// keep the existing value so the returned record is stable.
final channel = autoChannel ? widget.radio.channel : _selected;

// AC4: selection equal to the stored value is a no-op — return null so the
// caller issues no mutation.
final unchanged = autoChannel == widget.radio.autoChannelEnable &&
(autoChannel || channel == widget.radio.channel);
if (unchanged) {
Navigator.of(context).pop();
return;
}

Navigator.of(context).pop((channel: channel, autoChannel: autoChannel));
}
}
10 changes: 3 additions & 7 deletions lib/page/local_network/services/usp_ethernet_data_service.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:privacy_gui/core/errors/service_error.dart';
import 'package:privacy_gui/core/utils/logger.dart';
import 'package:privacy_gui/core/utils/tr181_path.dart';
import 'package:privacy_gui/core/usp/errors/usp_error.dart';
import 'package:privacy_gui/generated/ethernet_interfaces.g.dart';
import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart';
Expand Down Expand Up @@ -129,11 +130,11 @@ class UspEthernetDataService {
final result = <EthernetPortUIModel>[];

final bridgeMemberPaths =
bridgePortMap.values.map(_ensureTrailingDot).toSet();
bridgePortMap.values.map(ensureTrailingDot).toSet();

EthernetInterface? lanAggregate;
for (final iface in ethernetInterfaces.items) {
final path = _ensureTrailingDot(iface.instancePath);
final path = ensureTrailingDot(iface.instancePath);
if (bridgeMemberPaths.contains(path)) {
lanAggregate ??= iface;
} else {
Expand Down Expand Up @@ -214,9 +215,4 @@ class UspEthernetDataService {

return result;
}

static String _ensureTrailingDot(String path) {
if (path.isEmpty) return path;
return path.endsWith('.') ? path : '$path.';
}
}
28 changes: 27 additions & 1 deletion lib/page/wifi_settings/providers/usp_wifi_advanced_provider.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:privacy_gui/core/errors/service_error.dart';
import 'package:privacy_gui/core/utils/logger.dart';
import 'package:privacy_gui/core/utils/tr181_path.dart';
import 'package:privacy_gui/core/utils/wifi_channel.dart';
import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart';
import 'package:privacy_gui/framework/preservable_contract.dart';
import 'package:privacy_gui/framework/preservable_notifier_mixin.dart';
Expand Down Expand Up @@ -106,15 +108,39 @@ class UspWifiAdvancedNotifier
final radioPaths = current.ieee80211hByRadio.keys.toList();
final enabled = current.isDfsEnabled;

// When DFS is being disabled, any radio parked on a DFS channel must be
// moved off it — the firmware leaves the channel set on its own
// (SSH-verified). Force AutoChannelEnable on those radios so the firmware
// reselects a legal non-DFS channel. Only radios being turned off and
// currently sitting on a manual DFS channel are affected.
final forceAutoChannelPaths = <String>[];
if (!enabled) {
final radios = ref.read(wifiDataProvider).valueOrNull?.radioModels ?? [];
final radioByPath = {
for (final r in radios) ensureTrailingDot(r.instancePath): r,
};
for (final path in radioPaths) {
// Radios staying on DFS need no channel remediation.
if (current.ieee80211hByRadio[path] == true) continue;
final radio = radioByPath[ensureTrailingDot(path)];
if (radio == null || radio.autoChannelEnable) continue;
if (isDfsChannel(radio.channel, band: radio.band)) {
forceAutoChannelPaths.add(path);
}
}
}

await ref.read(uspMutationLockProvider).withLock(() async {
await _svc.setIeee80211hEnabled(
radioPaths: radioPaths,
enabled: enabled,
forceAutoChannelPaths: forceAutoChannelPaths,
);
});

logger.d('[USP][WiFi][Advanced]: Save succeeded — '
'radios=${radioPaths.length}, enabled=$enabled');
'radios=${radioPaths.length}, enabled=$enabled, '
'forcedAutoChannel=${forceAutoChannelPaths.length}');
// Refresh Layer 1 cache so post-save fetch() reads fresh data.
// Using refresh() instead of invalidate() because the latter only marks
// the provider dirty — without an active subscriber it won't rebuild,
Expand Down
33 changes: 32 additions & 1 deletion lib/page/wifi_settings/services/usp_wifi_advanced_service.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:privacy_gui/core/errors/service_error.dart';
import 'package:privacy_gui/core/usp/errors/usp_error.dart';
import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart';
import 'package:privacy_gui/core/usp/services/usp_client.dart';
Expand Down Expand Up @@ -43,17 +44,47 @@ class UspWifiAdvancedService {
}

/// Sets IEEE 802.11h on all given radio paths.
///
/// [forceAutoChannelPaths] additionally receives `AutoChannelEnable = true`
/// in the same set() call. This is used when disabling DFS on a radio that is
/// parked on a DFS channel: the firmware does not vacate the channel on its
/// own (SSH-verified), so forcing auto-channel makes it reselect a legal
/// non-DFS channel. Paths not in this list keep their channel settings.
Future<void> setIeee80211hEnabled({
required List<String> radioPaths,
required bool enabled,
List<String> forceAutoChannelPaths = const [],
}) async {
if (radioPaths.isEmpty) return;
try {
final params = <String, dynamic>{
for (final path in radioPaths) '${path}IEEE80211hEnabled': enabled,
for (final path in forceAutoChannelPaths)
'${path}AutoChannelEnable': true,
};
await _usp.set(params);
final result = await _usp.set(params);
// Parse the batch result so a firmware partial rejection (e.g. accepts
// IEEE80211hEnabled but rejects a forced AutoChannelEnable) surfaces as an
// error instead of being silently swallowed.
final parsed = UspResultParser.parseSetResult(result);
switch (parsed) {
case UspSuccess():
break;
case UspPartialSuccess(failures: final f):
throw UspPartialFailureError(
summary:
'IEEE80211h update partial failure: ${f.first.errorMessage}',
successPaths: const [],
failures: f,
);
case UspFailure(errors: final e):
throw UspCompleteFailureError(
summary: 'IEEE80211h update failed: ${e.first.errorMessage}',
failures: e,
);
}
} catch (e) {
if (e is ServiceError) rethrow;
throw mapUspErrorToServiceError(e);
}
}
Expand Down
Loading
Loading