Skip to content
Merged
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
28 changes: 28 additions & 0 deletions definitions/wifi/mac_filter_access_points.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# WiFi access point MAC address filtering (Instant Privacy)
# TR-181: Device.WiFi.AccessPoint.*.MACAddressControlEnabled
# Device.WiFi.AccessPoint.*.AllowedMACAddress

name: MacFilterAccessPoints
singularName: MacFilterAccessPoint
version: 1.0.0
multiInstance: Device.WiFi.AccessPoint.
category: wifi
description: WiFi access point MAC address filtering control

parameters:
- field_name: ssidReference
path: .SSIDReference
type: string
description: Reference to the SSID object — always non-empty, used to ensure the AP row is never filtered out by codegen's all-null check

- field_name: macAddressControlEnabled
path: .MACAddressControlEnabled
type: boolean
writable: true
description: Whether MAC address filtering (whitelist mode) is enabled for this access point

- field_name: allowedMACAddress
path: .AllowedMACAddress
type: string
writable: true
description: Comma-separated list of MAC addresses allowed to connect to this access point
1 change: 1 addition & 0 deletions lib/generated/index.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export 'wi_fi_radios.g.dart';
export 'wi_fi_access_points.g.dart';
export 'wi_fi_ssids.g.dart';
export 'data_elements_network.g.dart';
export 'mac_filter_access_points.g.dart';
export 'system_info.g.dart';
export 'vendor_log_files.g.dart';
export 'firmware_images.g.dart';
Expand Down
120 changes: 120 additions & 0 deletions lib/generated/mac_filter_access_points.g.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// 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';

/// Single instance from MacFilterAccessPoints
class MacFilterAccessPoint {
final String instancePath;
final String ssidReference;
final bool macAddressControlEnabled;
final String allowedMACAddress;

const MacFilterAccessPoint({
required this.instancePath,
required this.ssidReference,
required this.macAddressControlEnabled,
required this.allowedMACAddress,
});
}

/// Update descriptor for MacFilterAccessPoint instances
class MacFilterAccessPointUpdate {
final String instancePath;
final bool? macAddressControlEnabled;
final String? allowedMACAddress;

const MacFilterAccessPointUpdate({
required this.instancePath,
this.macAddressControlEnabled,
this.allowedMACAddress,
});
}

/// WiFi access point MAC address filtering control
class MacFilterAccessPoints {
final List<MacFilterAccessPoint> items;

const MacFilterAccessPoints({required this.items});

static const _paths = [
'Device.WiFi.AccessPoint.*.SSIDReference',
'Device.WiFi.AccessPoint.*.MACAddressControlEnabled',
'Device.WiFi.AccessPoint.*.AllowedMACAddress',
];

/// Fetch all instances via USP Get message
static Future<MacFilterAccessPoints> fetch(UspService client) async {
final response = await client.get(_paths);
return MacFilterAccessPoints._fromResponse(response);
}

factory MacFilterAccessPoints._fromResponse(Map<String, dynamic> response) {
final items = <MacFilterAccessPoint>[];
const basePath = 'Device.WiFi.AccessPoint.';
final ids = <String>{};
for (final key in response.keys) {
if (key.startsWith(basePath)) {
final rest = key.substring(basePath.length);
final dot = rest.indexOf('.');
if (dot > 0) ids.add(rest.substring(0, dot));
}
}
final sorted = ids.toList()
..sort((a, b) => (int.tryParse(a) ?? 0).compareTo(int.tryParse(b) ?? 0));
for (final id in sorted) {
final p = '$basePath$id.';
if ([
response['${p}SSIDReference'],
response['${p}MACAddressControlEnabled'],
response['${p}AllowedMACAddress']
].every((v) =>
v == null ||
v == '' ||
v == '0' ||
v == 0 ||
v == false ||
v == 'false')) continue;
items.add(MacFilterAccessPoint(
instancePath: p,
ssidReference: (response['${p}SSIDReference'] ?? '') as String,
macAddressControlEnabled:
response['${p}MACAddressControlEnabled'] == true ||
response['${p}MACAddressControlEnabled'] == 'true' ||
response['${p}MACAddressControlEnabled'] == '1',
allowedMACAddress: (response['${p}AllowedMACAddress'] ?? '') as String,
));
}
return MacFilterAccessPoints(items: items);
}

/// Update a single instance via USP Set message
static Future<void> update(
UspService client, MacFilterAccessPointUpdate update) async {
final params = <String, dynamic>{};
if (update.macAddressControlEnabled != null)
params['${update.instancePath}MACAddressControlEnabled'] =
update.macAddressControlEnabled;
if (update.allowedMACAddress != null)
params['${update.instancePath}AllowedMACAddress'] =
update.allowedMACAddress;
if (params.isNotEmpty) await client.set(params);
}

/// Update multiple instances in a single USP Set message
static Future<void> updateMany(
UspService client, List<MacFilterAccessPointUpdate> updates,
{bool allowPartial = false}) async {
final params = <String, dynamic>{};
for (final update in updates) {
if (update.macAddressControlEnabled != null)
params['${update.instancePath}MACAddressControlEnabled'] =
update.macAddressControlEnabled;
if (update.allowedMACAddress != null)
params['${update.instancePath}AllowedMACAddress'] =
update.allowedMACAddress;
}
if (params.isNotEmpty) await client.set(params, allowPartial: allowPartial);
}
}
2 changes: 2 additions & 0 deletions lib/route/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class RoutePath {
static const uspTopology = '/uspTopology';
static const uspNodeDetail = 'uspNodeDetail';
static const uspInstantSafety = '/uspInstantSafety';
static const uspInstantPrivacy = '/uspInstantPrivacy';
static const uspAdmin = '/uspAdmin';
static const uspDhcpDetail = '/uspDhcpDetail';
static const uspPortForwardingDetail = '/uspPortForwardingDetail';
Expand Down Expand Up @@ -190,6 +191,7 @@ class RouteNamed {
static const uspTopology = 'uspTopology';
static const uspNodeDetail = 'uspNodeDetail';
static const uspInstantSafety = 'uspInstantSafety';
static const uspInstantPrivacy = 'uspInstantPrivacy';
static const uspAdmin = 'uspAdmin';
static const uspDhcpDetail = 'uspDhcpDetail';
static const uspPortForwardingDetail = 'uspPortForwardingDetail';
Expand Down
6 changes: 6 additions & 0 deletions lib/route/route_usp_dashboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ final uspDashboardRoute = ShellRoute(
path: RoutePath.uspInstantSafety,
builder: (context, state) => const UspInstantSafetyView(),
),
LinksysRoute(
name: RouteNamed.uspInstantPrivacy,
path: RoutePath.uspInstantPrivacy,
builder: (context, state) =>
const usp_instant_privacy.InstantPrivacyView(),
),
LinksysRoute(
name: RouteNamed.uspAdmin,
path: RoutePath.uspAdmin,
Expand Down
2 changes: 2 additions & 0 deletions lib/route/router_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ import 'package:privacy_gui/usp_page/devices/views/usp_device_detail_view.dart';
import 'package:privacy_gui/usp_page/topology/views/usp_topology_view.dart';
import 'package:privacy_gui/usp_page/topology/views/usp_node_detail_view.dart';
import 'package:privacy_gui/usp_page/instant_safety/views/instant_safety_view.dart';
import 'package:privacy_gui/usp_page/instant_privacy/views/instant_privacy_view.dart'
as usp_instant_privacy;
import 'package:privacy_gui/usp_page/admin/views/usp_admin_view.dart';
import 'package:privacy_gui/usp_page/dhcp/views/usp_dhcp_detail_view.dart';
import 'package:privacy_gui/usp_page/port_forwarding/views/usp_port_forwarding_detail_view.dart';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';

/// Presentation layer model for a device in the Instant Privacy device list.
///
/// Used for both the "connected devices" list (when feature is OFF)
/// and the "allowed devices" list (when feature is ON).
/// Implements [Equatable] per Constitution Article XI.
class InstantPrivacyDeviceUIModel extends Equatable {
/// Normalized uppercase colon-separated MAC address (e.g. AA:BB:CC:DD:EE:FF).
final String mac;

/// Display name: hostname if available, otherwise falls back to [mac].
final String displayName;

const InstantPrivacyDeviceUIModel({
required this.mac,
required this.displayName,
});

@override
List<Object?> get props => [mac, displayName];

Map<String, dynamic> toMap() => {
'mac': mac,
'displayName': displayName,
};

Map<String, dynamic> toJson() => toMap();

factory InstantPrivacyDeviceUIModel.fromMap(Map<String, dynamic> map) {
return InstantPrivacyDeviceUIModel(
mac: map['mac'] as String,
displayName: map['displayName'] as String,
);
}

factory InstantPrivacyDeviceUIModel.fromJson(Map<String, dynamic> json) =>
InstantPrivacyDeviceUIModel.fromMap(json);
}
128 changes: 128 additions & 0 deletions lib/usp_page/instant_privacy/providers/instant_privacy_notifier.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:privacy_gui/core/utils/logger.dart';
import 'package:privacy_gui/generated/connected_devices.g.dart';
import 'package:privacy_gui/generated/mac_filter_access_points.g.dart';
import 'package:privacy_gui/usp/providers/usp_service_provider.dart';
import 'package:privacy_gui/usp_page/instant_privacy/models/instant_privacy_device_ui_model.dart';
import 'package:privacy_gui/usp_page/instant_privacy/providers/instant_privacy_state.dart';
import 'package:privacy_gui/usp_page/instant_privacy/services/instant_privacy_service.dart';

final uspInstantPrivacyProvider =
AsyncNotifierProvider<UspInstantPrivacyNotifier, UspInstantPrivacyState>(
UspInstantPrivacyNotifier.new,
);
Comment on lines +10 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. uspinstantprivacyprovider wrong directory 📘 Rule violation ⛯ Reliability

A new Riverpod state provider (uspInstantPrivacyProvider) is introduced under lib/usp_page/...
instead of lib/providers/. This conflicts with the required provider centralization and can make
state management harder to maintain consistently.
Agent Prompt
## Issue description
A new Riverpod state provider (`uspInstantPrivacyProvider`) was added outside `lib/providers/`, which violates the architecture requirement to centralize state providers under `lib/providers/`.

## Issue Context
The provider currently lives under the feature folder (`lib/usp_page/.../providers`). To comply, either relocate the provider definition under `lib/providers/` or introduce a `lib/providers/` entry-point that defines/exports it, then update any imports to use the centralized path.

## Fix Focus Areas
- lib/usp_page/instant_privacy/providers/instant_privacy_notifier.dart[10-13]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


class UspInstantPrivacyNotifier extends AsyncNotifier<UspInstantPrivacyState> {
UspInstantPrivacyService get _svc =>
ref.read(uspInstantPrivacyServiceProvider);

@override
Future<UspInstantPrivacyState> build() async {
final usp = ref.watch(uspServiceProvider);
if (usp == null) throw StateError('USP service not available');

final results = await Future.wait([
ConnectedDevices.fetch(usp),
MacFilterAccessPoints.fetch(usp),
]);

final devices = results[0] as ConnectedDevices;
final macAps = results[1] as MacFilterAccessPoints;
final svc = _svc;

logger.d('[USP] Instant Privacy fetched — '
'activeDevices: ${svc.activeDevices(devices).length}, '
'isEnabled: ${svc.isEnabled(macAps)}');

final active = svc.activeDevices(devices);

// Build a MAC → hostname lookup from all known hosts (active + inactive)
// so that allowed devices shown in the ON state can display friendly names.
final hostnameByMac = {
for (final d in devices.items)
if (d.macAddress.isNotEmpty)
svc.normalizeMac(d.macAddress): d.hostName.isNotEmpty
? d.hostName
: svc.normalizeMac(d.macAddress),
};

final allowed = svc.allowedDevices(macAps).map((d) {
final name = hostnameByMac[d.mac] ?? 'Unknown Device';
return name == d.displayName
? d
: InstantPrivacyDeviceUIModel(mac: d.mac, displayName: name);
}).toList();

logger.d('[USP] Instant Privacy fetched — '
'activeDevices: ${active.length}, '
'isEnabled: ${svc.isEnabled(macAps)}');

return UspInstantPrivacyState(
isEnabled: svc.isEnabled(macAps),
connectedDevices: active,
allowedDevices: allowed,
rawMacFilterAps: macAps,
);
}

/// Enables Instant Privacy by snapshotting currently connected devices
/// as the MAC whitelist across all APs (atomic, allowPartial: false).
Future<void> enable() async {
final s = state.valueOrNull;
if (s == null || s.isEnabled) return;

state = AsyncData(s.copyWith(isToggleLocked: true));
try {
final usp = ref.read(uspServiceProvider)!;
final macs = s.connectedDevices.map((d) => d.mac).toList();
final updates = _svc.buildEnableUpdates(macs, s.rawMacFilterAps);
await MacFilterAccessPoints.updateMany(usp, updates);
logger.d('[USP] Instant Privacy enabled — ${macs.length} MACs');
ref.invalidateSelf();
} catch (e) {
state = AsyncData(s.copyWith(isToggleLocked: false));
rethrow;
}
}

/// Disables Instant Privacy by clearing MAC filtering on all APs (atomic).
Future<void> disable() async {
final s = state.valueOrNull;
if (s == null || !s.isEnabled) return;

state = AsyncData(s.copyWith(isToggleLocked: true));
try {
final usp = ref.read(uspServiceProvider)!;
final updates = _svc.buildDisableUpdates(s.rawMacFilterAps);
await MacFilterAccessPoints.updateMany(usp, updates);
logger.d('[USP] Instant Privacy disabled');
ref.invalidateSelf();
} catch (e) {
state = AsyncData(s.copyWith(isToggleLocked: false));
rethrow;
}
}

/// Adds [mac] to the allowed list across all APs.
/// Precondition: [mac] is validated and normalized by the caller.
Future<void> addMac(String mac) async {
final s = state.valueOrNull;
if (s == null || !s.isEnabled) return;

state = AsyncData(s.copyWith(isToggleLocked: true));
try {
final usp = ref.read(uspServiceProvider)!;
final updates = _svc.buildAddMacUpdates(mac, s.rawMacFilterAps);
if (updates.isEmpty) {
state = AsyncData(s.copyWith(isToggleLocked: false));
return;
}
await MacFilterAccessPoints.updateMany(usp, updates);
logger.d('[USP] Instant Privacy addMac — $mac');
ref.invalidateSelf();
} catch (e) {
state = AsyncData(s.copyWith(isToggleLocked: false));
rethrow;
}
}
}
Loading
Loading