diff --git a/lib/ai/providers/usp_command_provider.dart b/lib/ai/providers/usp_command_provider.dart index 855d84c1d..afd089962 100644 --- a/lib/ai/providers/usp_command_provider.dart +++ b/lib/ai/providers/usp_command_provider.dart @@ -364,16 +364,15 @@ class UspCommandProvider implements IRouterCommandProvider { ' - ${d['name']} (${d['ip']}) ${d['connectionType']} signal=${d['signalStrength']}'); } - // Mesh extenders (non-master nodes) - final extenders = data.nodeModels - .where((n) => !n.isMaster) + // Mesh extenders (slave nodes) + final extenders = data.slaves .map((node) => { 'name': node.displayName, 'mac': node.deviceId, 'model': node.model, - 'backhaulMediaType': node.backhaulMediaType, - 'backhaulSignalStrength': node.backhaulSignalStrength, - 'backhaulUplinkRate': node.backhaulUplinkRate, + 'backhaulMediaType': node.backhaul.mediaType, + 'backhaulSignalStrength': node.backhaul.signalStrength, + 'backhaulUplinkRate': node.backhaul.uplinkRate, }) .toList(); @@ -870,16 +869,15 @@ String buildRouterContext(ProviderReader read) { buffer.writeln('- Currently online: ${devices.onlineClientCount}'); buffer.writeln(); - // Mesh nodes (extenders) - final nodeModels = devices.nodeModels; - final extenders = nodeModels.where((n) => !n.isMaster).toList(); + // Mesh nodes (slave extenders) + final extenders = devices.slaves; if (extenders.isNotEmpty) { _log('buildRouterContext: extenders=${extenders.length}'); buffer.writeln('## Mesh Extenders'); for (final ext in extenders) { _log('buildRouterContext: - ${ext.displayName} (${ext.deviceId})'); buffer.writeln( - '- ${ext.displayName}: ${ext.model}, backhaul=${ext.backhaulMediaType}, rssi=${ext.backhaulSignalStrength ?? "N/A"}'); + '- ${ext.displayName}: ${ext.model}, backhaul=${ext.backhaul.mediaType}, rssi=${ext.backhaul.signalStrength ?? "N/A"}'); } buffer.writeln(); } diff --git a/lib/core/cloud/providers/remote_assistance/device_credentials_provider.dart b/lib/core/cloud/providers/remote_assistance/device_credentials_provider.dart index 1e7d430fa..1fc466811 100644 --- a/lib/core/cloud/providers/remote_assistance/device_credentials_provider.dart +++ b/lib/core/cloud/providers/remote_assistance/device_credentials_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/session/providers/session_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/core/cloud/providers/remote_assistance/remote_client_provider.dart'; @@ -17,18 +16,17 @@ final deviceCredentialsProvider = Provider((ref) { final deviceInfo = session.deviceInfo; if (deviceInfo == null) return null; - // Get master node for MAC address (from nodeModels) - final masterNode = - devicesData.nodeModels.where((n) => n.isMaster).firstOrNull; - if (masterNode == null) return null; - - // Get master device for hostsDeviceId (UUID) (from deviceModels) - final masterDevice = devicesData.deviceModels.masterNode; - if (masterDevice?.hostsDeviceId == null) return null; + // Get master node for MAC address and hostsDeviceId (UUID) + final master = devicesData.master; + // Master's hostsDeviceId comes from the Hosts table during MeshNetwork build. + // Guardian Remote Assistance requires the Hosts DeviceID/UUID, NOT the MAC — + // without it, session lookup / PIN creation would receive the wrong value. + final hostsDeviceId = master.hostsDeviceId; + if (hostsDeviceId == null || hostsDeviceId.isEmpty) return null; return DeviceCredentials( serialNumber: deviceInfo.serialNumber, - macAddress: masterNode.deviceId, - deviceUUID: masterDevice!.hostsDeviceId!, + macAddress: master.deviceId, + deviceUUID: hostsDeviceId, ); }); diff --git a/lib/page/_shared/extensions/device_ui_extensions.dart b/lib/page/_shared/extensions/device_ui_extensions.dart deleted file mode 100644 index d2f3ca0a7..000000000 --- a/lib/page/_shared/extensions/device_ui_extensions.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; - -/// Extension to get [IconData] for [DeviceConnectionType]. -extension DeviceConnectionTypeExt on DeviceConnectionType { - IconData get icon => switch (this) { - DeviceConnectionType.wifi => Icons.wifi, - DeviceConnectionType.wired => Icons.settings_ethernet, - }; -} diff --git a/lib/page/_shared/models/backhaul_info.dart b/lib/page/_shared/models/backhaul_info.dart new file mode 100644 index 000000000..6eac048f6 --- /dev/null +++ b/lib/page/_shared/models/backhaul_info.dart @@ -0,0 +1,77 @@ +import 'package:equatable/equatable.dart'; + +/// Backhaul connection info for slave mesh nodes. +/// +/// Describes how a slave node connects to its parent (WiFi or Ethernet). +class BackhaulInfo with EquatableMixin { + /// Media type description, e.g., "IEEE 802.11ax", "Ethernet". + final String mediaType; + + /// Link type: "Wi-Fi" or "Ethernet". + final String? linkType; + + /// PHY rate in Mbps. + final int phyRate; + + /// Signal strength in dBm (RSSI). Null for Ethernet backhaul. + final int? signalStrength; + + /// Uplink data rate in kbps. + final int? uplinkRate; + + /// Downlink data rate in kbps. + final int? downlinkRate; + + /// Parent node's device ID (MAC). + final String? parentNodeId; + + /// Parent node's BSSID the slave connects to. + final String? parentBssid; + + /// Last contact time in ISO 8601 format. + final String? lastContactTime; + + /// Raw AL ID from DataElements (parent node MAC). + final String? backhaulAlId; + + /// Backhaul interface MAC address. + final String? backhaulMacAddress; + + const BackhaulInfo({ + required this.mediaType, + this.linkType, + this.phyRate = 0, + this.signalStrength, + this.uplinkRate, + this.downlinkRate, + this.parentNodeId, + this.parentBssid, + this.lastContactTime, + this.backhaulAlId, + this.backhaulMacAddress, + }); + + /// Whether the backhaul is Ethernet (wired). + bool get isEthernet => linkType == 'Ethernet'; + + /// Whether the backhaul is WiFi (wireless). + bool get isWifi => !isEthernet; + + /// Whether backhaul info is available. + bool get hasInfo => mediaType.isNotEmpty; + + @override + List get props => [ + mediaType, + linkType, + phyRate, + signalStrength, + uplinkRate, + downlinkRate, + parentNodeId, + parentBssid, + lastContactTime, + backhaulAlId, + backhaulMacAddress, + ]; +} diff --git a/lib/page/_shared/models/client_device.dart b/lib/page/_shared/models/client_device.dart new file mode 100644 index 000000000..011d528af --- /dev/null +++ b/lib/page/_shared/models/client_device.dart @@ -0,0 +1,302 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:privacy_gui/page/_shared/models/network_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; + +/// Connection type for client devices. +enum ConnectionType { wifi, wired } + +/// Extension for connection type icons. +extension ConnectionTypeExt on ConnectionType { + IconData get icon => switch (this) { + ConnectionType.wifi => Icons.wifi, + ConnectionType.wired => Icons.settings_ethernet, + }; +} + +/// Network interface info for multi-interface devices. +/// +/// When a device connects via multiple interfaces (e.g., WiFi + Ethernet), +/// the primary interface is stored in [ClientDevice] fields and additional +/// interfaces are stored in [ClientDevice.additionalInterfaces]. +class ClientInterfaceInfo with EquatableMixin { + /// MAC address of this interface. + final String mac; + + /// IP address of this interface. + final String ip; + + /// Connection type (WiFi or wired). + final ConnectionType connectionType; + + /// Whether this interface is currently active. + final bool isActive; + + /// Layer1Interface path (for port correlation). + final String layer1Interface; + + /// WiFi connection details (null if wired). + final WifiConnectionInfo? wifi; + + const ClientInterfaceInfo({ + required this.mac, + required this.ip, + required this.connectionType, + required this.isActive, + this.layer1Interface = '', + this.wifi, + }); + + /// Whether this is a WiFi interface. + bool get isWifi => connectionType == ConnectionType.wifi; + + /// Signal strength in dBm (from WiFi info). + int? get signalStrength => wifi?.signalStrength; + + /// WiFi band (2.4GHz, 5GHz, 6GHz). + String? get band => wifi?.band; + + /// SSID name. + String? get ssidName => wifi?.ssidName; + + @override + List get props => [ + mac, + ip, + connectionType, + isActive, + layer1Interface, + wifi, + ]; +} + +/// Client device connected to the mesh network. +/// +/// Represents end-user devices (phones, laptops, etc.) that connect to +/// mesh nodes. Implements [NetworkEntity] for unified identity handling. +final class ClientDevice extends NetworkEntity { + // ─── Identity ─── + /// MAC address (uppercase, normalized). + final String mac; + + /// Hostname from Hosts table. + final String hostName; + + /// User-friendly name (editable). + final String? friendlyName; + + // ─── Status ─── + /// Whether the device is currently online. + final bool isActive; + + // ─── Network ─── + /// IPv4 address. + final String ip; + + /// IPv6 addresses. + @override + final List ipv6Addresses; + + /// Layer1Interface path (for port correlation). + final String layer1Interface; + + // ─── Connection ─── + /// Connection type (WiFi or wired). + final ConnectionType connectionType; + + /// WiFi connection details (null if wired). + final WifiConnectionInfo? wifi; + + /// Parent mesh node ID this device is connected to. + final String? parentNodeId; + + /// Parent mesh node display name (for UI). + final String? parentNodeName; + + // ─── Device Info ─── + /// Device manufacturer. + final String? manufacturer; + + /// Device model name. + final String? modelName; + + /// Device operating system. + final String? operatingSystem; + + /// Hosts DeviceID (UUID, for DataElements matching). + final String? hostsDeviceId; + + // ─── Multi-interface ─── + /// Additional network interfaces for this device. + final List additionalInterfaces; + + ClientDevice({ + required this.mac, + required this.hostName, + this.friendlyName, + required this.isActive, + required this.ip, + this.ipv6Addresses = const [], + this.layer1Interface = '', + required this.connectionType, + this.wifi, + this.parentNodeId, + this.parentNodeName, + this.manufacturer, + this.modelName, + this.operatingSystem, + this.hostsDeviceId, + this.additionalInterfaces = const [], + }); + + // ─── NetworkEntity implementation ─── + + @override + String get id => mac; + + @override + String get displayName { + if (friendlyName != null && friendlyName!.isNotEmpty) return friendlyName!; + if (hostName.isNotEmpty) return hostName; + return mac; + } + + @override + bool get isOnline => isActive; + + @override + String? get ipAddress => ip.isNotEmpty ? ip : null; + + // ─── Computed getters ─── + + /// Whether this is a WiFi device. + bool get isWifi => connectionType == ConnectionType.wifi; + + /// Signal strength in dBm (from WiFi info). + int? get signalStrength => wifi?.signalStrength; + + /// Signal quality (0.0–1.0). + double get signalQuality => wifi?.signalQuality ?? 0; + + /// Signal level (0–3). + int get signalLevel => wifi?.signalLevel ?? 0; + + /// WiFi band (2.4GHz, 5GHz, 6GHz). + String? get band => wifi?.band; + + /// SSID name. + String? get ssidName => wifi?.ssidName; + + /// Downlink rate in kbps. + int? get downlinkRate => wifi?.downlinkRate; + + /// Uplink rate in kbps. + int? get uplinkRate => wifi?.uplinkRate; + + /// Whether WiFi details should be displayed. + bool get hasWifiData => wifi?.hasData ?? false; + + /// Whether this device has multiple network interfaces. + bool get hasMultipleInterfaces => additionalInterfaces.isNotEmpty; + + /// All MAC addresses for this device (primary + additional). + List get allMacAddresses => + [mac, ...additionalInterfaces.map((i) => i.mac)]; + + /// Total number of interfaces. + int get interfaceCount => 1 + additionalInterfaces.length; + + /// Whether any interface is active. + bool get hasAnyActiveInterface => + isActive || additionalInterfaces.any((i) => i.isActive); + + /// Total throughput in kbps (uplink + downlink). + int get totalThroughput => (downlinkRate ?? 0) + (uplinkRate ?? 0); + + /// Whether to display signal information (WiFi + online + has signal). + bool get hasSignalDisplay => isWifi && isActive && signalStrength != null; + + /// Whether WiFi signal details should be shown in detail view. + bool get shouldShowWifiDetails => + isWifi && isActive && (hasWifiData || signalStrength != null); + + /// Whether this device is interactive (can be tapped for detail). + bool get isInteractive => isActive; + + /// Display opacity for list items (dimmed when offline). + double get displayOpacity => isActive ? 1.0 : 0.5; + + /// Creates a copy with optional field overrides. + ClientDevice copyWith({ + String? mac, + String? hostName, + String? friendlyName, + bool? isActive, + String? ip, + List? ipv6Addresses, + String? layer1Interface, + ConnectionType? connectionType, + WifiConnectionInfo? wifi, + String? parentNodeId, + String? parentNodeName, + String? manufacturer, + String? modelName, + String? operatingSystem, + String? hostsDeviceId, + List? additionalInterfaces, + }) { + return ClientDevice( + mac: mac ?? this.mac, + hostName: hostName ?? this.hostName, + friendlyName: friendlyName ?? this.friendlyName, + isActive: isActive ?? this.isActive, + ip: ip ?? this.ip, + ipv6Addresses: ipv6Addresses ?? this.ipv6Addresses, + layer1Interface: layer1Interface ?? this.layer1Interface, + connectionType: connectionType ?? this.connectionType, + wifi: wifi ?? this.wifi, + parentNodeId: parentNodeId ?? this.parentNodeId, + parentNodeName: parentNodeName ?? this.parentNodeName, + manufacturer: manufacturer ?? this.manufacturer, + modelName: modelName ?? this.modelName, + operatingSystem: operatingSystem ?? this.operatingSystem, + hostsDeviceId: hostsDeviceId ?? this.hostsDeviceId, + additionalInterfaces: additionalInterfaces ?? this.additionalInterfaces, + ); + } + + @override + List get props => [ + mac, + hostName, + friendlyName, + isActive, + ip, + ipv6Addresses, + layer1Interface, + connectionType, + wifi, + parentNodeId, + parentNodeName, + manufacturer, + modelName, + operatingSystem, + hostsDeviceId, + additionalInterfaces, + ]; +} + +/// Extension methods for List. +extension ClientDeviceListExt on List { + /// Returns only online devices. + List get online => where((d) => d.isOnline).toList(); + + /// Returns only offline devices. + List get offline => where((d) => !d.isOnline).toList(); + + /// Returns only WiFi devices. + List get wifiDevices => where((d) => d.isWifi).toList(); + + /// Returns only wired devices. + List get wiredDevices => where((d) => !d.isWifi).toList(); +} diff --git a/lib/page/_shared/models/device_ui_model.dart b/lib/page/_shared/models/device_ui_model.dart deleted file mode 100644 index a84252b8d..000000000 --- a/lib/page/_shared/models/device_ui_model.dart +++ /dev/null @@ -1,314 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/core/utils/wifi.dart'; - -// --------------------------------------------------------------------------- -// Additional Interface Info (for multi-interface devices) -// --------------------------------------------------------------------------- - -/// Information about an additional network interface for a device. -/// -/// When a device connects via multiple interfaces (e.g., WiFi + Ethernet), -/// the primary interface is stored in [DeviceUIModel] and additional -/// interfaces are stored in [DeviceUIModel.additionalInterfaces]. -class DeviceInterfaceInfo extends Equatable { - final String mac; - final String ip; - final bool isWifi; - final bool isActive; - final String layer1Interface; - final String? band; - final String? ssidName; - final int? signalStrength; - - const DeviceInterfaceInfo({ - required this.mac, - required this.ip, - required this.isWifi, - required this.isActive, - required this.layer1Interface, - this.band, - this.ssidName, - this.signalStrength, - }); - - @override - List get props => [ - mac, - ip, - isWifi, - isActive, - layer1Interface, - band, - ssidName, - signalStrength, - ]; -} - -// --------------------------------------------------------------------------- -// Connection Type Enum -// --------------------------------------------------------------------------- - -/// Connection type for UI display decisions. -enum DeviceConnectionType { wifi, wired } - -/// Presentation Layer Model — aggregates codegen + enricher per-device info. -/// -/// UI widgets depend only on this class, never directly on codegen Data Models. -/// Naming follows constitution Section 3.3.4 (class name ends with `UIModel`). -/// Implements [Equatable] per Article XI. -class DeviceUIModel extends Equatable { - // ─── Base info (from ConnectedDevice codegen) ─── - final String mac; // PhysAddress (uppercase, normalized) - final String ip; // IPAddress - final String hostName; // HostName - final bool isActive; // Active - final bool isWifi; // Derived from Layer1Interface - - // ─── WiFi enrichment (null if ethernet) ─── - final int? signalStrength; // RSSI dBm (from WifiClient) - final int? - downlinkRate; // kbps (from TR-181 LastDataDownlinkRate/LastDataUplinkRate) - final int? - uplinkRate; // kbps (from TR-181 LastDataDownlinkRate/LastDataUplinkRate) - final String? - band; // "2.4GHz" / "5GHz" / "6GHz" (from ClientConnectionDetail) - final String? ssidName; // SSID name (from ClientConnectionDetail) - - // ─── IPv6 addresses (from ConnectedDeviceIpv6 children) ─── - final List ipv6Addresses; - - // ─── Layer1 interface path (for port correlation) ─── - final String layer1Interface; // Raw TR-181 Layer1Interface path - - // ─── Mesh enrichment ─── - final String? parentNodeId; // Connected mesh node device ID - final String? parentNodeName; // Mesh node model name (display) - - // ─── Device classification (from Hosts) ─── - final String? deviceRole; // "master" / "slave" / "client" - final String? interfaceType; // "WiFi" / "Ethernet" / etc. - final String? friendlyName; // User-friendly device name - final String? manufacturer; // Device manufacturer - final String? modelName; // Device model name - final String? operatingSystem; // Device OS - final String? - hostsDeviceId; // Hosts DeviceID (UUID, last 12 chars = MAC for DataElements match) - - // ─── Multi-interface grouping (hostname-based) ─── - /// Additional interfaces for this device (when connected via multiple interfaces). - /// Primary interface data is stored in this model's fields; this list contains - /// secondary interfaces (e.g., if primary is WiFi, this may contain Ethernet). - final List additionalInterfaces; - - const DeviceUIModel({ - required this.mac, - required this.ip, - required this.hostName, - required this.isActive, - required this.isWifi, - this.layer1Interface = '', - this.signalStrength, - this.downlinkRate, - this.uplinkRate, - this.band, - this.ssidName, - this.ipv6Addresses = const [], - this.parentNodeId, - this.parentNodeName, - this.deviceRole, - this.interfaceType, - this.friendlyName, - this.manufacturer, - this.modelName, - this.operatingSystem, - this.hostsDeviceId, - this.additionalInterfaces = const [], - }); - - // ─── Computed getters ─── - - /// Display name: friendlyName > hostName > MAC. - String get displayName { - if (friendlyName != null && friendlyName!.isNotEmpty) return friendlyName!; - if (hostName.isNotEmpty) return hostName; - return mac; - } - - /// Signal quality: 0.0–1.0, mapped from RSSI. - /// -30 dBm (excellent) → 1.0, -90 dBm (poor) → 0.0 - double get signalQuality { - if (signalStrength == null) return 0; - return ((signalStrength! + 90) / 60).clamp(0.0, 1.0); - } - - /// Signal level: 0 (no signal) to 3 (excellent). - /// Uses thresholds from [getWifiSignalLevel] for consistency. - int get signalLevel { - if (signalStrength == null) return 0; - return switch (getWifiSignalLevel(signalStrength)) { - NodeSignalLevel.excellent => 3, - NodeSignalLevel.good => 2, - NodeSignalLevel.fair => 1, - NodeSignalLevel.poor || NodeSignalLevel.none => 0, - NodeSignalLevel.wired => 0, - }; - } - - /// Total throughput in bits/sec. - int get totalThroughput => (downlinkRate ?? 0) + (uplinkRate ?? 0); - - // ─── Display computed getters ─── - - /// Connection type enum (UI layer uses this for i18n lookup). - DeviceConnectionType get connectionType => - isWifi ? DeviceConnectionType.wifi : DeviceConnectionType.wired; - - /// Whether to display signal strength indicator. - /// True only for active WiFi devices with RSSI data. - bool get hasSignalDisplay => isActive && isWifi && signalStrength != null; - - /// Whether WiFi details card should be visible. - /// True only for active WiFi devices. - bool get shouldShowWifiDetails => isWifi && isActive; - - /// Whether any WiFi detail data is available (signal, speed, or band/SSID). - bool get hasWifiData => - signalStrength != null || - downlinkRate != null || - uplinkRate != null || - band != null || - ssidName != null; - - /// Whether the device tile should be interactive (tappable). - /// Offline devices are not interactive. - bool get isInteractive => isActive; - - /// Display opacity: 1.0 for active, 0.5 for offline devices. - double get displayOpacity => isActive ? 1.0 : 0.5; - - /// Whether this is a client device (not a mesh node master/slave). - bool get isClientDevice => deviceRole != 'master' && deviceRole != 'slave'; - - /// Whether this device is a mesh node (master or slave router). - bool get isMeshNode => deviceRole == 'master' || deviceRole == 'slave'; - - /// Whether this device is the master (gateway) mesh node. - bool get isMasterNode => deviceRole == 'master'; - - /// Whether this device is a slave (extender) mesh node. - bool get isSlaveNode => deviceRole == 'slave'; - - // ─── Multi-interface getters ─── - - /// Whether this device has multiple network interfaces. - bool get hasMultipleInterfaces => additionalInterfaces.isNotEmpty; - - /// All MAC addresses for this device (primary + additional interfaces). - List get allMacAddresses => - [mac, ...additionalInterfaces.map((i) => i.mac)]; - - /// Total number of interfaces for this device. - int get interfaceCount => 1 + additionalInterfaces.length; - - /// Whether any interface is active (primary or additional). - bool get hasAnyActiveInterface => - isActive || additionalInterfaces.any((i) => i.isActive); - - /// Signal strength display text (technical value, no i18n needed). - /// Returns null if no signal data available. - String? get signalDisplayText => - signalStrength != null ? '$signalStrength dBm' : null; - - /// Creates a copy with optional field overrides. - DeviceUIModel copyWith({ - String? mac, - String? ip, - String? hostName, - bool? isActive, - bool? isWifi, - String? layer1Interface, - int? signalStrength, - int? downlinkRate, - int? uplinkRate, - String? band, - String? ssidName, - List? ipv6Addresses, - String? parentNodeId, - String? parentNodeName, - String? deviceRole, - String? interfaceType, - String? friendlyName, - String? manufacturer, - String? modelName, - String? operatingSystem, - String? hostsDeviceId, - List? additionalInterfaces, - }) { - return DeviceUIModel( - mac: mac ?? this.mac, - ip: ip ?? this.ip, - hostName: hostName ?? this.hostName, - isActive: isActive ?? this.isActive, - isWifi: isWifi ?? this.isWifi, - layer1Interface: layer1Interface ?? this.layer1Interface, - signalStrength: signalStrength ?? this.signalStrength, - downlinkRate: downlinkRate ?? this.downlinkRate, - uplinkRate: uplinkRate ?? this.uplinkRate, - band: band ?? this.band, - ssidName: ssidName ?? this.ssidName, - ipv6Addresses: ipv6Addresses ?? this.ipv6Addresses, - parentNodeId: parentNodeId ?? this.parentNodeId, - parentNodeName: parentNodeName ?? this.parentNodeName, - deviceRole: deviceRole ?? this.deviceRole, - interfaceType: interfaceType ?? this.interfaceType, - friendlyName: friendlyName ?? this.friendlyName, - manufacturer: manufacturer ?? this.manufacturer, - modelName: modelName ?? this.modelName, - operatingSystem: operatingSystem ?? this.operatingSystem, - hostsDeviceId: hostsDeviceId ?? this.hostsDeviceId, - additionalInterfaces: additionalInterfaces ?? this.additionalInterfaces, - ); - } - - @override - List get props => [ - mac, - ip, - hostName, - isActive, - isWifi, - layer1Interface, - ipv6Addresses, - signalStrength, - downlinkRate, - uplinkRate, - band, - ssidName, - parentNodeId, - parentNodeName, - deviceRole, - interfaceType, - friendlyName, - manufacturer, - modelName, - operatingSystem, - hostsDeviceId, - additionalInterfaces, - ]; -} - -/// Extension methods for List to simplify common filtering. -extension DeviceUIModelListExt on List { - /// Returns only client devices (excludes mesh nodes). - List get clientDevices => - where((d) => d.isClientDevice).toList(); - - /// Returns only mesh nodes (master and slave routers). - List get meshNodes => where((d) => d.isMeshNode).toList(); - - /// Returns the master (gateway) node, or null if not found. - DeviceUIModel? get masterNode => where((d) => d.isMasterNode).firstOrNull; - - /// Returns all slave (extender) nodes. - List get slaveNodes => where((d) => d.isSlaveNode).toList(); -} diff --git a/lib/page/_shared/models/mesh_network.dart b/lib/page/_shared/models/mesh_network.dart new file mode 100644 index 000000000..92b98daae --- /dev/null +++ b/lib/page/_shared/models/mesh_network.dart @@ -0,0 +1,132 @@ +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; + +/// Top-level container for mesh network topology. +/// +/// Single Source of Truth (SSoT) for all network entities. +/// Contains one master node, zero or more slave nodes, and all client devices +/// organized by their parent node. +class MeshNetwork with EquatableMixin { + /// The master (gateway) node. + final MasterNode master; + + /// Slave (extender) nodes. + final List slaves; + + /// Clients not yet assigned to a node (mesh data timing issue). + /// + /// When Hosts data arrives before DataElements, clients may not have + /// parentNodeId. These are stored here until mesh topology is available. + final List unassignedClients; + + MeshNetwork({ + required this.master, + this.slaves = const [], + this.unassignedClients = const [], + }); + + // ─── Accessors ─── + + /// All mesh nodes (master + slaves). + List get allNodes => [master, ...slaves]; + + /// All client devices across all nodes + unassigned. + List get allClients => [ + ...master.connectedClients, + ...slaves.expand((s) => s.connectedClients), + ...unassignedClients, + ]; + + /// Total number of client devices. + int get totalClientCount => allClients.length; + + /// Number of online client devices. + int get onlineClientCount => allClients.where((c) => c.isOnline).length; + + /// Number of offline client devices. + int get offlineClientCount => allClients.where((c) => !c.isOnline).length; + + /// Whether this is a mesh network (has slave nodes). + bool get hasMesh => slaves.isNotEmpty; + + /// Total number of nodes. + int get nodeCount => 1 + slaves.length; + + // ─── Lookups ─── + + /// Find a node by device ID (supports both deviceId and dataElementsId). + NodeEntity? findNode(String id) { + final normalized = id.toUpperCase(); + for (final node in allNodes) { + if (node.deviceId.toUpperCase() == normalized) return node; + if (node.dataElementsId?.toUpperCase() == normalized) return node; + } + return null; + } + + /// Find a client device by MAC address. + ClientDevice? findClient(String mac) { + final normalized = mac.toUpperCase(); + for (final client in allClients) { + if (client.mac.toUpperCase() == normalized) return client; + // Also check additional interfaces + for (final iface in client.additionalInterfaces) { + if (iface.mac.toUpperCase() == normalized) return client; + } + } + return null; + } + + /// Find the parent node for a client device. + NodeEntity? findParentNode(ClientDevice client) { + if (client.parentNodeId == null) return master; + return findNode(client.parentNodeId!); + } + + /// Get all clients connected to a specific node. + List clientsForNode(String nodeId) { + final node = findNode(nodeId); + return node?.connectedClients ?? []; + } + + // ─── Statistics ─── + + /// WiFi client count (online only). + int get wifiClientCount => + allClients.where((c) => c.isOnline && c.isWifi).length; + + /// Wired client count (online only). + int get wiredClientCount => + allClients.where((c) => c.isOnline && !c.isWifi).length; + + /// Clients grouped by parent node ID. + Map> get clientsByNode { + final result = >{}; + result[master.deviceId] = master.connectedClients; + for (final slave in slaves) { + result[slave.deviceId] = slave.connectedClients; + } + if (unassignedClients.isNotEmpty) { + result['_unassigned'] = unassignedClients; + } + return result; + } + + // ─── Copy ─── + + MeshNetwork copyWith({ + MasterNode? master, + List? slaves, + List? unassignedClients, + }) { + return MeshNetwork( + master: master ?? this.master, + slaves: slaves ?? this.slaves, + unassignedClients: unassignedClients ?? this.unassignedClients, + ); + } + + @override + List get props => [master, slaves, unassignedClients]; +} diff --git a/lib/page/_shared/models/mesh_topology_info.dart b/lib/page/_shared/models/mesh_topology_info.dart index f1f3d36d7..216c70097 100644 --- a/lib/page/_shared/models/mesh_topology_info.dart +++ b/lib/page/_shared/models/mesh_topology_info.dart @@ -1,13 +1,16 @@ import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; /// Result of mesh topology fetch from DataElements. /// /// Contains mesh nodes and client-to-node mapping for determining /// which mesh node each client device is connected to. +/// +/// NOTE: The [nodes] list contains NodeEntity instances with empty +/// [connectedClients] — client assignment happens in [MeshNetworkBuilder]. class MeshTopologyInfo extends Equatable { /// Mesh nodes discovered via DataElements. - final List nodes; + final List nodes; /// Client MAC (uppercase) → node device ID mapping. final Map clientToNodeMap; @@ -19,10 +22,18 @@ class MeshTopologyInfo extends Equatable { /// signal data (WifiClients only covers master node clients). final Map clientSignalMap; + /// Client MAC (uppercase) → (band, ssid) from DataElements BSS. + /// + /// Populated from DataElements BSS for clients on ALL nodes, + /// including child nodes. Used as fallback when connectionDetailMap + /// doesn't have band/SSID data (connectionDetailMap only covers master clients). + final Map clientBandSsidMap; + const MeshTopologyInfo({ required this.nodes, required this.clientToNodeMap, this.clientSignalMap = const {}, + this.clientBandSsidMap = const {}, }); /// Empty result — used as fallback when DataElements is not supported. @@ -30,11 +41,13 @@ class MeshTopologyInfo extends Equatable { nodes: [], clientToNodeMap: {}, clientSignalMap: {}, + clientBandSsidMap: {}, ); bool get isEmpty => nodes.isEmpty; bool get isNotEmpty => nodes.isNotEmpty; @override - List get props => [nodes, clientToNodeMap, clientSignalMap]; + List get props => + [nodes, clientToNodeMap, clientSignalMap, clientBandSsidMap]; } diff --git a/lib/page/_shared/models/network_entity.dart b/lib/page/_shared/models/network_entity.dart new file mode 100644 index 000000000..6aeb11a29 --- /dev/null +++ b/lib/page/_shared/models/network_entity.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +/// Abstract interface for all network entities (nodes and clients). +/// +/// Provides a common interface for identity and display across the +/// MeshNetwork architecture. Implementers: [NodeEntity], [ClientDevice]. +abstract class NetworkEntity with EquatableMixin { + /// Unique identifier (MAC address, normalized uppercase). + String get id; + + /// Display name for UI (computed from available name fields). + String get displayName; + + /// Whether the entity is currently online/active. + bool get isOnline; + + /// Primary IPv4 address, or null if unavailable. + String? get ipAddress; + + /// All IPv6 addresses for this entity. + List get ipv6Addresses; +} diff --git a/lib/page/_shared/models/node_entity.dart b/lib/page/_shared/models/node_entity.dart new file mode 100644 index 000000000..cc75e4379 --- /dev/null +++ b/lib/page/_shared/models/node_entity.dart @@ -0,0 +1,321 @@ +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/network_entity.dart'; + +/// Sealed class for mesh network nodes (master or slave). +/// +/// Use pattern matching to distinguish between [MasterNode] and [SlaveNode]: +/// ```dart +/// switch (node) { +/// case MasterNode m: print('Gateway: ${m.wanIpAddress}'); +/// case SlaveNode s: print('Extender via ${s.backhaul.linkType}'); +/// } +/// ``` +sealed class NodeEntity extends NetworkEntity { + // ─── Identity ─── + /// Device ID (MAC address, uppercase, normalized). + String get deviceId; + + /// DataElements ID (may differ from deviceId for matching). + String? get dataElementsId; + + /// User-friendly name. + String? get friendlyName; + + /// Hostname from Hosts table. + String? get hostName; + + // ─── Device Info ─── + /// Model name (e.g., "MR7500"). + String get model; + + /// Manufacturer name. + String get manufacturer; + + /// Serial number. + String get serialNumber; + + /// Firmware version. + String get softwareVersion; + + // ─── Network ─── + /// LAN IP address. + @override + String? get ipAddress; + + /// LAN IPv6 addresses. + @override + List get ipv6Addresses; + + // ─── DataElements internal ─── + /// DataElements instance path. + String? get instancePath; + + // ─── Children ─── + /// Client devices connected to this node. + List get connectedClients; + + // ─── NetworkEntity implementation ─── + @override + String get id => deviceId; + + @override + String get displayName { + if (friendlyName != null && friendlyName!.isNotEmpty) return friendlyName!; + if (hostName != null && hostName!.isNotEmpty) return hostName!; + if (model.isNotEmpty) return model; + return deviceId; + } + + @override + bool get isOnline => true; // Nodes are always online if visible + + // ─── Computed ─── + /// Whether this is the master (gateway) node. + bool get isMaster; + + /// Role label for UI display. + String get roleLabel => isMaster ? 'Master' : 'Slave'; + + /// Number of connected clients. + int get connectedDeviceCount => connectedClients.length; +} + +/// Master (gateway) mesh node. +/// +/// The primary router that connects to the internet via WAN. +final class MasterNode extends NodeEntity { + @override + final String deviceId; + @override + final String? dataElementsId; + @override + final String? friendlyName; + @override + final String? hostName; + @override + final String model; + @override + final String manufacturer; + @override + final String serialNumber; + @override + final String softwareVersion; + @override + final String? ipAddress; + @override + final List ipv6Addresses; + @override + final String? instancePath; + @override + final List connectedClients; + + /// WAN IPv4 address. + final String? wanIpAddress; + + /// WAN IPv6 address. + final String? wanIpv6Address; + + /// Hosts DeviceID (UUID) — used by Remote Assistance / Guardian API calls. + final String? hostsDeviceId; + + MasterNode({ + required this.deviceId, + this.dataElementsId, + this.friendlyName, + this.hostName, + required this.model, + this.manufacturer = '', + this.serialNumber = '', + this.softwareVersion = '', + this.ipAddress, + this.ipv6Addresses = const [], + this.instancePath, + this.connectedClients = const [], + this.wanIpAddress, + this.wanIpv6Address, + this.hostsDeviceId, + }); + + @override + bool get isMaster => true; + + MasterNode copyWith({ + String? deviceId, + String? dataElementsId, + String? friendlyName, + String? hostName, + String? model, + String? manufacturer, + String? serialNumber, + String? softwareVersion, + String? ipAddress, + List? ipv6Addresses, + String? instancePath, + List? connectedClients, + String? wanIpAddress, + String? wanIpv6Address, + String? hostsDeviceId, + }) { + return MasterNode( + deviceId: deviceId ?? this.deviceId, + dataElementsId: dataElementsId ?? this.dataElementsId, + friendlyName: friendlyName ?? this.friendlyName, + hostName: hostName ?? this.hostName, + model: model ?? this.model, + manufacturer: manufacturer ?? this.manufacturer, + serialNumber: serialNumber ?? this.serialNumber, + softwareVersion: softwareVersion ?? this.softwareVersion, + ipAddress: ipAddress ?? this.ipAddress, + ipv6Addresses: ipv6Addresses ?? this.ipv6Addresses, + instancePath: instancePath ?? this.instancePath, + connectedClients: connectedClients ?? this.connectedClients, + wanIpAddress: wanIpAddress ?? this.wanIpAddress, + wanIpv6Address: wanIpv6Address ?? this.wanIpv6Address, + hostsDeviceId: hostsDeviceId ?? this.hostsDeviceId, + ); + } + + @override + List get props => [ + deviceId, + dataElementsId, + friendlyName, + hostName, + model, + manufacturer, + serialNumber, + softwareVersion, + ipAddress, + ipv6Addresses, + instancePath, + connectedClients, + wanIpAddress, + wanIpv6Address, + hostsDeviceId, + ]; +} + +/// Slave (extender) mesh node. +/// +/// Extends the network via WiFi or Ethernet backhaul to the parent node. +final class SlaveNode extends NodeEntity { + @override + final String deviceId; + @override + final String? dataElementsId; + @override + final String? friendlyName; + @override + final String? hostName; + @override + final String model; + @override + final String manufacturer; + @override + final String serialNumber; + @override + final String softwareVersion; + @override + final String? ipAddress; + @override + final List ipv6Addresses; + @override + final String? instancePath; + @override + final List connectedClients; + + /// Backhaul connection info to parent node. + final BackhaulInfo backhaul; + + SlaveNode({ + required this.deviceId, + this.dataElementsId, + this.friendlyName, + this.hostName, + required this.model, + this.manufacturer = '', + this.serialNumber = '', + this.softwareVersion = '', + this.ipAddress, + this.ipv6Addresses = const [], + this.instancePath, + this.connectedClients = const [], + required this.backhaul, + }); + + @override + bool get isMaster => false; + + /// Whether backhaul is Ethernet. + bool get isEthernetBackhaul => backhaul.isEthernet; + + /// Whether backhaul info is available. + bool get hasBackhaul => backhaul.hasInfo; + + SlaveNode copyWith({ + String? deviceId, + String? dataElementsId, + String? friendlyName, + String? hostName, + String? model, + String? manufacturer, + String? serialNumber, + String? softwareVersion, + String? ipAddress, + List? ipv6Addresses, + String? instancePath, + List? connectedClients, + BackhaulInfo? backhaul, + }) { + return SlaveNode( + deviceId: deviceId ?? this.deviceId, + dataElementsId: dataElementsId ?? this.dataElementsId, + friendlyName: friendlyName ?? this.friendlyName, + hostName: hostName ?? this.hostName, + model: model ?? this.model, + manufacturer: manufacturer ?? this.manufacturer, + serialNumber: serialNumber ?? this.serialNumber, + softwareVersion: softwareVersion ?? this.softwareVersion, + ipAddress: ipAddress ?? this.ipAddress, + ipv6Addresses: ipv6Addresses ?? this.ipv6Addresses, + instancePath: instancePath ?? this.instancePath, + connectedClients: connectedClients ?? this.connectedClients, + backhaul: backhaul ?? this.backhaul, + ); + } + + @override + List get props => [ + deviceId, + dataElementsId, + friendlyName, + hostName, + model, + manufacturer, + serialNumber, + softwareVersion, + ipAddress, + ipv6Addresses, + instancePath, + connectedClients, + backhaul, + ]; +} + +/// Extension methods for List. +extension NodeEntityListExt on List { + /// Returns the master (gateway) node, or null if not found. + MasterNode? get master { + for (final n in this) { + if (n is MasterNode) return n; + } + return null; + } + + /// Returns all slave (extender) nodes. + List get slaves => whereType().toList(); + + /// Whether this topology has mesh extenders. + bool get hasMesh => any((n) => n is SlaveNode); +} diff --git a/lib/page/_shared/models/pdf_report_data.dart b/lib/page/_shared/models/pdf_report_data.dart index 66ace0974..67559ecb9 100644 --- a/lib/page/_shared/models/pdf_report_data.dart +++ b/lib/page/_shared/models/pdf_report_data.dart @@ -2,9 +2,9 @@ import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; import 'package:privacy_gui/page/_shared/models/ethernet_port_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/system_monitor_state.dart'; import 'package:privacy_gui/page/_shared/models/traffic_analysis_state.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; @@ -78,11 +78,11 @@ class PdfReportData { /// WiFi radio models (from wifiDataProvider). final List? radioModels; - /// Device UI models (from devicesDataProvider). - final List? deviceModels; + /// Client devices (from devicesDataProvider.meshNetwork.allClients). + final List? clientDevices; - /// Node UI models (from devicesDataProvider). - final List? nodeModels; + /// Mesh nodes (from devicesDataProvider.meshNetwork.allNodes). + final List? nodes; const PdfReportData({ this.ethernetPortModels, @@ -103,7 +103,7 @@ class PdfReportData { this.wanStatus, this.systemInfo, this.radioModels, - this.deviceModels, - this.nodeModels, + this.clientDevices, + this.nodes, }); } diff --git a/lib/page/_shared/models/wifi_connection_info.dart b/lib/page/_shared/models/wifi_connection_info.dart new file mode 100644 index 000000000..33cbe7694 --- /dev/null +++ b/lib/page/_shared/models/wifi_connection_info.dart @@ -0,0 +1,73 @@ +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/utils/wifi.dart'; + +/// WiFi connection details for a client device. +/// +/// Encapsulates signal strength, band, SSID, and throughput metrics. +/// Null fields indicate data not available (e.g., wired device or no enrichment). +class WifiConnectionInfo with EquatableMixin { + /// Signal strength in dBm (RSSI). Typically -30 to -90. + final int? signalStrength; + + /// WiFi band: "2.4GHz", "5GHz", or "6GHz". + final String? band; + + /// SSID name the client is connected to. + final String? ssidName; + + /// Downlink data rate in kbps. + final int? downlinkRate; + + /// Uplink data rate in kbps. + final int? uplinkRate; + + const WifiConnectionInfo({ + this.signalStrength, + this.band, + this.ssidName, + this.downlinkRate, + this.uplinkRate, + }); + + /// Signal quality as a normalized value (0.0–1.0). + /// + /// -30 dBm (excellent) → 1.0, -90 dBm (poor) → 0.0. + double get signalQuality { + if (signalStrength == null) return 0; + return ((signalStrength! + 90) / 60).clamp(0.0, 1.0); + } + + /// Signal level (0–3) using standard RSSI thresholds. + /// + /// 3 = Excellent (>= -65), 2 = Good, 1 = Fair, 0 = Poor. + int get signalLevel { + if (signalStrength == null) return 0; + return switch (getWifiSignalLevel(signalStrength)) { + NodeSignalLevel.excellent => 3, + NodeSignalLevel.good => 2, + NodeSignalLevel.fair => 1, + NodeSignalLevel.poor || NodeSignalLevel.none => 0, + NodeSignalLevel.wired => 0, + }; + } + + /// Total throughput in kbps (downlink + uplink). + int get totalThroughput => (downlinkRate ?? 0) + (uplinkRate ?? 0); + + /// Whether any WiFi data is available. + bool get hasData => + signalStrength != null || + band != null || + ssidName != null || + downlinkRate != null || + uplinkRate != null; + + @override + List get props => [ + signalStrength, + band, + ssidName, + downlinkRate, + uplinkRate, + ]; +} diff --git a/lib/page/_shared/providers/usp_device_analytics_notifier.dart b/lib/page/_shared/providers/usp_device_analytics_notifier.dart index 20f44aea4..033a75476 100644 --- a/lib/page/_shared/providers/usp_device_analytics_notifier.dart +++ b/lib/page/_shared/providers/usp_device_analytics_notifier.dart @@ -1,7 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/providers/device_analytics_persistence.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; @@ -33,6 +33,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { ref.listen(devicesDataProvider, (previous, next) { final data = next.valueOrNull; if (data == null) return; + // Use clientDevices to exclude mesh nodes (master/slave) _onDashboardUpdated(data.clientDevices); }); @@ -112,13 +113,18 @@ class UspDeviceAnalyticsNotifier extends Notifier { } /// Returns the set of router MACs (master + slave nodes). + /// Used to clean legacy persisted data that may contain mesh node MACs. Set _getRouterMacs() { - final allDeviceModels = - ref.read(devicesDataProvider).valueOrNull?.deviceModels ?? []; - return allDeviceModels.where((d) => d.isMeshNode).map((d) => d.mac).toSet(); + final data = ref.read(devicesDataProvider).valueOrNull; + if (data == null) return {}; + final nodeMacs = {data.master.deviceId}; + for (final slave in data.slaves) { + nodeMacs.add(slave.deviceId); + } + return nodeMacs; } - void _onDashboardUpdated(List devices) { + void _onDashboardUpdated(List devices) { // 1. Compute current distribution final distribution = _computeDistribution(devices); @@ -159,13 +165,10 @@ class UspDeviceAnalyticsNotifier extends Notifier { final cutoff = now.subtract(Duration(hours: DeviceAnalyticsState.maxHours)); history = history.where((h) => h.hour.isAfter(cutoff)).toList(); - // Get router MACs to filter out from history (mesh nodes should not appear) - final routerMacs = _getRouterMacs(); - - // Rebuild allKnownMacs from history, excluding router MACs + // Rebuild allKnownMacs from history (clientDevices already excludes mesh nodes) final allMacs = {}; for (final h in history) { - allMacs.addAll(h.activeMacs.where((mac) => !routerMacs.contains(mac))); + allMacs.addAll(h.activeMacs); } state = state.copyWith( @@ -179,7 +182,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { _persistState(); } - DeviceDistribution _computeDistribution(List devices) { + DeviceDistribution _computeDistribution(List devices) { final online = devices.where((d) => d.isActive).toList(); final offline = devices.where((d) => !d.isActive).toList(); @@ -187,14 +190,14 @@ class UspDeviceAnalyticsNotifier extends Notifier { final wifiDevices = online.where((d) => d.isWifi).toList(); final wiredDevices = online.where((d) => !d.isWifi).toList(); - // Band distribution (online WiFi only + wired category) - final bandDist = {}; - for (final d in wifiDevices) { - final band = d.band ?? 'Unknown'; - bandDist[band] = (bandDist[band] ?? 0) + 1; - } - if (wiredDevices.isNotEmpty) { - bandDist['Wired'] = wiredDevices.length; + // Category distribution (online only) — see _getDeviceCategory: + // - WiFi with a band: show band (2.4GHz, 5GHz, 6GHz) + // - Wired: show "Wired" + // - WiFi without a band (e.g. slave clients pending #1118): show node name + final categoryDist = {}; + for (final d in online) { + final category = _getDeviceCategory(d); + categoryDist[category] = (categoryDist[category] ?? 0) + 1; } // Signal level distribution (online WiFi only) @@ -204,17 +207,20 @@ class UspDeviceAnalyticsNotifier extends Notifier { signalDist[level] = (signalDist[level] ?? 0) + 1; } - // Average signal quality per band (for radar chart) - final bandQualitySum = {}; - final bandQualityCount = {}; + // Average signal quality per category (WiFi devices only) + final categoryQualitySum = {}; + final categoryQualityCount = {}; for (final d in wifiDevices) { - final band = d.band ?? 'Unknown'; - bandQualitySum[band] = (bandQualitySum[band] ?? 0) + d.signalQuality; - bandQualityCount[band] = (bandQualityCount[band] ?? 0) + 1; + final category = _getDeviceCategory(d); + categoryQualitySum[category] = + (categoryQualitySum[category] ?? 0) + d.signalQuality; + categoryQualityCount[category] = + (categoryQualityCount[category] ?? 0) + 1; } final bandSignalQuality = {}; - for (final band in bandQualitySum.keys) { - bandSignalQuality[band] = bandQualitySum[band]! / bandQualityCount[band]!; + for (final cat in categoryQualitySum.keys) { + bandSignalQuality[cat] = + categoryQualitySum[cat]! / categoryQualityCount[cat]!; } return DeviceDistribution( @@ -222,12 +228,38 @@ class UspDeviceAnalyticsNotifier extends Notifier { wiredCount: wiredDevices.length, onlineCount: online.length, offlineCount: offline.length, - bandDistribution: bandDist, + bandDistribution: categoryDist, signalLevelDistribution: signalDist, bandSignalQuality: bandSignalQuality, ); } + /// Determines the display category for a device. + /// + /// - WiFi client with a resolved band: the band (2.4GHz / 5GHz / 6GHz) + /// - Wired client: "Wired" + /// - WiFi client without a band: the connected node name, else "WiFi" + /// + /// Band takes priority over the node grouping, because a master WiFi client + /// keeps a valid band in a mesh too (it comes from the local + /// `WiFi.AccessPoint` chain, not DataElements). Keying off `parentNodeId` + /// alone was wrong: `MeshTopologyBuilder` maps EVERY node's associated STAs — + /// including the master's own clients — into `clientToNodeMap`, so master + /// clients also get a non-null `parentNodeId` on a mesh, which previously + /// collapsed their band under the gateway name. + /// + /// Slave WiFi clients currently have no band (DataElements band resolution is + /// pending — see #1118), so they fall through to the node-name grouping. + String _getDeviceCategory(ClientDevice d) { + if (d.isWifi) { + final band = d.band; + if (band != null && band.isNotEmpty) return band; + // WiFi client without a resolved band: group under its node. + return d.parentNodeName ?? 'WiFi'; + } + return 'Wired'; + } + Future _persistState() async { // Don't persist before history is loaded — _serialNumber isn't set yet, // and we'd write to the legacy key instead of the scoped key. diff --git a/lib/page/_shared/services/usp_pdf_service.dart b/lib/page/_shared/services/usp_pdf_service.dart index d65a3f4f1..838f6cde2 100644 --- a/lib/page/_shared/services/usp_pdf_service.dart +++ b/lib/page/_shared/services/usp_pdf_service.dart @@ -352,11 +352,9 @@ class UspPdfService { // =========================================================================== static List _buildDevices(PdfReportData data) { - final allDevices = data.deviceModels ?? []; - // Exclude mesh nodes (routers) — only show client devices in report - final devices = allDevices.where((d) => d.isClientDevice).toList(); - final online = devices.where((d) => d.isActive).toList(); - final offline = devices.where((d) => !d.isActive).toList(); + final devices = data.clientDevices ?? []; + final online = devices.where((d) => d.isOnline).toList(); + final offline = devices.where((d) => !d.isOnline).toList(); final widgets = [ _sectionTitle('Connected Devices (${online.length} online / ' @@ -847,7 +845,7 @@ class UspPdfService { } static List _buildMeshTopology(PdfReportData data) { - final nodes = data.nodeModels ?? []; + final nodes = data.nodes ?? []; if (nodes.isEmpty) return []; return [ _sectionTitle('Mesh Topology (${nodes.length} nodes)'), @@ -861,10 +859,10 @@ class UspPdfService { data: nodes .map((n) => [ n.displayName, - n.roleLabel, + n.isMaster ? 'Master' : 'Extender', n.model, n.softwareVersion, - '${n.connectedDeviceCount}', + '${n.connectedClients.length}', ]) .toList(), ), diff --git a/lib/page/_shared/utils/mesh_network_builder.dart b/lib/page/_shared/utils/mesh_network_builder.dart new file mode 100644 index 000000000..56e13afbf --- /dev/null +++ b/lib/page/_shared/utils/mesh_network_builder.dart @@ -0,0 +1,488 @@ +import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/generated/connected_devices.g.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; + +/// Builds [MeshNetwork] from raw data sources. +/// +/// Transforms Hosts (ConnectedDevices), WiFi enrichment, and DataElements +/// into the unified MeshNetwork architecture. +class MeshNetworkBuilder { + MeshNetworkBuilder._(); + + /// Builds a [MeshNetwork] from the various data sources. + /// + /// Data sources: + /// - [connectedDevices]: Hosts.Host table (all devices + mesh nodes) + /// - [wifiClientMap]: WiFi STA enrichment (signal, rate for master clients) + /// - [connectionDetailMap]: band/SSID info + /// - [meshTopology]: DataElements (clientToNodeMap, clientSignalMap, nodes) + /// - [gatewayName]: Fallback name for master node + /// - [systemInfo]: Gateway device info (model, firmware, etc.) + static MeshNetwork build({ + required ConnectedDevices connectedDevices, + required Map wifiClientMap, + required Map connectionDetailMap, + required MeshTopologyInfo meshTopology, + required String gatewayName, + SystemInfoUIModel? systemInfo, + }) { + // 1. Separate mesh nodes and client devices + final meshDevices = []; + final clientHostDevices = []; + + for (final d in connectedDevices.items) { + if (d.deviceRole == 'master' || d.deviceRole == 'slave') { + meshDevices.add(d); + } else if (d.interface_.isNotEmpty || d.isActive) { + clientHostDevices.add(d); + } + } + + // 2. Build node display name map (Hosts hostname → DataElements node ID) + final nodeDisplayNameMap = + _buildNodeDisplayNameMap(connectedDevices, meshTopology); + + // 3. Build all ClientDevice models + final allBuiltClients = clientHostDevices + .map((d) => _buildClientDevice( + device: d, + wifiClientMap: wifiClientMap, + connectionDetailMap: connectionDetailMap, + meshTopology: meshTopology, + gatewayName: gatewayName, + nodeDisplayNameMap: nodeDisplayNameMap, + )) + .toList(); + + // 4. Apply hostname grouping (merge multi-interface devices) + final groupedClients = _groupByHostname(allBuiltClients); + + // 5. Group clients by parentNodeId + final clientsByNodeId = >{}; + for (final client in groupedClients) { + final nodeId = client.parentNodeId; + (clientsByNodeId[nodeId] ??= []).add(client); + } + + // 6. Build MasterNode + final masterDevice = + meshDevices.where((d) => d.deviceRole == 'master').firstOrNull; + final masterMeshInfo = meshTopology.nodes.master; + final masterNodeId = masterDevice?.macAddress.trim().toUpperCase() ?? + masterMeshInfo?.deviceId ?? + 'GATEWAY'; + + // Clients for master: those with null parentNodeId or matching master ID + final masterClients = []; + final nullParentClients = clientsByNodeId[null] ?? []; + masterClients.addAll(nullParentClients); + if (clientsByNodeId.containsKey(masterNodeId)) { + masterClients.addAll(clientsByNodeId[masterNodeId]!); + } + // Also match by DataElements master ID + if (masterMeshInfo != null && + masterMeshInfo.deviceId != masterNodeId && + clientsByNodeId.containsKey(masterMeshInfo.deviceId)) { + masterClients.addAll(clientsByNodeId[masterMeshInfo.deviceId]!); + } + + final master = _buildMasterNode( + masterDevice: masterDevice, + masterMeshInfo: masterMeshInfo, + systemInfo: systemInfo, + gatewayName: gatewayName, + connectedClients: masterClients, + ); + + // Compute master displayName for patching clients + final masterDisplayName = master.displayName; + + // Patch master's connected clients with parentNodeName + final patchedMasterClients = masterClients + .map((c) => c.copyWith(parentNodeName: masterDisplayName)) + .toList(); + final patchedMaster = MasterNode( + deviceId: master.deviceId, + dataElementsId: master.dataElementsId, + friendlyName: master.friendlyName, + hostName: master.hostName, + model: master.model, + manufacturer: master.manufacturer, + serialNumber: master.serialNumber, + softwareVersion: master.softwareVersion, + ipAddress: master.ipAddress, + ipv6Addresses: master.ipv6Addresses, + instancePath: master.instancePath, + connectedClients: patchedMasterClients, + hostsDeviceId: master.hostsDeviceId, + ); + + // 7. Build SlaveNodes + final slaves = meshDevices.where((d) => d.deviceRole == 'slave').map((d) { + final slaveMeshInfo = _findMatchingMeshNode(d, meshTopology.nodes.slaves); + final slaveNodeId = d.macAddress.trim().toUpperCase(); + + // Clients for this slave + final slaveClients = []; + if (clientsByNodeId.containsKey(slaveNodeId)) { + slaveClients.addAll(clientsByNodeId[slaveNodeId]!); + } + if (slaveMeshInfo != null && + slaveMeshInfo.deviceId != slaveNodeId && + clientsByNodeId.containsKey(slaveMeshInfo.deviceId)) { + slaveClients.addAll(clientsByNodeId[slaveMeshInfo.deviceId]!); + } + + final slave = _buildSlaveNode( + slaveDevice: d, + slaveMeshInfo: slaveMeshInfo, + connectedClients: slaveClients, + ); + + // Patch slave's connected clients with parentNodeName + final slaveDisplayName = slave.displayName; + final patchedSlaveClients = slaveClients + .map((c) => c.copyWith(parentNodeName: slaveDisplayName)) + .toList(); + return SlaveNode( + deviceId: slave.deviceId, + dataElementsId: slave.dataElementsId, + friendlyName: slave.friendlyName, + hostName: slave.hostName, + model: slave.model, + manufacturer: slave.manufacturer, + serialNumber: slave.serialNumber, + softwareVersion: slave.softwareVersion, + ipAddress: slave.ipAddress, + ipv6Addresses: slave.ipv6Addresses, + instancePath: slave.instancePath, + connectedClients: patchedSlaveClients, + backhaul: slave.backhaul, + ); + }).toList(); + + // 8. Find unassigned clients (parentNodeId doesn't match any known node) + final assignedNodeIds = { + masterNodeId, + if (masterMeshInfo != null) masterMeshInfo.deviceId, + ...slaves.map((s) => s.deviceId), + ...slaves.map((s) => s.dataElementsId).whereType(), + }; + final unassigned = clientsByNodeId.entries + .where((e) => e.key != null && !assignedNodeIds.contains(e.key)) + .expand((e) => e.value) + .toList(); + + return MeshNetwork( + master: patchedMaster, + slaves: slaves, + unassignedClients: unassigned, + ); + } + + // --------------------------------------------------------------------------- + // Private: Node display name map + // --------------------------------------------------------------------------- + + static Map _buildNodeDisplayNameMap( + ConnectedDevices devices, + MeshTopologyInfo meshTopology, + ) { + final map = {}; + for (final d in devices.items) { + // Include both master and slave nodes + if (d.deviceRole != 'master' && d.deviceRole != 'slave') continue; + + final displayName = (d.friendlyName?.isNotEmpty == true) + ? d.friendlyName! + : (d.hostName.isNotEmpty ? d.hostName : null); + if (displayName == null) continue; + + // Match via embedded MAC in Hosts DeviceID + final hostsDeviceId = d.deviceId?.toUpperCase().replaceAll('-', '') ?? ''; + if (hostsDeviceId.length >= 12) { + final embeddedMac = hostsDeviceId.substring(hostsDeviceId.length - 12); + for (final node in meshTopology.nodes) { + final nodeIdNormalized = + node.deviceId.toUpperCase().replaceAll(':', ''); + if (nodeIdNormalized == embeddedMac) { + map[node.deviceId] = displayName; + break; + } + } + } + + // For master, also try matching via MAC address directly + if (d.deviceRole == 'master') { + final masterMac = d.macAddress.trim().toUpperCase(); + if (masterMac.isNotEmpty && !map.containsKey(masterMac)) { + map[masterMac] = displayName; + } + } + } + return map; + } + + // --------------------------------------------------------------------------- + // Private: ClientDevice builder + // --------------------------------------------------------------------------- + + static ClientDevice _buildClientDevice({ + required ConnectedDevice device, + required Map wifiClientMap, + required Map connectionDetailMap, + required MeshTopologyInfo meshTopology, + required String gatewayName, + required Map nodeDisplayNameMap, + }) { + final mac = device.macAddress.trim().toUpperCase(); + + // Determine WiFi via Layer1Interface or InterfaceType + final interfaceType = device.interfaceType?.toLowerCase() ?? ''; + final isWifi = device.interface_.toLowerCase().contains('wifi') || + interfaceType.contains('wi-fi') || + interfaceType.contains('wifi'); + + final wifiClient = wifiClientMap[mac]; + final detail = connectionDetailMap[mac]; + + // Resolve parent node + String? parentNodeId; + String? parentNodeName; + if (meshTopology.isEmpty) { + // Non-mesh: all active devices are on the gateway + if (device.isActive) parentNodeName = gatewayName; + } else { + parentNodeId = meshTopology.clientToNodeMap[mac]; + if (parentNodeId != null) { + // Try display name map first (friendlyName or hostName from Hosts) + parentNodeName = nodeDisplayNameMap[parentNodeId]; + if (parentNodeName == null) { + // Fallback: use model name from DataElements + final matchingNode = meshTopology.nodes + .where((n) => n.deviceId == parentNodeId) + .firstOrNull; + parentNodeName = matchingNode?.model.isNotEmpty == true + ? matchingNode!.model + : gatewayName; + } + } + } + + // Build WiFi info if applicable + WifiConnectionInfo? wifi; + if (isWifi) { + final signalStrength = device.signalStrength ?? + wifiClient?.signalStrength ?? + meshTopology.clientSignalMap[mac]; + // Fallback to DataElements band/SSID for slave node clients. + // ClientConnectionDetail.band/ssidName are non-nullable Strings that are + // '' when AP→SSID→radio resolution fails, so treat empty as absent — + // otherwise the empty string would mask the DataElements fallback value. + final bandSsid = meshTopology.clientBandSsidMap[mac]; + wifi = WifiConnectionInfo( + signalStrength: signalStrength, + band: _nonEmpty(detail?.band) ?? bandSsid?.band, + ssidName: _nonEmpty(detail?.ssidName) ?? bandSsid?.ssid, + downlinkRate: + device.lastDataDownlinkRate ?? wifiClient?.lastDataDownlinkRate, + uplinkRate: device.lastDataUplinkRate ?? wifiClient?.lastDataUplinkRate, + ); + } + + return ClientDevice( + mac: mac, + ip: device.ipAddress, + hostName: device.hostName, + friendlyName: device.friendlyName, + isActive: device.isActive, + ipv6Addresses: device.ipv6Addresses + .map((e) => e.address) + .where((a) => a.isNotEmpty) + .toList(), + layer1Interface: device.interface_, + connectionType: isWifi ? ConnectionType.wifi : ConnectionType.wired, + wifi: wifi, + parentNodeId: parentNodeId, + parentNodeName: parentNodeName, + manufacturer: device.manufacturer, + modelName: device.modelName, + operatingSystem: device.operatingSystem, + hostsDeviceId: device.deviceId, + ); + } + + // --------------------------------------------------------------------------- + // Private: Hostname grouping + // --------------------------------------------------------------------------- + + /// Returns [s] if it is non-null and non-empty, otherwise null. + /// Used so an empty String from a non-nullable source doesn't mask a `??` + /// fallback to another source. + static String? _nonEmpty(String? s) => (s != null && s.isNotEmpty) ? s : null; + + static String _normalizeHostname(String hostname) { + var normalized = hostname.trim().toLowerCase(); + if (normalized.isEmpty) return ''; + final mdnsSuffixIndex = normalized.indexOf('._'); + if (mdnsSuffixIndex > 0) { + normalized = normalized.substring(0, mdnsSuffixIndex); + } + return normalized; + } + + static List _groupByHostname(List clients) { + final grouped = >{}; + final ungrouped = []; + + for (final client in clients) { + final hostname = _normalizeHostname(client.hostName); + if (hostname.isEmpty) { + ungrouped.add(client); + } else { + grouped.putIfAbsent(hostname, () => []).add(client); + } + } + + final result = []; + for (final devices in grouped.values) { + if (devices.length == 1) { + result.add(devices.first); + } else { + result.add(_mergeClientsByHostname(devices)); + } + } + result.addAll(ungrouped); + return result; + } + + static ClientDevice _mergeClientsByHostname(List devices) { + final sorted = List.from(devices) + ..sort((a, b) { + if (a.isActive != b.isActive) return a.isActive ? -1 : 1; + if (a.isWifi != b.isWifi) return a.isWifi ? -1 : 1; + return 0; + }); + + final primary = sorted.first; + final additional = sorted + .skip(1) + .map((d) => ClientInterfaceInfo( + mac: d.mac, + ip: d.ip, + connectionType: d.connectionType, + isActive: d.isActive, + layer1Interface: d.layer1Interface, + wifi: d.wifi, + )) + .toList(); + + logger.d('[MeshNetworkBuilder]: Merged ${devices.length} interfaces for ' + 'hostname="${primary.hostName}" — primary=${primary.mac}'); + + return primary.copyWith(additionalInterfaces: additional); + } + + // --------------------------------------------------------------------------- + // Private: MasterNode builder + // --------------------------------------------------------------------------- + + static MasterNode _buildMasterNode({ + required ConnectedDevice? masterDevice, + required MasterNode? masterMeshInfo, + required SystemInfoUIModel? systemInfo, + required String gatewayName, + required List connectedClients, + }) { + final deviceId = masterDevice?.macAddress.trim().toUpperCase() ?? + masterMeshInfo?.deviceId ?? + 'GATEWAY'; + + return MasterNode( + deviceId: deviceId, + dataElementsId: masterMeshInfo?.deviceId, + friendlyName: masterDevice?.friendlyName, + hostName: masterDevice?.hostName ?? gatewayName, + model: masterMeshInfo?.model ?? systemInfo?.modelName ?? '', + manufacturer: + masterMeshInfo?.manufacturer ?? systemInfo?.manufacturer ?? '', + serialNumber: + masterMeshInfo?.serialNumber ?? systemInfo?.serialNumber ?? '', + softwareVersion: + masterMeshInfo?.softwareVersion ?? systemInfo?.softwareVersion ?? '', + ipAddress: masterDevice?.ipAddress, + ipv6Addresses: masterDevice?.ipv6Addresses + .map((e) => e.address) + .where((a) => a.isNotEmpty) + .toList() ?? + [], + instancePath: masterMeshInfo?.instancePath, + connectedClients: connectedClients, + hostsDeviceId: masterDevice?.deviceId, + ); + } + + // --------------------------------------------------------------------------- + // Private: SlaveNode builder + // --------------------------------------------------------------------------- + + static SlaveNode _buildSlaveNode({ + required ConnectedDevice slaveDevice, + required SlaveNode? slaveMeshInfo, + required List connectedClients, + }) { + final deviceId = slaveDevice.macAddress.trim().toUpperCase(); + + final backhaul = + slaveMeshInfo?.backhaul ?? const BackhaulInfo(mediaType: ''); + + return SlaveNode( + deviceId: deviceId, + dataElementsId: slaveMeshInfo?.deviceId, + friendlyName: slaveDevice.friendlyName, + hostName: slaveDevice.hostName, + model: slaveMeshInfo?.model ?? slaveDevice.modelName ?? '', + manufacturer: + slaveMeshInfo?.manufacturer ?? slaveDevice.manufacturer ?? '', + serialNumber: slaveMeshInfo?.serialNumber ?? '', + softwareVersion: slaveMeshInfo?.softwareVersion ?? '', + ipAddress: + slaveDevice.ipAddress.isNotEmpty ? slaveDevice.ipAddress : null, + ipv6Addresses: slaveDevice.ipv6Addresses + .map((e) => e.address) + .where((a) => a.isNotEmpty) + .toList(), + instancePath: slaveMeshInfo?.instancePath, + connectedClients: connectedClients, + backhaul: backhaul, + ); + } + + // --------------------------------------------------------------------------- + // Private: Mesh node matching + // --------------------------------------------------------------------------- + + static SlaveNode? _findMatchingMeshNode( + ConnectedDevice device, + List meshSlaves, + ) { + final hostsDeviceId = + device.deviceId?.toUpperCase().replaceAll('-', '') ?? ''; + if (hostsDeviceId.length < 12) return null; + + final embeddedMac = hostsDeviceId.substring(hostsDeviceId.length - 12); + + return meshSlaves.where((n) { + final nodeIdNormalized = n.deviceId.toUpperCase().replaceAll(':', ''); + return nodeIdNormalized == embeddedMac; + }).firstOrNull; + } +} diff --git a/lib/page/_shared/utils/mesh_topology_builder.dart b/lib/page/_shared/utils/mesh_topology_builder.dart index 5088bb12c..a17165b04 100644 --- a/lib/page/_shared/utils/mesh_topology_builder.dart +++ b/lib/page/_shared/utils/mesh_topology_builder.dart @@ -1,6 +1,7 @@ import 'package:privacy_gui/generated/data_elements_network.g.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/components/wifi_ui.dart'; /// Builds [MeshTopologyInfo] from DataElements network data. @@ -15,23 +16,34 @@ class MeshTopologyBuilder { /// Extracts mesh nodes and builds client MAC → node ID mapping /// for determining which mesh node each client device is connected to. /// + /// [bssidToBandMap] is optional mapping of BSSID (uppercase) → band string + /// (e.g., "2.4GHz", "5GHz"). When provided, allows extracting band info + /// for clients connected to any node (including slave nodes). + /// /// Set [includeBackhaulStats] to true to include backhaul signal strength /// and uplink rate (requires DataElements backhaul stats to be available). static MeshTopologyInfo build( DataElementsNetwork network, { + Map bssidToBandMap = const {}, bool includeBackhaulStats = true, }) { - final nodes = []; + final nodes = []; final clientToNodeMap = {}; final clientSignalMap = {}; + final clientBandSsidMap = {}; for (final node in network.items) { final rawId = node.id.trim().toUpperCase(); final nodeDeviceId = rawId.isNotEmpty ? rawId : node.instancePath; - // Build client MAC → node ID mapping and signal strength from station list + // Build client MAC → node ID mapping, signal strength, and band/SSID for (final radio in node.radios) { for (final bss in radio.bssList) { + // Resolve band from BSSID → band mapping + final bssidUpper = bss.bssid.trim().toUpperCase(); + final band = bssidToBandMap[bssidUpper] ?? ''; + final ssid = bss.ssid.trim(); + for (final sta in bss.stations) { final mac = sta.macAddress.trim(); if (mac.isNotEmpty && nodeDeviceId.isNotEmpty) { @@ -42,6 +54,10 @@ class MeshTopologyBuilder { if (rssi != null) { clientSignalMap[upperMac] = rssi; } + // Store band + SSID for this client + if (band.isNotEmpty || ssid.isNotEmpty) { + clientBandSsidMap[upperMac] = (band: band, ssid: ssid); + } } } } @@ -49,56 +65,67 @@ class MeshTopologyBuilder { int? backhaulSignalStrength; int? backhaulUplinkRate; + int? backhaulDownlinkRate; if (includeBackhaulStats) { backhaulSignalStrength = rcpiToRssi(node.backhaulStatsSignalStrength); if (node.backhaulStatsLastDataUplinkRate > 0) { backhaulUplinkRate = node.backhaulStatsLastDataUplinkRate; } + if (node.backhaulStatsLastDataDownlinkRate > 0) { + backhaulDownlinkRate = node.backhaulStatsLastDataDownlinkRate; + } } // Master node = no backhaul parent (backhaulAlId is empty) final isMaster = node.backhaulAlId.trim().isEmpty; - // Downlink rate (kbps) - int? backhaulDownlinkRate; - if (includeBackhaulStats && node.backhaulStatsLastDataDownlinkRate > 0) { - backhaulDownlinkRate = node.backhaulStatsLastDataDownlinkRate; + if (isMaster) { + nodes.add(MasterNode( + deviceId: nodeDeviceId, + model: node.manufacturerModel.trim(), + manufacturer: node.manufacturer.trim(), + serialNumber: node.serialNumber.trim(), + softwareVersion: node.softwareVersion.trim(), + instancePath: node.instancePath, + )); + } else { + nodes.add(SlaveNode( + deviceId: nodeDeviceId, + model: node.manufacturerModel.trim(), + manufacturer: node.manufacturer.trim(), + serialNumber: node.serialNumber.trim(), + softwareVersion: node.softwareVersion.trim(), + instancePath: node.instancePath, + backhaul: BackhaulInfo( + mediaType: node.backhaulMediaType.trim(), + linkType: node.backhaulLinkType.trim().isNotEmpty + ? node.backhaulLinkType.trim() + : null, + phyRate: node.backhaulPhyRate, + signalStrength: backhaulSignalStrength, + uplinkRate: backhaulUplinkRate, + downlinkRate: backhaulDownlinkRate, + parentNodeId: node.backhaulBackhaulDeviceId.trim().isNotEmpty + ? node.backhaulBackhaulDeviceId.trim() + : null, + parentBssid: node.backhaulMacAddressMultiAp.trim().isNotEmpty + ? node.backhaulMacAddressMultiAp.trim() + : null, + lastContactTime: node.multiApLastContactTime.trim().isNotEmpty + ? node.multiApLastContactTime.trim() + : null, + backhaulAlId: node.backhaulAlId.trim(), + backhaulMacAddress: node.backhaulMacAddress.trim(), + ), + )); } - - nodes.add(NodeUIModel( - deviceId: nodeDeviceId, - model: node.manufacturerModel.trim(), - manufacturer: node.manufacturer.trim(), - serialNumber: node.serialNumber.trim(), - softwareVersion: node.softwareVersion.trim(), - isMaster: isMaster, - backhaulMediaType: node.backhaulMediaType.trim(), - backhaulPhyRate: node.backhaulPhyRate, - backhaulSignalStrength: backhaulSignalStrength, - backhaulUplinkRate: backhaulUplinkRate, - instancePath: node.instancePath, - backhaulAlId: node.backhaulAlId.trim(), - backhaulMacAddress: node.backhaulMacAddress.trim(), - backhaulLinkType: node.backhaulLinkType.trim().isNotEmpty - ? node.backhaulLinkType.trim() - : null, - backhaulDownlinkRate: backhaulDownlinkRate, - backhaulParentDeviceId: node.backhaulBackhaulDeviceId.trim().isNotEmpty - ? node.backhaulBackhaulDeviceId.trim() - : null, - backhaulParentBssid: node.backhaulMacAddressMultiAp.trim().isNotEmpty - ? node.backhaulMacAddressMultiAp.trim() - : null, - lastContactTime: node.multiApLastContactTime.trim().isNotEmpty - ? node.multiApLastContactTime.trim() - : null, - )); } return MeshTopologyInfo( nodes: nodes, clientToNodeMap: clientToNodeMap, clientSignalMap: clientSignalMap, + clientBandSsidMap: clientBandSsidMap, ); } } diff --git a/lib/page/admin/cards/usp_device_info_card.dart b/lib/page/admin/cards/usp_device_info_card.dart index 372c8787f..8f5176ef7 100644 --- a/lib/page/admin/cards/usp_device_info_card.dart +++ b/lib/page/admin/cards/usp_device_info_card.dart @@ -26,8 +26,7 @@ class UspDeviceInfoCard extends ConsumerWidget { // Get MAC and hostname from master node final devicesData = ref.watch(devicesDataProvider).valueOrNull; - final masterNode = - devicesData?.nodeModels.where((n) => n.isMaster).firstOrNull; + final masterNode = devicesData?.nodes.where((n) => n.isMaster).firstOrNull; final macAddress = masterNode?.deviceId; final hostName = masterNode?.displayName; diff --git a/lib/page/dashboard/mascot/health/dimensions/devices_dimension.dart b/lib/page/dashboard/mascot/health/dimensions/devices_dimension.dart index c77c9ddee..6591725ec 100644 --- a/lib/page/dashboard/mascot/health/dimensions/devices_dimension.dart +++ b/lib/page/dashboard/mascot/health/dimensions/devices_dimension.dart @@ -64,7 +64,7 @@ class DevicesHealthDimension extends HealthDimension { final online = devices.onlineClientCount; final total = devices.totalClientCount; - final meshNodes = devices.nodeModels.where((n) => !n.isMaster).length; + final meshNodes = devices.nodes.where((n) => !n.isMaster).length; String status; if (total == 0) { diff --git a/lib/page/dashboard/mascot/mascot_message_provider.dart b/lib/page/dashboard/mascot/mascot_message_provider.dart index c60f4bda9..197dd8670 100644 --- a/lib/page/dashboard/mascot/mascot_message_provider.dart +++ b/lib/page/dashboard/mascot/mascot_message_provider.dart @@ -99,8 +99,7 @@ class MascotMessageNotifier { final devicesData = _ref.read(devicesDataProvider).valueOrNull; final onlineDeviceCount = devicesData?.onlineClientCount; final totalDeviceCount = devicesData?.totalClientCount; - final meshNodeCount = - devicesData?.nodeModels.where((n) => !n.isMaster).length; + final meshNodeCount = devicesData?.nodes.where((n) => !n.isMaster).length; // WAN final wanData = _ref.read(wanDataProvider).valueOrNull; diff --git a/lib/page/dashboard/providers/pdf_report_data_provider.dart b/lib/page/dashboard/providers/pdf_report_data_provider.dart index 08bec4565..20f9a6317 100644 --- a/lib/page/dashboard/providers/pdf_report_data_provider.dart +++ b/lib/page/dashboard/providers/pdf_report_data_provider.dart @@ -62,7 +62,7 @@ final pdfReportDataProvider = Provider((ref) { wanStatus: ref.read(wanDataProvider).valueOrNull?.model, systemInfo: ref.read(systemInfoDataProvider).valueOrNull?.model, radioModels: ref.read(wifiDataProvider).valueOrNull?.radioModels, - deviceModels: ref.read(devicesDataProvider).valueOrNull?.deviceModels, - nodeModels: ref.read(devicesDataProvider).valueOrNull?.nodeModels, + clientDevices: ref.read(devicesDataProvider).valueOrNull?.clientDevices, + nodes: ref.read(devicesDataProvider).valueOrNull?.nodes, ); }); diff --git a/lib/page/dashboard/views/components/usp_device_analytics_card.dart b/lib/page/dashboard/views/components/usp_device_analytics_card.dart index a56e56856..413475f25 100644 --- a/lib/page/dashboard/views/components/usp_device_analytics_card.dart +++ b/lib/page/dashboard/views/components/usp_device_analytics_card.dart @@ -5,14 +5,15 @@ import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; import 'package:privacy_gui/page/_shared/providers/card_tab_state_provider.dart'; import 'package:privacy_gui/page/_shared/providers/usp_device_analytics_notifier.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; +import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// Device Connection Analytics card — 4 chart views via tab selector. /// -/// - Distribution (Donut): WiFi vs Wired with band breakdown -/// - Trend (Stacked Column): 24h hourly device count +/// - Overview (Donut): Online/Offline with WiFi/Wired breakdown +/// - Signal (Bar): WiFi signal quality distribution (Excellent/Good/Fair/Poor) +/// - Trend (Line): 24h total device count /// - Activity (Heatmap): 24h per-device activity matrix -/// - Signal (Radar): WiFi signal quality per band class UspDeviceAnalyticsCard extends ConsumerStatefulWidget { const UspDeviceAnalyticsCard({super.key}); @@ -41,15 +42,15 @@ class _UspDeviceAnalyticsCardState content: _buildChartView(context, analyticsState, 0), ), CardTab( - label: loc(context).trend, + label: loc(context).signal, content: _buildChartView(context, analyticsState, 1), ), CardTab( - label: loc(context).activity, + label: loc(context).trend, content: _buildChartView(context, analyticsState, 2), ), CardTab( - label: loc(context).signal, + label: loc(context).activity, content: _buildChartView(context, analyticsState, 3), ), ], @@ -65,21 +66,21 @@ class _UspDeviceAnalyticsCardState return switch (selectedTab) { 0 => current != null - ? _DistributionView(distribution: current) + ? _OverviewView(distribution: current) : _buildEmptyState(context, loc(context).waitingForDeviceData), - 1 => state.hourlyHistory.isNotEmpty + 1 => current != null && current.signalLevelDistribution.isNotEmpty + ? _SignalView(distribution: current) + : _buildEmptyState(context, loc(context).noWifiSignalData), + 2 => state.hourlyHistory.isNotEmpty ? _TrendView(history: state.hourlyHistory) : _buildEmptyState(context, loc(context).collectingHourlyData), - 2 => state.hourlyHistory.isNotEmpty + 3 => state.hourlyHistory.isNotEmpty ? _ActivityView( history: state.hourlyHistory, allKnownMacs: state.allKnownMacs, macDisplayNames: state.macDisplayNames, ) : _buildEmptyState(context, loc(context).collectingActivityData), - 3 => current != null && current.bandSignalQuality.isNotEmpty - ? _SignalView(distribution: current) - : _buildEmptyState(context, loc(context).noWifiSignalData), _ => const SizedBox.shrink(), }; } @@ -95,16 +96,19 @@ class _UspDeviceAnalyticsCardState } // ============================================================================= -// Tab 1: Distribution (Donut + Band bars) +// Tab 1: Overview (Online/Offline donut + WiFi/Wired breakdown) // ============================================================================= -class _DistributionView extends StatelessWidget { +class _OverviewView extends StatelessWidget { final DeviceDistribution distribution; - const _DistributionView({required this.distribution}); + const _OverviewView({required this.distribution}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final online = distribution.onlineCount; + final offline = distribution.offlineCount; + final total = distribution.totalCount; return Column( children: [ @@ -113,20 +117,23 @@ class _DistributionView extends StatelessWidget { child: AppPieChart( sections: [ AppPieSection( - value: distribution.wifiCount.toDouble(), - label: loc(context).wifi, - color: colorScheme.primary), - AppPieSection( - value: distribution.wiredCount.toDouble(), - label: loc(context).wired, - color: colorScheme.secondary), + value: online.toDouble(), + label: loc(context).online, + color: colorScheme.primary, + ), + if (offline > 0) + AppPieSection( + value: offline.toDouble(), + label: loc(context).offline, + color: colorScheme.outlineVariant, + ), ], donut: true, centerWidget: Column( mainAxisSize: MainAxisSize.min, children: [ - AppText.titleMedium('${distribution.onlineCount}'), - AppText.labelSmall(loc(context).online, + AppText.titleMedium('$total'), + AppText.labelSmall(loc(context).devices, color: colorScheme.onSurfaceVariant), ], ), @@ -134,27 +141,95 @@ class _DistributionView extends StatelessWidget { ), ), ), - AppGap.sm(), - if (distribution.bandDistribution.isNotEmpty) - _BandDistributionBars( - bandDistribution: distribution.bandDistribution, - ), + AppGap.md(), + // Connection type + status breakdown + Row( + children: [ + Expanded( + child: LayoutBlock( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: Row( + children: [ + Icon(Icons.wifi, color: colorScheme.primary, size: 20), + AppGap.sm(), + Expanded( + child: AppText.bodyMedium(loc(context).wifi), + ), + AppText.titleSmall('${distribution.wifiCount}'), + ], + ), + ), + ), + AppGap.sm(), + Expanded( + child: LayoutBlock( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: Row( + children: [ + Icon(Icons.settings_ethernet, + color: colorScheme.secondary, size: 20), + AppGap.sm(), + Expanded( + child: AppText.bodyMedium(loc(context).wired), + ), + AppText.titleSmall('${distribution.wiredCount}'), + ], + ), + ), + ), + ], + ), AppGap.sm(), Row( - mainAxisAlignment: MainAxisAlignment.center, children: [ - _LegendDot(color: colorScheme.primary), - AppGap.xs(), - AppText.labelSmall(loc(context).wifiCount(distribution.wifiCount)), - AppGap.lg(), - _LegendDot(color: colorScheme.secondary), - AppGap.xs(), - AppText.labelSmall( - loc(context).wiredCount(distribution.wiredCount)), - AppGap.lg(), - AppText.labelSmall( - loc(context).nOffline(distribution.offlineCount), - color: colorScheme.onSurfaceVariant, + Expanded( + child: LayoutBlock( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + ), + AppGap.sm(), + Expanded( + child: AppText.bodyMedium(loc(context).online), + ), + AppText.titleSmall('$online'), + ], + ), + ), + ), + AppGap.sm(), + Expanded( + child: LayoutBlock( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + shape: BoxShape.circle, + ), + ), + AppGap.sm(), + Expanded( + child: AppText.bodyMedium(loc(context).offline), + ), + AppText.titleSmall('$offline'), + ], + ), + ), ), ], ), @@ -163,82 +238,82 @@ class _DistributionView extends StatelessWidget { } } -class _BandDistributionBars extends StatelessWidget { - final Map bandDistribution; +// ============================================================================= +// Tab 2: Signal (Signal quality distribution) +// ============================================================================= - const _BandDistributionBars({required this.bandDistribution}); +class _SignalView extends StatelessWidget { + final DeviceDistribution distribution; + const _SignalView({required this.distribution}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final maxCount = bandDistribution.values.fold(0, (a, b) => a > b ? a : b); - final seriesColors = [ - colorScheme.primary, - colorScheme.secondary, - colorScheme.tertiary, + final signalDist = distribution.signalLevelDistribution; + + // Signal levels: 3=Excellent, 2=Good, 1=Fair, 0=Poor + final levels = [ + (3, loc(context).excellent, colorScheme.primary), + (2, loc(context).good, Colors.lightGreen), + (1, loc(context).fair, Colors.orange), + (0, loc(context).poor, colorScheme.error), ]; + final data = levels.map((l) => (signalDist[l.$1] ?? 0).toDouble()).toList(); + final labels = levels.map((l) => l.$2).toList(); + final colors = levels.map((l) => l.$3).toList(); + final total = data.fold(0.0, (a, b) => a + b); + return Column( children: [ - for (var i = 0; i < bandDistribution.entries.length; i++) - Padding( - padding: EdgeInsets.only(bottom: 2), - child: Row( - children: [ - SizedBox( - width: 56, - child: AppText.labelSmall( - bandDistribution.entries.elementAt(i).key, - textAlign: TextAlign.end, - ), - ), - AppGap.sm(), - Expanded( - child: _HorizontalBar( - value: bandDistribution.entries.elementAt(i).value, - maxValue: maxCount, - color: seriesColors[i % seriesColors.length], - ), - ), - AppGap.sm(), - SizedBox( - width: 20, - child: AppText.labelSmall( - '${bandDistribution.entries.elementAt(i).value}', + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: AppBarChart( + series: [ + for (var i = 0; i < levels.length; i++) + AppChartSeries( + label: labels[i], + data: [data[i]], + color: colors[i], ), - ), ], + xLabels: [''], + yAxis: AppChartAxis( + min: 0, + max: total > 0 ? total : 1, + interval: (total / 4).ceilToDouble().clamp(1, double.infinity), + ), + showTooltip: false, ), ), + ), + AppGap.sm(), + // Legend + Wrap( + alignment: WrapAlignment.center, + spacing: AppSpacing.md, + runSpacing: AppSpacing.xs, + children: [ + for (var i = 0; i < levels.length; i++) + if (data[i] > 0) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _LegendDot(color: colors[i]), + AppGap.xs(), + AppText.labelSmall('${labels[i]}: ${data[i].toInt()}'), + ], + ), + ], + ), ], ); } } -class _HorizontalBar extends StatelessWidget { - final int value; - final int maxValue; - final Color color; - - const _HorizontalBar({ - required this.value, - required this.maxValue, - required this.color, - }); - - @override - Widget build(BuildContext context) { - final fraction = maxValue > 0 ? value / maxValue : 0.0; - return AppLoader( - variant: LoaderVariant.linear, - value: fraction, - color: color, - ); - } -} - // ============================================================================= -// Tab 2: Trend (Stacked Column Chart) +// Tab 3: Trend (Total device count line chart) // ============================================================================= class _TrendView extends StatelessWidget { @@ -255,58 +330,39 @@ class _TrendView extends StatelessWidget { final slots = List.generate(24, (i) { final hour = currentHour.subtract(Duration(hours: 23 - i)); final match = history.where((h) => h.hour == hour).firstOrNull; - return ( - hour: hour, - wifi: match?.wifiCount ?? 0, - wired: match?.wiredCount ?? 0, - ); + final total = (match?.wifiCount ?? 0) + (match?.wiredCount ?? 0); + return (hour: hour, total: total); }); - final wifiData = slots.map((s) => s.wifi.toDouble()).toList(); - final wiredData = slots.map((s) => s.wired.toDouble()).toList(); - final xLabels = slots - .map( - (s) => s.hour.hour % 3 == 0 ? '${s.hour.hour}'.padLeft(2, '0') : '') - .toList(); + final totalData = slots.map((s) => s.total.toDouble()).toList(); - // Calculate Y-axis bounds to avoid duplicate labels when count is small - final maxCount = - slots.map((s) => s.wifi + s.wired).reduce((a, b) => a > b ? a : b); + // Calculate Y-axis bounds to avoid duplicate labels with small counts + final maxCount = slots.map((s) => s.total).reduce((a, b) => a > b ? a : b); final yMax = maxCount < 2 ? 2.0 : (maxCount + 1).toDouble(); final yInterval = yMax <= 4 ? 1.0 : (yMax / 4).ceilToDouble(); return Column( children: [ Expanded( - child: AppBarChart( - series: [ - AppChartSeries( - label: loc(context).wifi, - data: wifiData, - color: colorScheme.primary), - AppChartSeries( - label: loc(context).wired, - data: wiredData, - color: colorScheme.secondary), - ], - stacked: true, - xLabels: xLabels, - yAxis: AppChartAxis(min: 0, max: yMax, interval: yInterval), - showTooltip: false, + child: Padding( + padding: EdgeInsets.only(top: 8), + child: AppLineChart( + series: [ + AppChartSeries( + label: loc(context).devices, + data: totalData, + color: colorScheme.primary, + ), + ], + yAxis: AppChartAxis(min: 0, max: yMax, interval: yInterval), + showTooltip: false, + ), ), ), AppGap.sm(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _LegendDot(color: colorScheme.primary), - AppGap.xs(), - AppText.labelSmall(loc(context).wifi), - AppGap.lg(), - _LegendDot(color: colorScheme.secondary), - AppGap.xs(), - AppText.labelSmall(loc(context).wired), - ], + AppText.labelSmall( + '24h', + color: colorScheme.onSurfaceVariant, ), ], ); @@ -385,85 +441,6 @@ class _ActivityView extends StatelessWidget { } } -// ============================================================================= -// Tab 4: Signal (Radar / Bar fallback) -// ============================================================================= - -class _SignalView extends StatelessWidget { - final DeviceDistribution distribution; - const _SignalView({required this.distribution}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final bands = distribution.bandSignalQuality; - - // Radar chart needs >= 3 axes; fallback to bar comparison for fewer - final useRadar = bands.length >= 3; - - return Column( - children: [ - Expanded( - child: Padding( - padding: EdgeInsets.only(top: 16), - child: useRadar - ? AppRadarChart( - series: [ - AppRadarSeries( - label: loc(context).signalQuality, - data: bands.values.map((v) => v * 100).toList(), - color: colorScheme.primary, - filled: true, - ), - ], - axisLabels: bands.keys.toList(), - tickCount: 4, - ) - : AppBarChart( - series: [ - AppChartSeries( - label: loc(context).signal, - data: bands.values.map((v) => v * 100).toList(), - color: colorScheme.primary, - ), - ], - xLabels: bands.keys.toList(), - yAxis: AppChartAxis(min: 0, max: 100, interval: 25), - yLabelFormatter: (v) => '${v.toInt()}%', - showValueLabels: true, - valueLabelFormatter: (v) => '${v.toInt()}%', - showTooltip: false, - ), - ), - ), - AppGap.sm(), - if (distribution.signalLevelDistribution.isNotEmpty) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final entry in [ - (3, loc(context).excellent, colorScheme.primary), - (2, loc(context).good, Colors.lightGreen), - (1, loc(context).fair, Colors.orange), - (0, loc(context).poor, colorScheme.error), - ]) ...[ - if (distribution.signalLevelDistribution - .containsKey(entry.$1)) ...[ - _LegendDot(color: entry.$3), - AppGap.xs(), - AppText.labelSmall( - '${entry.$2}: ${distribution.signalLevelDistribution[entry.$1]}', - ), - AppGap.md(), - ], - ], - ], - ), - ], - ); - } -} - // ============================================================================= // Shared widgets // ============================================================================= diff --git a/lib/page/dashboard/views/components/usp_stats_panel.dart b/lib/page/dashboard/views/components/usp_stats_panel.dart index ee0290043..5d41772e0 100644 --- a/lib/page/dashboard/views/components/usp_stats_panel.dart +++ b/lib/page/dashboard/views/components/usp_stats_panel.dart @@ -22,7 +22,7 @@ class UspStatsPanel extends ConsumerWidget { final devices = devicesData.clientDevices; final onlineCount = devices.where((d) => d.isActive).length; - final nodeCount = devicesData.nodeModels.length; + final nodeCount = devicesData.nodes.length; final wifiData = ref.watch(wifiDataProvider).valueOrNull; final radioCount = wifiData?.radioModels.length ?? 0; final enabledRadios = diff --git a/lib/page/devices/cards/usp_connected_devices_card.dart b/lib/page/devices/cards/usp_connected_devices_card.dart index 4bfdb91d7..c59a7cebe 100644 --- a/lib/page/devices/cards/usp_connected_devices_card.dart +++ b/lib/page/devices/cards/usp_connected_devices_card.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; @@ -14,7 +14,7 @@ import 'package:ui_kit_library/ui_kit.dart'; import 'package:privacy_gui/page/_shared/components/usp_status_dot.dart'; class UspConnectedDevicesCard extends ConsumerWidget { - final List? devices; + final List? devices; const UspConnectedDevicesCard({ super.key, @@ -110,7 +110,7 @@ class UspConnectedDevicesCard extends ConsumerWidget { ); } - Widget _buildDeviceRow(BuildContext context, DeviceUIModel device) { + Widget _buildDeviceRow(BuildContext context, ClientDevice device) { final scheme = Theme.of(context).colorScheme; final deviceCategory = DeviceClassifier.classify( hostname: device.hostName, diff --git a/lib/page/devices/providers/device_detail_provider.dart b/lib/page/devices/providers/device_detail_provider.dart index 28c027e7e..9e2a51555 100644 --- a/lib/page/devices/providers/device_detail_provider.dart +++ b/lib/page/devices/providers/device_detail_provider.dart @@ -1,7 +1,7 @@ import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/dhcp_data_provider.dart'; @@ -11,7 +11,7 @@ final uspDeviceDetailProvider = Provider.family((ref, mac) { final data = ref.watch(devicesDataProvider).valueOrNull; if (data == null) return DeviceDetailState.empty(); - final device = data.deviceModels.firstWhereOrNull( + final device = data.clientDevices.firstWhereOrNull( (d) => d.mac.toUpperCase() == mac.toUpperCase(), ); final dhcpData = ref.watch(dhcpDataProvider).valueOrNull; @@ -23,7 +23,7 @@ final uspDeviceDetailProvider = /// Aggregated state for a single device's detail page. class DeviceDetailState extends Equatable { - final DeviceUIModel? device; + final ClientDevice? device; final DhcpReservationUIModel? reservation; const DeviceDetailState({this.device, this.reservation}); diff --git a/lib/page/devices/providers/device_filter_provider.dart b/lib/page/devices/providers/device_filter_provider.dart index c968beea6..b874cf5e0 100644 --- a/lib/page/devices/providers/device_filter_provider.dart +++ b/lib/page/devices/providers/device_filter_provider.dart @@ -1,7 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/utils/oui_lookup.dart'; import 'package:privacy_gui/core/utils/wifi.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; @@ -61,9 +61,9 @@ class DeviceFilterNotifier extends StateNotifier { state = state.copyWith(status: value); } - void setConnections(Set values) { + void setConnections(Set values) { final isEthernetOnly = - values.length == 1 && values.contains(DeviceConnectionType.wired); + values.length == 1 && values.contains(ConnectionType.wired); if (isEthernetOnly) { state = state.copyWith( connections: values, @@ -77,8 +77,8 @@ class DeviceFilterNotifier extends StateNotifier { state = state.copyWith(connections: values); } - void toggleConnection(DeviceConnectionType type) { - var next = Set.from(state.connections); + void toggleConnection(ConnectionType type) { + var next = Set.from(state.connections); if (next.contains(type)) { next.remove(type); } else { @@ -253,15 +253,16 @@ final deviceFilterOptionsProvider = Provider((ref) { ); }); -final filteredDeviceListProvider = Provider>((ref) { +/// Filtered device list — applies every active dimension + search. +final filteredDeviceListProvider = Provider>((ref) { final data = ref.watch(devicesDataProvider).valueOrNull; if (data == null) return []; final filter = ref.watch(deviceFilterConfigProvider); return data.clientDevices.where((d) => _matches(d, filter)).toList(); }); -bool _matches(DeviceUIModel device, DeviceFilterConfig filter) { - // Status +bool _matches(ClientDevice device, DeviceFilterConfig filter) { + // Status. if (filter.status == DeviceStatusFilter.online && !device.isActive) { return false; } @@ -272,7 +273,7 @@ bool _matches(DeviceUIModel device, DeviceFilterConfig filter) { // Connection type (multi-select OR) if (filter.connections.isNotEmpty) { final deviceType = - device.isWifi ? DeviceConnectionType.wifi : DeviceConnectionType.wired; + device.isWifi ? ConnectionType.wifi : ConnectionType.wired; if (!filter.connections.contains(deviceType)) { return false; } @@ -280,7 +281,7 @@ bool _matches(DeviceUIModel device, DeviceFilterConfig filter) { // BUG FIX: Exclude Ethernet when WiFi-specific filters are active if (filter.hasWifiOnlyFilter && !device.isWifi) { - if (!filter.connections.contains(DeviceConnectionType.wired)) { + if (!filter.connections.contains(ConnectionType.wired)) { return false; } } diff --git a/lib/page/devices/providers/device_filter_state.dart b/lib/page/devices/providers/device_filter_state.dart index 1737bfaae..42d61f829 100644 --- a/lib/page/devices/providers/device_filter_state.dart +++ b/lib/page/devices/providers/device_filter_state.dart @@ -1,7 +1,7 @@ import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; enum DeviceStatusFilter { all, online, offline } @@ -12,7 +12,7 @@ enum PrivateMacFilter { all, privateOnly, publicOnly } class DeviceFilterConfig extends Equatable { final String searchQuery; final DeviceStatusFilter status; - final Set connections; + final Set connections; final Set deviceCategories; final PrivateMacFilter privateMac; final Set signals; @@ -68,13 +68,12 @@ class DeviceFilterConfig extends Equatable { bands.isNotEmpty; bool get isEthernetOnly => - connections.length == 1 && - connections.contains(DeviceConnectionType.wired); + connections.length == 1 && connections.contains(ConnectionType.wired); DeviceFilterConfig copyWith({ String? searchQuery, DeviceStatusFilter? status, - Set? connections, + Set? connections, Set? deviceCategories, PrivateMacFilter? privateMac, Set? signals, @@ -113,7 +112,7 @@ class DeviceFilterConfig extends Equatable { } class DeviceFilterOptions extends Equatable { - final List nodes; + final List nodes; final List ssids; final List bands; final List deviceCategories; diff --git a/lib/page/devices/providers/devices_data_provider.dart b/lib/page/devices/providers/devices_data_provider.dart index e1dea64fb..b94231bee 100644 --- a/lib/page/devices/providers/devices_data_provider.dart +++ b/lib/page/devices/providers/devices_data_provider.dart @@ -5,73 +5,80 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/services/usp_devices_data_service.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_data_provider.dart'; +import 'package:privacy_gui/page/wifi_settings/services/usp_wifi_data_service.dart'; // Re-export so existing consumers can still import DevicesCodegenContext from here. export 'package:privacy_gui/page/devices/services/usp_devices_data_service.dart' show DevicesCodegenContext; // --------------------------------------------------------------------------- -// Data Model (Layer 1 — UIModel only) +// Data Model (Layer 1 — MeshNetwork as SSoT) // --------------------------------------------------------------------------- class DevicesData extends Equatable { final DevicesCodegenContext codegenContext; final MeshTopologyInfo meshTopology; - // UI models (computed from raw + cross-domain enrichment) - final List deviceModels; - final List nodeModels; - /// Pre-computed MAC → hostname map for DHCP hostname enrichment. final Map hostNameByMac; + /// Unified MeshNetwork container (SSoT for nodes and clients). + final MeshNetwork meshNetwork; + const DevicesData({ this.codegenContext = DevicesCodegenContext.empty, this.meshTopology = MeshTopologyInfo.empty, - this.deviceModels = const [], - this.nodeModels = const [], this.hostNameByMac = const {}, + required this.meshNetwork, }); - /// Client devices only (excludes mesh nodes: master/slave). - List get clientDevices => deviceModels - .where((d) => d.deviceRole != 'master' && d.deviceRole != 'slave') - .toList(); + /// All client devices. + List get clientDevices => meshNetwork.allClients; + + /// All mesh nodes (master + slaves). + List get nodes => meshNetwork.allNodes; + + /// Master node. + MasterNode get master => meshNetwork.master; + + /// Slave nodes. + List get slaves => meshNetwork.slaves; /// Count of online client devices. - int get onlineClientCount => clientDevices.where((d) => d.isActive).length; + int get onlineClientCount => meshNetwork.onlineClientCount; /// Total count of client devices. - int get totalClientCount => clientDevices.length; + int get totalClientCount => meshNetwork.totalClientCount; + + /// Whether this is a mesh network (has slave nodes). + bool get hasMesh => meshNetwork.hasMesh; DevicesData copyWith({ DevicesCodegenContext? codegenContext, MeshTopologyInfo? meshTopology, - List? deviceModels, - List? nodeModels, Map? hostNameByMac, + MeshNetwork? meshNetwork, }) { return DevicesData( codegenContext: codegenContext ?? this.codegenContext, meshTopology: meshTopology ?? this.meshTopology, - deviceModels: deviceModels ?? this.deviceModels, - nodeModels: nodeModels ?? this.nodeModels, hostNameByMac: hostNameByMac ?? this.hostNameByMac, + meshNetwork: meshNetwork ?? this.meshNetwork, ); } @override List get props => [ codegenContext, - meshTopology.nodes.length, - deviceModels, - nodeModels, - hostNameByMac.length, + meshTopology, + hostNameByMac, + meshNetwork, ]; } @@ -100,7 +107,7 @@ class DevicesDataNotifier extends AsyncNotifier { } }); - // WiFi data changes → rebuild deviceModels with updated enrichment. + // WiFi data changes → rebuild MeshNetwork with updated enrichment. ref.listen(wifiDataProvider, (_, next) { final wd = next.valueOrNull; final cur = state.valueOrNull; @@ -113,7 +120,7 @@ class DevicesDataNotifier extends AsyncNotifier { 'Router'; final sysInfo = ref.read(systemInfoDataProvider).valueOrNull?.model; - final rebuilt = svc.rebuildWithWifiData( + final meshNetwork = svc.rebuildWithWifiData( context: cur.codegenContext, wifiClientMap: wd.wifiClientMap, connectionDetailMap: wd.connectionDetailMap, @@ -122,10 +129,7 @@ class DevicesDataNotifier extends AsyncNotifier { systemInfo: sysInfo, ); - state = AsyncData(cur.copyWith( - deviceModels: rebuilt.deviceModels, - nodeModels: rebuilt.nodeModels, - )); + state = AsyncData(cur.copyWith(meshNetwork: meshNetwork)); }); ref.onDispose(() => _debounce?.cancel()); @@ -160,8 +164,8 @@ class DevicesDataNotifier extends AsyncNotifier { ); logger.d('[USP][DevicesData]: Fetched — ' - 'deviceModels: ${result.deviceModels.length}, ' - 'nodeModels: ${result.nodeModels.length}'); + 'clients: ${result.meshNetwork.totalClientCount}, ' + 'nodes: ${result.meshNetwork.allNodes.length}'); // Preserve existing mesh topology during refetch to avoid UI flicker. // Fire-and-forget will update it shortly after. @@ -174,9 +178,8 @@ class DevicesDataNotifier extends AsyncNotifier { return DevicesData( codegenContext: result.codegenContext, meshTopology: existingMesh, - deviceModels: result.deviceModels, - nodeModels: result.nodeModels, hostNameByMac: result.hostNameByMac, + meshNetwork: result.meshNetwork, ); } @@ -188,13 +191,22 @@ class DevicesDataNotifier extends AsyncNotifier { SystemInfoData? sysData, DevicesDataFetchResult fetchResult, ) async { - final meshTopology = await svc.fetchMeshTopology(); + // Build BSSID → band mapping for slave client band resolution + final wifiCodegen = wifiData.codegenContext.raw; + final bssidToBandMap = UspWifiDataService.buildBssidToBandMap( + ssids: wifiCodegen.ssids, + radios: wifiCodegen.radios, + ); + + final meshTopology = await svc.fetchMeshTopology( + bssidToBandMap: bssidToBandMap, + ); if (meshTopology.isEmpty) return; final cur = state.valueOrNull; if (cur == null) return; - final rebuilt = svc.rebuildWithMesh( + final meshNetwork = svc.rebuildWithMesh( context: cur.codegenContext, wifiClientMap: wifiData.wifiClientMap, connectionDetailMap: wifiData.connectionDetailMap, @@ -205,13 +217,11 @@ class DevicesDataNotifier extends AsyncNotifier { logger.d('[USP][DevicesData]: Mesh update — ' 'meshNodes: ${meshTopology.nodes.length}, ' - 'nodeModels: ${rebuilt.nodeModels.length}, ' - 'deviceModels: ${rebuilt.deviceModels.length}'); + 'clients: ${meshNetwork.totalClientCount}'); state = AsyncData(cur.copyWith( meshTopology: meshTopology, - deviceModels: rebuilt.deviceModels, - nodeModels: rebuilt.nodeModels, + meshNetwork: meshNetwork, )); } @@ -257,8 +267,8 @@ class DevicesDataNotifier extends AsyncNotifier { ); // Rebuild with existing mesh to preserve slave node visibility. - final rebuilt = existingMesh.isEmpty - ? (deviceModels: result.deviceModels, nodeModels: result.nodeModels) + final meshNetwork = existingMesh.isEmpty + ? result.meshNetwork : svc.rebuildWithMesh( context: result.codegenContext, wifiClientMap: wifiData.wifiClientMap, @@ -269,17 +279,15 @@ class DevicesDataNotifier extends AsyncNotifier { ); logger.d('[USP][DevicesData]: Refetch (preserve mesh) — ' - 'deviceModels: ${rebuilt.deviceModels.length}, ' - 'nodeModels: ${rebuilt.nodeModels.length}, ' + 'clients: ${meshNetwork.totalClientCount}, ' 'existingMesh: ${existingMesh.nodes.length}'); // Update state with new device data but preserve existing mesh topology. state = AsyncData(DevicesData( codegenContext: result.codegenContext, meshTopology: existingMesh, - deviceModels: rebuilt.deviceModels, - nodeModels: rebuilt.nodeModels, hostNameByMac: result.hostNameByMac, + meshNetwork: meshNetwork, )); // Fire-and-forget: fetch mesh topology in background, then update state. diff --git a/lib/page/devices/services/usp_devices_data_service.dart b/lib/page/devices/services/usp_devices_data_service.dart index 20fae0562..9be950f9d 100644 --- a/lib/page/devices/services/usp_devices_data_service.dart +++ b/lib/page/devices/services/usp_devices_data_service.dart @@ -6,14 +6,14 @@ import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/generated/connected_devices.g.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; import 'package:privacy_gui/page/_shared/utils/mesh_topology_builder.dart'; +import 'package:privacy_gui/page/_shared/utils/mesh_network_builder.dart'; import 'package:privacy_gui/generated/data_elements_network.g.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; // --------------------------------------------------------------------------- // Provider @@ -57,15 +57,15 @@ class DevicesCodegenContext extends Equatable { /// Result of a devices data fetch, returned by [UspDevicesDataService.fetch]. class DevicesDataFetchResult { final DevicesCodegenContext codegenContext; - final List deviceModels; - final List nodeModels; final Map hostNameByMac; + /// Unified MeshNetwork container (SSoT for nodes and clients). + final MeshNetwork meshNetwork; + const DevicesDataFetchResult({ required this.codegenContext, - required this.deviceModels, - required this.nodeModels, required this.hostNameByMac, + required this.meshNetwork, }); } @@ -103,27 +103,20 @@ class UspDevicesDataService { final hostNameByMac = _buildHostNameMap(connectedDevices); - final deviceModels = _buildDeviceUIModels( + // Build MeshNetwork (SSoT for nodes and clients) + final meshNetwork = MeshNetworkBuilder.build( connectedDevices: connectedDevices, wifiClientMap: wifiClientMap, connectionDetailMap: connectionDetailMap, meshTopology: MeshTopologyInfo.empty, gatewayName: gatewayName, + systemInfo: systemInfo, ); - final nodeModels = systemInfo != null - ? _buildNodeUIModels( - meshTopology: MeshTopologyInfo.empty, - deviceModels: deviceModels, - systemInfo: systemInfo, - ) - : []; - return DevicesDataFetchResult( codegenContext: context, - deviceModels: deviceModels, - nodeModels: nodeModels, hostNameByMac: hostNameByMac, + meshNetwork: meshNetwork, ); } @@ -134,9 +127,15 @@ class UspDevicesDataService { /// to codegen [DataElementsNetwork.fetch], then transforms the tree /// into a flat [MeshTopologyInfo] with client→node mapping. /// + /// [bssidToBandMap] is a BSSID → band mapping for resolving band info + /// for clients on slave nodes. Build it via + /// [UspWifiDataService.buildBssidToBandMap]. + /// /// Returns [MeshTopologyInfo.empty] if the router doesn't support /// DataElements or the subtree is empty (non-mesh / single router). - Future fetchMeshTopology() async { + Future fetchMeshTopology({ + Map bssidToBandMap = const {}, + }) async { try { final network = await DataElementsNetwork.fetch(_usp); if (network.items.isEmpty) { @@ -144,7 +143,7 @@ class UspDevicesDataService { '[USP][Dashboard]: DataElements empty — not a mesh or unsupported'); return MeshTopologyInfo.empty; } - return _buildTopologyInfo(network); + return _buildTopologyInfo(network, bssidToBandMap); } catch (e) { logger.d( '[USP][Dashboard]: DataElements not supported or fetch failed: $e'); @@ -152,19 +151,25 @@ class UspDevicesDataService { } } - MeshTopologyInfo _buildTopologyInfo(DataElementsNetwork network) { - final result = MeshTopologyBuilder.build(network); + MeshTopologyInfo _buildTopologyInfo( + DataElementsNetwork network, + Map bssidToBandMap, + ) { + final result = MeshTopologyBuilder.build( + network, + bssidToBandMap: bssidToBandMap, + ); logger.d('[USP][Dashboard]: Mesh nodes: ${result.nodes.length}, ' - 'client→node mappings: ${result.clientToNodeMap.length}'); + 'client→node mappings: ${result.clientToNodeMap.length}, ' + 'band/SSID mappings: ${result.clientBandSsidMap.length}'); return result; } - /// Rebuilds device + node UI models with updated WiFi enrichment data. + /// Rebuilds MeshNetwork with updated WiFi enrichment data. /// /// Called by the provider's WiFi listener for incremental rebuild /// without re-fetching ConnectedDevices. - ({List deviceModels, List nodeModels}) - rebuildWithWifiData({ + MeshNetwork rebuildWithWifiData({ required DevicesCodegenContext context, required Map wifiClientMap, required Map connectionDetailMap, @@ -172,28 +177,18 @@ class UspDevicesDataService { required String gatewayName, SystemInfoUIModel? systemInfo, }) { - final deviceModels = _buildDeviceUIModels( + return MeshNetworkBuilder.build( connectedDevices: context._connectedDevices, wifiClientMap: wifiClientMap, connectionDetailMap: connectionDetailMap, meshTopology: meshTopology, gatewayName: gatewayName, + systemInfo: systemInfo, ); - - final nodeModels = systemInfo != null - ? _buildNodeUIModels( - meshTopology: meshTopology, - deviceModels: deviceModels, - systemInfo: systemInfo, - ) - : []; - - return (deviceModels: deviceModels, nodeModels: nodeModels); } - /// Rebuilds device + node UI models after mesh topology arrives. - ({List deviceModels, List nodeModels}) - rebuildWithMesh({ + /// Rebuilds MeshNetwork after mesh topology arrives. + MeshNetwork rebuildWithMesh({ required DevicesCodegenContext context, required Map wifiClientMap, required Map connectionDetailMap, @@ -201,7 +196,6 @@ class UspDevicesDataService { required String gatewayName, SystemInfoUIModel? systemInfo, }) { - // Same logic as rebuildWithWifiData — both rebuild from context + enrichment. return rebuildWithWifiData( context: context, wifiClientMap: wifiClientMap, @@ -225,328 +219,4 @@ class UspDevicesDataService { } return map; } - - /// Normalizes hostname for grouping: lowercase + strip mDNS suffix. - /// - /// mDNS suffixes (e.g., "._tcp.local", "._device-info._tcp.local") are - /// stripped to ensure devices advertising via mDNS group correctly with - /// those using plain hostnames. - /// - /// Examples: - /// - "MacBook-Pro" → "macbook-pro" - /// - "MacBook._tcp.local" → "macbook" - /// - "iPhone._device-info._tcp.local" → "iphone" - String _normalizeHostname(String hostname) { - var normalized = hostname.trim().toLowerCase(); - if (normalized.isEmpty) return ''; - - // Strip mDNS suffix (matches device_classifier.dart behavior) - final mdnsSuffixIndex = normalized.indexOf('._'); - if (mdnsSuffixIndex > 0) { - normalized = normalized.substring(0, mdnsSuffixIndex); - } - return normalized; - } - - List _buildDeviceUIModels({ - required ConnectedDevices connectedDevices, - required Map wifiClientMap, - required Map connectionDetailMap, - required MeshTopologyInfo meshTopology, - required String gatewayName, - }) { - // Step 1: Build all DeviceUIModels (ungrouped) - final allDevices = connectedDevices.items - .where((d) => d.interface_.isNotEmpty || d.isActive) - .map((d) => _toDeviceUIModel( - d, wifiClientMap, connectionDetailMap, meshTopology, gatewayName)) - .toList(); - - // Step 2: Group by hostname (empty hostname devices stay ungrouped) - // Also exclude mesh nodes (master/slave) from grouping - final grouped = >{}; - final ungrouped = []; - - for (final device in allDevices) { - // Mesh nodes should not be grouped - if (device.isMeshNode) { - ungrouped.add(device); - continue; - } - - final hostname = _normalizeHostname(device.hostName); - if (hostname.isEmpty) { - ungrouped.add(device); - } else { - grouped.putIfAbsent(hostname, () => []).add(device); - } - } - - // Step 3: Merge devices with same hostname - final result = []; - - for (final devices in grouped.values) { - if (devices.length == 1) { - result.add(devices.first); - } else { - result.add(_mergeDevicesByHostname(devices)); - } - } - - result.addAll(ungrouped); - return result; - } - - /// Merges multiple DeviceUIModels with the same hostname into one. - /// - /// Primary interface selection priority: active > WiFi > Ethernet. - /// Additional interfaces are stored in [DeviceUIModel.additionalInterfaces]. - DeviceUIModel _mergeDevicesByHostname(List devices) { - // Sort to select primary interface: active first, then WiFi over Ethernet - final sorted = List.from(devices) - ..sort((a, b) { - // Active interfaces first - if (a.isActive != b.isActive) return a.isActive ? -1 : 1; - // WiFi preferred (typically has more enrichment data like signal) - if (a.isWifi != b.isWifi) return a.isWifi ? -1 : 1; - return 0; - }); - - final primary = sorted.first; - final additional = sorted - .skip(1) - .map((d) => DeviceInterfaceInfo( - mac: d.mac, - ip: d.ip, - isWifi: d.isWifi, - isActive: d.isActive, - layer1Interface: d.layer1Interface, - band: d.band, - ssidName: d.ssidName, - signalStrength: d.signalStrength, - )) - .toList(); - - logger.d('[USP][Devices]: Merged ${devices.length} interfaces for ' - 'hostname="${primary.hostName}" — primary=${primary.mac} (${primary.isWifi ? "WiFi" : "Ethernet"}), ' - 'additional=${additional.map((i) => "${i.mac} (${i.isWifi ? "WiFi" : "Ethernet"})").join(", ")}'); - - return primary.copyWith(additionalInterfaces: additional); - } - - List _buildNodeUIModels({ - required MeshTopologyInfo meshTopology, - required List deviceModels, - required SystemInfoUIModel systemInfo, - }) { - // Extract mesh nodes from Hosts (deviceRole = master/slave). - // This is available immediately without waiting for DataElements fetch. - final meshDevices = deviceModels.meshNodes; - - // Client devices only (excluding mesh nodes). - final clientDevices = deviceModels.clientDevices; - - // If no mesh devices found in Hosts, create gateway-only node. - if (meshDevices.isEmpty) { - return [ - NodeUIModel( - deviceId: 'gateway', - model: systemInfo.modelName, - manufacturer: systemInfo.manufacturer, - serialNumber: systemInfo.serialNumber, - softwareVersion: systemInfo.softwareVersion, - isMaster: true, - connectedDeviceCount: clientDevices.where((d) => d.isActive).length, - ), - ]; - } - - // Build nodes from Hosts deviceRole, enrich with DataElements if available. - final nodes = []; - - // Find master first. - final master = meshDevices.masterNode ?? meshDevices.first; - - // Master node — use systemInfo for details (Hosts doesn't have model/firmware). - final masterMeshInfo = - meshTopology.nodes.isNotEmpty ? meshTopology.nodes.first : null; - final masterConnectedCount = clientDevices - .where((d) => - d.isActive && - (d.parentNodeId == null || - d.parentNodeId!.toUpperCase() == master.mac.toUpperCase() || - (masterMeshInfo != null && - d.parentNodeId!.toUpperCase() == - masterMeshInfo.deviceId.toUpperCase()))) - .length; - - nodes.add(NodeUIModel( - deviceId: master.mac, - friendlyName: master.friendlyName, - hostName: master.hostName, - model: masterMeshInfo?.model ?? systemInfo.modelName, - manufacturer: masterMeshInfo?.manufacturer ?? systemInfo.manufacturer, - serialNumber: masterMeshInfo?.serialNumber ?? systemInfo.serialNumber, - softwareVersion: - masterMeshInfo?.softwareVersion ?? systemInfo.softwareVersion, - isMaster: true, - connectedDeviceCount: masterConnectedCount, - ipAddress: master.ip.isNotEmpty ? master.ip : null, - ipv6Addresses: master.ipv6Addresses, - )); - - // Slave nodes. - for (final slave in meshDevices.slaveNodes) { - final slaveMeshInfo = _findMatchingMeshNode(slave, meshTopology.nodes); - logger.d('[USP][Topology]: Slave ${slave.mac} matched to meshInfo: ' - '${slaveMeshInfo != null ? "yes (signalStrength=${slaveMeshInfo.backhaulSignalStrength})" : "no"}, ' - 'meshTopology.nodes.length=${meshTopology.nodes.length}'); - - final slaveConnectedCount = clientDevices - .where((d) => - d.isActive && - d.parentNodeId != null && - (d.parentNodeId!.toUpperCase() == slave.mac.toUpperCase() || - (slaveMeshInfo != null && - d.parentNodeId!.toUpperCase() == - slaveMeshInfo.deviceId.toUpperCase()))) - .length; - - nodes.add(NodeUIModel( - deviceId: slave.mac, - dataElementsId: slaveMeshInfo?.deviceId, - friendlyName: slave.friendlyName, - hostName: slave.hostName, - model: slaveMeshInfo?.model ?? slave.modelName ?? '', - manufacturer: slaveMeshInfo?.manufacturer ?? slave.manufacturer ?? '', - serialNumber: slaveMeshInfo?.serialNumber ?? '', - softwareVersion: slaveMeshInfo?.softwareVersion ?? '', - isMaster: false, - connectedDeviceCount: slaveConnectedCount, - ipAddress: slave.ip.isNotEmpty ? slave.ip : null, - ipv6Addresses: slave.ipv6Addresses, - backhaulMediaType: slaveMeshInfo?.backhaulMediaType ?? '', - backhaulPhyRate: slaveMeshInfo?.backhaulPhyRate ?? 0, - backhaulSignalStrength: slaveMeshInfo?.backhaulSignalStrength, - backhaulUplinkRate: slaveMeshInfo?.backhaulUplinkRate, - backhaulLinkType: slaveMeshInfo?.backhaulLinkType, - backhaulDownlinkRate: slaveMeshInfo?.backhaulDownlinkRate, - backhaulParentDeviceId: slaveMeshInfo?.backhaulParentDeviceId, - backhaulParentBssid: slaveMeshInfo?.backhaulParentBssid, - lastContactTime: slaveMeshInfo?.lastContactTime, - )); - } - - return nodes; - } - - DeviceUIModel _toDeviceUIModel( - ConnectedDevice device, - Map wifiClientMap, - Map connectionDetailMap, - MeshTopologyInfo meshTopology, - String gatewayName, - ) { - final mac = device.macAddress.trim().toUpperCase(); - // Determine WiFi via Layer1Interface or InterfaceType (fallback for empty Layer1Interface). - final interfaceType = device.interfaceType?.toLowerCase() ?? ''; - final isWifi = device.interface_.toLowerCase().contains('wifi') || - interfaceType.contains('wi-fi') || - interfaceType.contains('wifi'); - final wifiClient = wifiClientMap[mac]; - final detail = connectionDetailMap[mac]; - - String? parentNodeId; - String? parentNodeName; - if (meshTopology.isEmpty) { - if (device.isActive) parentNodeName = gatewayName; - } else { - parentNodeId = meshTopology.clientToNodeMap[mac]; - if (parentNodeId != null) { - final isGateway = meshTopology.nodes.isNotEmpty && - meshTopology.nodes.first.deviceId == parentNodeId; - if (isGateway) { - parentNodeName = gatewayName; - } else { - final matchingNode = meshTopology.nodes - .where((n) => n.deviceId == parentNodeId) - .firstOrNull; - parentNodeName = matchingNode?.model.isNotEmpty == true - ? matchingNode!.model - : parentNodeId; - } - } else { - parentNodeName = gatewayName; - } - } - - return DeviceUIModel( - mac: mac, - ip: device.ipAddress, - hostName: device.hostName, - isActive: device.isActive, - isWifi: isWifi, - layer1Interface: device.interface_, - ipv6Addresses: device.ipv6Addresses - .map((e) => e.address) - .where((a) => a.isNotEmpty) - .toList(), - // Prefer Hosts data, fallback to WiFi STA table, then DataElements. - // DataElements provides signal for clients on ALL nodes (including child nodes), - // while WifiClients only covers master node clients. - signalStrength: isWifi - ? (device.signalStrength ?? - wifiClient?.signalStrength ?? - meshTopology.clientSignalMap[mac]) - : null, - downlinkRate: isWifi - ? (device.lastDataDownlinkRate ?? wifiClient?.lastDataDownlinkRate) - : null, - uplinkRate: isWifi - ? (device.lastDataUplinkRate ?? wifiClient?.lastDataUplinkRate) - : null, - band: detail?.band, - ssidName: detail?.ssidName, - parentNodeId: parentNodeId, - parentNodeName: parentNodeName, - deviceRole: device.deviceRole, - interfaceType: device.interfaceType, - friendlyName: device.friendlyName, - manufacturer: device.manufacturer, - hostsDeviceId: device.deviceId, - modelName: device.modelName, - operatingSystem: device.operatingSystem, - ); - } - - // --------------------------------------------------------------------------- - // Mesh Node Matching - // --------------------------------------------------------------------------- - - /// Finds a matching [NodeUIModel] for a slave device from Hosts. - /// - /// Strategy: Extract embedded MAC from Hosts DeviceID (UUID format) and match - /// against DataElements node ID. - /// - /// Hosts DeviceID format: "0217B8A4-1082-4532-8345-80691ABB4694" - /// Last 12 chars (no hyphens) = MAC: "80691ABB4694" → matches "80:69:1A:BB:46:94" - /// - /// Future alternatives if this approach proves unreliable: - /// - Match via BSSID: DataElements Radio.*.BSS.*.BSSID = Hosts PhysAddress - /// - Match via hostName suffix: "Linksys03027" → SerialNumber ending "03027" - NodeUIModel? _findMatchingMeshNode( - DeviceUIModel slave, - List meshNodes, - ) { - final hostsDeviceId = - slave.hostsDeviceId?.toUpperCase().replaceAll('-', '') ?? ''; - if (hostsDeviceId.length < 12) return null; - - final embeddedMac = hostsDeviceId.substring(hostsDeviceId.length - 12); - - return meshNodes.where((n) { - final nodeIdNormalized = n.deviceId.toUpperCase().replaceAll(':', ''); - return nodeIdNormalized == embeddedMac; - }).firstOrNull; - } } diff --git a/lib/page/devices/views/components/usp_device_filter_panel.dart b/lib/page/devices/views/components/usp_device_filter_panel.dart index 8e0646973..547d71f0b 100644 --- a/lib/page/devices/views/components/usp_device_filter_panel.dart +++ b/lib/page/devices/views/components/usp_device_filter_panel.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; import 'package:privacy_gui/page/devices/views/components/usp_signal_strength_indicator.dart'; import 'package:privacy_gui/page/_shared/components/wifi_ui.dart'; -import 'package:ui_kit_library/ui_kit.dart'; +import 'package:ui_kit_library/ui_kit.dart' hide ConnectionType; String _signalLabel(BuildContext context, DeviceSignalLevel level) { final nodeLevel = nodeLevelOf(level); @@ -299,17 +299,17 @@ class UspDeviceFilterPanel extends ConsumerWidget { } } -Set _connectionToIndices(Set types) { +Set _connectionToIndices(Set types) { final indices = {}; - if (types.contains(DeviceConnectionType.wifi)) indices.add(0); - if (types.contains(DeviceConnectionType.wired)) indices.add(1); + if (types.contains(ConnectionType.wifi)) indices.add(0); + if (types.contains(ConnectionType.wired)) indices.add(1); return indices; } -Set _indicesToConnections(Set indices) { - final types = {}; - if (indices.contains(0)) types.add(DeviceConnectionType.wifi); - if (indices.contains(1)) types.add(DeviceConnectionType.wired); +Set _indicesToConnections(Set indices) { + final types = {}; + if (indices.contains(0)) types.add(ConnectionType.wifi); + if (indices.contains(1)) types.add(ConnectionType.wired); return types; } @@ -555,17 +555,17 @@ class UspDeviceFilterChipBar extends ConsumerWidget { label: filter.connections.isEmpty ? loc(context).connection : filter.connections.length == 1 - ? (filter.connections.first == DeviceConnectionType.wifi + ? (filter.connections.first == ConnectionType.wifi ? loc(context).wifi : loc(context).ethernet) : '${loc(context).connection} (${filter.connections.length})', isActive: filter.connections.isNotEmpty, - onTap: () => _showMultiSelectPicker( + onTap: () => _showMultiSelectPicker( context: context, title: loc(context).connection, - items: DeviceConnectionType.values, + items: ConnectionType.values, selected: filter.connections, - labelOf: (v) => v == DeviceConnectionType.wifi + labelOf: (v) => v == ConnectionType.wifi ? loc(context).wifi : loc(context).ethernet, onChanged: notifier.setConnections, diff --git a/lib/page/devices/views/components/usp_device_list_tile.dart b/lib/page/devices/views/components/usp_device_list_tile.dart index bf78a270c..eac76e390 100644 --- a/lib/page/devices/views/components/usp_device_list_tile.dart +++ b/lib/page/devices/views/components/usp_device_list_tile.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:privacy_gui/core/utils/oui_lookup.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/components/usp_status_dot.dart'; import 'package:privacy_gui/page/devices/views/components/device_icon_with_badge.dart'; import 'package:privacy_gui/page/devices/views/components/usp_signal_strength_indicator.dart'; @@ -35,7 +35,7 @@ enum DeviceListTileVariant { /// - Set [variant] to [DeviceListTileVariant.flat] for embedded lists (e.g. /// inside a card) to avoid double card borders. class UspDeviceListTile extends StatelessWidget { - final DeviceUIModel device; + final ClientDevice device; final VoidCallback? onTap; final DeviceListTileVariant variant; diff --git a/lib/page/devices/views/usp_device_detail_view.dart b/lib/page/devices/views/usp_device_detail_view.dart index 28e2a5103..3949c2355 100644 --- a/lib/page/devices/views/usp_device_detail_view.dart +++ b/lib/page/devices/views/usp_device_detail_view.dart @@ -6,8 +6,8 @@ import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/core/utils/oui_lookup.dart'; import 'package:privacy_gui/route/constants.dart'; -import 'package:privacy_gui/page/_shared/extensions/device_ui_extensions.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart' + hide ConnectionType; import 'package:privacy_gui/page/_shared/components/usp_mutation_helper.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; @@ -82,7 +82,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // =========================================================================== Widget _buildMobileLayout(BuildContext context, WidgetRef ref, - DeviceUIModel device, DeviceDetailState detail, bool isLoading) { + ClientDevice device, DeviceDetailState detail, bool isLoading) { return Column( children: [ _buildDeviceIdentityCard(context, device), @@ -99,7 +99,7 @@ class _UspDeviceDetailViewState extends ConsumerState { } Widget _buildDesktopLayout(BuildContext context, WidgetRef ref, - DeviceUIModel device, DeviceDetailState detail, bool isLoading) { + ClientDevice device, DeviceDetailState detail, bool isLoading) { return Column( children: [ DetailGridRow( @@ -121,7 +121,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // Device Identity Card // =========================================================================== - Widget _buildDeviceIdentityCard(BuildContext context, DeviceUIModel device) { + Widget _buildDeviceIdentityCard(BuildContext context, ClientDevice device) { final classification = DeviceClassifier.classifyWithConfidence( hostname: device.hostName, mac: device.mac, @@ -214,8 +214,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // Connection Status Card // =========================================================================== - Widget _buildConnectionStatusCard( - BuildContext context, DeviceUIModel device) { + Widget _buildConnectionStatusCard(BuildContext context, ClientDevice device) { final hasMultipleInterfaces = device.hasMultipleInterfaces; return AppCard( @@ -264,7 +263,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // =========================================================================== Widget _buildMultiInterfaceSection( - BuildContext context, DeviceUIModel device) { + BuildContext context, ClientDevice device) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -423,7 +422,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // WiFi Details Card // =========================================================================== - Widget _buildWifiDetailsCard(BuildContext context, DeviceUIModel device) { + Widget _buildWifiDetailsCard(BuildContext context, ClientDevice device) { final hasSignalData = device.signalStrength != null; final hasSpeedData = device.downlinkRate != null || device.uplinkRate != null; @@ -467,39 +466,9 @@ class _UspDeviceDetailViewState extends ConsumerState { _buildSpeedRow(context, device), AppGap.sm(), ], - if (hasBandSsid || device.interfaceType?.isNotEmpty == true) + if (hasBandSsid) Row( children: [ - if (device.interfaceType?.isNotEmpty == true) ...[ - Expanded( - child: LayoutBlock( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.settings_input_antenna, - size: 16, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant), - AppGap.xs(), - AppText.labelSmall(loc(context).labelInterface, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant), - ], - ), - AppGap.xs(), - AppText.bodyMedium(device.interfaceType!), - ], - ), - ), - ), - if (device.band != null || device.ssidName != null) - AppGap.sm(), - ], if (device.band != null) ...[ Expanded( child: LayoutBlock( @@ -563,7 +532,7 @@ class _UspDeviceDetailViewState extends ConsumerState { ); } - Widget _buildSignalLayoutBlock(BuildContext context, DeviceUIModel device) { + Widget _buildSignalLayoutBlock(BuildContext context, ClientDevice device) { return LayoutBlock( padding: const EdgeInsets.all(AppSpacing.md), child: Row( @@ -595,7 +564,7 @@ class _UspDeviceDetailViewState extends ConsumerState { ); } - Widget _buildSpeedRow(BuildContext context, DeviceUIModel device) { + Widget _buildSpeedRow(BuildContext context, ClientDevice device) { final colorScheme = Theme.of(context).colorScheme; return Row( children: [ @@ -627,8 +596,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // Network Addresses Card (for non-active or wired devices on desktop) // =========================================================================== - Widget _buildNetworkAddressesCard( - BuildContext context, DeviceUIModel device) { + Widget _buildNetworkAddressesCard(BuildContext context, ClientDevice device) { final isWifi = device.isWifi; return AppCard( padding: const EdgeInsets.all(AppSpacing.md), @@ -667,7 +635,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // =========================================================================== Widget _buildDhcpCard(BuildContext context, WidgetRef ref, - DeviceUIModel device, DeviceDetailState detail, bool isLoading) { + ClientDevice device, DeviceDetailState detail, bool isLoading) { final colorScheme = Theme.of(context).colorScheme; final hasValidIpv4 = NetworkUtils.isValidIpAddress(device.ip); @@ -825,7 +793,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // =========================================================================== Future _reserveIp( - BuildContext context, WidgetRef ref, DeviceUIModel device) async { + BuildContext context, WidgetRef ref, ClientDevice device) async { await performUspMutation( context, ref, diff --git a/lib/page/firmware_update/providers/firmware_update_notifier.dart b/lib/page/firmware_update/providers/firmware_update_notifier.dart index d2a3e0e50..b944528df 100644 --- a/lib/page/firmware_update/providers/firmware_update_notifier.dart +++ b/lib/page/firmware_update/providers/firmware_update_notifier.dart @@ -9,7 +9,6 @@ import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/firmware_update/models/firmware_image_ui_model.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/firmware_update/models/firmware_ota_info.dart'; import 'package:privacy_gui/page/firmware_update/models/firmware_update_phase.dart'; import 'package:privacy_gui/page/firmware_update/models/firmware_update_state.dart'; @@ -89,15 +88,11 @@ class FirmwareUpdateNotifier extends AutoDisposeNotifier { /// Build OTA check parameters from device data providers. /// - /// Returns `null` if required data is unavailable (e.g., master node not found). + /// Returns `null` if required data is unavailable. Future buildOtaCheckParams() async { try { final devicesData = await ref.read(devicesDataProvider.future); - final masterNode = devicesData.nodeModels.master; - if (masterNode == null) { - logger.w('[FirmwareUpdate] Master node not found'); - return null; - } + final master = devicesData.master; final systemInfoData = await ref.read(systemInfoDataProvider.future); final hardwareVersion = @@ -107,9 +102,9 @@ class FirmwareUpdateNotifier extends AutoDisposeNotifier { final ipAddress = wanData.model.ipAddress; return FirmwareOtaCheckParams( - macAddress: _formatMacAddress(masterNode.deviceId), - installedVersion: masterNode.softwareVersion, - modelNumber: masterNode.model, + macAddress: _formatMacAddress(master.deviceId), + installedVersion: master.softwareVersion, + modelNumber: master.model, hardwareVersion: hardwareVersion, ipAddress: ipAddress, ); diff --git a/lib/page/instant_setup/models/pnp_state.dart b/lib/page/instant_setup/models/pnp_state.dart index a59d9ae85..5b5f7920d 100644 --- a/lib/page/instant_setup/models/pnp_state.dart +++ b/lib/page/instant_setup/models/pnp_state.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/internet_settings/models/usp_internet_settings_form.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'pnp_wifi_config.dart'; /// Whether this is a factory-default first-time setup or a reconfigure. @@ -138,7 +138,7 @@ class WizardInitializing extends PnpPhase { /// User is editing WiFi name / password / guest WiFi. class WizardConfiguring extends PnpPhase { final PnpWifiConfig wifiConfig; - final List meshNodes; + final List meshNodes; const WizardConfiguring({ required this.wifiConfig, diff --git a/lib/page/local_network/providers/ethernet_data_provider.dart b/lib/page/local_network/providers/ethernet_data_provider.dart index bca54c8ef..a8ec0b1d5 100644 --- a/lib/page/local_network/providers/ethernet_data_provider.dart +++ b/lib/page/local_network/providers/ethernet_data_provider.dart @@ -60,9 +60,9 @@ class EthernetDataNotifier extends AsyncNotifier { Future _fetch() async { final svc = ref.read(uspEthernetDataServiceProvider); final devicesData = ref.read(devicesDataProvider).valueOrNull; - final deviceModels = devicesData?.deviceModels ?? []; + final devices = devicesData?.clientDevices ?? []; - final result = await svc.fetch(deviceModels: deviceModels); + final result = await svc.fetch(deviceModels: devices); logger.d('[USP][Ethernet]: Fetch complete — ' '${result.portModels.length} port models'); diff --git a/lib/page/local_network/services/usp_ethernet_data_service.dart b/lib/page/local_network/services/usp_ethernet_data_service.dart index 486359e25..b838e37e6 100644 --- a/lib/page/local_network/services/usp_ethernet_data_service.dart +++ b/lib/page/local_network/services/usp_ethernet_data_service.dart @@ -5,7 +5,7 @@ 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'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/ethernet_port_ui_model.dart'; // --------------------------------------------------------------------------- @@ -49,7 +49,7 @@ class UspEthernetDataService { /// Fetches Ethernet interfaces + bridge port map, builds port UI models. Future fetch({ - required List deviceModels, + required List deviceModels, }) async { final List results; try { @@ -123,7 +123,7 @@ class UspEthernetDataService { /// active wired device is shown as its own LAN port entry. List _buildEthernetPortUIModels({ required EthernetInterfaces ethernetInterfaces, - required List deviceModels, + required List deviceModels, Map bridgePortMap = const {}, }) { final result = []; @@ -156,7 +156,6 @@ class UspEthernetDataService { final wiredConnections = <({String displayName, String mac, String ip})>[]; for (final d in deviceModels) { - if (!d.isClientDevice) continue; // Check primary interface if (d.isActive && !d.isWifi) { wiredConnections.add(( diff --git a/lib/page/statistics/views/sections/stats_wifi_signal_section.dart b/lib/page/statistics/views/sections/stats_wifi_signal_section.dart index a18e60ecf..f25c88eb7 100644 --- a/lib/page/statistics/views/sections/stats_wifi_signal_section.dart +++ b/lib/page/statistics/views/sections/stats_wifi_signal_section.dart @@ -53,7 +53,7 @@ class StatsWifiSignalSection extends ConsumerWidget { for (final entry in wifiData.wifiClientMap.entries) { final client = entry.value; if (!client.active) continue; - final device = devicesData?.deviceModels + final device = devicesData?.clientDevices .where((d) => d.mac.toUpperCase() == entry.key.toUpperCase()) .firstOrNull; final name = device?.hostName ?? entry.key; diff --git a/lib/page/statistics/views/sections/stats_wifi_speed_section.dart b/lib/page/statistics/views/sections/stats_wifi_speed_section.dart index 5e815657c..0026c065e 100644 --- a/lib/page/statistics/views/sections/stats_wifi_speed_section.dart +++ b/lib/page/statistics/views/sections/stats_wifi_speed_section.dart @@ -52,7 +52,7 @@ class StatsWifiSpeedSection extends ConsumerWidget { for (final entry in wifiData.wifiClientMap.entries) { final client = entry.value; if (!client.active) continue; - final device = devicesData?.deviceModels + final device = devicesData?.clientDevices .where((d) => d.mac.toUpperCase() == entry.key.toUpperCase()) .firstOrNull; final name = device?.hostName ?? entry.key; diff --git a/lib/page/topology/cards/usp_network_topology_card.dart b/lib/page/topology/cards/usp_network_topology_card.dart index d9aef7d3a..6fb65f0d0 100644 --- a/lib/page/topology/cards/usp_network_topology_card.dart +++ b/lib/page/topology/cards/usp_network_topology_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; @@ -9,7 +9,6 @@ import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/topology/helpers/topology_node_content_builder.dart'; import 'package:privacy_gui/page/topology/helpers/usp_topology_builder.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/views/components/node_detail_popup.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -22,14 +21,12 @@ import 'package:ui_kit_library/ui_kit.dart'; /// gateway and their connected clients. class UspNetworkTopologyCard extends ConsumerWidget { final SystemInfoUIModel? info; - final List? devices; - final List? nodeModels; + final MeshNetwork? meshNetwork; const UspNetworkTopologyCard({ super.key, this.info, - this.devices, - this.nodeModels, + this.meshNetwork, }); @override @@ -37,17 +34,16 @@ class UspNetworkTopologyCard extends ConsumerWidget { final devicesData = ref.watch(devicesDataProvider).valueOrNull; final info = this.info ?? ref.watch(systemInfoDataProvider).valueOrNull?.model; - if (info == null) return const CardSkeleton.topology(); - final devices = this.devices ?? devicesData?.deviceModels ?? []; - final nodeModels = this.nodeModels ?? devicesData?.nodeModels ?? []; - final topology = UspTopologyBuilder.build( + final meshNetwork = this.meshNetwork ?? devicesData?.meshNetwork; + if (info == null || meshNetwork == null) + return const CardSkeleton.topology(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, info: info, - devices: devices, - nodeModels: nodeModels, ); - final onlineCount = devicesData?.onlineClientCount ?? - devices.where((d) => d.isActive).length; - final totalCount = devicesData?.totalClientCount ?? devices.length; + final onlineCount = meshNetwork.onlineClientCount; + final totalCount = meshNetwork.totalClientCount; final useRing = totalCount >= 8; return DashboardCardTemplate( diff --git a/lib/page/topology/helpers/usp_topology_builder.dart b/lib/page/topology/helpers/usp_topology_builder.dart index d991f23c5..f7d9b23d0 100644 --- a/lib/page/topology/helpers/usp_topology_builder.dart +++ b/lib/page/topology/helpers/usp_topology_builder.dart @@ -1,11 +1,11 @@ import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/core/utils/device_image_helper.dart'; import 'package:privacy_gui/core/utils/icon_rules.dart'; -import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/utils/wifi.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart' + hide ConnectionType; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// Builds a [MeshTopology] from USP dashboard state for [AppTopology] widget. @@ -14,195 +14,164 @@ import 'package:ui_kit_library/ui_kit.dart'; class UspTopologyBuilder { UspTopologyBuilder._(); - static MeshTopology build({ + /// Builds topology from new [MeshNetwork] architecture. + /// + /// Preferred method — uses SSoT container with pre-organized nodes and clients. + static MeshTopology buildFromMeshNetwork({ + required MeshNetwork meshNetwork, required SystemInfoUIModel info, - required List devices, - required List nodeModels, }) { final nodes = []; final links = []; - // Find master node from nodeModels - final masterNode = nodeModels.master; + final master = meshNetwork.master; - // Gateway node (the router) + // Gateway node const gatewayId = 'gateway'; - // Use master node's deviceId (MAC) for navigation, fallback to 'gateway' - final gatewayDeviceId = masterNode?.deviceId ?? 'gateway'; - final gatewayIconName = routerIconTestByModel( - modelNumber: info.modelName, + modelNumber: master.model.isNotEmpty ? master.model : info.modelName, hardwareVersion: info.hardwareVersion, ); nodes.add(MeshNode( id: gatewayId, - name: masterNode?.displayName ?? info.gatewayName, + name: + master.displayName.isNotEmpty ? master.displayName : info.gatewayName, type: MeshNodeType.gateway, status: MeshNodeStatus.online, image: DeviceImageHelper.getRouterImage(gatewayIconName), - extra: info.manufacturer, + extra: master.manufacturer.isNotEmpty + ? master.manufacturer + : info.manufacturer, level: 1.0, metadata: { - 'deviceId': gatewayDeviceId, - 'model': masterNode?.model ?? info.modelName, - 'manufacturer': masterNode?.manufacturer ?? info.manufacturer, - 'serialNumber': masterNode?.serialNumber ?? info.serialNumber, - 'softwareVersion': masterNode?.softwareVersion ?? info.softwareVersion, + 'deviceId': master.deviceId, + 'model': master.model.isNotEmpty ? master.model : info.modelName, + 'manufacturer': master.manufacturer.isNotEmpty + ? master.manufacturer + : info.manufacturer, + 'serialNumber': master.serialNumber.isNotEmpty + ? master.serialNumber + : info.serialNumber, + 'softwareVersion': master.softwareVersion.isNotEmpty + ? master.softwareVersion + : info.softwareVersion, 'isMaster': true, }, )); - // Mesh extender nodes (slave nodes) - final slaveNodes = nodeModels.slaves; - final hasMesh = nodeModels.hasMesh; - logger.d('[USP][TopologyBuilder]: hasMesh=$hasMesh, ' - 'slaveNodes=${slaveNodes.length}, ' - 'slaveDeviceIds=${slaveNodes.map((n) => '${n.deviceId}|DE:${n.dataElementsId}').toList()}'); - // Normalized set (no colons, uppercase) for matching against parentNodeId - // which comes from DataElements clientToNodeMap (no colons). - // We add BOTH deviceId (from Hosts) and dataElementsId (from DataElements) - // since they may be different MAC addresses for the same node. + // Build extender ID lookup maps final extenderNodeIdsNormalized = {}; - // Map from normalized ID back to original deviceId for building 'extender-X' IDs. final normalizedToOriginal = {}; - // Map from normalized Device ID to extender node ID (for parent resolution) final deviceIdToExtenderId = {}; - for (final slaveNode in slaveNodes) { - final extenderId = 'extender-${slaveNode.deviceId}'; - // Add Hosts MAC (deviceId) + for (final slave in meshNetwork.slaves) { + final extenderId = 'extender-${slave.deviceId}'; final normalizedHostsMac = - slaveNode.deviceId.toUpperCase().replaceAll(':', ''); + slave.deviceId.toUpperCase().replaceAll(':', ''); extenderNodeIdsNormalized.add(normalizedHostsMac); - normalizedToOriginal[normalizedHostsMac] = slaveNode.deviceId; + normalizedToOriginal[normalizedHostsMac] = slave.deviceId; deviceIdToExtenderId[normalizedHostsMac] = extenderId; - // Also add DataElements ID if different (may be a different interface MAC) - if (slaveNode.dataElementsId != null && - slaveNode.dataElementsId!.isNotEmpty) { + + if (slave.dataElementsId != null && slave.dataElementsId!.isNotEmpty) { final normalizedDeMac = - slaveNode.dataElementsId!.toUpperCase().replaceAll(':', ''); + slave.dataElementsId!.toUpperCase().replaceAll(':', ''); if (normalizedDeMac != normalizedHostsMac) { extenderNodeIdsNormalized.add(normalizedDeMac); - normalizedToOriginal[normalizedDeMac] = slaveNode.deviceId; + normalizedToOriginal[normalizedDeMac] = slave.deviceId; deviceIdToExtenderId[normalizedDeMac] = extenderId; } } - logger.d('[USP][TopologyBuilder]: Slave ${slaveNode.deviceId} ' - '→ hostsMac: $normalizedHostsMac, ' - 'dataElementsId: ${slaveNode.dataElementsId}, ' - 'backhaulParentDeviceId: ${slaveNode.backhaulParentDeviceId}'); } - // Helper to resolve slave parent ID using backhaulParentDeviceId - String resolveSlaveParentId(NodeUIModel slaveNode) { - final parentDeviceId = slaveNode.backhaulParentDeviceId; - if (parentDeviceId == null || parentDeviceId.isEmpty) { - return gatewayId; - } - final normalizedParentId = - parentDeviceId.toUpperCase().replaceAll(':', ''); - return deviceIdToExtenderId[normalizedParentId] ?? gatewayId; - } + // Slave nodes + for (final slave in meshNetwork.slaves) { + final extenderId = 'extender-${slave.deviceId}'; - // Build extender nodes and links - for (final slaveNode in slaveNodes) { - final extenderId = 'extender-${slaveNode.deviceId}'; - final parentId = resolveSlaveParentId(slaveNode); + // Resolve parent + String parentId = gatewayId; + final parentDeviceId = slave.backhaul.parentNodeId; + if (parentDeviceId != null && parentDeviceId.isNotEmpty) { + final normalizedParentId = + parentDeviceId.toUpperCase().replaceAll(':', ''); + parentId = deviceIdToExtenderId[normalizedParentId] ?? gatewayId; + } - final extenderIconName = routerIconTestByModel( - modelNumber: slaveNode.model, - ); + final extenderIconName = routerIconTestByModel(modelNumber: slave.model); nodes.add(MeshNode( id: extenderId, - name: slaveNode.displayName, + name: slave.displayName, type: MeshNodeType.extender, status: MeshNodeStatus.online, parentId: parentId, image: DeviceImageHelper.getRouterImage(extenderIconName), - level: _backhaulRssiToLevel(slaveNode.backhaulSignalStrength), + level: _backhaulRssiToLevel(slave.backhaul.signalStrength), metadata: { - 'deviceId': slaveNode.deviceId, - 'model': slaveNode.model, - 'manufacturer': slaveNode.manufacturer, - 'serialNumber': slaveNode.serialNumber, - 'softwareVersion': slaveNode.softwareVersion, + 'deviceId': slave.deviceId, + 'model': slave.model, + 'manufacturer': slave.manufacturer, + 'serialNumber': slave.serialNumber, + 'softwareVersion': slave.softwareVersion, 'isMaster': false, - 'backhaulLinkType': slaveNode.backhaulLinkType, - 'backhaulParentDeviceId': slaveNode.backhaulParentDeviceId, - 'backhaulSignalStrength': slaveNode.backhaulSignalStrength, - 'backhaulUplinkRate': slaveNode.backhaulUplinkRate, - 'backhaulDownlinkRate': slaveNode.backhaulDownlinkRate, - 'lastContactTime': slaveNode.lastContactTime, + 'backhaulLinkType': slave.backhaul.linkType, + 'backhaulParentDeviceId': slave.backhaul.parentNodeId, + 'backhaulSignalStrength': slave.backhaul.signalStrength, + 'backhaulUplinkRate': slave.backhaul.uplinkRate, + 'backhaulDownlinkRate': slave.backhaul.downlinkRate, + 'lastContactTime': slave.backhaul.lastContactTime, }, )); links.add(MeshLink( sourceId: parentId, targetId: extenderId, - connectionType: slaveNode.backhaulLinkType == 'Ethernet' + connectionType: slave.backhaul.isEthernet ? ConnectionType.ethernet : ConnectionType.wifi, - rssi: slaveNode.backhaulSignalStrength, - linkQuality: _rssiToLinkQuality(slaveNode.backhaulSignalStrength), - throughput: slaveNode.backhaulUplinkRate != null - ? slaveNode.backhaulUplinkRate! / 1000.0 + rssi: slave.backhaul.signalStrength, + linkQuality: _rssiToLinkQuality(slave.backhaul.signalStrength), + throughput: slave.backhaul.uplinkRate != null + ? slave.backhaul.uplinkRate! / 1000.0 : null, )); } - // Client nodes from DeviceUIModel (excluding mesh nodes) - for (final device in devices) { - // Skip devices that are mesh nodes (master/slave) — already rendered - // as gateway or extenders. Only show "client" role devices. - if (device.isMeshNode) { - continue; - } + // Client devices — use allClients which includes master + slave clients + for (final client in meshNetwork.allClients) { + final clientId = 'client-${client.mac}'; + final isEthernet = !client.isWifi; - final clientId = 'client-${device.mac}'; - final isEthernet = !device.isWifi; - - // Determine parent: use parentNodeId from UI Model. - // parentNodeId comes from DataElements clientToNodeMap (no colons), - // so we normalize it before matching against extenderNodeIdsNormalized. + // Determine parent node String parentId = gatewayId; - if (hasMesh && device.parentNodeId != null) { + if (meshNetwork.hasMesh && client.parentNodeId != null) { final parentNormalized = - device.parentNodeId!.toUpperCase().replaceAll(':', ''); - logger.d('[USP][TopologyBuilder]: Device ${device.displayName} ' - 'parentNodeId=${device.parentNodeId}, ' - 'normalized=$parentNormalized, ' - 'inExtenders=${extenderNodeIdsNormalized.contains(parentNormalized)}'); + client.parentNodeId!.toUpperCase().replaceAll(':', ''); if (extenderNodeIdsNormalized.contains(parentNormalized)) { final originalDeviceId = normalizedToOriginal[parentNormalized]!; parentId = 'extender-$originalDeviceId'; } - } else { - logger.d('[USP][TopologyBuilder]: Device ${device.displayName} ' - 'hasMesh=$hasMesh, parentNodeId=${device.parentNodeId} → gateway'); } - // Classify device for icon final category = DeviceClassifier.classify( - hostname: device.displayName, - mac: device.mac, + hostname: client.displayName, + mac: client.mac, ); nodes.add(MeshNode( id: clientId, - name: device.displayName, + name: client.displayName, type: MeshNodeType.client, status: - device.isActive ? MeshNodeStatus.online : MeshNodeStatus.offline, + client.isOnline ? MeshNodeStatus.online : MeshNodeStatus.offline, parentId: parentId, iconData: category.icon, - extra: device.ip, - linkQuality: _resolveLinkQuality(device), - level: _rssiToLevel(device), + extra: client.ip, + linkQuality: _resolveLinkQualityForClient(client), + level: _rssiToLevelForClient(client), metadata: { - 'mac': device.mac, - 'hasMultipleInterfaces': device.hasMultipleInterfaces, - 'interfaceCount': device.interfaceCount, - 'allMacAddresses': device.allMacAddresses, + 'mac': client.mac, + 'hasMultipleInterfaces': client.hasMultipleInterfaces, + 'interfaceCount': client.interfaceCount, + 'allMacAddresses': client.allMacAddresses, }, )); @@ -211,13 +180,14 @@ class UspTopologyBuilder { targetId: clientId, connectionType: isEthernet ? ConnectionType.ethernet : ConnectionType.wifi, - rssi: device.signalStrength, + rssi: client.signalStrength, linkQuality: isEthernet ? LinkQuality.stable - : _rssiToLinkQuality(device.signalStrength), - throughput: - device.totalThroughput > 0 ? device.totalThroughput / 1000.0 : null, - distanceFactor: _rssiToDistanceFactor(device.signalStrength), + : _rssiToLinkQuality(client.signalStrength), + throughput: (client.downlinkRate ?? 0) + (client.uplinkRate ?? 0) > 0 + ? ((client.downlinkRate ?? 0) + (client.uplinkRate ?? 0)) / 1000.0 + : null, + distanceFactor: _rssiToDistanceFactor(client.signalStrength), )); } @@ -228,9 +198,14 @@ class UspTopologyBuilder { ); } - static double _rssiToLevel(DeviceUIModel device) { - if (!device.isWifi) return 1.0; - return _rssiValueToLevel(device.signalStrength); + static double _rssiToLevelForClient(ClientDevice client) { + if (!client.isWifi) return 1.0; + return _rssiValueToLevel(client.signalStrength); + } + + static LinkQuality _resolveLinkQualityForClient(ClientDevice client) { + if (!client.isWifi) return LinkQuality.stable; + return _rssiToLinkQuality(client.signalStrength); } /// Converts backhaul RSSI to level for extender nodes. @@ -253,11 +228,6 @@ class UspTopologyBuilder { }; } - static LinkQuality _resolveLinkQuality(DeviceUIModel device) { - if (!device.isWifi) return LinkQuality.stable; - return _rssiToLinkQuality(device.signalStrength); - } - /// Converts RSSI to LinkQuality using wifi.dart thresholds. /// /// Maps [NodeSignalLevel] 1:1 to [LinkQuality] for consistency with @@ -282,16 +252,8 @@ class UspTopologyBuilder { } /// Maps RSSI (dBm) to normalized distance factor [0.0, 1.0]. - /// - /// Aligned with [signalThresholdRSSI] from wifi.dart: [-65, -71, -78] - /// - >= -65 (excellent): close (0.0 - 0.25) - /// - >= -78 (fair): medium (0.25 - 0.75) - /// - < -78 (poor): far (0.75 - 1.0) - /// - /// Ethernet (null RSSI) → null (use default spacing). static double? _rssiToDistanceFactor(int? rssi) { if (rssi == null) return null; - // Range: -90 (far) to -50 (close) final clamped = rssi.clamp(-90, -50); return (clamped - (-50)).abs() / 40.0; } diff --git a/lib/page/topology/models/node_ui_model.dart b/lib/page/topology/models/node_ui_model.dart deleted file mode 100644 index c99f7e5a5..000000000 --- a/lib/page/topology/models/node_ui_model.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Presentation Layer Model for a mesh node. -/// -/// UI widgets depend only on this class, never directly on codegen Data Models. -/// Naming follows constitution Section 3.3.4 (class name ends with `UIModel`). -/// Implements [Equatable] per Article XI. -class NodeUIModel extends Equatable { - final String deviceId; // MAC of the mesh node (from Hosts) - - // ─── DataElements ID (may differ from deviceId) ─── - final String? - dataElementsId; // node.id from DataElements (used in clientToNodeMap) - - // ─── Name fields (from Hosts) ─── - final String? friendlyName; // User-friendly name from Hosts - final String? hostName; // Hostname from Hosts - - final String model; // ManufacturerModel (e.g., MR7500) - final String manufacturer; - final String serialNumber; - final String softwareVersion; - final bool isMaster; // First node in DataElements = gateway - final int connectedDeviceCount; // Devices connected to this node - - // ─── Network addresses ─── - final String? ipAddress; // LAN IP address of the node - final List ipv6Addresses; // LAN IPv6 addresses of the node - final String? wanIpAddress; // WAN IP address (master only) - - // ─── Backhaul info (for child nodes) ─── - final String backhaulMediaType; // "IEEE 802.11ax" / "Ethernet" - final int backhaulPhyRate; // PHY rate in Mbps - final int? backhaulSignalStrength; // RSSI in dBm (converted from RCPI) - final int? backhaulUplinkRate; // kbps (from TR-181 LastDataUplinkRate) - - // ─── DataElements enrichment (Service internal use) ─── - final String? instancePath; // DataElements instance path - final String? backhaulAlId; // Parent node's AL ID (MAC) - final String? backhaulMacAddress; // Backhaul interface MAC - - // ─── Enhanced backhaul fields (from codegen) ─── - final String? backhaulLinkType; // "Wi-Fi" or "Ethernet" - final int? backhaulDownlinkRate; // kbps (from TR-181 LastDataDownlinkRate) - final String? backhaulParentDeviceId; // Parent node's Device ID - final String? backhaulParentBssid; // Connected BSSID - final String? lastContactTime; // ISO 8601 timestamp - - const NodeUIModel({ - required this.deviceId, - this.dataElementsId, - this.friendlyName, - this.hostName, - required this.model, - this.manufacturer = '', - this.serialNumber = '', - this.softwareVersion = '', - this.isMaster = false, - this.connectedDeviceCount = 0, - this.ipAddress, - this.ipv6Addresses = const [], - this.wanIpAddress, - this.backhaulMediaType = '', - this.backhaulPhyRate = 0, - this.backhaulSignalStrength, - this.backhaulUplinkRate, - this.instancePath, - this.backhaulAlId, - this.backhaulMacAddress, - this.backhaulLinkType, - this.backhaulDownlinkRate, - this.backhaulParentDeviceId, - this.backhaulParentBssid, - this.lastContactTime, - }); - - /// Display name priority: friendlyName > hostName > model > deviceId. - String get displayName { - if (friendlyName != null && friendlyName!.isNotEmpty) return friendlyName!; - if (hostName != null && hostName!.isNotEmpty) return hostName!; - if (model.isNotEmpty) return model; - return deviceId; - } - - /// Role label for UI display. - String get roleLabel => isMaster ? 'Master' : 'Slave'; - - /// Whether this node has backhaul info (i.e., it's a child node). - bool get hasBackhaul => backhaulMediaType.isNotEmpty; - - /// Whether backhaul connection is Ethernet (wired). - bool get isEthernetBackhaul => backhaulLinkType == 'Ethernet'; - - @override - List get props => [ - deviceId, - dataElementsId, - friendlyName, - hostName, - model, - manufacturer, - serialNumber, - softwareVersion, - isMaster, - connectedDeviceCount, - ipAddress, - ipv6Addresses, - wanIpAddress, - backhaulMediaType, - backhaulPhyRate, - backhaulSignalStrength, - backhaulUplinkRate, - instancePath, - backhaulAlId, - backhaulMacAddress, - backhaulLinkType, - backhaulDownlinkRate, - backhaulParentDeviceId, - backhaulParentBssid, - lastContactTime, - ]; -} - -/// Extension methods for List to simplify common filtering. -extension NodeUIModelListExt on List { - /// Returns the master (gateway) node, or null if not found. - NodeUIModel? get master => where((n) => n.isMaster).firstOrNull; - - /// Returns all slave (extender) nodes. - List get slaves => where((n) => !n.isMaster).toList(); - - /// Whether this topology has mesh extenders. - bool get hasMesh => slaves.isNotEmpty; -} diff --git a/lib/page/topology/providers/node_detail_provider.dart b/lib/page/topology/providers/node_detail_provider.dart index f4bb9693b..0ef467986 100644 --- a/lib/page/topology/providers/node_detail_provider.dart +++ b/lib/page/topology/providers/node_detail_provider.dart @@ -1,67 +1,67 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; /// Computed provider — looks up a single node + its connected devices by deviceId. +/// +/// Uses [MeshNetwork] for direct node lookup and pre-organized connected clients. final uspNodeDetailProvider = Provider.family((ref, deviceId) { final data = ref.watch(devicesDataProvider).valueOrNull; - if (data == null) return UspNodeDetailState.empty(); + final meshNetwork = data?.meshNetwork; - final node = data.nodeModels - .where((n) => n.deviceId.toUpperCase() == deviceId.toUpperCase()) - .firstOrNull; + if (meshNetwork == null) return UspNodeDetailState.empty(); + final node = meshNetwork.findNode(deviceId); if (node == null) return UspNodeDetailState.empty(); - // Look up parent node using backhaulParentDeviceId (parent's Device ID) - NodeUIModel? parentNode; - if (node.backhaulParentDeviceId != null && - node.backhaulParentDeviceId!.isNotEmpty) { - final parentId = - node.backhaulParentDeviceId!.toUpperCase().replaceAll(':', ''); - parentNode = data.nodeModels.where((n) { - final nodeId = n.deviceId.toUpperCase().replaceAll(':', ''); - final nodeDeId = n.dataElementsId?.toUpperCase().replaceAll(':', ''); - return nodeId == parentId || nodeDeId == parentId; - }).firstOrNull; + // Look up parent node for slaves + NodeEntity? parentNode; + if (node is SlaveNode && node.backhaul.parentNodeId != null) { + parentNode = meshNetwork.findNode(node.backhaul.parentNodeId!); } - // For non-mesh routers the synthetic gateway uses deviceId 'gateway', - // and devices have parentNodeId == null (no DataElements mapping). - // Treat null parentNodeId as "connected to gateway". - final isGatewayLookup = deviceId.toUpperCase() == 'GATEWAY'; - final connectedDevices = data.deviceModels - .where((d) => - (d.parentNodeId != null && - d.parentNodeId!.toUpperCase() == deviceId.toUpperCase()) || - (isGatewayLookup && d.parentNodeId == null)) - .toList(); - return UspNodeDetailState( node: node, parentNode: parentNode, - connectedDevices: connectedDevices, + connectedClients: node.connectedClients, ); }); +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + class UspNodeDetailState extends Equatable { - final NodeUIModel? node; - final NodeUIModel? parentNode; - final List connectedDevices; + final NodeEntity? node; + final NodeEntity? parentNode; + final List connectedClients; const UspNodeDetailState({ this.node, this.parentNode, - this.connectedDevices = const [], + this.connectedClients = const [], }); factory UspNodeDetailState.empty() => const UspNodeDetailState(); - int get activeDeviceCount => connectedDevices.where((d) => d.isActive).length; + /// Whether data is available. + bool get hasData => node != null; + + /// Active (online) client count. + int get activeClientCount => connectedClients.where((c) => c.isOnline).length; + + /// Total connected client count. + int get totalClientCount => connectedClients.length; + + /// Node display name. + String get displayName => node?.displayName ?? ''; + + /// Whether this is the master node. + bool get isMaster => node?.isMaster ?? false; @override - List get props => [node, parentNode, connectedDevices]; + List get props => [node, parentNode, connectedClients]; } diff --git a/lib/page/topology/views/usp_node_detail_view.dart b/lib/page/topology/views/usp_node_detail_view.dart index de0344ea4..b6d9a40c3 100644 --- a/lib/page/topology/views/usp_node_detail_view.dart +++ b/lib/page/topology/views/usp_node_detail_view.dart @@ -9,10 +9,10 @@ import 'package:privacy_gui/components/ui_kit_page_view.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/views/components/usp_device_list_tile.dart'; import 'package:privacy_gui/page/internet_settings/providers/wan_data_provider.dart'; import 'package:privacy_gui/page/shell/usp_top_bar.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/providers/node_detail_provider.dart'; import 'package:privacy_gui/page/topology/views/components/backhaul_signal_indicator.dart'; import 'package:privacy_gui/util/date_format_utils.dart'; @@ -71,13 +71,13 @@ class UspNodeDetailView extends ConsumerWidget { // =========================================================================== Widget _buildMobileLayout(BuildContext context, WidgetRef ref, - NodeUIModel node, UspNodeDetailState detail) { + NodeEntity node, UspNodeDetailState detail) { return Column( children: [ _buildNodeInfoCard(context, node), AppGap.lg(), _buildNetworkCard(context, ref, node), - if (!node.isMaster && node.hasBackhaul) ...[ + if (node is SlaveNode) ...[ AppGap.lg(), _buildBackhaulCard(context, node, detail.parentNode), ], @@ -88,7 +88,7 @@ class UspNodeDetailView extends ConsumerWidget { } Widget _buildDesktopLayout(BuildContext context, WidgetRef ref, - NodeUIModel node, UspNodeDetailState detail) { + NodeEntity node, UspNodeDetailState detail) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -99,7 +99,7 @@ class UspNodeDetailView extends ConsumerWidget { _buildNodeInfoCard(context, node), AppGap.lg(), _buildNetworkCard(context, ref, node), - if (!node.isMaster && node.hasBackhaul) ...[ + if (node is SlaveNode) ...[ AppGap.lg(), _buildBackhaulCard(context, node, detail.parentNode), ], @@ -119,7 +119,7 @@ class UspNodeDetailView extends ConsumerWidget { // Node Info Card // =========================================================================== - Widget _buildNodeInfoCard(BuildContext context, NodeUIModel node) { + Widget _buildNodeInfoCard(BuildContext context, NodeEntity node) { final colorScheme = Theme.of(context).colorScheme; return AppCard( padding: const EdgeInsets.all(AppSpacing.md), @@ -154,7 +154,9 @@ class UspNodeDetailView extends ConsumerWidget { AppGap.xs(), DetailStatusBadge( isActive: true, - activeLabel: node.roleLabel, + activeLabel: node.isMaster + ? loc(context).master + : loc(context).slave, ), ], ), @@ -207,7 +209,7 @@ class UspNodeDetailView extends ConsumerWidget { // =========================================================================== Widget _buildNetworkCard( - BuildContext context, WidgetRef ref, NodeUIModel node) { + BuildContext context, WidgetRef ref, NodeEntity node) { final wanData = node.isMaster ? ref.watch(wanDataProvider).valueOrNull?.model : null; final wanIp = wanData?.ipAddress; @@ -265,9 +267,10 @@ class UspNodeDetailView extends ConsumerWidget { // =========================================================================== Widget _buildBackhaulCard( - BuildContext context, NodeUIModel node, NodeUIModel? parentNode) { + BuildContext context, SlaveNode node, NodeEntity? parentNode) { final colorScheme = Theme.of(context).colorScheme; - final isWifiBackhaul = !node.isEthernetBackhaul; + final backhaul = node.backhaul; + final isWifiBackhaul = !backhaul.isEthernet; return AppCard( padding: const EdgeInsets.all(AppSpacing.md), @@ -299,7 +302,7 @@ class UspNodeDetailView extends ConsumerWidget { AppText.labelSmall(loc(context).connectedTo, color: colorScheme.onSurfaceVariant), AppText.bodyMedium( - '${parentNode.roleLabel} (${parentNode.model})'), + '${parentNode.isMaster ? loc(context).master : loc(context).slave} (${parentNode.model})'), ], ), ), @@ -331,16 +334,16 @@ class UspNodeDetailView extends ConsumerWidget { ), AppGap.xs(), AppText.bodyMedium( - node.backhaulLinkType ?? node.backhaulMediaType), + backhaul.linkType ?? backhaul.mediaType), ], ), ), ), - if (node.backhaulSignalStrength != null) ...[ + if (backhaul.signalStrength != null) ...[ AppGap.sm(), Expanded( - child: BackhaulSignalIndicator( - rssi: node.backhaulSignalStrength!), + child: + BackhaulSignalIndicator(rssi: backhaul.signalStrength!), ), ], ], @@ -363,36 +366,34 @@ class UspNodeDetailView extends ConsumerWidget { ], ), AppGap.xs(), - AppText.bodyMedium( - node.backhaulLinkType ?? node.backhaulMediaType), + AppText.bodyMedium(backhaul.linkType ?? backhaul.mediaType), ], ), ), AppGap.sm(), ], // Throughput Block - if (node.backhaulUplinkRate != null || - node.backhaulDownlinkRate != null) ...[ + if (backhaul.uplinkRate != null || backhaul.downlinkRate != null) ...[ Row( children: [ - if (node.backhaulUplinkRate != null) + if (backhaul.uplinkRate != null) Expanded( child: DetailSpeedCard( icon: Icons.upload, label: loc(context).upload, - speedKbps: node.backhaulUplinkRate!, + speedKbps: backhaul.uplinkRate!, color: colorScheme.tertiary, ), ), - if (node.backhaulUplinkRate != null && - node.backhaulDownlinkRate != null) + if (backhaul.uplinkRate != null && + backhaul.downlinkRate != null) AppGap.sm(), - if (node.backhaulDownlinkRate != null) + if (backhaul.downlinkRate != null) Expanded( child: DetailSpeedCard( icon: Icons.download, label: loc(context).download, - speedKbps: node.backhaulDownlinkRate!, + speedKbps: backhaul.downlinkRate!, color: colorScheme.primary, ), ), @@ -401,10 +402,10 @@ class UspNodeDetailView extends ConsumerWidget { AppGap.sm(), ], // PHY Rate + Last Contact row - if (node.backhaulPhyRate > 0 || node.lastContactTime != null) + if (backhaul.phyRate > 0 || backhaul.lastContactTime != null) Row( children: [ - if (node.backhaulPhyRate > 0) + if (backhaul.phyRate > 0) Expanded( child: LayoutBlock( padding: const EdgeInsets.all(AppSpacing.md), @@ -423,14 +424,14 @@ class UspNodeDetailView extends ConsumerWidget { ), AppGap.xs(), AppText.bodyMedium(NetworkUtils.formatSpeed( - node.backhaulPhyRate * 1000)), + backhaul.phyRate * 1000)), ], ), ), ), - if (node.backhaulPhyRate > 0 && node.lastContactTime != null) + if (backhaul.phyRate > 0 && backhaul.lastContactTime != null) AppGap.sm(), - if (node.lastContactTime != null) + if (backhaul.lastContactTime != null) Expanded( child: LayoutBlock( padding: const EdgeInsets.all(AppSpacing.md), @@ -449,7 +450,7 @@ class UspNodeDetailView extends ConsumerWidget { ), AppGap.xs(), AppText.bodyMedium(DateFormatUtils.formatRelativeTime( - node.lastContactTime)), + backhaul.lastContactTime)), ], ), ), @@ -467,8 +468,8 @@ class UspNodeDetailView extends ConsumerWidget { Widget _buildConnectedDevicesCard( BuildContext context, UspNodeDetailState detail) { - final devices = detail.connectedDevices; - final activeCount = detail.activeDeviceCount; + final devices = detail.connectedClients; + final activeCount = detail.activeClientCount; return AppCard( padding: const EdgeInsets.all(AppSpacing.md), diff --git a/lib/page/topology/views/usp_topology_view.dart b/lib/page/topology/views/usp_topology_view.dart index 8949c99ae..eb3a2ff31 100644 --- a/lib/page/topology/views/usp_topology_view.dart +++ b/lib/page/topology/views/usp_topology_view.dart @@ -53,11 +53,12 @@ class _UspTopologyViewState extends ConsumerState { ), data: (data) { final sysInfo = ref.read(systemInfoDataProvider).valueOrNull?.model; - if (sysInfo == null) return const SizedBox.shrink(); - final topology = UspTopologyBuilder.build( + if (sysInfo == null) { + return const SizedBox.shrink(); + } + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: data.meshNetwork, info: sysInfo, - devices: data.deviceModels, - nodeModels: data.nodeModels, ); return _buildTopologyCard(context, topology); diff --git a/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart b/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart index bc51176c5..036188739 100644 --- a/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart +++ b/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart @@ -4,7 +4,6 @@ import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/_shared/components/wifi_ui.dart'; -import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:privacy_gui/page/_shared/providers/card_tab_state_provider.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; @@ -25,27 +24,43 @@ class UspWifiPerformanceCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final wifiData = ref.watch(wifiDataProvider).valueOrNull; - if (wifiData == null) return const CardSkeleton.chart(); final devicesData = ref.watch(devicesDataProvider).valueOrNull; + // Wait for both providers to load — devicesData contains meshNetwork with + // slave node clients, wifiData contains radioModels for Channels tab + if (wifiData == null || devicesData == null) { + return const CardSkeleton.chart(); + } final selectedTab = ref.watch(cardTabIndexProvider(_cardId)); - // Collect active WiFi clients with their device names and band info + // Collect backhaul MACs from slave nodes to filter out mesh node STAs + final backhaulMacs = {}; + for (final slave in devicesData.meshNetwork.slaves) { + final mac = slave.backhaul.backhaulMacAddress; + if (mac != null && mac.isNotEmpty) { + backhaulMacs.add(mac.toUpperCase()); + } + } + + // Collect active WiFi clients from MeshNetwork (includes slave node clients) + // Also enrich with master-only data (noise, rates) from wifiClientMap final activeClients = <_ClientInfo>[]; - for (final entry in wifiData.wifiClientMap.entries) { - final client = entry.value; - if (!client.active) continue; - // Resolve display name from deviceModels - final device = devicesData?.deviceModels - .where((d) => d.mac.toUpperCase() == entry.key.toUpperCase()) - .firstOrNull; - final displayName = device?.hostName ?? entry.key; - // Resolve band from connectionDetailMap (AP → SSID → Radio chain) - final detail = wifiData.connectionDetailMap[entry.key]; + final allWifiClients = devicesData.meshNetwork.allClients + .where((c) => c.isWifi && c.isActive) + .where((c) => !backhaulMacs.contains(c.mac.toUpperCase())) + .toList(); + + for (final client in allWifiClients) { + // Get additional data from wifiClientMap (only available for master clients) + final masterData = wifiData.wifiClientMap[client.mac.toUpperCase()]; activeClients.add(_ClientInfo( - mac: entry.key, - displayName: displayName, - client: client, - band: detail?.band ?? '', + mac: client.mac, + displayName: client.displayName, + signalStrength: client.signalStrength ?? -100, + noise: masterData?.noise ?? 0, + downlinkRate: + client.downlinkRate ?? masterData?.lastDataDownlinkRate ?? 0, + uplinkRate: client.uplinkRate ?? masterData?.lastDataUplinkRate ?? 0, + band: client.band ?? '', )); } @@ -84,15 +99,24 @@ class UspWifiPerformanceCard extends ConsumerWidget { class _ClientInfo { final String mac; final String displayName; - final WifiClientUIModel client; + final int signalStrength; + final int noise; + final int downlinkRate; // kbps + final int uplinkRate; // kbps final String band; // "2.4GHz", "5GHz", "6GHz", or "" const _ClientInfo({ required this.mac, required this.displayName, - required this.client, + required this.signalStrength, + this.noise = 0, + this.downlinkRate = 0, + this.uplinkRate = 0, this.band = '', }); + + /// Whether this client has rate data (master node clients have it, slaves don't). + bool get hasRateData => downlinkRate > 0 || uplinkRate > 0; } // ============================================================================= @@ -125,7 +149,7 @@ class _SignalTab extends StatelessWidget { separatorBuilder: (_, __) => AppGap.sm(), itemBuilder: (context, index) { final c = clients[index]; - final rssi = c.client.signalStrength; + final rssi = c.signalStrength; final tier = getSignalTier(rssi); final color = tier.resolveColor(colorScheme); // Normalize: -100 dBm → 0.0, -30 dBm → 1.0 @@ -199,11 +223,17 @@ class _SpeedTab extends StatelessWidget { final List<_ClientInfo> clients; const _SpeedTab({required this.clients}); + static String _truncateName(String name, [int maxLen = 10]) => + name.length > maxLen ? '${name.substring(0, maxLen)}…' : name; + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - if (clients.isEmpty) { + // Filter to only clients with rate data (master node clients) + final clientsWithRates = clients.where((c) => c.hasRateData).toList(); + + if (clientsWithRates.isEmpty) { return Center( child: AppText.bodyMedium( 'No WiFi clients connected', @@ -213,11 +243,10 @@ class _SpeedTab extends StatelessWidget { } // Convert kbps to Mbps for chart display - final dlData = - clients.map((c) => c.client.lastDataDownlinkRate / 1000).toList(); - final ulData = - clients.map((c) => c.client.lastDataUplinkRate / 1000).toList(); - final xLabels = clients.map((c) => c.displayName).toList(); + final dlData = clientsWithRates.map((c) => c.downlinkRate / 1000).toList(); + final ulData = clientsWithRates.map((c) => c.uplinkRate / 1000).toList(); + final xLabels = + clientsWithRates.map((c) => _truncateName(c.displayName)).toList(); return Column( children: [ @@ -237,7 +266,7 @@ class _SpeedTab extends StatelessWidget { ], xLabels: xLabels, yLabelFormatter: (v) => '${v.toInt()} Mbps', - showValueLabels: clients.length <= 4, + showValueLabels: clientsWithRates.length <= 4, valueLabelFormatter: (v) => '${v.toInt()}', showTooltip: false, ), @@ -315,9 +344,16 @@ class _ChannelsTab extends StatelessWidget { final radioIdx = bandToRadioIdx[c.band]; if (radioIdx == null) continue; clientsPerRadio[radioIdx] = (clientsPerRadio[radioIdx] ?? 0) + 1; - final snr = computeSNR(c.client.signalStrength, c.client.noise); - snrSumPerRadio[radioIdx] = (snrSumPerRadio[radioIdx] ?? 0) + snr; - snrCountPerRadio[radioIdx] = (snrCountPerRadio[radioIdx] ?? 0) + 1; + // Only clients with real noise data contribute to the average SNR. + // Slave-node clients have no noise (they aren't in wifiClientMap), so + // computeSNR returns 0; including them would deflate the per-radio + // average once #1118 gives them a resolved band. Count them as clients + // but exclude them from the SNR aggregation until noise is available. + if (c.noise != 0) { + final snr = computeSNR(c.signalStrength, c.noise); + snrSumPerRadio[radioIdx] = (snrSumPerRadio[radioIdx] ?? 0) + snr; + snrCountPerRadio[radioIdx] = (snrCountPerRadio[radioIdx] ?? 0) + 1; + } } // Compute average SNR per radio diff --git a/lib/page/wifi_settings/services/usp_wifi_data_service.dart b/lib/page/wifi_settings/services/usp_wifi_data_service.dart index 04fe76713..a5310b24e 100644 --- a/lib/page/wifi_settings/services/usp_wifi_data_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_data_service.dart @@ -441,4 +441,36 @@ class UspWifiDataService { result.sort(); return result; } + + /// Builds a BSSID → band mapping from WiFi SSID and Radio data. + /// + /// Used by [MeshTopologyBuilder] to determine band for clients on slave nodes + /// (via DataElements BSS.BSSID → this map → band). + /// + /// The mapping is: SSID.BSSID + SSID.LowerLayers → Radio.OperatingFrequencyBand + static Map buildBssidToBandMap({ + required WiFiSsids ssids, + required WiFiRadios radios, + }) { + // Build Radio path → band lookup + final bandByRadioPath = {}; + for (final radio in radios.items) { + final path = _ensureTrailingDot(radio.instancePath); + bandByRadioPath[path] = _normalizeBand(radio.operatingFrequencyBand); + } + + // Build BSSID → band mapping via SSID.LowerLayers → Radio + final result = {}; + for (final ssid in ssids.items) { + final bssid = ssid.bssid.trim().toUpperCase(); + if (bssid.isEmpty) continue; + + final radioPath = _ensureTrailingDot(ssid.lowerLayers); + final band = bandByRadioPath[radioPath]; + if (band != null && band.isNotEmpty) { + result[bssid] = band; + } + } + return result; + } } diff --git a/test/ai/providers/usp_command_provider_test.dart b/test/ai/providers/usp_command_provider_test.dart index 625f03e6d..cec2aa6b2 100644 --- a/test/ai/providers/usp_command_provider_test.dart +++ b/test/ai/providers/usp_command_provider_test.dart @@ -2,9 +2,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/ai/abstraction/_abstraction.dart'; import 'package:privacy_gui/ai/providers/usp_command_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wan_status_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; @@ -425,26 +428,30 @@ final _testSystemInfoData = SystemInfoData( ); final _testDevicesData = DevicesData( - deviceModels: const [ - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'iPhone-15-Pro', - isActive: true, - isWifi: true, - signalStrength: -42, - band: '5GHz', - ), - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - hostName: 'MacBook-Air', - isActive: true, - isWifi: true, - signalStrength: -68, - band: '5GHz', + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'MR7500', + connectedClients: [ + ClientDevice( + mac: 'AA:BB:CC:DD:EE:01', + ip: '192.168.1.100', + hostName: 'iPhone-15-Pro', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -42, band: '5GHz'), + ), + ClientDevice( + mac: 'AA:BB:CC:DD:EE:02', + ip: '192.168.1.101', + hostName: 'MacBook-Air', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -68, band: '5GHz'), + ), + ], ), - ], + ), ); final _testWifiData = WifiData( diff --git a/test/core/cloud/providers/remote_assistance/device_credentials_provider_test.dart b/test/core/cloud/providers/remote_assistance/device_credentials_provider_test.dart new file mode 100644 index 000000000..dd0b6076a --- /dev/null +++ b/test/core/cloud/providers/remote_assistance/device_credentials_provider_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/cloud/providers/remote_assistance/device_credentials_provider.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; +import 'package:privacy_gui/core/session/providers/session_provider.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; + +// ============================================================================= +// deviceCredentialsProvider — verifies the Hosts DeviceID (UUID) is used for +// Remote Assistance / Guardian API calls, NOT the master's MAC address. +// ============================================================================= + +void main() { + const deviceInfo = NodeDeviceInfo( + modelNumber: 'M60TB', + firmwareVersion: '1.0.16', + description: '', + firmwareDate: '', + manufacturer: 'Linksys', + serialNumber: 'SN-12345', + hardwareVersion: '1', + ); + + const masterMac = 'AA:BB:CC:DD:EE:FF'; + const hostsUuid = 'uuid-hosts-9876'; + + DevicesData devicesDataWith({ + String? hostsDeviceId, + String deviceId = masterMac, + }) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: deviceId, + model: 'M60TB', + hostsDeviceId: hostsDeviceId, + ), + ), + ); + } + + ProviderContainer createContainer({ + required DevicesData? devices, + NodeDeviceInfo? session = deviceInfo, + }) { + return ProviderContainer( + overrides: [ + sessionProvider.overrideWith(() => _FakeSessionNotifier(session)), + devicesDataProvider.overrideWith(() => _FakeDevicesNotifier(devices)), + ], + ); + } + + /// Ensures [devicesDataProvider] has resolved (its AsyncNotifier is async, + /// so a synchronous read would otherwise see AsyncLoading → valueOrNull null). + Future settleDevices(ProviderContainer container) async { + try { + await container.read(devicesDataProvider.future); + } catch (_) { + // Unavailable case: build throws → AsyncError → valueOrNull null. + } + } + + group('deviceCredentialsProvider', () { + test('uses Hosts DeviceID (UUID) as deviceUUID, not the MAC', () async { + final container = createContainer( + devices: devicesDataWith(hostsDeviceId: hostsUuid), + ); + addTearDown(container.dispose); + await settleDevices(container); + + final creds = container.read(deviceCredentialsProvider); + + expect(creds, isNotNull); + expect(creds!.deviceUUID, hostsUuid); + // MAC is still carried separately. + expect(creds.macAddress, masterMac); + expect(creds.serialNumber, 'SN-12345'); + // deviceUUID must NOT fall back to the MAC address (the regression). + expect(creds.deviceUUID, isNot(masterMac)); + }); + + test('returns null when master has no Hosts DeviceID', () async { + final container = createContainer( + devices: devicesDataWith(hostsDeviceId: null), + ); + addTearDown(container.dispose); + await settleDevices(container); + + expect(container.read(deviceCredentialsProvider), isNull); + }); + + test('returns null when Hosts DeviceID is empty', () async { + final container = createContainer( + devices: devicesDataWith(hostsDeviceId: ''), + ); + addTearDown(container.dispose); + await settleDevices(container); + + expect(container.read(deviceCredentialsProvider), isNull); + }); + + test('returns null when devices data is unavailable', () async { + final container = createContainer(devices: null); + addTearDown(container.dispose); + await settleDevices(container); + + expect(container.read(deviceCredentialsProvider), isNull); + }); + + test('returns null when session deviceInfo is unavailable', () async { + final container = createContainer( + devices: devicesDataWith(hostsDeviceId: hostsUuid), + session: null, + ); + addTearDown(container.dispose); + await settleDevices(container); + + expect(container.read(deviceCredentialsProvider), isNull); + }); + }); +} + +// ============================================================================= +// Fakes +// ============================================================================= + +class _FakeSessionNotifier extends Notifier + implements SessionNotifier { + final NodeDeviceInfo? _deviceInfo; + _FakeSessionNotifier(this._deviceInfo); + + @override + SessionState build() => SessionState(deviceInfo: _deviceInfo); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeDevicesNotifier extends AsyncNotifier + implements DevicesDataNotifier { + final DevicesData? _data; + _FakeDevicesNotifier(this._data); + + @override + Future build() async { + final data = _data; + if (data == null) { + // Simulate "not loaded" — valueOrNull resolves to null via AsyncError. + throw StateError('no devices data'); + } + return data; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/golden_test/golden_framework/mocks/mock_devices.dart b/test/golden_test/golden_framework/mocks/mock_devices.dart index c1ab450c9..4b763530e 100644 --- a/test/golden_test/golden_framework/mocks/mock_devices.dart +++ b/test/golden_test/golden_framework/mocks/mock_devices.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/device_detail_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; @@ -25,15 +27,30 @@ class FixedDhcpDataNotifier extends DhcpDataNotifier { Future build() async => _fixedData; } +/// Creates a minimal MeshNetwork from a list of ClientDevices. +MeshNetwork _meshNetworkFromClients(List clients) { + return MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'Test Router', + manufacturer: 'Test', + serialNumber: 'SN123', + softwareVersion: '1.0.0', + connectedClients: clients, + ), + slaves: [], + ); +} + List devicesListOverrides({ - required List devices, + required List devices, DeviceFilterConfig filter = const DeviceFilterConfig(), DeviceFilterOptions options = const DeviceFilterOptions(), }) => [ devicesDataProvider.overrideWith( () => FixedDevicesDataNotifier( - DevicesData(deviceModels: devices), + DevicesData(meshNetwork: _meshNetworkFromClients(devices)), ), ), filteredDeviceListProvider.overrideWith((ref) => devices), @@ -57,7 +74,21 @@ List deviceDetailOverrides({ (ref, mac) => detail, ), devicesDataProvider.overrideWith( - () => FixedDevicesDataNotifier(const DevicesData()), + () => FixedDevicesDataNotifier( + DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'Test Router', + manufacturer: 'Test', + serialNumber: 'SN123', + softwareVersion: '1.0.0', + connectedClients: [], + ), + slaves: [], + ), + ), + ), ), dhcpDataProvider.overrideWith( () => FixedDhcpDataNotifier( diff --git a/test/golden_test/golden_framework/mocks/mock_dhcp.dart b/test/golden_test/golden_framework/mocks/mock_dhcp.dart index e041ea6ba..3b44579ef 100644 --- a/test/golden_test/golden_framework/mocks/mock_dhcp.dart +++ b/test/golden_test/golden_framework/mocks/mock_dhcp.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/dhcp/models/dhcp_reservation_list.dart'; import 'package:privacy_gui/page/dhcp/models/dhcp_reservations_feature_state.dart'; @@ -60,7 +62,11 @@ class FixedDhcpDataNotifier extends DhcpDataNotifier { class FixedDevicesDataNotifier extends DevicesDataNotifier { @override - Future build() async => const DevicesData(); + Future build() async => DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test Router'), + ), + ); } List dhcpDetailOverrides({ diff --git a/test/golden_test/golden_framework/mocks/mock_ipv6_port_service.dart b/test/golden_test/golden_framework/mocks/mock_ipv6_port_service.dart index b7bc9a5d6..afb8e6a5b 100644 --- a/test/golden_test/golden_framework/mocks/mock_ipv6_port_service.dart +++ b/test/golden_test/golden_framework/mocks/mock_ipv6_port_service.dart @@ -1,4 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/ipv6_port_service/models/ipv6_port_service_feature_state.dart'; import 'package:privacy_gui/page/ipv6_port_service/models/ipv6_port_service_rule_list.dart'; @@ -39,7 +41,11 @@ class FixedIpv6PortServiceNotifier extends UspIpv6PortServiceNotifier { class FixedDevicesDataNotifier extends DevicesDataNotifier { @override - Future build() async => const DevicesData(); + Future build() async => DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test Router'), + ), + ); } List ipv6PortServiceOverrides(Ipv6PortServiceFeatureState state) => [ diff --git a/test/golden_test/golden_framework/mocks/mock_statistics.dart b/test/golden_test/golden_framework/mocks/mock_statistics.dart index 55f42bab0..a7cb4d217 100644 --- a/test/golden_test/golden_framework/mocks/mock_statistics.dart +++ b/test/golden_test/golden_framework/mocks/mock_statistics.dart @@ -6,6 +6,8 @@ import 'package:privacy_gui/page/_shared/providers/usp_device_analytics_notifier import 'package:privacy_gui/page/_shared/providers/usp_system_monitor_notifier.dart'; import 'package:privacy_gui/page/_shared/providers/usp_traffic_analysis_notifier.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/firewall/providers/firewall_data_provider.dart'; import 'package:privacy_gui/page/port_forwarding/providers/port_forwarding_data_provider.dart'; @@ -85,7 +87,11 @@ class FixedWifiDataNotifier extends WifiDataNotifier { class FixedDevicesDataNotifierForStats extends DevicesDataNotifier { @override - Future build() async => const DevicesData(); + Future build() async => DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test Router'), + ), + ); } List statisticsOverrides({ diff --git a/test/golden_test/golden_framework/mocks/mock_topology.dart b/test/golden_test/golden_framework/mocks/mock_topology.dart index 6fea8811d..f78512ac2 100644 --- a/test/golden_test/golden_framework/mocks/mock_topology.dart +++ b/test/golden_test/golden_framework/mocks/mock_topology.dart @@ -1,4 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/topology/providers/node_detail_provider.dart'; @@ -40,6 +42,12 @@ List topologyViewOverrides({ List nodeDetailOverrides(UspNodeDetailState state) => [ uspNodeDetailProvider.overrideWith((ref, deviceId) => state), devicesDataProvider.overrideWith( - () => FixedDevicesDataNotifierForTopology(const DevicesData()), + () => FixedDevicesDataNotifierForTopology( + DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test Router'), + ), + ), + ), ), ]; diff --git a/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart b/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart index 647a0be79..0943084ce 100644 --- a/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart +++ b/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart @@ -1,11 +1,12 @@ import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/ethernet_port_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; -import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/port_forwarding_rule_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/system_monitor_state.dart'; @@ -13,6 +14,7 @@ import 'package:privacy_gui/page/_shared/models/time_settings_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/traffic_analysis_state.dart'; import 'package:privacy_gui/page/_shared/models/wan_status_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/admin/providers/time_data_provider.dart'; @@ -28,7 +30,6 @@ import 'package:privacy_gui/page/local_network/providers/lan_data_provider.dart' import 'package:privacy_gui/page/port_forwarding/models/port_triggering_rule_ui_model.dart'; import 'package:privacy_gui/page/port_forwarding/providers/port_forwarding_data_provider.dart'; import 'package:privacy_gui/page/port_forwarding/providers/port_triggering_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_data_provider.dart'; // --------------------------------------------------------------------------- @@ -206,88 +207,96 @@ final testEthernetData = EthernetData(ethernetPortModels: testEthernetPorts); // Connected Devices // --------------------------------------------------------------------------- -const testDevices = [ - DeviceUIModel( +final testDevices = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.101', hostName: 'Desktop-PC', isActive: true, - isWifi: false, - layer1Interface: 'Device.Ethernet.Interface.2.', + connectionType: ConnectionType.wired, ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.102', hostName: 'iPhone-15', isActive: true, - isWifi: true, - signalStrength: -45, - band: '5GHz', - ssidName: 'HomeNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -45, + band: '5GHz', + ssidName: 'HomeNetwork', + ), parentNodeName: 'MR7500', ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.103', hostName: 'MacBook-Air', isActive: true, - isWifi: true, - signalStrength: -55, - band: '5GHz', - ssidName: 'HomeNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -55, + band: '5GHz', + ssidName: 'HomeNetwork', + ), parentNodeName: 'MR7500', ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:04', ip: '192.168.1.104', hostName: 'Smart-Speaker', isActive: true, - isWifi: true, - signalStrength: -65, - band: '2.4GHz', - ssidName: 'HomeNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -65, + band: '2.4GHz', + ssidName: 'HomeNetwork', + ), ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:05', ip: '192.168.1.105', hostName: 'Gaming-Console', isActive: true, - isWifi: false, - layer1Interface: 'Device.Ethernet.Interface.3.', + connectionType: ConnectionType.wired, ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:06', ip: '192.168.1.106', hostName: 'Old-Tablet', isActive: false, - isWifi: true, - signalStrength: -80, - band: '2.4GHz', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -80, + band: '2.4GHz', + ), ), ]; -const testNodes = [ - NodeUIModel( +final testMeshNetwork = MeshNetwork( + master: MasterNode( deviceId: 'GATEWAY', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456789', softwareVersion: '1.0.16', - isMaster: true, - connectedDeviceCount: 5, + connectedClients: testDevices, ), -]; - -final testDevicesData = DevicesData( - deviceModels: testDevices, - nodeModels: testNodes, - meshTopology: MeshTopologyInfo.empty, ); +final testDevicesData = DevicesData(meshNetwork: testMeshNetwork); + final testDevicesEmptyData = DevicesData( - deviceModels: const [], - nodeModels: testNodes, - meshTopology: MeshTopologyInfo.empty, + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'MR7500', + manufacturer: 'Linksys', + serialNumber: 'ABC123456789', + softwareVersion: '1.0.16', + connectedClients: [], + ), + ), ); // --------------------------------------------------------------------------- diff --git a/test/golden_test/page/devices/fixtures/devices_test_data.dart b/test/golden_test/page/devices/fixtures/devices_test_data.dart index 3ac9b34d8..67e2570da 100644 --- a/test/golden_test/page/devices/fixtures/devices_test_data.dart +++ b/test/golden_test/page/devices/fixtures/devices_test_data.dart @@ -1,81 +1,90 @@ -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/devices/providers/device_detail_provider.dart'; -const wifiDevice1 = DeviceUIModel( +final wifiDevice1 = ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'iPhone-15-Pro', isActive: true, - isWifi: true, - signalStrength: -42, - downlinkRate: 866000000, - uplinkRate: 433000000, - band: '5GHz', - ssidName: 'MyNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -42, + downlinkRate: 866000, + uplinkRate: 433000, + band: '5GHz', + ssidName: 'MyNetwork', + ), parentNodeId: 'node-1', parentNodeName: 'Living Room', ); -const wifiDeviceGood = DeviceUIModel( +final wifiDeviceGood = ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'MacBook-Air', isActive: true, - isWifi: true, - signalStrength: -68, - downlinkRate: 400000000, - uplinkRate: 200000000, - band: '5GHz', - ssidName: 'MyNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -68, + downlinkRate: 400000, + uplinkRate: 200000, + band: '5GHz', + ssidName: 'MyNetwork', + ), parentNodeId: 'node-1', parentNodeName: 'Living Room', ); -const wiredDevice1 = DeviceUIModel( +final wiredDevice1 = ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.102', hostName: 'PlayStation-5', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, parentNodeId: 'node-1', parentNodeName: 'Living Room', ); -const offlineDevice = DeviceUIModel( +final offlineDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:04', ip: '192.168.1.103', hostName: 'iPad-Mini', isActive: false, - isWifi: true, + connectionType: ConnectionType.wifi, ); -const wifiDeviceFair = DeviceUIModel( +final wifiDeviceFair = ClientDevice( mac: 'AA:BB:CC:DD:EE:05', ip: '192.168.1.104', hostName: 'Samsung-TV', isActive: true, - isWifi: true, - signalStrength: -75, - downlinkRate: 72000000, - uplinkRate: 36000000, - band: '2.4GHz', - ssidName: 'MyNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -75, + downlinkRate: 72000, + uplinkRate: 36000, + band: '2.4GHz', + ssidName: 'MyNetwork', + ), parentNodeId: 'node-2', parentNodeName: 'Bedroom', ); -const wifiDevicePoor = DeviceUIModel( +final wifiDevicePoor = ClientDevice( mac: 'AA:BB:CC:DD:EE:06', ip: '192.168.1.105', hostName: 'Nest-Cam-Outdoor', isActive: true, - isWifi: true, - signalStrength: -82, - downlinkRate: 24000000, - uplinkRate: 12000000, - band: '2.4GHz', - ssidName: 'MyNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -82, + downlinkRate: 24000, + uplinkRate: 12000, + band: '2.4GHz', + ssidName: 'MyNetwork', + ), parentNodeId: 'node-2', parentNodeName: 'Bedroom', ); @@ -87,7 +96,7 @@ final testReservation = DhcpReservationUIModel( enable: true, ); -List get allDevices => [ +List get allDevices => [ wifiDevice1, wifiDeviceGood, wiredDevice1, diff --git a/test/golden_test/page/topology/fixtures/topology_test_data.dart b/test/golden_test/page/topology/fixtures/topology_test_data.dart index cc2a8ee3d..a311620d6 100644 --- a/test/golden_test/page/topology/fixtures/topology_test_data.dart +++ b/test/golden_test/page/topology/fixtures/topology_test_data.dart @@ -1,9 +1,11 @@ -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; -import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/providers/node_detail_provider.dart'; // --------------------------------------------------------------------------- @@ -28,64 +30,60 @@ final testSystemInfoData = SystemInfoData(model: _testSystemInfo); // Devices // --------------------------------------------------------------------------- -const _testDevices = [ - DeviceUIModel( +final _testClients = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'iPhone', isActive: true, - isWifi: true, - signalStrength: -45, - band: '5GHz', - parentNodeId: null, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -45, band: '5GHz'), ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'MacBook Pro', isActive: true, - isWifi: true, - signalStrength: -55, - band: '5GHz', - parentNodeId: null, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -55, band: '5GHz'), ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.102', hostName: 'Desktop PC', isActive: true, - isWifi: false, - parentNodeId: null, + connectionType: ConnectionType.wired, ), ]; -const _meshDevices = [ - DeviceUIModel( +final _meshMasterClients = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'iPhone', isActive: true, - isWifi: true, - signalStrength: -45, - band: '5GHz', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -45, band: '5GHz'), parentNodeId: '11:22:33:44:55:66', ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'MacBook Pro', isActive: true, - isWifi: true, - signalStrength: -55, - band: '5GHz', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -55, band: '5GHz'), parentNodeId: '11:22:33:44:55:66', ), - DeviceUIModel( +]; + +final _meshSlaveClients = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.102', hostName: 'Desktop PC', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, parentNodeId: 'AA:BB:CC:DD:FF:01', ), ]; @@ -95,71 +93,39 @@ const _meshDevices = [ // --------------------------------------------------------------------------- final singleNodeDevicesData = DevicesData( - deviceModels: _testDevices, - nodeModels: const [ - NodeUIModel( + meshNetwork: MeshNetwork( + master: MasterNode( deviceId: 'gateway', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456', softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 3, + connectedClients: _testClients, ), - ], - meshTopology: MeshTopologyInfo.empty, + ), ); final meshNetworkDevicesData = DevicesData( - deviceModels: _meshDevices, - nodeModels: const [ - NodeUIModel( + meshNetwork: MeshNetwork( + master: MasterNode( deviceId: '11:22:33:44:55:66', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456', softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 2, + connectedClients: _meshMasterClients, ), - NodeUIModel( - deviceId: 'AA:BB:CC:DD:FF:01', - model: 'MX2000', - manufacturer: 'Linksys', - serialNumber: 'DEF789012', - softwareVersion: '1.0.10.200000', - isMaster: false, - connectedDeviceCount: 1, - ), - ], - meshTopology: const MeshTopologyInfo( - nodes: [ - NodeUIModel( - deviceId: '11:22:33:44:55:66', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'ABC123456', - softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 2, - instancePath: 'Device.DeviceInfo.1.', - ), - NodeUIModel( + slaves: [ + SlaveNode( deviceId: 'AA:BB:CC:DD:FF:01', model: 'MX2000', manufacturer: 'Linksys', serialNumber: 'DEF789012', softwareVersion: '1.0.10.200000', - isMaster: false, - connectedDeviceCount: 1, - instancePath: 'Device.DeviceInfo.2.', + connectedClients: _meshSlaveClients, + backhaul: BackhaulInfo(mediaType: 'Wi-Fi', signalStrength: -50), ), ], - clientToNodeMap: { - 'AA:BB:CC:DD:EE:01': '11:22:33:44:55:66', - 'AA:BB:CC:DD:EE:02': '11:22:33:44:55:66', - 'AA:BB:CC:DD:EE:03': 'AA:BB:CC:DD:FF:01', - }, ), ); @@ -167,73 +133,41 @@ final meshNetworkDevicesData = DevicesData( // Node Detail States // --------------------------------------------------------------------------- -const masterNodeWithDevices = UspNodeDetailState( - node: NodeUIModel( +final masterNodeWithDevices = UspNodeDetailState( + node: MasterNode( deviceId: '11:22:33:44:55:66', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456', softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 2, + connectedClients: _meshMasterClients, ), - connectedDevices: [ - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - signalStrength: -45, - band: '5GHz', - ), - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - hostName: 'MacBook Pro', - isActive: true, - isWifi: true, - signalStrength: -55, - band: '5GHz', - ), - ], + connectedClients: _meshMasterClients, ); -const slaveNodeWithDevices = UspNodeDetailState( - node: NodeUIModel( +final slaveNodeWithDevices = UspNodeDetailState( + node: SlaveNode( deviceId: 'AA:BB:CC:DD:FF:01', model: 'MX2000', manufacturer: 'Linksys', serialNumber: 'DEF789012', softwareVersion: '1.0.10.200000', - isMaster: false, - connectedDeviceCount: 1, + connectedClients: _meshSlaveClients, + backhaul: BackhaulInfo(mediaType: 'Wi-Fi', signalStrength: -50), ), - connectedDevices: [ - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:03', - ip: '192.168.1.102', - hostName: 'Desktop PC', - isActive: true, - isWifi: false, - ), - ], + connectedClients: _meshSlaveClients, ); -const masterNodeEmptyDevices = UspNodeDetailState( - node: NodeUIModel( +final masterNodeEmptyDevices = UspNodeDetailState( + node: MasterNode( deviceId: '11:22:33:44:55:66', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456', softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 0, + connectedClients: [], ), - connectedDevices: [], + connectedClients: [], ); -const nodeNotFoundState = UspNodeDetailState( - node: null, - connectedDevices: [], -); +const nodeNotFoundState = UspNodeDetailState(); diff --git a/test/mocks/test_data/devices_test_data.dart b/test/mocks/test_data/devices_test_data.dart new file mode 100644 index 000000000..d997c829c --- /dev/null +++ b/test/mocks/test_data/devices_test_data.dart @@ -0,0 +1,486 @@ +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; +import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; + +/// Test data builder for device/mesh network tests. +/// +/// Provides factory methods to create test instances with sensible defaults. +/// Centralizes test data creation to avoid inline construction duplication. +class DevicesTestData { + // =========================================================================== + // Constants + // =========================================================================== + + static const masterMac = 'AA:BB:CC:DD:EE:00'; + static const slaveMac1 = 'AA:BB:CC:DD:EE:01'; + static const slaveMac2 = 'AA:BB:CC:DD:EE:02'; + + static const clientMac1 = '11:22:33:44:55:01'; + static const clientMac2 = '11:22:33:44:55:02'; + static const clientMac3 = '11:22:33:44:55:03'; + static const clientMac4 = '11:22:33:44:55:04'; + static const clientMac5 = '11:22:33:44:55:05'; + + static const masterIp = '192.168.1.1'; + static const clientIp1 = '192.168.1.101'; + static const clientIp2 = '192.168.1.102'; + static const clientIp3 = '192.168.1.103'; + + static const defaultModel = 'MR7500'; + static const defaultManufacturer = 'Linksys'; + static const defaultSerialNumber = 'SN123456789'; + static const defaultSoftwareVersion = '1.0.16.26013014'; + + // =========================================================================== + // WifiConnectionInfo Factories + // =========================================================================== + + static WifiConnectionInfo createWifiInfo({ + int? signalStrength = -50, + String? band = '5GHz', + String? ssidName = 'HomeNetwork', + int? downlinkRate = 866000, + int? uplinkRate = 433000, + }) => + WifiConnectionInfo( + signalStrength: signalStrength, + band: band, + ssidName: ssidName, + downlinkRate: downlinkRate, + uplinkRate: uplinkRate, + ); + + static WifiConnectionInfo createExcellentSignal() => createWifiInfo( + signalStrength: -45, + band: '5GHz', + ); + + static WifiConnectionInfo createGoodSignal() => createWifiInfo( + signalStrength: -68, + band: '5GHz', + ); + + static WifiConnectionInfo createFairSignal() => createWifiInfo( + signalStrength: -75, + band: '2.4GHz', + ); + + static WifiConnectionInfo createPoorSignal() => createWifiInfo( + signalStrength: -85, + band: '2.4GHz', + ); + + // =========================================================================== + // BackhaulInfo Factories + // =========================================================================== + + static BackhaulInfo createWifiBackhaul({ + int signalStrength = -55, + String parentNodeId = masterMac, + }) => + BackhaulInfo( + mediaType: 'IEEE 802.11ax', + linkType: 'Wi-Fi', + phyRate: 2402, + signalStrength: signalStrength, + uplinkRate: 500000, + downlinkRate: 1000000, + parentNodeId: parentNodeId, + ); + + static BackhaulInfo createEthernetBackhaul({ + String parentNodeId = masterMac, + }) => + BackhaulInfo( + mediaType: 'Ethernet', + linkType: 'Ethernet', + phyRate: 1000, + parentNodeId: parentNodeId, + ); + + static const emptyBackhaul = BackhaulInfo(mediaType: ''); + + // =========================================================================== + // ClientInterfaceInfo Factories + // =========================================================================== + + static ClientInterfaceInfo createWifiInterface({ + String mac = '11:22:33:44:55:FF', + String ip = '192.168.1.150', + bool isActive = true, + WifiConnectionInfo? wifi, + }) => + ClientInterfaceInfo( + mac: mac, + ip: ip, + connectionType: ConnectionType.wifi, + isActive: isActive, + wifi: wifi ?? createWifiInfo(), + ); + + static ClientInterfaceInfo createWiredInterface({ + String mac = '11:22:33:44:55:FE', + String ip = '192.168.1.151', + bool isActive = true, + }) => + ClientInterfaceInfo( + mac: mac, + ip: ip, + connectionType: ConnectionType.wired, + isActive: isActive, + ); + + // =========================================================================== + // ClientDevice Factories + // =========================================================================== + + /// Creates an online WiFi client device. + static ClientDevice createWifiClient({ + String mac = clientMac1, + String ip = clientIp1, + String hostName = 'MacBook-Pro', + String? friendlyName, + bool isActive = true, + WifiConnectionInfo? wifi, + String? parentNodeId, + String? parentNodeName, + List additionalInterfaces = const [], + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + friendlyName: friendlyName, + isActive: isActive, + connectionType: ConnectionType.wifi, + wifi: wifi ?? createWifiInfo(), + parentNodeId: parentNodeId, + parentNodeName: parentNodeName, + additionalInterfaces: additionalInterfaces, + ); + + /// Creates an online wired client device. + static ClientDevice createWiredClient({ + String mac = clientMac2, + String ip = clientIp2, + String hostName = 'Desktop-PC', + String? friendlyName, + bool isActive = true, + String? parentNodeId, + String? parentNodeName, + List additionalInterfaces = const [], + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + friendlyName: friendlyName, + isActive: isActive, + connectionType: ConnectionType.wired, + parentNodeId: parentNodeId, + parentNodeName: parentNodeName, + additionalInterfaces: additionalInterfaces, + ); + + /// Creates an offline client device. + static ClientDevice createOfflineClient({ + String mac = clientMac3, + String ip = clientIp3, + String hostName = 'Offline-Device', + ConnectionType connectionType = ConnectionType.wifi, + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + isActive: false, + connectionType: connectionType, + ); + + /// Creates a client with multiple network interfaces. + static ClientDevice createMultiInterfaceClient({ + String mac = clientMac4, + String ip = '192.168.1.104', + String hostName = 'Multi-Interface-Device', + bool isActive = true, + List? additionalInterfaces, + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + isActive: isActive, + connectionType: ConnectionType.wifi, + wifi: createWifiInfo(), + additionalInterfaces: additionalInterfaces ?? + [ + createWiredInterface( + mac: '${mac.substring(0, 14)}:FE', + ip: '192.168.1.204', + ), + ], + ); + + /// Creates a client connected to a slave (extender) node. + static ClientDevice createSlaveConnectedClient({ + String mac = clientMac5, + String ip = '192.168.1.105', + String hostName = 'Extender-Client', + String parentNodeId = slaveMac1, + String parentNodeName = 'Extender-1', + bool isWifi = true, + bool isActive = true, + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + isActive: isActive, + connectionType: isWifi ? ConnectionType.wifi : ConnectionType.wired, + wifi: isWifi ? createWifiInfo() : null, + parentNodeId: parentNodeId, + parentNodeName: parentNodeName, + ); + + // =========================================================================== + // NodeEntity Factories + // =========================================================================== + + /// Creates a master (gateway) node. + static MasterNode createMaster({ + String deviceId = masterMac, + String? dataElementsId, + String? friendlyName, + String? hostName = 'Linksys-Router', + String model = defaultModel, + String manufacturer = defaultManufacturer, + String serialNumber = defaultSerialNumber, + String softwareVersion = defaultSoftwareVersion, + String? ipAddress = masterIp, + List connectedClients = const [], + String? wanIpAddress = '100.64.1.100', + String? hostsDeviceId, + }) => + MasterNode( + deviceId: deviceId, + dataElementsId: dataElementsId, + friendlyName: friendlyName, + hostName: hostName, + model: model, + manufacturer: manufacturer, + serialNumber: serialNumber, + softwareVersion: softwareVersion, + ipAddress: ipAddress, + connectedClients: connectedClients, + wanIpAddress: wanIpAddress, + hostsDeviceId: hostsDeviceId, + ); + + /// Creates a slave (extender) node with WiFi backhaul. + static SlaveNode createWifiSlave({ + String deviceId = slaveMac1, + String? dataElementsId, + String? friendlyName, + String? hostName = 'Extender-1', + String model = defaultModel, + String? ipAddress = '192.168.1.2', + List connectedClients = const [], + BackhaulInfo? backhaul, + }) => + SlaveNode( + deviceId: deviceId, + dataElementsId: dataElementsId, + friendlyName: friendlyName, + hostName: hostName, + model: model, + manufacturer: defaultManufacturer, + ipAddress: ipAddress, + connectedClients: connectedClients, + backhaul: backhaul ?? createWifiBackhaul(), + ); + + /// Creates a slave (extender) node with Ethernet backhaul. + static SlaveNode createEthernetSlave({ + String deviceId = slaveMac2, + String? dataElementsId, + String? friendlyName, + String? hostName = 'Extender-2', + String model = defaultModel, + String? ipAddress = '192.168.1.3', + List connectedClients = const [], + }) => + SlaveNode( + deviceId: deviceId, + dataElementsId: dataElementsId, + friendlyName: friendlyName, + hostName: hostName, + model: model, + manufacturer: defaultManufacturer, + ipAddress: ipAddress, + connectedClients: connectedClients, + backhaul: createEthernetBackhaul(), + ); + + // =========================================================================== + // MeshNetwork Factories + // =========================================================================== + + /// Creates a simple single-node (non-mesh) network. + static MeshNetwork createSingleNodeNetwork({ + MasterNode? master, + List? masterClients, + }) { + final clients = masterClients ?? + [ + createWifiClient(), + createWiredClient(), + ]; + return MeshNetwork( + master: master?.copyWith(connectedClients: clients) ?? + createMaster(connectedClients: clients), + ); + } + + /// Creates a mesh network with one master and one WiFi slave. + static MeshNetwork createMeshNetwork({ + MasterNode? master, + List? masterClients, + SlaveNode? slave, + List? slaveClients, + }) { + final mClients = masterClients ?? [createWifiClient()]; + final sClients = slaveClients ?? + [ + createSlaveConnectedClient(), + ]; + + return MeshNetwork( + master: master?.copyWith(connectedClients: mClients) ?? + createMaster(connectedClients: mClients), + slaves: [ + slave?.copyWith(connectedClients: sClients) ?? + createWifiSlave(connectedClients: sClients), + ], + ); + } + + /// Creates a multi-slave mesh network. + static MeshNetwork createMultiSlaveMeshNetwork({ + List? masterClients, + List? slave1Clients, + List? slave2Clients, + }) { + return MeshNetwork( + master: createMaster( + connectedClients: masterClients ?? [createWifiClient()], + ), + slaves: [ + createWifiSlave( + deviceId: slaveMac1, + hostName: 'Extender-1', + connectedClients: slave1Clients ?? + [ + createSlaveConnectedClient( + mac: clientMac3, + parentNodeId: slaveMac1, + parentNodeName: 'Extender-1', + ), + ], + ), + createEthernetSlave( + deviceId: slaveMac2, + hostName: 'Extender-2', + connectedClients: slave2Clients ?? + [ + createSlaveConnectedClient( + mac: clientMac4, + parentNodeId: slaveMac2, + parentNodeName: 'Extender-2', + ), + ], + ), + ], + ); + } + + /// Creates an empty network (master only, no clients). + static MeshNetwork createEmptyNetwork() => MeshNetwork( + master: createMaster(connectedClients: []), + ); + + /// Creates a network with unassigned clients. + static MeshNetwork createNetworkWithUnassignedClients({ + List? unassignedClients, + }) => + MeshNetwork( + master: createMaster(connectedClients: []), + unassignedClients: unassignedClients ?? + [ + createWifiClient(parentNodeId: null), + createWiredClient(parentNodeId: null), + ], + ); + + // =========================================================================== + // DevicesData Factories + // =========================================================================== + + /// Creates a complete DevicesData instance. + static DevicesData createDevicesData({ + MeshNetwork? meshNetwork, + MeshTopologyInfo meshTopology = MeshTopologyInfo.empty, + Map? hostNameByMac, + }) => + DevicesData( + codegenContext: DevicesCodegenContext.empty, + meshTopology: meshTopology, + hostNameByMac: hostNameByMac ?? {}, + meshNetwork: meshNetwork ?? createSingleNodeNetwork(), + ); + + /// Creates DevicesData with a simple single-node network. + static DevicesData createSimpleDevicesData() => createDevicesData( + meshNetwork: createSingleNodeNetwork(), + ); + + /// Creates DevicesData with a mesh network. + static DevicesData createMeshDevicesData() => createDevicesData( + meshNetwork: createMeshNetwork(), + ); + + // =========================================================================== + // Client Device Lists (for filter/list tests) + // =========================================================================== + + /// Creates a mixed list of online and offline devices. + static List createMixedClientList() => [ + createWifiClient(mac: clientMac1, isActive: true), + createWiredClient(mac: clientMac2, isActive: true), + createOfflineClient(mac: clientMac3), + createWifiClient(mac: clientMac4, isActive: false), + ]; + + /// Creates a list of devices with different signal strengths. + static List createSignalVarietyList() => [ + createWifiClient( + mac: '11:22:33:44:55:E1', + wifi: createExcellentSignal(), + ), + createWifiClient( + mac: '11:22:33:44:55:E2', + wifi: createGoodSignal(), + ), + createWifiClient( + mac: '11:22:33:44:55:E3', + wifi: createFairSignal(), + ), + createWifiClient( + mac: '11:22:33:44:55:E4', + wifi: createPoorSignal(), + ), + ]; +} diff --git a/test/page/_shared/models/client_device_test.dart b/test/page/_shared/models/client_device_test.dart new file mode 100644 index 000000000..233b4a57e --- /dev/null +++ b/test/page/_shared/models/client_device_test.dart @@ -0,0 +1,525 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; + +import '../../../mocks/test_data/devices_test_data.dart'; + +void main() { + group('ClientDevice', () { + // ========================================================================= + // NetworkEntity Implementation + // ========================================================================= + + group('NetworkEntity implementation', () { + test('id returns mac', () { + final device = DevicesTestData.createWifiClient( + mac: DevicesTestData.clientMac1, + ); + + expect(device.id, DevicesTestData.clientMac1); + }); + + test('isOnline returns isActive', () { + final online = DevicesTestData.createWifiClient(isActive: true); + final offline = DevicesTestData.createWifiClient(isActive: false); + + expect(online.isOnline, isTrue); + expect(offline.isOnline, isFalse); + }); + + test('ipAddress returns ip if not empty', () { + final withIp = DevicesTestData.createWifiClient(ip: '192.168.1.100'); + final noIp = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: 'test', + isActive: true, + ip: '', + connectionType: ConnectionType.wifi, + ); + + expect(withIp.ipAddress, '192.168.1.100'); + expect(noIp.ipAddress, isNull); + }); + }); + + // ========================================================================= + // displayName Priority + // ========================================================================= + + group('displayName', () { + test('returns friendlyName when set', () { + final device = DevicesTestData.createWifiClient( + friendlyName: 'My MacBook', + hostName: 'MacBook-Pro', + mac: DevicesTestData.clientMac1, + ); + + expect(device.displayName, 'My MacBook'); + }); + + test('returns hostName when friendlyName is null', () { + final device = DevicesTestData.createWifiClient( + friendlyName: null, + hostName: 'MacBook-Pro', + mac: DevicesTestData.clientMac1, + ); + + expect(device.displayName, 'MacBook-Pro'); + }); + + test('returns hostName when friendlyName is empty', () { + final device = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: 'MacBook-Pro', + friendlyName: '', + isActive: true, + ip: '192.168.1.100', + connectionType: ConnectionType.wifi, + ); + + expect(device.displayName, 'MacBook-Pro'); + }); + + test('returns mac when both friendlyName and hostName are empty', () { + final device = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: '', + friendlyName: '', + isActive: true, + ip: '192.168.1.100', + connectionType: ConnectionType.wifi, + ); + + expect(device.displayName, DevicesTestData.clientMac1); + }); + }); + + // ========================================================================= + // WiFi Properties + // ========================================================================= + + group('WiFi properties', () { + test('isWifi returns true for WiFi connection', () { + final wifi = DevicesTestData.createWifiClient(); + final wired = DevicesTestData.createWiredClient(); + + expect(wifi.isWifi, isTrue); + expect(wired.isWifi, isFalse); + }); + + test('signalStrength delegates to wifi', () { + final withSignal = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(signalStrength: -50), + ); + final noWifi = DevicesTestData.createWiredClient(); + + expect(withSignal.signalStrength, -50); + expect(noWifi.signalStrength, isNull); + }); + + test('signalQuality delegates to wifi with default 0', () { + final withWifi = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(signalStrength: -50), + ); + final noWifi = DevicesTestData.createWiredClient(); + + expect(withWifi.signalQuality, greaterThan(0)); + expect(noWifi.signalQuality, 0); + }); + + test('signalLevel delegates to wifi with default 0', () { + final excellent = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createExcellentSignal(), + ); + final poor = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createPoorSignal(), + ); + final wired = DevicesTestData.createWiredClient(); + + expect(excellent.signalLevel, 3); + expect(poor.signalLevel, 0); + expect(wired.signalLevel, 0); + }); + + test('band delegates to wifi', () { + final wifi5g = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(band: '5GHz'), + ); + final wifi24g = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(band: '2.4GHz'), + ); + final wired = DevicesTestData.createWiredClient(); + + expect(wifi5g.band, '5GHz'); + expect(wifi24g.band, '2.4GHz'); + expect(wired.band, isNull); + }); + + test('ssidName delegates to wifi', () { + final withSsid = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(ssidName: 'MyNetwork'), + ); + final wired = DevicesTestData.createWiredClient(); + + expect(withSsid.ssidName, 'MyNetwork'); + expect(wired.ssidName, isNull); + }); + + test('hasWifiData returns false when wifi is null', () { + final wired = DevicesTestData.createWiredClient(); + expect(wired.hasWifiData, isFalse); + }); + + test('hasWifiData returns true when wifi has data', () { + final wifi = DevicesTestData.createWifiClient(); + expect(wifi.hasWifiData, isTrue); + }); + }); + + // ========================================================================= + // Throughput + // ========================================================================= + + group('throughput', () { + test('downlinkRate delegates to wifi', () { + final wifi = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(downlinkRate: 866000), + ); + expect(wifi.downlinkRate, 866000); + }); + + test('uplinkRate delegates to wifi', () { + final wifi = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(uplinkRate: 433000), + ); + expect(wifi.uplinkRate, 433000); + }); + + test('totalThroughput sums uplink and downlink', () { + final wifi = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo( + downlinkRate: 866000, + uplinkRate: 433000, + ), + ); + expect(wifi.totalThroughput, 866000 + 433000); + }); + + test('totalThroughput handles null rates', () { + final wifi = DevicesTestData.createWifiClient( + wifi: const WifiConnectionInfo(), + ); + expect(wifi.totalThroughput, 0); + }); + }); + + // ========================================================================= + // Multi-Interface + // ========================================================================= + + group('multi-interface', () { + test('hasMultipleInterfaces returns false when no additional interfaces', + () { + final device = DevicesTestData.createWifiClient(); + expect(device.hasMultipleInterfaces, isFalse); + }); + + test('hasMultipleInterfaces returns true when has additional interfaces', + () { + final device = DevicesTestData.createMultiInterfaceClient(); + expect(device.hasMultipleInterfaces, isTrue); + }); + + test('allMacAddresses includes primary and additional MACs', () { + final device = DevicesTestData.createMultiInterfaceClient( + mac: DevicesTestData.clientMac1, + additionalInterfaces: [ + DevicesTestData.createWiredInterface(mac: 'FF:FF:FF:FF:FF:01'), + DevicesTestData.createWifiInterface(mac: 'FF:FF:FF:FF:FF:02'), + ], + ); + + expect(device.allMacAddresses, hasLength(3)); + expect(device.allMacAddresses, contains(DevicesTestData.clientMac1)); + expect(device.allMacAddresses, contains('FF:FF:FF:FF:FF:01')); + expect(device.allMacAddresses, contains('FF:FF:FF:FF:FF:02')); + }); + + test('interfaceCount returns correct count', () { + final single = DevicesTestData.createWifiClient(); + final multi = DevicesTestData.createMultiInterfaceClient( + additionalInterfaces: [ + DevicesTestData.createWiredInterface(), + DevicesTestData.createWifiInterface(), + ], + ); + + expect(single.interfaceCount, 1); + expect(multi.interfaceCount, 3); + }); + + test('hasAnyActiveInterface checks primary and additional', () { + final allInactive = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: 'test', + isActive: false, + ip: '', + connectionType: ConnectionType.wifi, + additionalInterfaces: [ + DevicesTestData.createWiredInterface(isActive: false), + ], + ); + final oneActive = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: 'test', + isActive: false, + ip: '', + connectionType: ConnectionType.wifi, + additionalInterfaces: [ + DevicesTestData.createWiredInterface(isActive: true), + ], + ); + + expect(allInactive.hasAnyActiveInterface, isFalse); + expect(oneActive.hasAnyActiveInterface, isTrue); + }); + }); + + // ========================================================================= + // Display Logic + // ========================================================================= + + group('display logic', () { + test('hasSignalDisplay requires WiFi + online + signalStrength', () { + final valid = DevicesTestData.createWifiClient( + isActive: true, + wifi: DevicesTestData.createWifiInfo(signalStrength: -50), + ); + final offline = DevicesTestData.createWifiClient( + isActive: false, + wifi: DevicesTestData.createWifiInfo(signalStrength: -50), + ); + final noSignal = DevicesTestData.createWifiClient( + isActive: true, + wifi: const WifiConnectionInfo(), + ); + final wired = DevicesTestData.createWiredClient(isActive: true); + + expect(valid.hasSignalDisplay, isTrue); + expect(offline.hasSignalDisplay, isFalse); + expect(noSignal.hasSignalDisplay, isFalse); + expect(wired.hasSignalDisplay, isFalse); + }); + + test('shouldShowWifiDetails requires WiFi + online + (data OR signal)', + () { + final withData = DevicesTestData.createWifiClient(isActive: true); + final offline = DevicesTestData.createWifiClient(isActive: false); + final wired = DevicesTestData.createWiredClient(isActive: true); + + expect(withData.shouldShowWifiDetails, isTrue); + expect(offline.shouldShowWifiDetails, isFalse); + expect(wired.shouldShowWifiDetails, isFalse); + }); + + test('isInteractive returns isActive', () { + final active = DevicesTestData.createWifiClient(isActive: true); + final inactive = DevicesTestData.createWifiClient(isActive: false); + + expect(active.isInteractive, isTrue); + expect(inactive.isInteractive, isFalse); + }); + + test('displayOpacity returns 1.0 for online, 0.5 for offline', () { + final online = DevicesTestData.createWifiClient(isActive: true); + final offline = DevicesTestData.createWifiClient(isActive: false); + + expect(online.displayOpacity, 1.0); + expect(offline.displayOpacity, 0.5); + }); + }); + + // ========================================================================= + // List Extensions + // ========================================================================= + + group('List extensions', () { + late List devices; + + setUp(() { + devices = DevicesTestData.createMixedClientList(); + }); + + test('online filters active devices', () { + final online = devices.online; + + expect(online, hasLength(2)); + expect(online.every((d) => d.isOnline), isTrue); + }); + + test('offline filters inactive devices', () { + final offline = devices.offline; + + expect(offline, hasLength(2)); + expect(offline.every((d) => !d.isOnline), isTrue); + }); + + test('wifiDevices filters WiFi devices', () { + final wifi = devices.wifiDevices; + + expect(wifi, hasLength(3)); + expect(wifi.every((d) => d.isWifi), isTrue); + }); + + test('wiredDevices filters wired devices', () { + final wired = devices.wiredDevices; + + expect(wired, hasLength(1)); + expect(wired.every((d) => !d.isWifi), isTrue); + }); + }); + + // ========================================================================= + // Equatable + // ========================================================================= + + group('Equatable', () { + test('equal devices are equal', () { + final device1 = DevicesTestData.createWifiClient( + mac: DevicesTestData.clientMac1, + hostName: 'Test', + ); + final device2 = DevicesTestData.createWifiClient( + mac: DevicesTestData.clientMac1, + hostName: 'Test', + ); + + expect(device1, equals(device2)); + }); + + test('different mac are not equal', () { + final device1 = + DevicesTestData.createWifiClient(mac: '11:11:11:11:11:11'); + final device2 = + DevicesTestData.createWifiClient(mac: '22:22:22:22:22:22'); + + expect(device1, isNot(equals(device2))); + }); + + test('different isActive are not equal', () { + final device1 = DevicesTestData.createWifiClient(isActive: true); + final device2 = DevicesTestData.createWifiClient(isActive: false); + + expect(device1, isNot(equals(device2))); + }); + }); + + // ========================================================================= + // copyWith + // ========================================================================= + + group('copyWith', () { + test('preserves unchanged fields', () { + final original = DevicesTestData.createWifiClient(); + final copied = original.copyWith(); + + expect(copied, equals(original)); + }); + + test('updates specified fields', () { + final original = DevicesTestData.createWifiClient( + mac: DevicesTestData.clientMac1, + isActive: true, + ); + final copied = original.copyWith( + mac: 'NEW:MAC:ADDR', + isActive: false, + ); + + expect(copied.mac, 'NEW:MAC:ADDR'); + expect(copied.isActive, isFalse); + expect(copied.hostName, original.hostName); + }); + }); + }); + + // =========================================================================== + // WifiConnectionInfo + // =========================================================================== + + group('WifiConnectionInfo', () { + test('signalQuality normalizes to 0.0-1.0 range', () { + final excellent = DevicesTestData.createWifiInfo(signalStrength: -30); + final poor = DevicesTestData.createWifiInfo(signalStrength: -90); + final veryPoor = DevicesTestData.createWifiInfo(signalStrength: -100); + final noSignal = const WifiConnectionInfo(); + + expect(excellent.signalQuality, 1.0); + expect(poor.signalQuality, 0.0); + expect(veryPoor.signalQuality, 0.0); + expect(noSignal.signalQuality, 0.0); + }); + + test('signalLevel maps to 0-3 scale', () { + final excellent = DevicesTestData.createExcellentSignal(); + final good = DevicesTestData.createGoodSignal(); + final fair = DevicesTestData.createFairSignal(); + final poor = DevicesTestData.createPoorSignal(); + + expect(excellent.signalLevel, 3); + expect(good.signalLevel, 2); + expect(fair.signalLevel, 1); + expect(poor.signalLevel, 0); + }); + + test('totalThroughput sums rates', () { + final wifi = DevicesTestData.createWifiInfo( + downlinkRate: 100, + uplinkRate: 50, + ); + expect(wifi.totalThroughput, 150); + }); + + test('hasData returns true when any field is set', () { + final withSignal = DevicesTestData.createWifiInfo( + signalStrength: -50, + band: null, + ssidName: null, + downlinkRate: null, + uplinkRate: null, + ); + final empty = const WifiConnectionInfo(); + + expect(withSignal.hasData, isTrue); + expect(empty.hasData, isFalse); + }); + }); + + // =========================================================================== + // ClientInterfaceInfo + // =========================================================================== + + group('ClientInterfaceInfo', () { + test('isWifi returns true for WiFi connection type', () { + final wifi = DevicesTestData.createWifiInterface(); + final wired = DevicesTestData.createWiredInterface(); + + expect(wifi.isWifi, isTrue); + expect(wired.isWifi, isFalse); + }); + + test('signalStrength delegates to wifi', () { + final wifi = DevicesTestData.createWifiInterface( + wifi: DevicesTestData.createWifiInfo(signalStrength: -55), + ); + expect(wifi.signalStrength, -55); + }); + + test('band delegates to wifi', () { + final wifi = DevicesTestData.createWifiInterface( + wifi: DevicesTestData.createWifiInfo(band: '6GHz'), + ); + expect(wifi.band, '6GHz'); + }); + }); +} diff --git a/test/page/_shared/models/device_ui_model_test.dart b/test/page/_shared/models/device_ui_model_test.dart deleted file mode 100644 index ecebbafde..000000000 --- a/test/page/_shared/models/device_ui_model_test.dart +++ /dev/null @@ -1,454 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; - -void main() { - // --------------------------------------------------------------------------- - // DeviceInterfaceInfo - // --------------------------------------------------------------------------- - - group('DeviceInterfaceInfo', () { - test('equality based on all fields', () { - const iface1 = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - isWifi: true, - isActive: true, - layer1Interface: 'Device.WiFi.Radio.1', - band: '5GHz', - ssidName: 'MyNetwork', - signalStrength: -55, - ); - const iface2 = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - isWifi: true, - isActive: true, - layer1Interface: 'Device.WiFi.Radio.1', - band: '5GHz', - ssidName: 'MyNetwork', - signalStrength: -55, - ); - const iface3 = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', // Different MAC - ip: '192.168.1.100', - isWifi: true, - isActive: true, - layer1Interface: 'Device.WiFi.Radio.1', - ); - - expect(iface1, equals(iface2)); - expect(iface1, isNot(equals(iface3))); - }); - - test('props includes all fields', () { - const iface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - isWifi: true, - isActive: false, - layer1Interface: 'Device.Ethernet.Interface.1', - band: null, - ssidName: null, - signalStrength: null, - ); - - expect(iface.props, hasLength(8)); - expect(iface.props, contains('AA:BB:CC:DD:EE:01')); - expect(iface.props, contains('192.168.1.100')); - expect(iface.props, contains(true)); // isWifi - expect(iface.props, contains(false)); // isActive - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModel — Multi-Interface Getters - // --------------------------------------------------------------------------- - - group('DeviceUIModel — multi-interface getters', () { - const baseDevice = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - signalStrength: -55, - ); - - const additionalInterface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - isWifi: false, - isActive: true, - layer1Interface: 'Device.Ethernet.Interface.1', - ); - - test('hasMultipleInterfaces is false when no additional interfaces', () { - expect(baseDevice.hasMultipleInterfaces, isFalse); - }); - - test('hasMultipleInterfaces is true when additional interfaces exist', () { - final multiDevice = - baseDevice.copyWith(additionalInterfaces: [additionalInterface]); - expect(multiDevice.hasMultipleInterfaces, isTrue); - }); - - test('allMacAddresses returns only primary MAC when no additional', () { - expect(baseDevice.allMacAddresses, ['AA:BB:CC:DD:EE:01']); - }); - - test('allMacAddresses returns primary + additional MACs', () { - const secondInterface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:03', - ip: '192.168.1.102', - isWifi: true, - isActive: true, - layer1Interface: 'Device.WiFi.Radio.2', - ); - final multiDevice = baseDevice.copyWith( - additionalInterfaces: [additionalInterface, secondInterface], - ); - - expect(multiDevice.allMacAddresses, [ - 'AA:BB:CC:DD:EE:01', - 'AA:BB:CC:DD:EE:02', - 'AA:BB:CC:DD:EE:03', - ]); - }); - - test('interfaceCount is 1 when no additional interfaces', () { - expect(baseDevice.interfaceCount, 1); - }); - - test('interfaceCount is 1 + additionalInterfaces.length', () { - final multiDevice = - baseDevice.copyWith(additionalInterfaces: [additionalInterface]); - expect(multiDevice.interfaceCount, 2); - }); - - test('hasAnyActiveInterface is true when primary is active', () { - expect(baseDevice.hasAnyActiveInterface, isTrue); - }); - - test('hasAnyActiveInterface is true when only additional is active', () { - const inactiveDevice = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: false, - isWifi: true, - ); - final multiDevice = inactiveDevice.copyWith( - additionalInterfaces: [additionalInterface], // isActive: true - ); - expect(multiDevice.hasAnyActiveInterface, isTrue); - }); - - test('hasAnyActiveInterface is false when all interfaces inactive', () { - const inactiveDevice = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: false, - isWifi: true, - ); - const inactiveInterface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - isWifi: false, - isActive: false, - layer1Interface: 'Device.Ethernet.Interface.1', - ); - final multiDevice = inactiveDevice.copyWith( - additionalInterfaces: [inactiveInterface], - ); - expect(multiDevice.hasAnyActiveInterface, isFalse); - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModel — copyWith - // --------------------------------------------------------------------------- - - group('DeviceUIModel — copyWith', () { - const baseDevice = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - ); - - test('copyWith preserves unchanged fields', () { - final copied = baseDevice.copyWith(ip: '192.168.1.200'); - expect(copied.mac, 'AA:BB:CC:DD:EE:01'); - expect(copied.hostName, 'MacBook'); - expect(copied.isActive, isTrue); - expect(copied.isWifi, isTrue); - expect(copied.ip, '192.168.1.200'); - }); - - test('copyWith additionalInterfaces replaces list', () { - const iface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - isWifi: false, - isActive: true, - layer1Interface: 'Device.Ethernet.Interface.1', - ); - final copied = baseDevice.copyWith(additionalInterfaces: [iface]); - expect(copied.additionalInterfaces, [iface]); - expect(copied.hasMultipleInterfaces, isTrue); - }); - - test('copyWith returns equal object when no changes', () { - final copied = baseDevice.copyWith(); - expect(copied, equals(baseDevice)); - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModel — Equatable - // --------------------------------------------------------------------------- - - group('DeviceUIModel — Equatable', () { - test('equality includes additionalInterfaces', () { - const device1 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - ); - const device2 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - ); - const iface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - isWifi: false, - isActive: true, - layer1Interface: 'Device.Ethernet.Interface.1', - ); - const device3 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - additionalInterfaces: [iface], - ); - - expect(device1, equals(device2)); - expect(device1, isNot(equals(device3))); - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModel — Other Getters - // --------------------------------------------------------------------------- - - group('DeviceUIModel — displayName', () { - test('displayName prefers friendlyName over hostName', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'macbook-pro', - friendlyName: "Austin's MacBook", - isActive: true, - isWifi: true, - ); - expect(device.displayName, "Austin's MacBook"); - }); - - test('displayName uses hostName when friendlyName is empty', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'macbook-pro', - friendlyName: '', - isActive: true, - isWifi: true, - ); - expect(device.displayName, 'macbook-pro'); - }); - - test('displayName falls back to MAC when hostName is empty', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: '', - isActive: true, - isWifi: true, - ); - expect(device.displayName, 'AA:BB:CC:DD:EE:01'); - }); - }); - - group('DeviceUIModel — isClientDevice', () { - test('isClientDevice is true for null deviceRole', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - deviceRole: null, - ); - expect(device.isClientDevice, isTrue); - }); - - test('isClientDevice is false for master role', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MR7500', - isActive: true, - isWifi: false, - deviceRole: 'master', - ); - expect(device.isClientDevice, isFalse); - }); - - test('isClientDevice is false for slave role', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MX5500', - isActive: true, - isWifi: false, - deviceRole: 'slave', - ); - expect(device.isClientDevice, isFalse); - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModelListExt — Extension Methods - // --------------------------------------------------------------------------- - - group('DeviceUIModelListExt', () { - const clientWifi = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - deviceRole: null, - ); - - const clientWired = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - hostName: 'Desktop', - isActive: true, - isWifi: false, - deviceRole: 'client', - ); - - const masterNode = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:10', - ip: '192.168.1.1', - hostName: 'MR7500', - isActive: true, - isWifi: false, - deviceRole: 'master', - ); - - const slaveNode1 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:11', - ip: '192.168.1.2', - hostName: 'MX5500-1', - isActive: true, - isWifi: false, - deviceRole: 'slave', - ); - - const slaveNode2 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:12', - ip: '192.168.1.3', - hostName: 'MX5500-2', - isActive: true, - isWifi: false, - deviceRole: 'slave', - ); - - final allDevices = [ - clientWifi, - clientWired, - masterNode, - slaveNode1, - slaveNode2, - ]; - - test('clientDevices returns only non-mesh devices', () { - final clients = allDevices.clientDevices; - - expect(clients, hasLength(2)); - expect(clients, contains(clientWifi)); - expect(clients, contains(clientWired)); - expect(clients, isNot(contains(masterNode))); - expect(clients, isNot(contains(slaveNode1))); - }); - - test('meshNodes returns only master and slave devices', () { - final meshes = allDevices.meshNodes; - - expect(meshes, hasLength(3)); - expect(meshes, contains(masterNode)); - expect(meshes, contains(slaveNode1)); - expect(meshes, contains(slaveNode2)); - expect(meshes, isNot(contains(clientWifi))); - }); - - test('masterNode returns the master device', () { - final master = allDevices.masterNode; - - expect(master, isNotNull); - expect(master, equals(masterNode)); - }); - - test('masterNode returns null when no master exists', () { - final devicesNoMaster = [clientWifi, clientWired, slaveNode1]; - final master = devicesNoMaster.masterNode; - - expect(master, isNull); - }); - - test('slaveNodes returns all slave devices', () { - final slaves = allDevices.slaveNodes; - - expect(slaves, hasLength(2)); - expect(slaves, contains(slaveNode1)); - expect(slaves, contains(slaveNode2)); - expect(slaves, isNot(contains(masterNode))); - }); - - test('slaveNodes returns empty list when no slaves', () { - final devicesNoSlaves = [clientWifi, masterNode]; - final slaves = devicesNoSlaves.slaveNodes; - - expect(slaves, isEmpty); - }); - - test('extensions work on empty list', () { - final List emptyList = []; - - expect(emptyList.clientDevices, isEmpty); - expect(emptyList.meshNodes, isEmpty); - expect(emptyList.masterNode, isNull); - expect(emptyList.slaveNodes, isEmpty); - }); - - test('clientDevices returns all when no mesh nodes', () { - final clientsOnly = [clientWifi, clientWired]; - final clients = clientsOnly.clientDevices; - - expect(clients, hasLength(2)); - expect(clients, equals(clientsOnly)); - }); - }); -} diff --git a/test/page/_shared/models/mesh_network_test.dart b/test/page/_shared/models/mesh_network_test.dart new file mode 100644 index 000000000..fedfa84f6 --- /dev/null +++ b/test/page/_shared/models/mesh_network_test.dart @@ -0,0 +1,428 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; + +import '../../../mocks/test_data/devices_test_data.dart'; + +void main() { + group('MeshNetwork', () { + // ========================================================================= + // Accessors + // ========================================================================= + + group('allNodes', () { + test('returns master only when no slaves', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + expect(network.allNodes, hasLength(1)); + expect(network.allNodes.first, isA()); + }); + + test('returns master + slaves in mesh network', () { + final network = DevicesTestData.createMultiSlaveMeshNetwork(); + + expect(network.allNodes, hasLength(3)); + expect(network.allNodes[0], isA()); + expect(network.allNodes[1], isA()); + expect(network.allNodes[2], isA()); + }); + }); + + group('allClients', () { + test('returns master clients in single-node network', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + expect(network.allClients, hasLength(2)); + }); + + test('combines clients from all nodes', () { + final network = DevicesTestData.createMeshNetwork(); + + // 1 client on master + 1 client on slave + expect(network.allClients, hasLength(2)); + }); + + test('includes unassigned clients', () { + final network = DevicesTestData.createNetworkWithUnassignedClients(); + + expect(network.allClients, hasLength(2)); + expect(network.unassignedClients, hasLength(2)); + }); + }); + + group('client counts', () { + test('totalClientCount returns correct count', () { + final network = DevicesTestData.createSingleNodeNetwork(); + expect(network.totalClientCount, 2); + }); + + test('onlineClientCount filters active clients only', () { + final mixedClients = [ + DevicesTestData.createWifiClient(isActive: true), + DevicesTestData.createWiredClient(isActive: false), + DevicesTestData.createOfflineClient(), + ]; + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: mixedClients, + ); + + expect(network.onlineClientCount, 1); + expect(network.offlineClientCount, 2); + }); + + test('wifiClientCount counts online WiFi clients', () { + final clients = [ + DevicesTestData.createWifiClient(isActive: true), + DevicesTestData.createWifiClient( + mac: '11:22:33:44:55:99', isActive: false), + DevicesTestData.createWiredClient(isActive: true), + ]; + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: clients, + ); + + expect(network.wifiClientCount, 1); + expect(network.wiredClientCount, 1); + }); + }); + + group('hasMesh', () { + test('returns false for single-node network', () { + final network = DevicesTestData.createSingleNodeNetwork(); + expect(network.hasMesh, isFalse); + }); + + test('returns true when slaves exist', () { + final network = DevicesTestData.createMeshNetwork(); + expect(network.hasMesh, isTrue); + }); + }); + + group('nodeCount', () { + test('returns 1 for single-node network', () { + final network = DevicesTestData.createSingleNodeNetwork(); + expect(network.nodeCount, 1); + }); + + test('returns correct count for mesh network', () { + final network = DevicesTestData.createMultiSlaveMeshNetwork(); + expect(network.nodeCount, 3); + }); + }); + + // ========================================================================= + // Lookups + // ========================================================================= + + group('findNode', () { + test('finds master by deviceId', () { + final network = DevicesTestData.createMeshNetwork(); + + final found = network.findNode(DevicesTestData.masterMac); + + expect(found, isNotNull); + expect(found, isA()); + }); + + test('finds slave by deviceId', () { + final network = DevicesTestData.createMeshNetwork(); + + final found = network.findNode(DevicesTestData.slaveMac1); + + expect(found, isNotNull); + expect(found, isA()); + }); + + test('finds node by dataElementsId', () { + final slave = DevicesTestData.createWifiSlave( + deviceId: DevicesTestData.slaveMac1, + dataElementsId: 'DE:AA:BB:CC:DD:EE', + ); + final network = MeshNetwork( + master: DevicesTestData.createMaster(), + slaves: [slave], + ); + + final found = network.findNode('DE:AA:BB:CC:DD:EE'); + + expect(found, isNotNull); + expect(found?.deviceId, DevicesTestData.slaveMac1); + }); + + test('is case-insensitive', () { + final network = DevicesTestData.createMeshNetwork(); + + final lowercase = + network.findNode(DevicesTestData.masterMac.toLowerCase()); + final uppercase = + network.findNode(DevicesTestData.masterMac.toUpperCase()); + + expect(lowercase, isNotNull); + expect(uppercase, isNotNull); + expect(lowercase, equals(uppercase)); + }); + + test('returns null when not found', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + expect(network.findNode('XX:XX:XX:XX:XX:XX'), isNull); + }); + }); + + group('findClient', () { + test('finds client by MAC address', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final found = network.findClient(DevicesTestData.clientMac1); + + expect(found, isNotNull); + expect(found?.mac, DevicesTestData.clientMac1); + }); + + test('finds client by additional interface MAC', () { + final multiClient = DevicesTestData.createMultiInterfaceClient( + mac: DevicesTestData.clientMac1, + additionalInterfaces: [ + DevicesTestData.createWiredInterface(mac: 'FF:FF:FF:FF:FF:01'), + ], + ); + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: [multiClient], + ); + + final found = network.findClient('FF:FF:FF:FF:FF:01'); + + expect(found, isNotNull); + expect(found?.mac, DevicesTestData.clientMac1); + }); + + test('is case-insensitive', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final lowercase = + network.findClient(DevicesTestData.clientMac1.toLowerCase()); + final uppercase = + network.findClient(DevicesTestData.clientMac1.toUpperCase()); + + expect(lowercase, isNotNull); + expect(uppercase, isNotNull); + }); + + test('returns null when not found', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + expect(network.findClient('XX:XX:XX:XX:XX:XX'), isNull); + }); + }); + + group('findParentNode', () { + test('returns master when parentNodeId is null', () { + final client = DevicesTestData.createWifiClient(parentNodeId: null); + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: [client], + ); + + final parent = network.findParentNode(client); + + expect(parent, isA()); + }); + + test('returns correct slave node when parentNodeId is set', () { + final client = DevicesTestData.createSlaveConnectedClient( + parentNodeId: DevicesTestData.slaveMac1, + ); + final network = DevicesTestData.createMeshNetwork( + slaveClients: [client], + ); + + final parent = network.findParentNode(client); + + expect(parent, isA()); + expect(parent?.deviceId, DevicesTestData.slaveMac1); + }); + + test('returns null when parentNodeId not found', () { + final client = DevicesTestData.createWifiClient( + parentNodeId: 'XX:XX:XX:XX:XX:XX', + ); + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: [client], + ); + + final parent = network.findParentNode(client); + + expect(parent, isNull); + }); + }); + + group('clientsForNode', () { + test('returns clients for master node', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final clients = network.clientsForNode(DevicesTestData.masterMac); + + expect(clients, hasLength(2)); + }); + + test('returns clients for slave node', () { + final network = DevicesTestData.createMeshNetwork(); + + final clients = network.clientsForNode(DevicesTestData.slaveMac1); + + expect(clients, hasLength(1)); + }); + + test('returns empty list when node not found', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final clients = network.clientsForNode('XX:XX:XX:XX:XX:XX'); + + expect(clients, isEmpty); + }); + }); + + group('clientsByNode', () { + test('groups clients by node ID', () { + final network = DevicesTestData.createMeshNetwork(); + + final byNode = network.clientsByNode; + + expect(byNode.containsKey(DevicesTestData.masterMac), isTrue); + expect(byNode.containsKey(DevicesTestData.slaveMac1), isTrue); + }); + + test('includes _unassigned key when unassigned clients exist', () { + final network = DevicesTestData.createNetworkWithUnassignedClients(); + + final byNode = network.clientsByNode; + + expect(byNode.containsKey('_unassigned'), isTrue); + expect(byNode['_unassigned'], hasLength(2)); + }); + + test('does not include _unassigned key when no unassigned clients', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final byNode = network.clientsByNode; + + expect(byNode.containsKey('_unassigned'), isFalse); + }); + }); + + // ========================================================================= + // Equatable + // ========================================================================= + + group('Equatable', () { + test('equal networks are equal', () { + final network1 = DevicesTestData.createSingleNodeNetwork(); + final network2 = DevicesTestData.createSingleNodeNetwork(); + + expect(network1, equals(network2)); + }); + + test('different masters are not equal', () { + final network1 = DevicesTestData.createSingleNodeNetwork( + master: DevicesTestData.createMaster(deviceId: 'AA:AA:AA:AA:AA:01'), + ); + final network2 = DevicesTestData.createSingleNodeNetwork( + master: DevicesTestData.createMaster(deviceId: 'AA:AA:AA:AA:AA:02'), + ); + + expect(network1, isNot(equals(network2))); + }); + + test('different slaves are not equal', () { + final network1 = DevicesTestData.createMeshNetwork(); + final network2 = DevicesTestData.createMultiSlaveMeshNetwork(); + + expect(network1, isNot(equals(network2))); + }); + + test('different unassigned clients are not equal', () { + final network1 = DevicesTestData.createNetworkWithUnassignedClients( + unassignedClients: [DevicesTestData.createWifiClient()], + ); + final network2 = DevicesTestData.createNetworkWithUnassignedClients( + unassignedClients: [ + DevicesTestData.createWifiClient(), + DevicesTestData.createWiredClient(), + ], + ); + + expect(network1, isNot(equals(network2))); + }); + }); + + // ========================================================================= + // copyWith + // ========================================================================= + + group('copyWith', () { + test('preserves unchanged fields', () { + final original = DevicesTestData.createMeshNetwork(); + final copied = original.copyWith(); + + expect(copied, equals(original)); + }); + + test('updates master', () { + final original = DevicesTestData.createMeshNetwork(); + final newMaster = DevicesTestData.createMaster(deviceId: 'NEW:MAC'); + final copied = original.copyWith(master: newMaster); + + expect(copied.master.deviceId, 'NEW:MAC'); + expect(copied.slaves, equals(original.slaves)); + }); + + test('updates slaves', () { + final original = DevicesTestData.createMeshNetwork(); + final copied = original.copyWith(slaves: []); + + expect(copied.slaves, isEmpty); + expect(copied.master, equals(original.master)); + }); + + test('updates unassignedClients', () { + final original = DevicesTestData.createSingleNodeNetwork(); + final unassigned = [DevicesTestData.createWifiClient()]; + final copied = original.copyWith(unassignedClients: unassigned); + + expect(copied.unassignedClients, hasLength(1)); + }); + }); + + // ========================================================================= + // Edge Cases + // ========================================================================= + + group('edge cases', () { + test('empty network works correctly', () { + final network = DevicesTestData.createEmptyNetwork(); + + expect(network.allClients, isEmpty); + expect(network.totalClientCount, 0); + expect(network.onlineClientCount, 0); + expect(network.offlineClientCount, 0); + expect(network.wifiClientCount, 0); + expect(network.wiredClientCount, 0); + expect(network.hasMesh, isFalse); + expect(network.nodeCount, 1); + }); + + test('all clients offline returns zero online counts', () { + final offlineClients = [ + DevicesTestData.createOfflineClient(mac: '11:11:11:11:11:01'), + DevicesTestData.createOfflineClient(mac: '11:11:11:11:11:02'), + ]; + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: offlineClients, + ); + + expect(network.totalClientCount, 2); + expect(network.onlineClientCount, 0); + expect(network.offlineClientCount, 2); + }); + }); + }); +} diff --git a/test/page/_shared/models/node_entity_test.dart b/test/page/_shared/models/node_entity_test.dart new file mode 100644 index 000000000..ac81bcc08 --- /dev/null +++ b/test/page/_shared/models/node_entity_test.dart @@ -0,0 +1,368 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; + +import '../../../mocks/test_data/devices_test_data.dart'; + +void main() { + // =========================================================================== + // MasterNode + // =========================================================================== + + group('MasterNode', () { + group('isMaster', () { + test('returns true', () { + final master = DevicesTestData.createMaster(); + expect(master.isMaster, isTrue); + }); + }); + + group('roleLabel', () { + test('returns Master', () { + final master = DevicesTestData.createMaster(); + expect(master.roleLabel, 'Master'); + }); + }); + + group('displayName', () { + test('returns friendlyName when set', () { + final node = DevicesTestData.createMaster( + friendlyName: 'My Router', + hostName: 'Linksys-Router', + model: 'MR7500', + deviceId: DevicesTestData.masterMac, + ); + + expect(node.displayName, 'My Router'); + }); + + test('returns hostName when friendlyName is null', () { + final node = DevicesTestData.createMaster( + friendlyName: null, + hostName: 'Linksys-Router', + model: 'MR7500', + ); + + expect(node.displayName, 'Linksys-Router'); + }); + + test('returns hostName when friendlyName is empty', () { + final node = MasterNode( + deviceId: DevicesTestData.masterMac, + friendlyName: '', + hostName: 'Linksys-Router', + model: 'MR7500', + ); + + expect(node.displayName, 'Linksys-Router'); + }); + + test('returns model when friendlyName and hostName are null/empty', () { + final node = MasterNode( + deviceId: DevicesTestData.masterMac, + friendlyName: null, + hostName: null, + model: 'MR7500', + ); + + expect(node.displayName, 'MR7500'); + }); + + test('returns deviceId when all others are null/empty', () { + final node = MasterNode( + deviceId: DevicesTestData.masterMac, + friendlyName: null, + hostName: null, + model: '', + ); + + expect(node.displayName, DevicesTestData.masterMac); + }); + }); + + group('NetworkEntity implementation', () { + test('id returns deviceId', () { + final node = DevicesTestData.createMaster( + deviceId: DevicesTestData.masterMac, + ); + + expect(node.id, DevicesTestData.masterMac); + }); + + test('isOnline always returns true', () { + final node = DevicesTestData.createMaster(); + expect(node.isOnline, isTrue); + }); + }); + + group('connectedDeviceCount', () { + test('returns number of connected clients', () { + final node = DevicesTestData.createMaster( + connectedClients: [ + DevicesTestData.createWifiClient(), + DevicesTestData.createWiredClient(), + ], + ); + + expect(node.connectedDeviceCount, 2); + }); + + test('returns 0 when no clients', () { + final node = DevicesTestData.createMaster(connectedClients: []); + expect(node.connectedDeviceCount, 0); + }); + }); + + group('copyWith', () { + test('preserves unchanged fields', () { + final original = DevicesTestData.createMaster(); + final copied = original.copyWith(); + + expect(copied.deviceId, original.deviceId); + expect(copied.model, original.model); + expect(copied.manufacturer, original.manufacturer); + }); + + test('updates specified fields', () { + final original = DevicesTestData.createMaster(); + final copied = original.copyWith( + friendlyName: 'New Name', + model: 'MR8000', + ); + + expect(copied.friendlyName, 'New Name'); + expect(copied.model, 'MR8000'); + expect(copied.deviceId, original.deviceId); + }); + + test('carries hostsDeviceId (UUID) through construction and equality', + () { + final withUuid = + DevicesTestData.createMaster(hostsDeviceId: 'uuid-1234'); + expect(withUuid.hostsDeviceId, 'uuid-1234'); + + final sameUuid = + DevicesTestData.createMaster(hostsDeviceId: 'uuid-1234'); + final otherUuid = + DevicesTestData.createMaster(hostsDeviceId: 'uuid-9999'); + + // hostsDeviceId participates in Equatable props. + expect(withUuid, equals(sameUuid)); + expect(withUuid, isNot(equals(otherUuid))); + }); + }); + }); + + // =========================================================================== + // SlaveNode + // =========================================================================== + + group('SlaveNode', () { + group('isMaster', () { + test('returns false', () { + final slave = DevicesTestData.createWifiSlave(); + expect(slave.isMaster, isFalse); + }); + }); + + group('roleLabel', () { + test('returns Slave', () { + final slave = DevicesTestData.createWifiSlave(); + expect(slave.roleLabel, 'Slave'); + }); + }); + + group('displayName', () { + test('follows same priority as MasterNode', () { + final withFriendly = DevicesTestData.createWifiSlave( + friendlyName: 'Living Room Extender', + hostName: 'Extender-1', + ); + final withHost = DevicesTestData.createWifiSlave( + friendlyName: null, + hostName: 'Extender-1', + ); + + expect(withFriendly.displayName, 'Living Room Extender'); + expect(withHost.displayName, 'Extender-1'); + }); + }); + + group('backhaul properties', () { + test('isEthernetBackhaul returns true for Ethernet backhaul', () { + final ethernetSlave = DevicesTestData.createEthernetSlave(); + final wifiSlave = DevicesTestData.createWifiSlave(); + + expect(ethernetSlave.isEthernetBackhaul, isTrue); + expect(wifiSlave.isEthernetBackhaul, isFalse); + }); + + test('hasBackhaul returns true when backhaul has info', () { + final withBackhaul = DevicesTestData.createWifiSlave(); + final noBackhaul = SlaveNode( + deviceId: DevicesTestData.slaveMac1, + model: 'MR7500', + backhaul: DevicesTestData.emptyBackhaul, + ); + + expect(withBackhaul.hasBackhaul, isTrue); + expect(noBackhaul.hasBackhaul, isFalse); + }); + }); + + group('copyWith', () { + test('preserves unchanged fields', () { + final original = DevicesTestData.createWifiSlave(); + final copied = original.copyWith(); + + expect(copied.deviceId, original.deviceId); + expect(copied.backhaul, original.backhaul); + }); + + test('updates backhaul', () { + final original = DevicesTestData.createWifiSlave(); + final newBackhaul = DevicesTestData.createEthernetBackhaul(); + final copied = original.copyWith(backhaul: newBackhaul); + + expect(copied.isEthernetBackhaul, isTrue); + }); + }); + }); + + // =========================================================================== + // List Extensions + // =========================================================================== + + group('List extensions', () { + late List nodes; + + setUp(() { + nodes = [ + DevicesTestData.createMaster(), + DevicesTestData.createWifiSlave(deviceId: DevicesTestData.slaveMac1), + DevicesTestData.createEthernetSlave( + deviceId: DevicesTestData.slaveMac2), + ]; + }); + + test('master returns the MasterNode', () { + final master = nodes.master; + + expect(master, isNotNull); + expect(master, isA()); + }); + + test('master returns null when no MasterNode', () { + final slavesOnly = [ + DevicesTestData.createWifiSlave(), + ]; + + expect(slavesOnly.master, isNull); + }); + + test('slaves returns only SlaveNodes', () { + final slaves = nodes.slaves; + + expect(slaves, hasLength(2)); + expect(slaves.first.deviceId, DevicesTestData.slaveMac1); + expect(slaves.last.deviceId, DevicesTestData.slaveMac2); + }); + + test('hasMesh returns true when slaves exist', () { + expect(nodes.hasMesh, isTrue); + }); + + test('hasMesh returns false when no slaves', () { + final masterOnly = [ + DevicesTestData.createMaster(), + ]; + + expect(masterOnly.hasMesh, isFalse); + }); + }); + + // =========================================================================== + // BackhaulInfo + // =========================================================================== + + group('BackhaulInfo', () { + test('isEthernet returns true for Ethernet linkType', () { + final ethernet = DevicesTestData.createEthernetBackhaul(); + final wifi = DevicesTestData.createWifiBackhaul(); + + expect(ethernet.isEthernet, isTrue); + expect(wifi.isEthernet, isFalse); + }); + + test('isWifi returns true for non-Ethernet linkType', () { + final wifi = DevicesTestData.createWifiBackhaul(); + final ethernet = DevicesTestData.createEthernetBackhaul(); + + expect(wifi.isWifi, isTrue); + expect(ethernet.isWifi, isFalse); + }); + + test('hasInfo returns true when mediaType is not empty', () { + final withInfo = DevicesTestData.createWifiBackhaul(); + const noInfo = BackhaulInfo(mediaType: ''); + + expect(withInfo.hasInfo, isTrue); + expect(noInfo.hasInfo, isFalse); + }); + + test('Equatable compares all fields', () { + final backhaul1 = DevicesTestData.createWifiBackhaul(signalStrength: -55); + final backhaul2 = DevicesTestData.createWifiBackhaul(signalStrength: -55); + final backhaul3 = DevicesTestData.createWifiBackhaul(signalStrength: -70); + + expect(backhaul1, equals(backhaul2)); + expect(backhaul1, isNot(equals(backhaul3))); + }); + }); + + // =========================================================================== + // Equatable + // =========================================================================== + + group('NodeEntity Equatable', () { + test('equal MasterNodes are equal', () { + final node1 = DevicesTestData.createMaster( + deviceId: DevicesTestData.masterMac, + model: 'MR7500', + ); + final node2 = DevicesTestData.createMaster( + deviceId: DevicesTestData.masterMac, + model: 'MR7500', + ); + + expect(node1, equals(node2)); + }); + + test('different deviceId makes nodes not equal', () { + final node1 = DevicesTestData.createMaster(deviceId: 'AA:AA:AA:AA:AA:01'); + final node2 = DevicesTestData.createMaster(deviceId: 'AA:AA:AA:AA:AA:02'); + + expect(node1, isNot(equals(node2))); + }); + + test('equal SlaveNodes are equal', () { + final node1 = DevicesTestData.createWifiSlave( + deviceId: DevicesTestData.slaveMac1, + ); + final node2 = DevicesTestData.createWifiSlave( + deviceId: DevicesTestData.slaveMac1, + ); + + expect(node1, equals(node2)); + }); + + test('different connectedClients makes nodes not equal', () { + final node1 = DevicesTestData.createMaster(connectedClients: []); + final node2 = DevicesTestData.createMaster( + connectedClients: [DevicesTestData.createWifiClient()], + ); + + expect(node1, isNot(equals(node2))); + }); + }); +} diff --git a/test/page/_shared/providers/usp_device_analytics_notifier_test.dart b/test/page/_shared/providers/usp_device_analytics_notifier_test.dart index 15c2e2be9..0a0a3ce39 100644 --- a/test/page/_shared/providers/usp_device_analytics_notifier_test.dart +++ b/test/page/_shared/providers/usp_device_analytics_notifier_test.dart @@ -1,11 +1,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; -import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/_shared/providers/usp_device_analytics_notifier.dart'; -import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; /// Test-only devices data notifier returning canned data. @@ -21,87 +22,78 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { } } -/// Test-only system info notifier returning canned data. -class _TestSystemInfoDataNotifier extends SystemInfoDataNotifier { - final String serialNumber; - _TestSystemInfoDataNotifier({this.serialNumber = 'TEST_SN_001'}); - - @override - Future build() async { - return SystemInfoData( - model: SystemInfoUIModel( - modelName: 'TestRouter', - hardwareVersion: '1.0', - manufacturer: 'Test', - serialNumber: serialNumber, - softwareVersion: '1.0.0', - uptime: 3600, - totalMemory: 512000, - freeMemory: 256000, - cpuUsage: 25, - ), - ); - } -} - void main() { - const wifiDevice5g = DeviceUIModel( + final wifiDevice5g = ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'Phone', isActive: true, - isWifi: true, - band: '5GHz', - signalStrength: -55, // level 3 (excellent, >= -65) + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + signalStrength: -55, // level 3 (excellent, >= -65) + ), ); - const wifiDevice24g = DeviceUIModel( + final wifiDevice24g = ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'Tablet', isActive: true, - isWifi: true, - band: '2.4GHz', - signalStrength: -75, // level 1 (fair, -71..-78) + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '2.4GHz', + signalStrength: -75, // level 1 (fair, -71..-78) + ), ); - const wiredDevice = DeviceUIModel( + final wiredDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.102', hostName: 'Desktop', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, ); - const offlineDevice = DeviceUIModel( + final offlineDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:04', ip: '192.168.1.103', hostName: 'Printer', isActive: false, - isWifi: true, - band: '2.4GHz', - signalStrength: -85, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '2.4GHz', + signalStrength: -85, + ), ); final testDevices = [wifiDevice5g, wifiDevice24g, wiredDevice, offlineDevice]; - final testDevicesData = DevicesData(deviceModels: testDevices); + + DevicesData createDevicesData(List clients) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'Router', + connectedClients: clients, + ), + ), + ); + } + + final testDevicesData = createDevicesData(testDevices); setUp(() { SharedPreferences.setMockInitialValues({}); }); - ProviderContainer createContainer({ - DevicesData? data, - bool shouldThrow = false, - String serialNumber = 'TEST_SN_001', - }) { + ProviderContainer createContainer( + {DevicesData? data, bool shouldThrow = false}) { final devicesData = data ?? testDevicesData; final container = ProviderContainer( overrides: [ devicesDataProvider.overrideWith(() => _TestDevicesDataNotifier(devicesData, shouldThrow: shouldThrow)), - systemInfoDataProvider.overrideWith( - () => _TestSystemInfoDataNotifier(serialNumber: serialNumber)), ], ); return container; @@ -206,7 +198,7 @@ void main() { }); test('empty device list produces empty distribution', () async { - final container = createContainer(data: const DevicesData()); + final container = createContainer(data: createDevicesData([])); await waitForAnalytics(container); final state = container.read(uspDeviceAnalyticsProvider); @@ -274,73 +266,31 @@ void main() { container.dispose(); }); - test('excludes mesh nodes from distribution', () async { - // Add mesh nodes (master and slave routers) to the device list - const masterNode = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:05', - ip: '192.168.1.1', - hostName: 'Router', - isActive: true, - isWifi: false, - deviceRole: 'master', - ); - const slaveNode = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:06', - ip: '192.168.1.2', - hostName: 'Extender', - isActive: true, - isWifi: true, - band: '5GHz', - signalStrength: -50, - deviceRole: 'slave', - ); - // Include mesh nodes alongside regular client devices - final dataWithMesh = DevicesData( - deviceModels: [...testDevices, masterNode, slaveNode], - ); - final container = createContainer(data: dataWithMesh); - await waitForAnalytics(container); - - final state = container.read(uspDeviceAnalyticsProvider); - expect(state.current, isNotNull); - - final dist = state.current!; - // Mesh nodes should NOT be counted — same as without them - // 2 wifi online + 1 wired online = 3 online, 1 offline (no mesh nodes) - expect(dist.onlineCount, 3); - expect(dist.offlineCount, 1); - expect(dist.wifiCount, 2); - expect(dist.wiredCount, 1); - expect(dist.totalCount, 4); - - // Band distribution should NOT include the slave's 5GHz - expect(dist.bandDistribution['5GHz'], 1); // Only wifiDevice5g - expect(dist.bandDistribution['2.4GHz'], 1); - expect(dist.bandDistribution['Wired'], 1); - container.dispose(); - }); - test('band signal quality computes average per band', () async { // Two 5GHz devices with different signal strengths - const wifi5a = DeviceUIModel( + final wifi5a = ClientDevice( mac: 'FF:00:00:00:00:01', ip: '192.168.1.200', hostName: 'DeviceA', isActive: true, - isWifi: true, - band: '5GHz', - signalStrength: -30, // quality 1.0 + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + signalStrength: -30, // quality 1.0 + ), ); - const wifi5b = DeviceUIModel( + final wifi5b = ClientDevice( mac: 'FF:00:00:00:00:02', ip: '192.168.1.201', hostName: 'DeviceB', isActive: true, - isWifi: true, - band: '5GHz', - signalStrength: -90, // quality 0.0 + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + signalStrength: -90, // quality 0.0 + ), ); - const data = DevicesData(deviceModels: [wifi5a, wifi5b]); + final data = createDevicesData([wifi5a, wifi5b]); final container = createContainer(data: data); await waitForAnalytics(container); @@ -350,93 +300,79 @@ void main() { container.dispose(); }); - test('filters router MACs from persisted history on load', () async { - // Simulate legacy persisted data that includes router MACs - final now = DateTime.now(); - final currentHour = DateTime(now.year, now.month, now.day, now.hour); - const routerMac = 'AA:BB:CC:DD:EE:05'; // Will be marked as master - const clientMac = 'AA:BB:CC:DD:EE:01'; // Regular client - - final legacyState = DeviceAnalyticsState( - hourlyHistory: [ - HourlyAggregate( - hour: currentHour.subtract(Duration(hours: 1)), - wifiCount: 2, - wiredCount: 0, - activeMacs: {routerMac, clientMac}, // Legacy: contains router MAC - ), - ], - allKnownMacs: {routerMac, clientMac}, - macDisplayNames: {routerMac: 'Router', clientMac: 'Phone'}, + test('categorizes by band first, node name only for band-less WiFi', + () async { + // On a REAL mesh, MeshNetworkBuilder maps every node's associated STAs — + // including the master's own clients — into clientToNodeMap, so a master + // WiFi client gets a NON-NULL parentNodeId AND a patched parentNodeName + // (the gateway display name). Categorization must key off the band, not + // parentNodeId, or master WiFi clients collapse under the gateway name. + + // Master WiFi with band — mesh shape: NON-NULL parentNodeId + patched name. + final masterWifiMeshShape = ClientDevice( + mac: 'FF:00:00:00:00:01', + ip: '192.168.1.200', + hostName: 'MasterClient', + isActive: true, + connectionType: ConnectionType.wifi, + parentNodeId: + 'MASTER_NODE_ID', // non-null, as the real builder produces + parentNodeName: 'MyGateway', + wifi: const WifiConnectionInfo( + band: '5GHz', + signalStrength: -50, + ), ); - - // Pre-populate SharedPreferences with legacy data - SharedPreferences.setMockInitialValues({ - 'flutter.usp_device_analytics_LEGACY_SN': legacyState.toJsonString(), - }); - - // Create container with a mesh node that has the router MAC - const masterNode = DeviceUIModel( - mac: routerMac, - ip: '192.168.1.1', - hostName: 'Router', + // Slave WiFi client WITHOUT a band (band resolution pending #1118) — + // falls through to its node name. + final slaveWifiNoBand = ClientDevice( + mac: 'FF:00:00:00:00:02', + ip: '192.168.1.201', + hostName: 'ChildClient', isActive: true, - isWifi: false, - deviceRole: 'master', + connectionType: ConnectionType.wifi, + parentNodeId: 'CHILD_NODE_ID', + parentNodeName: 'Extender-1', + wifi: const WifiConnectionInfo( + signalStrength: -60, + ), ); - final dataWithRouter = DevicesData( - deviceModels: [wifiDevice5g, masterNode], + // Slave Wired client → "Wired". + final slaveWired = ClientDevice( + mac: 'FF:00:00:00:00:03', + ip: '192.168.1.202', + hostName: 'ChildWired', + isActive: true, + connectionType: ConnectionType.wired, + parentNodeId: 'CHILD_NODE_ID', + parentNodeName: 'Extender-1', ); - - final container = createContainer( - data: dataWithRouter, - serialNumber: 'LEGACY_SN', + // Master Wired client — patched gateway name, non-null parentNodeId → Wired. + final masterWired = ClientDevice( + mac: 'FF:00:00:00:00:04', + ip: '192.168.1.203', + hostName: 'MasterWired', + isActive: true, + connectionType: ConnectionType.wired, + parentNodeId: 'MASTER_NODE_ID', + parentNodeName: 'MyGateway', ); + final data = createDevicesData( + [masterWifiMeshShape, slaveWifiNoBand, slaveWired, masterWired]); + final container = createContainer(data: data); await waitForAnalytics(container); - final state = container.read(uspDeviceAnalyticsProvider); - - // Router MAC should be filtered out from allKnownMacs - expect(state.allKnownMacs, isNot(contains(routerMac))); - expect(state.allKnownMacs, contains(clientMac)); - - // Hourly history activeMacs should also exclude router MAC - for (final h in state.hourlyHistory) { - expect(h.activeMacs, isNot(contains(routerMac))); - } - + final dist = container.read(uspDeviceAnalyticsProvider).current!; + // Master WiFi with band: bucketed under its BAND even on a mesh + // (non-null parentNodeId), NOT the gateway name. + expect(dist.bandDistribution['5GHz'], 1); + // Band-less slave WiFi client: grouped under its node name. + expect(dist.bandDistribution['Extender-1'], 1); + // Both wired clients (master + slave): "Wired". + expect(dist.bandDistribution['Wired'], 2); + // The gateway name must never become a category. + expect(dist.bandDistribution.containsKey('MyGateway'), isFalse); container.dispose(); }); - - test('persistence is scoped by router serial number', () async { - // First router with SN "ROUTER_A" - final containerA = createContainer(serialNumber: 'ROUTER_A'); - await waitForAnalytics(containerA); - final stateA = containerA.read(uspDeviceAnalyticsProvider); - expect(stateA.hourlyHistory, hasLength(1)); - containerA.dispose(); - - // Second router with different SN "ROUTER_B" - final containerB = createContainer( - data: const DevicesData(deviceModels: []), - serialNumber: 'ROUTER_B', - ); - await waitForAnalytics(containerB); - final stateB = containerB.read(uspDeviceAnalyticsProvider); - // Should NOT inherit history from Router A — different SN means different key - expect(stateB.hourlyHistory, hasLength(1)); // Only its own empty entry - expect(stateB.current!.onlineCount, 0); // Empty device list - containerB.dispose(); - - // Back to Router A — should still have its data - final containerA2 = createContainer(serialNumber: 'ROUTER_A'); - await waitForAnalytics(containerA2); - final stateA2 = containerA2.read(uspDeviceAnalyticsProvider); - // Should have 2 entries now (original + this session's update) - expect(stateA2.hourlyHistory.isNotEmpty, isTrue); - expect( - stateA2.current!.onlineCount, 3); // Has devices from testDevicesData - containerA2.dispose(); - }); }); } diff --git a/test/page/_shared/utils/mesh_network_builder_test.dart b/test/page/_shared/utils/mesh_network_builder_test.dart new file mode 100644 index 000000000..ca478c18b --- /dev/null +++ b/test/page/_shared/utils/mesh_network_builder_test.dart @@ -0,0 +1,606 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/generated/connected_devices.g.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; +import 'package:privacy_gui/page/_shared/utils/mesh_network_builder.dart'; + +void main() { + // --------------------------------------------------------------------------- + // Test Data Builders + // --------------------------------------------------------------------------- + + ConnectedDevice buildConnectedDevice({ + required String macAddress, + String deviceRole = 'client', + String hostName = '', + String? friendlyName, + String ipAddress = '', + String interface_ = '', + String? interfaceType, + bool isActive = true, + int? signalStrength, + int? lastDataDownlinkRate, + int? lastDataUplinkRate, + String? deviceId, + }) { + return ConnectedDevice( + instancePath: 'Device.Hosts.Host.1.', + macAddress: macAddress, + deviceRole: deviceRole, + hostName: hostName, + friendlyName: friendlyName, + ipAddress: ipAddress, + interface_: interface_, + interfaceType: interfaceType, + isActive: isActive, + signalStrength: signalStrength, + lastDataDownlinkRate: lastDataDownlinkRate, + lastDataUplinkRate: lastDataUplinkRate, + deviceId: deviceId, + ipv4Addresses: const [], + ipv6Addresses: const [], + manufacturer: '', + modelName: '', + operatingSystem: '', + ); + } + + MasterNode buildMasterNode({ + required String deviceId, + String model = 'TestRouter', + }) { + return MasterNode( + deviceId: deviceId, + model: model, + manufacturer: 'Test', + serialNumber: 'SN123', + softwareVersion: '1.0.0', + ); + } + + SlaveNode buildSlaveNode({ + required String deviceId, + String model = 'TestExtender', + BackhaulInfo? backhaul, + }) { + return SlaveNode( + deviceId: deviceId, + model: model, + manufacturer: 'Test', + serialNumber: 'SN456', + softwareVersion: '1.0.0', + backhaul: backhaul ?? const BackhaulInfo(mediaType: 'Wi-Fi'), + ); + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + group('MeshNetworkBuilder.build', () { + test('separates master and slave nodes from clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + hostName: 'Router', + ), + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:02', + deviceRole: 'slave', + hostName: 'Extender', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.deviceId, 'AA:BB:CC:DD:EE:01'); + expect(result.slaves.length, 1); + expect(result.slaves.first.deviceId, 'AA:BB:CC:DD:EE:02'); + expect(result.allClients.length, 1); + expect(result.allClients.first.mac, '11:22:33:44:55:01'); + }); + + test('assigns clients to master when no mesh topology', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + hostName: 'Router', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.connectedClients.length, 1); + expect(result.master.connectedClients.first.mac, '11:22:33:44:55:01'); + }); + + test('assigns clients to correct node via clientToNodeMap', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + hostName: 'Router', + ), + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:02', + deviceRole: 'slave', + hostName: 'Extender', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'MasterClient', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:02', + deviceRole: 'client', + hostName: 'SlaveClient', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [ + buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01'), + buildSlaveNode(deviceId: 'AA:BB:CC:DD:EE:02'), + ], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:01', // on master + '11:22:33:44:55:02': 'AA:BB:CC:DD:EE:02', // on slave + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + expect(result.master.connectedClients.length, 1); + expect(result.master.connectedClients.first.hostName, 'MasterClient'); + expect(result.slaves.first.connectedClients.length, 1); + expect( + result.slaves.first.connectedClients.first.hostName, 'SlaveClient'); + }); + + test('uses clientSignalMap as signal fallback for slave clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:02', + deviceRole: 'slave', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'SlaveClient', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + signalStrength: null, // No signal from Hosts + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [ + buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01'), + buildSlaveNode(deviceId: 'AA:BB:CC:DD:EE:02'), + ], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:02', + }, + clientSignalMap: { + '11:22:33:44:55:01': -45, // Signal from DataElements + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + final client = result.slaves.first.connectedClients.first; + expect(client.signalStrength, -45); + }); + + test('uses clientBandSsidMap as band/SSID fallback for slave clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:02', + deviceRole: 'slave', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'SlaveClient', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [ + buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01'), + buildSlaveNode(deviceId: 'AA:BB:CC:DD:EE:02'), + ], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:02', + }, + clientBandSsidMap: { + '11:22:33:44:55:01': (band: '5GHz', ssid: 'HomeNetwork'), + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, // No connectionDetailMap for slave client + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + final client = result.slaves.first.connectedClients.first; + expect(client.band, '5GHz'); + expect(client.wifi?.ssidName, 'HomeNetwork'); + }); + + test('connectionDetailMap takes precedence over clientBandSsidMap', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'MasterClient', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01')], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:01', + }, + clientBandSsidMap: { + '11:22:33:44:55:01': (band: '2.4GHz', ssid: 'OldSSID'), + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: { + '11:22:33:44:55:01': + ClientConnectionDetail(band: '5GHz', ssidName: 'NewSSID'), + }, + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + final client = result.master.connectedClients.first; + expect(client.band, '5GHz'); // From connectionDetailMap + expect(client.wifi?.ssidName, 'NewSSID'); // From connectionDetailMap + }); + + test( + 'empty-string band/SSID in connectionDetailMap falls back to ' + 'clientBandSsidMap', () { + // Regression: ClientConnectionDetail.band is a non-nullable String that + // is '' when AP→SSID→radio resolution fails. An empty string must be + // treated as absent so the DataElements value is used instead. + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'MasterClient', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01')], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:01', + }, + clientBandSsidMap: { + '11:22:33:44:55:01': (band: '5GHz', ssid: 'ResolvedSSID'), + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: { + // Present but unresolved → empty strings. + '11:22:33:44:55:01': ClientConnectionDetail(band: '', ssidName: ''), + }, + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + final client = result.master.connectedClients.first; + expect(client.band, '5GHz'); // Fell back to clientBandSsidMap + expect(client.wifi?.ssidName, 'ResolvedSSID'); // Fell back + }); + + test('merges multi-interface devices by hostname', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Laptop', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:02', + deviceRole: 'client', + hostName: 'Laptop', // Same hostname + interface_: 'Device.Ethernet.Interface.1', + interfaceType: 'Ethernet', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + // Should merge into 1 client with additional interfaces + expect(result.allClients.length, 1); + expect(result.allClients.first.hostName, 'Laptop'); + expect(result.allClients.first.hasMultipleInterfaces, isTrue); + expect(result.allClients.first.additionalInterfaces.length, 1); + }); + + test('patches parentNodeName on clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + friendlyName: 'Living Room Router', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect( + result.master.connectedClients.first.parentNodeName, + 'Living Room Router', + ); + }); + + test('uses wifiClientMap for signal enrichment on master clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + signalStrength: null, // No signal from Hosts + ), + ]); + + final wifiClientMap = { + '11:22:33:44:55:01': WifiClientUIModel( + macAddress: '11:22:33:44:55:01', + signalStrength: -50, + noise: -90, + lastDataDownlinkRate: 100000, + lastDataUplinkRate: 50000, + active: true, + ), + }; + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: wifiClientMap, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + final client = result.master.connectedClients.first; + expect(client.signalStrength, -50); + expect(client.downlinkRate, 100000); + expect(client.uplinkRate, 50000); + }); + + test('detects WiFi client via interfaceType containing wi-fi', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: '', // Empty interface + interfaceType: 'Wi-Fi', // But interfaceType indicates WiFi + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.connectedClients.first.isWifi, isTrue); + }); + + test('detects wired client correctly', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Desktop', + interface_: 'Device.Ethernet.Interface.1', + interfaceType: 'Ethernet', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.connectedClients.first.isWifi, isFalse); + expect( + result.master.connectedClients.first.connectionType, + ConnectionType.wired, + ); + }); + + test('handles empty connectedDevices gracefully', () { + final result = MeshNetworkBuilder.build( + connectedDevices: ConnectedDevices(items: []), + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.deviceId, 'GATEWAY'); + expect(result.slaves, isEmpty); + expect(result.allClients, isEmpty); + }); + + test('uses gatewayName as fallback when no master device found', () { + final result = MeshNetworkBuilder.build( + connectedDevices: ConnectedDevices(items: []), + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'MyRouter', + ); + + expect(result.master.hostName, 'MyRouter'); + }); + + test('normalizes MAC addresses to uppercase', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'aa:bb:cc:dd:ee:01', // lowercase + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', // lowercase + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.deviceId, 'AA:BB:CC:DD:EE:01'); + expect(result.allClients.first.mac, '11:22:33:44:55:01'); + }); + }); +} diff --git a/test/page/_shared/utils/mesh_topology_builder_test.dart b/test/page/_shared/utils/mesh_topology_builder_test.dart index 0f7c4b866..aa2e942ed 100644 --- a/test/page/_shared/utils/mesh_topology_builder_test.dart +++ b/test/page/_shared/utils/mesh_topology_builder_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/generated/data_elements_network.g.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/utils/mesh_topology_builder.dart'; void main() { @@ -146,8 +147,9 @@ void main() { final result = MeshTopologyBuilder.build(network); + final slave = result.nodes[0] as SlaveNode; // RCPI = 180 → RSSI = (180/2) - 110 = -20 dBm - expect(result.nodes[0].backhaulSignalStrength, -20); + expect(slave.backhaul.signalStrength, -20); }); test('includes backhaul uplink rate when available', () { @@ -155,7 +157,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulUplinkRate, 500000); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.uplinkRate, 500000); }); test('excludes backhaul stats when includeBackhaulStats is false', () { @@ -164,11 +167,12 @@ void main() { final result = MeshTopologyBuilder.build(network, includeBackhaulStats: false); - expect(result.nodes[0].backhaulSignalStrength, isNull); - expect(result.nodes[0].backhaulUplinkRate, isNull); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.signalStrength, isNull); + expect(slave.backhaul.uplinkRate, isNull); // Other backhaul fields are still included - expect(result.nodes[0].backhaulMediaType, 'IEEE 802.11ax'); - expect(result.nodes[0].backhaulPhyRate, 1200); + expect(slave.backhaul.mediaType, 'IEEE 802.11ax'); + expect(slave.backhaul.phyRate, 1200); }); test('normalizes MAC addresses to uppercase', () { @@ -279,10 +283,10 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].instancePath, - 'Device.WiFi.DataElements.Network.Device.2.'); - expect(result.nodes[0].backhaulAlId, 'AA:BB:CC:DD:EE:01'); - expect(result.nodes[0].backhaulMacAddress, 'AA:BB:CC:DD:EE:02'); + final slave = result.nodes[0] as SlaveNode; + expect(slave.instancePath, 'Device.WiFi.DataElements.Network.Device.2.'); + expect(slave.backhaul.backhaulAlId, 'AA:BB:CC:DD:EE:01'); + expect(slave.backhaul.backhaulMacAddress, 'AA:BB:CC:DD:EE:02'); }); test('includes backhaulLinkType', () { @@ -290,7 +294,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulLinkType, 'Wi-Fi'); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.linkType, 'Wi-Fi'); }); test('includes backhaulDownlinkRate', () { @@ -298,7 +303,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulDownlinkRate, 600000); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.downlinkRate, 600000); }); test('includes backhaulParentDeviceId', () { @@ -306,7 +312,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulParentDeviceId, 'AA:BB:CC:DD:EE:01'); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.parentNodeId, 'AA:BB:CC:DD:EE:01'); }); test('includes backhaulParentBssid', () { @@ -314,7 +321,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulParentBssid, 'AA:BB:CC:DD:EE:01'); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.parentBssid, 'AA:BB:CC:DD:EE:01'); }); test('excludes backhaulDownlinkRate when includeBackhaulStats is false', @@ -324,22 +332,99 @@ void main() { final result = MeshTopologyBuilder.build(network, includeBackhaulStats: false); - expect(result.nodes[0].backhaulDownlinkRate, isNull); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.downlinkRate, isNull); // Non-stats fields are still included - expect(result.nodes[0].backhaulLinkType, 'Wi-Fi'); - expect(result.nodes[0].backhaulParentDeviceId, 'AA:BB:CC:DD:EE:01'); + expect(slave.backhaul.linkType, 'Wi-Fi'); + expect(slave.backhaul.parentNodeId, 'AA:BB:CC:DD:EE:01'); }); - test('returns null for empty new backhaul fields', () { + test('master node has no backhaul fields', () { final network = DataElementsNetwork(items: [masterNode]); final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulLinkType, isNull); - expect(result.nodes[0].backhaulDownlinkRate, isNull); - expect(result.nodes[0].backhaulParentDeviceId, isNull); - expect(result.nodes[0].backhaulParentBssid, isNull); - expect(result.nodes[0].lastContactTime, isNull); + expect(result.nodes[0], isA()); + expect(result.nodes[0].isMaster, isTrue); + }); + + test('populates clientBandSsidMap when bssidToBandMap is provided', () { + final nodeWithClient = MeshNode( + instancePath: 'Device.WiFi.DataElements.Network.Device.1.', + id: 'AA:BB:CC:DD:EE:01', + manufacturerModel: 'TestRouter', + manufacturer: 'Test', + serialNumber: 'SN123', + softwareVersion: '1.0.0', + backhaulAlId: '', + backhaulMacAddress: '', + backhaulMediaType: '', + backhaulPhyRate: 0, + multiApLastContactTime: '', + multiApAssocIEEE1905DeviceRef: '', + multiApEasyMeshAgentOperationMode: '', + backhaulBackhaulDeviceId: '', + backhaulBackhaulMacAddress: '', + backhaulLinkType: '', + backhaulMacAddressMultiAp: '', + backhaulStatsLastDataDownlinkRate: 0, + backhaulStatsPacketsSent: 0, + backhaulStatsPacketsReceived: 0, + backhaulStatsErrorsSent: 0, + backhaulStatsErrorsReceived: 0, + backhaulStatsTimeStamp: '', + backhaulStatsLastDataUplinkRate: 0, + backhaulStatsSignalStrength: 0, + radios: [ + MeshRadio( + instancePath: 'Device.WiFi.DataElements.Network.Device.1.Radio.1.', + bssList: [ + MeshBss( + instancePath: + 'Device.WiFi.DataElements.Network.Device.1.Radio.1.BSS.1.', + bssid: '11:22:33:44:55:01', + ssid: 'TestNetwork', + stations: [ + MeshStation( + instancePath: + 'Device.WiFi.DataElements.Network.Device.1.Radio.1.BSS.1.STA.1.', + macAddress: 'aa:bb:cc:dd:ee:ff', + signalStrength: 140, + ), + ], + ), + ], + ), + ], + ); + + final network = DataElementsNetwork(items: [nodeWithClient]); + final bssidToBandMap = {'11:22:33:44:55:01': '5GHz'}; + + final result = MeshTopologyBuilder.build( + network, + bssidToBandMap: bssidToBandMap, + ); + + expect(result.clientBandSsidMap, isNotEmpty); + expect(result.clientBandSsidMap['AA:BB:CC:DD:EE:FF']?.band, '5GHz'); + expect( + result.clientBandSsidMap['AA:BB:CC:DD:EE:FF']?.ssid, 'TestNetwork'); + }); + + test('clientBandSsidMap has SSID but empty band without bssidToBandMap', + () { + final network = DataElementsNetwork(items: [masterNode]); + + final result = MeshTopologyBuilder.build(network); + + // Has SSID from BSS but no band since no bssidToBandMap provided + expect(result.clientBandSsidMap, isNotEmpty); + // Band should be empty string + for (final entry in result.clientBandSsidMap.values) { + expect(entry.band, isEmpty); + expect(entry.ssid, 'HomeNetwork'); + } }); }); } diff --git a/test/page/dashboard/mascot/health/dimensions/devices_dimension_test.dart b/test/page/dashboard/mascot/health/dimensions/devices_dimension_test.dart index 606be67a4..db2db0c7d 100644 --- a/test/page/dashboard/mascot/health/dimensions/devices_dimension_test.dart +++ b/test/page/dashboard/mascot/health/dimensions/devices_dimension_test.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/l10n/gen/app_localizations.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/dashboard/mascot/health/dimensions/devices_dimension.dart'; import 'package:privacy_gui/page/dashboard/mascot/health/health_dimension.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; @@ -15,18 +17,28 @@ void main() { dimension = DevicesHealthDimension(); }); - DeviceUIModel createDevice({ + ClientDevice createDevice({ required String mac, required bool isActive, - String? deviceRole, }) { - return DeviceUIModel( + return ClientDevice( mac: mac, ip: '192.168.1.10', hostName: 'device-$mac', isActive: isActive, - isWifi: true, - deviceRole: deviceRole, + connectionType: ConnectionType.wifi, + ); + } + + DevicesData createDevicesData(List clients) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'MR7500', + connectedClients: clients, + ), + ), ); } @@ -41,7 +53,7 @@ void main() { test('returns 100 when no devices', () { final context = HealthEvaluationContext( - devices: const DevicesData(deviceModels: []), + devices: createDevicesData([]), ); final score = dimension.evaluate(context); @@ -51,12 +63,10 @@ void main() { test('returns 100 when all devices online', () { final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), + ]), ); final score = dimension.evaluate(context); @@ -66,15 +76,13 @@ void main() { test('returns 80 when > 80% online', () { final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:03', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:04', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:05', isActive: false), // 80% - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:03', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:04', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:05', isActive: false), // 80% + ]), ); final score = dimension.evaluate(context); @@ -84,14 +92,12 @@ void main() { test('returns 60 when > 50% online', () { final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:03', isActive: false), - createDevice(mac: 'AA:BB:CC:DD:EE:04', isActive: false), // 50% - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:03', isActive: false), + createDevice(mac: 'AA:BB:CC:DD:EE:04', isActive: false), // 50% + ]), ); final score = dimension.evaluate(context); @@ -100,20 +106,12 @@ void main() { }); test('excludes mesh nodes from client count', () { + // Mesh nodes are tracked separately in MeshNetwork.allNodes, + // so we only need to pass client devices to the master's connectedClients final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice( - mac: 'AA:BB:CC:DD:EE:02', - isActive: true, - deviceRole: 'master'), - createDevice( - mac: 'AA:BB:CC:DD:EE:03', - isActive: true, - deviceRole: 'slave'), - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + ]), ); final score = dimension.evaluate(context); @@ -125,12 +123,10 @@ void main() { group('getSummary', () { test('returns All Online when all devices active', () { final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), + ]), ); final summary = dimension.getSummary(context); @@ -141,7 +137,7 @@ void main() { test('returns No devices when empty', () { final context = HealthEvaluationContext( - devices: const DevicesData(deviceModels: []), + devices: createDevicesData([]), ); final summary = dimension.getSummary(context); diff --git a/test/page/dashboard/providers/dashboard_domain_ready_provider_test.dart b/test/page/dashboard/providers/dashboard_domain_ready_provider_test.dart index fde41c3e8..9ff3e4dba 100644 --- a/test/page/dashboard/providers/dashboard_domain_ready_provider_test.dart +++ b/test/page/dashboard/providers/dashboard_domain_ready_provider_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/dashboard/providers/dashboard_domain_ready_provider.dart'; @@ -37,7 +39,11 @@ class _FailSystemInfoNotifier extends SystemInfoDataNotifier { class _OkDevicesNotifier extends DevicesDataNotifier { @override - Future build() async => DevicesData(); + Future build() async => DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + ); } class _FailDevicesNotifier extends DevicesDataNotifier { @@ -61,7 +67,11 @@ class _SlowDevicesNotifier extends DevicesDataNotifier { @override Future build() async { await _gate.future; - return DevicesData(); + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + ); } } diff --git a/test/page/devices/providers/device_detail_provider_test.dart b/test/page/devices/providers/device_detail_provider_test.dart index d686604f7..b4e78065b 100644 --- a/test/page/devices/providers/device_detail_provider_test.dart +++ b/test/page/devices/providers/device_detail_provider_test.dart @@ -1,7 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/devices/providers/device_detail_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/dhcp_data_provider.dart'; @@ -11,21 +14,21 @@ void main() { // Shared test data // --------------------------------------------------------------------------- - const wifiDevice = DeviceUIModel( + final wifiDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'iPhone', isActive: true, - isWifi: true, - signalStrength: -55, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -55), ); - const ethernetDevice = DeviceUIModel( + final ethernetDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'Desktop', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, ); final reservation = DhcpReservationUIModel( @@ -35,8 +38,14 @@ void main() { enable: true, ); - const devicesData = DevicesData( - deviceModels: [wifiDevice, ethernetDevice], + final devicesData = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'TestRouter', + connectedClients: [wifiDevice, ethernetDevice], + ), + ), ); final dhcpData = DhcpData( @@ -58,105 +67,107 @@ void main() { group('DeviceDetailProvider', () { // ----------------------------------------------------------------------- - // Basic lookup + // Success cases // ----------------------------------------------------------------------- - test('returns device and reservation by MAC', () async { + test('returns device with reservation', () async { final container = createContainer( devices: devicesData, dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); await container.read(dhcpDataProvider.future); - final detail = + final result = container.read(uspDeviceDetailProvider('AA:BB:CC:DD:EE:01')); - expect(detail.device, wifiDevice); - expect(detail.reservation, reservation); - expect(detail.hasReservation, isTrue); - container.dispose(); + expect(result.device, isNotNull); + expect(result.device!.mac, 'AA:BB:CC:DD:EE:01'); + expect(result.reservation, isNotNull); + expect(result.reservation!.mac, 'AA:BB:CC:DD:EE:01'); }); - test('returns device without reservation when no match', () async { + test('returns device without reservation', () async { final container = createContainer( devices: devicesData, dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); await container.read(dhcpDataProvider.future); - final detail = + final result = container.read(uspDeviceDetailProvider('AA:BB:CC:DD:EE:02')); - expect(detail.device, ethernetDevice); - expect(detail.reservation, isNull); - expect(detail.hasReservation, isFalse); - container.dispose(); + expect(result.device, isNotNull); + expect(result.device!.mac, 'AA:BB:CC:DD:EE:02'); + expect(result.reservation, isNull); }); // ----------------------------------------------------------------------- - // Case-insensitive matching + // Not found cases // ----------------------------------------------------------------------- - test('MAC lookup is case-insensitive', () async { + test('returns empty when device not found', () async { final container = createContainer( devices: devicesData, dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); await container.read(dhcpDataProvider.future); - final detail = - container.read(uspDeviceDetailProvider('aa:bb:cc:dd:ee:01')); + final result = + container.read(uspDeviceDetailProvider('NOT:FO:UN:DD:EV:IC')); - expect(detail.device, wifiDevice); - expect(detail.reservation, reservation); - container.dispose(); + expect(result, DeviceDetailState.empty()); }); // ----------------------------------------------------------------------- - // Edge cases + // Case-insensitive MAC lookup // ----------------------------------------------------------------------- - test('returns empty state when devices data is null', () { - final container = createContainer(devices: null, dhcp: null); - - final detail = - container.read(uspDeviceDetailProvider('AA:BB:CC:DD:EE:01')); - - expect(detail.device, isNull); - expect(detail.reservation, isNull); - container.dispose(); - }); - - test('returns null device for unknown MAC', () async { + test('finds device with lowercase MAC', () async { final container = createContainer( devices: devicesData, dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); + await container.read(dhcpDataProvider.future); - final detail = - container.read(uspDeviceDetailProvider('FF:FF:FF:FF:FF:FF')); + final result = + container.read(uspDeviceDetailProvider('aa:bb:cc:dd:ee:01')); - expect(detail.device, isNull); - container.dispose(); + expect(result.device, isNotNull); + expect(result.device!.mac.toUpperCase(), 'AA:BB:CC:DD:EE:01'); }); - test('returns device when DHCP data is unavailable', () async { + test('finds device with mixed-case MAC', () async { final container = createContainer( devices: devicesData, - dhcp: null, + dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); + await container.read(dhcpDataProvider.future); - final detail = - container.read(uspDeviceDetailProvider('AA:BB:CC:DD:EE:01')); + final result = + container.read(uspDeviceDetailProvider('Aa:Bb:Cc:Dd:Ee:01')); - expect(detail.device, wifiDevice); - expect(detail.reservation, isNull); - container.dispose(); + expect(result.device, isNotNull); + expect(result.device!.mac.toUpperCase(), 'AA:BB:CC:DD:EE:01'); }); }); } @@ -171,7 +182,13 @@ class _FakeDevicesNotifier extends AsyncNotifier _FakeDevicesNotifier(this._data); @override - Future build() async => _data ?? const DevicesData(); + Future build() async => + _data ?? + DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + ); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); diff --git a/test/page/devices/providers/device_filter_provider_test.dart b/test/page/devices/providers/device_filter_provider_test.dart index f6ddd2d41..c079ded8f 100644 --- a/test/page/devices/providers/device_filter_provider_test.dart +++ b/test/page/devices/providers/device_filter_provider_test.dart @@ -1,86 +1,106 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; void main() { // --------------------------------------------------------------------------- // Shared test data + // + // Migrated from the deleted DeviceUIModel/NodeUIModel to ClientDevice / + // MeshNetwork. isWifi/band/ssidName/signalStrength are exposed as getters + // via the WifiConnectionInfo attached to each WiFi device; wired devices + // carry no `wifi` (null). // --------------------------------------------------------------------------- - const wifiOnlineExcellent = DeviceUIModel( + final wifiOnlineExcellent = ClientDevice( mac: 'AA:AA:AA:AA:AA:01', ip: '192.168.1.101', hostName: 'iPhone', isActive: true, - isWifi: true, - band: '5GHz', - ssidName: 'Home', - signalStrength: -40, // excellent (>= -65) + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + ssidName: 'Home', + signalStrength: -40, // excellent (>= -65) + ), parentNodeId: 'NODE-01', ); - const wifiOfflineHome = DeviceUIModel( + final wifiOfflineHome = ClientDevice( mac: 'AA:AA:AA:AA:AA:02', ip: '192.168.1.102', hostName: 'iPad', isActive: false, - isWifi: true, - band: '2.4GHz', - ssidName: 'Home', + connectionType: ConnectionType.wifi, + // No signalStrength — offline WiFi device with null RSSI. + wifi: const WifiConnectionInfo( + band: '2.4GHz', + ssidName: 'Home', + ), parentNodeId: 'NODE-01', ); - const ethernetOnline = DeviceUIModel( + final ethernetOnline = ClientDevice( mac: 'AA:AA:AA:AA:AA:03', ip: '192.168.1.103', hostName: 'Desktop', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, parentNodeId: 'NODE-01', ); - const wifiGuestGood = DeviceUIModel( + final wifiGuestGood = ClientDevice( mac: 'AA:AA:AA:AA:AA:04', ip: '192.168.1.104', hostName: 'GuestPhone', isActive: true, - isWifi: true, - band: '5GHz', - ssidName: 'Guest', - signalStrength: -68, // good (-65..-71) + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + ssidName: 'Guest', + signalStrength: -68, // good (-65..-71) + ), parentNodeId: 'NODE-02', ); - const wifiOnlineNullRssi = DeviceUIModel( + final wifiOnlineNullRssi = ClientDevice( mac: 'AA:AA:AA:AA:AA:05', ip: '192.168.1.105', hostName: 'NewPhone', isActive: true, - isWifi: true, - band: '5GHz', - ssidName: 'Home', + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + ssidName: 'Home', + // signalStrength intentionally null — firmware state where RSSI is absent. + ), parentNodeId: 'NODE-01', ); - const wifiOnlineFair = DeviceUIModel( + final wifiOnlineFair = ClientDevice( mac: 'AA:AA:AA:AA:AA:06', ip: '192.168.1.106', hostName: 'Laptop', isActive: true, - isWifi: true, - band: '2.4GHz', - ssidName: 'Home', - signalStrength: -75, // fair (-71..-78) + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '2.4GHz', + ssidName: 'Home', + signalStrength: -75, // fair (-71..-78) + ), parentNodeId: 'NODE-01', ); - const allDevices = [ + final allDevices = [ wifiOnlineExcellent, wifiOfflineHome, ethernetOnline, @@ -89,16 +109,49 @@ void main() { wifiOnlineFair, ]; - const devicesData = DevicesData( - deviceModels: allDevices, - meshTopology: MeshTopologyInfo( - nodes: [ - NodeUIModel(deviceId: 'NODE-01', model: 'MR7500'), - NodeUIModel(deviceId: 'NODE-02', model: 'MX5500'), - ], - clientToNodeMap: {}, - ), - ); + /// Builds a [DevicesData] whose clients live on the master node and whose + /// topology exposes both NODE-01 / NODE-02 by default. Node membership for + /// filtering is driven by each device's `parentNodeId`, so placing every + /// client on the master node is fine. + /// + /// [topologyNodes] overrides the topology node list (used by the node-orphan + /// reconciliation test that needs a node to disappear). + DevicesData createDevicesData( + List clients, { + List? topologyNodes, + }) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'NODE-01', + model: 'MR7500', + connectedClients: clients, + ), + slaves: [ + SlaveNode( + deviceId: 'NODE-02', + model: 'MX5500', + connectedClients: const [], + backhaul: const BackhaulInfo(mediaType: 'Wi-Fi'), + ), + ], + ), + meshTopology: MeshTopologyInfo( + nodes: topologyNodes ?? + [ + MasterNode(deviceId: 'NODE-01', model: 'MR7500'), + SlaveNode( + deviceId: 'NODE-02', + model: 'MX5500', + backhaul: const BackhaulInfo(mediaType: 'Wi-Fi'), + ), + ], + clientToNodeMap: const {}, + ), + ); + } + + final devicesData = createDevicesData(allDevices); ProviderContainer createContainer({DevicesData? data}) { return ProviderContainer( @@ -159,7 +212,7 @@ void main() { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setConnections({DeviceConnectionType.wifi}); + .setConnections({ConnectionType.wifi}); final filtered = container.read(filteredDeviceListProvider); @@ -172,7 +225,7 @@ void main() { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setConnections({DeviceConnectionType.wired}); + .setConnections({ConnectionType.wired}); final filtered = container.read(filteredDeviceListProvider); @@ -183,8 +236,9 @@ void main() { test('selecting both WiFi and Ethernet is same as All', () async { final container = await createReadyContainer(); - container.read(deviceFilterConfigProvider.notifier).setConnections( - {DeviceConnectionType.wifi, DeviceConnectionType.wired}); + container + .read(deviceFilterConfigProvider.notifier) + .setConnections({ConnectionType.wifi, ConnectionType.wired}); final filtered = container.read(filteredDeviceListProvider); @@ -253,6 +307,7 @@ void main() { final filtered = container.read(filteredDeviceListProvider); expect(filtered.map((d) => d.mac), contains(wifiGuestGood.mac)); + // wifiOfflineHome is offline → passes through despite being on NODE-01. expect(filtered.map((d) => d.mac), contains(wifiOfflineHome.mac)); expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isFalse); container.dispose(); @@ -273,25 +328,19 @@ void main() { test( 'regression: Status=All + Node=X must not drop offline devices ' '(they have null parentNodeId in reality)', () async { - const offlineWithNullNode = DeviceUIModel( + // Replace wifiOfflineHome with one that has a null parentNodeId — the + // realistic offline state. Filter by NODE-01 must still include it. + final offlineWithNullNode = ClientDevice( mac: 'AA:AA:AA:AA:AA:02', ip: '192.168.1.102', hostName: 'iPad', isActive: false, - isWifi: true, + connectionType: ConnectionType.wifi, + // parentNodeId: null — realistic offline state. ); final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [wifiOnlineExcellent, offlineWithNullNode], - meshTopology: MeshTopologyInfo( - nodes: [ - NodeUIModel(deviceId: 'NODE-01', model: 'MR7500'), - NodeUIModel(deviceId: 'NODE-02', model: 'MX5500'), - ], - clientToNodeMap: {}, - ), - ), + data: createDevicesData([wifiOnlineExcellent, offlineWithNullNode]), ); container .read(deviceFilterConfigProvider.notifier) @@ -299,6 +348,7 @@ void main() { final filtered = container.read(filteredDeviceListProvider); + // wifiOnlineExcellent on NODE-01, offline passes through. expect(filtered, hasLength(2)); container.dispose(); }); @@ -395,8 +445,7 @@ void main() { final container = await createReadyContainer(); final notifier = container.read(deviceFilterConfigProvider.notifier); notifier.setSignals({DeviceSignalLevel.excellent}); - notifier.setConnections( - {DeviceConnectionType.wifi, DeviceConnectionType.wired}); + notifier.setConnections({ConnectionType.wifi, ConnectionType.wired}); final filtered = container.read(filteredDeviceListProvider); @@ -478,7 +527,7 @@ void main() { final container = await createReadyContainer(); final notifier = container.read(deviceFilterConfigProvider.notifier); - notifier.setConnections({DeviceConnectionType.wifi}); + notifier.setConnections({ConnectionType.wifi}); notifier.setSignals({DeviceSignalLevel.good}); notifier.setSsidNames({'Home'}); notifier.setBands({'5GHz'}); @@ -505,10 +554,10 @@ void main() { notifier.setSsidNames({'Home'}); notifier.setBands({'5GHz'}); notifier.setNodeIds({'NODE-01'}); - notifier.setConnections({DeviceConnectionType.wired}); + notifier.setConnections({ConnectionType.wired}); final state = container.read(deviceFilterConfigProvider); - expect(state.connections, {DeviceConnectionType.wired}); + expect(state.connections, {ConnectionType.wired}); expect(state.signals, isEmpty); expect(state.ssidNames, isEmpty); expect(state.bands, isEmpty); @@ -522,8 +571,7 @@ void main() { final notifier = container.read(deviceFilterConfigProvider.notifier); notifier.setSsidNames({'Home'}); - notifier.setConnections( - {DeviceConnectionType.wifi, DeviceConnectionType.wired}); + notifier.setConnections({ConnectionType.wifi, ConnectionType.wired}); expect(container.read(deviceFilterConfigProvider).ssidNames, {'Home'}); container.dispose(); @@ -535,7 +583,7 @@ void main() { final notifier = container.read(deviceFilterConfigProvider.notifier); notifier.setSsidNames({'Home'}); - notifier.setConnections({DeviceConnectionType.wired}); + notifier.setConnections({ConnectionType.wired}); notifier.setConnections({}); expect(container.read(deviceFilterConfigProvider).ssidNames, isEmpty); @@ -569,15 +617,10 @@ void main() { .read(deviceFilterConfigProvider.notifier) .setSsidNames({'Guest', 'Home'}); + // Push a new dataset without any Guest device. final notifier = container.read(devicesDataProvider.notifier) as _FakeDevicesNotifier; - notifier.emit(const DevicesData( - deviceModels: [wifiOnlineExcellent], - meshTopology: MeshTopologyInfo( - nodes: [NodeUIModel(deviceId: 'NODE-01', model: 'MR7500')], - clientToNodeMap: {}, - ), - )); + notifier.emit(createDevicesData([wifiOnlineExcellent])); await Future.value(); expect(container.read(deviceFilterConfigProvider).ssidNames, {'Home'}); @@ -592,12 +635,9 @@ void main() { final notifier = container.read(devicesDataProvider.notifier) as _FakeDevicesNotifier; - notifier.emit(const DevicesData( - deviceModels: [wifiOnlineExcellent], - meshTopology: MeshTopologyInfo( - nodes: [NodeUIModel(deviceId: 'NODE-01', model: 'MR7500')], - clientToNodeMap: {}, - ), + notifier.emit(createDevicesData( + [wifiOnlineExcellent], + topologyNodes: [MasterNode(deviceId: 'NODE-01', model: 'MR7500')], )); await Future.value(); @@ -614,13 +654,7 @@ void main() { final notifier = container.read(devicesDataProvider.notifier) as _FakeDevicesNotifier; - notifier.emit(const DevicesData( - deviceModels: [wifiOnlineExcellent, ethernetOnline], - meshTopology: MeshTopologyInfo( - nodes: [NodeUIModel(deviceId: 'NODE-01', model: 'MR7500')], - clientToNodeMap: {}, - ), - )); + notifier.emit(createDevicesData([wifiOnlineExcellent, ethernetOnline])); await Future.value(); expect(container.read(deviceFilterConfigProvider).includeUnknownSignal, @@ -642,6 +676,7 @@ void main() { expect(options.nodes, hasLength(2)); expect(options.ssids, containsAll(['Guest', 'Home'])); expect(options.bands, containsAll(['2.4GHz', '5GHz'])); + // wifiOnlineNullRssi + wifiOfflineHome both have null RSSI. expect(options.hasUnknownSignalDevices, isTrue); container.dispose(); }); @@ -649,10 +684,8 @@ void main() { test('hasUnknownSignalDevices false when every WiFi device has RSSI', () async { final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [wifiOnlineExcellent, wifiGuestGood, ethernetOnline], - meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), - ), + data: createDevicesData( + [wifiOnlineExcellent, wifiGuestGood, ethernetOnline]), ); expect( @@ -701,7 +734,8 @@ void main() { group('DeviceFilterConfig helpers', () { test('hasWifiOnlyFilter returns true when signal is set', () { - final config = DeviceFilterConfig(signals: {DeviceSignalLevel.excellent}); + final config = + DeviceFilterConfig(signals: const {DeviceSignalLevel.excellent}); expect(config.hasWifiOnlyFilter, isTrue); }); @@ -712,12 +746,12 @@ void main() { }); test('hasWifiOnlyFilter returns true when ssidNames is set', () { - final config = DeviceFilterConfig(ssidNames: {'Home'}); + final config = DeviceFilterConfig(ssidNames: const {'Home'}); expect(config.hasWifiOnlyFilter, isTrue); }); test('hasWifiOnlyFilter returns true when bands is set', () { - final config = DeviceFilterConfig(bands: {'5GHz'}); + final config = DeviceFilterConfig(bands: const {'5GHz'}); expect(config.hasWifiOnlyFilter, isTrue); }); @@ -728,14 +762,14 @@ void main() { test('isEthernetOnly returns true only for single wired selection', () { expect( - DeviceFilterConfig(connections: {DeviceConnectionType.wired}) + DeviceFilterConfig(connections: const {ConnectionType.wired}) .isEthernetOnly, isTrue, ); expect( - DeviceFilterConfig(connections: { - DeviceConnectionType.wifi, - DeviceConnectionType.wired + DeviceFilterConfig(connections: const { + ConnectionType.wifi, + ConnectionType.wired, }).isEthernetOnly, isFalse, ); @@ -748,14 +782,14 @@ void main() { test('activeCount counts non-empty dimensions', () { expect(const DeviceFilterConfig().activeCount, 0); expect( - DeviceFilterConfig(connections: {DeviceConnectionType.wifi}) + DeviceFilterConfig(connections: const {ConnectionType.wifi}) .activeCount, 1, ); expect( DeviceFilterConfig( - connections: {DeviceConnectionType.wifi}, - signals: {DeviceSignalLevel.excellent}, + connections: const {ConnectionType.wifi}, + signals: const {DeviceSignalLevel.excellent}, ).activeCount, 2, ); @@ -763,7 +797,7 @@ void main() { test('activeCount includes deviceCategories and privateMac', () { expect( - DeviceFilterConfig(deviceCategories: {DeviceCategory.phone}) + DeviceFilterConfig(deviceCategories: const {DeviceCategory.phone}) .activeCount, 1, ); @@ -774,7 +808,7 @@ void main() { ); expect( DeviceFilterConfig( - deviceCategories: {DeviceCategory.phone}, + deviceCategories: const {DeviceCategory.phone}, privateMac: PrivateMacFilter.privateOnly, ).activeCount, 2, @@ -784,37 +818,63 @@ void main() { // --------------------------------------------------------------------------- // Device Category filter + // + // Classification is driven by DeviceClassifier.classify(hostname, mac). + // To keep these tests independent of OUI-database state and MAC-fallback + // heuristics, every fixture below pins its category via an explicit HOSTNAME + // pattern and uses an OUI-registered (non-randomized) MAC so the outcome is + // decided solely by the hostname rule under test: + // iPhone → phone (hostname pattern `iphone`) + // Galaxy S23 → phone (hostname pattern `galaxy s`) + // iPad → tablet (hostname pattern `ipad`) + // Desktop-PC → computer (hostname pattern `desktop`) // --------------------------------------------------------------------------- group('filteredDeviceListProvider Device Category filter', () { - test('filters by device category', () async { - final container = await createReadyContainer(); + // OUI-registered first byte (0x00, bit 1 = 0) → never a randomized MAC, so + // classification cannot fall back to the private-MAC heuristic. + ClientDevice categoryDevice(String mac, String hostName) => ClientDevice( + mac: mac, + ip: '192.168.5.${mac.hashCode.abs() % 250 + 1}', + hostName: hostName, + isActive: true, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo(band: '5GHz', ssidName: 'Home'), + ); + + final phoneA = categoryDevice('00:11:22:00:00:01', 'iPhone'); + final phoneB = categoryDevice('00:11:22:00:00:02', 'Galaxy S23'); + final tablet = categoryDevice('00:11:22:00:00:03', 'iPad'); + final computer = categoryDevice('00:11:22:00:00:04', 'Desktop-PC'); + final categoryData = createDevicesData([phoneA, phoneB, tablet, computer]); + + test('filters by a single device category (hostname-pinned)', () async { + final container = await createReadyContainer(data: categoryData); container .read(deviceFilterConfigProvider.notifier) .setDeviceCategories({DeviceCategory.phone}); final filtered = container.read(filteredDeviceListProvider); + final macs = filtered.map((d) => d.mac).toSet(); - // iPhone and GuestPhone should match phone category - expect(filtered.any((d) => d.hostName == 'iPhone'), isTrue); - expect(filtered.any((d) => d.hostName == 'GuestPhone'), isTrue); - // Desktop should not match - expect(filtered.any((d) => d.hostName == 'Desktop'), isFalse); + // Both phones match; tablet and computer are excluded. + expect(macs, {phoneA.mac, phoneB.mac}); container.dispose(); }); test('multi-select device categories uses OR logic', () async { - final container = await createReadyContainer(); + final container = await createReadyContainer(data: categoryData); container.read(deviceFilterConfigProvider.notifier).setDeviceCategories({ DeviceCategory.phone, DeviceCategory.tablet, }); final filtered = container.read(filteredDeviceListProvider); + final macs = filtered.map((d) => d.mac).toSet(); - // iPhone, GuestPhone (phone), iPad (tablet) should match - expect(filtered.any((d) => d.hostName == 'iPhone'), isTrue); - expect(filtered.any((d) => d.hostName == 'iPad'), isTrue); + // phones (OR) tablet match; computer excluded. + expect(macs, {phoneA.mac, phoneB.mac, tablet.mac}); + expect(macs, isNot(contains(computer.mac))); container.dispose(); }); }); @@ -826,30 +886,29 @@ void main() { group('filteredDeviceListProvider Private MAC filter', () { // Private MAC: bit 1 of first byte = 1 (locally administered) // 0x02 = 00000010, bit 1 = 1 -> private - const privateMacDevice = DeviceUIModel( + final privateMacDevice = ClientDevice( mac: '02:00:00:AA:AA:01', // Locally administered (private) ip: '192.168.1.200', hostName: 'PrivatePhone', isActive: true, - isWifi: true, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo(band: '5GHz', ssidName: 'Home'), ); // Public MAC: bit 1 of first byte = 0 (OUI registered) // 0x00 = 00000000, bit 1 = 0 -> public - const publicMacDevice = DeviceUIModel( + final publicMacDevice = ClientDevice( mac: '00:11:22:33:44:55', // OUI registered (public) ip: '192.168.1.201', hostName: 'PublicPhone', isActive: true, - isWifi: true, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo(band: '5GHz', ssidName: 'Home'), ); test('privateOnly shows only private MAC devices', () async { final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [privateMacDevice, publicMacDevice], - meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), - ), + data: createDevicesData([privateMacDevice, publicMacDevice]), ); container .read(deviceFilterConfigProvider.notifier) @@ -864,10 +923,7 @@ void main() { test('publicOnly shows only public MAC devices', () async { final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [privateMacDevice, publicMacDevice], - meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), - ), + data: createDevicesData([privateMacDevice, publicMacDevice]), ); container .read(deviceFilterConfigProvider.notifier) @@ -882,10 +938,7 @@ void main() { test('all shows both private and public MAC devices', () async { final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [privateMacDevice, publicMacDevice], - meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), - ), + data: createDevicesData([privateMacDevice, publicMacDevice]), ); // Default is PrivateMacFilter.all, no need to set @@ -908,8 +961,19 @@ class _FakeDevicesNotifier extends AsyncNotifier DevicesData? _data; @override - Future build() async => _data ?? const DevicesData(); + Future build() async { + if (_data == null) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Unknown'), + ), + ); + } + return _data!; + } + /// Push a new dataset so that `ref.listen(deviceFilterOptionsProvider)` + /// in the notifier fires reconciliation. void emit(DevicesData next) { _data = next; state = AsyncData(next); diff --git a/test/page/devices/providers/device_filter_state_test.dart b/test/page/devices/providers/device_filter_state_test.dart index e015d64cc..afa490b51 100644 --- a/test/page/devices/providers/device_filter_state_test.dart +++ b/test/page/devices/providers/device_filter_state_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; void main() { @@ -24,8 +24,7 @@ void main() { }); test('isActive true when connections is set', () { - const config = - DeviceFilterConfig(connections: {DeviceConnectionType.wifi}); + const config = DeviceFilterConfig(connections: {ConnectionType.wifi}); expect(config.isActive, isTrue); }); @@ -64,7 +63,7 @@ void main() { final updated = config.copyWith( searchQuery: 'test', status: DeviceStatusFilter.offline, - connections: const {DeviceConnectionType.wifi}, + connections: const {ConnectionType.wifi}, signals: const {DeviceSignalLevel.good}, includeUnknownSignal: true, nodeIds: () => const {'node1'}, @@ -74,7 +73,7 @@ void main() { expect(updated.searchQuery, 'test'); expect(updated.status, DeviceStatusFilter.offline); - expect(updated.connections, const {DeviceConnectionType.wifi}); + expect(updated.connections, const {ConnectionType.wifi}); expect(updated.signals, const {DeviceSignalLevel.good}); expect(updated.includeUnknownSignal, isTrue); expect(updated.nodeIds, const {'node1'}); @@ -149,19 +148,18 @@ void main() { test('isEthernetOnly returns true only for single wired selection', () { expect( - const DeviceFilterConfig(connections: {DeviceConnectionType.wired}) + const DeviceFilterConfig(connections: {ConnectionType.wired}) .isEthernetOnly, isTrue, ); expect( - const DeviceFilterConfig(connections: { - DeviceConnectionType.wifi, - DeviceConnectionType.wired - }).isEthernetOnly, + const DeviceFilterConfig( + connections: {ConnectionType.wifi, ConnectionType.wired}) + .isEthernetOnly, isFalse, ); expect( - const DeviceFilterConfig(connections: {DeviceConnectionType.wifi}) + const DeviceFilterConfig(connections: {ConnectionType.wifi}) .isEthernetOnly, isFalse, ); @@ -174,13 +172,13 @@ void main() { test('activeCount counts non-empty dimensions correctly', () { expect(const DeviceFilterConfig().activeCount, 0); expect( - const DeviceFilterConfig(connections: {DeviceConnectionType.wifi}) + const DeviceFilterConfig(connections: {ConnectionType.wifi}) .activeCount, 1, ); expect( const DeviceFilterConfig( - connections: {DeviceConnectionType.wifi}, + connections: {ConnectionType.wifi}, signals: {DeviceSignalLevel.excellent}, ).activeCount, 2, @@ -188,7 +186,7 @@ void main() { expect( const DeviceFilterConfig( status: DeviceStatusFilter.online, - connections: {DeviceConnectionType.wifi}, + connections: {ConnectionType.wifi}, signals: {DeviceSignalLevel.excellent}, nodeIds: {'node1'}, ssidNames: {'Home'}, @@ -207,7 +205,7 @@ void main() { expect( const DeviceFilterConfig( status: DeviceStatusFilter.online, - connections: {DeviceConnectionType.wifi}, + connections: {ConnectionType.wifi}, ).activeCountExcludingStatus, 1, ); diff --git a/test/page/devices/providers/devices_data_provider_test.dart b/test/page/devices/providers/devices_data_provider_test.dart index ca0f4a279..37701d9a5 100644 --- a/test/page/devices/providers/devices_data_provider_test.dart +++ b/test/page/devices/providers/devices_data_provider_test.dart @@ -8,7 +8,9 @@ import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; @@ -16,7 +18,6 @@ import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/devices/services/usp_devices_data_service.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_data_provider.dart'; class MockUspClient extends Mock implements UspClient {} @@ -27,42 +28,43 @@ void main() { late MockUspClient mockUsp; late MockUspDevicesDataService mockDevicesSvc; - final sampleDeviceModels = [ - DeviceUIModel( + final sampleClients = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.101', hostName: 'MyLaptop', isActive: true, - isWifi: true, + connectionType: ConnectionType.wifi, ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.102', hostName: '', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, ), ]; - final sampleNodeModels = [ - NodeUIModel( - deviceId: 'gateway', + final sampleMeshNetwork = MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', model: 'M60TB', - isMaster: true, + connectedClients: sampleClients, ), - ]; - - final sampleFetchResult = DevicesDataFetchResult( - codegenContext: DevicesCodegenContext.empty, - deviceModels: sampleDeviceModels, - nodeModels: [], - hostNameByMac: {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, ); + late DevicesDataFetchResult sampleFetchResult; + setUp(() { mockUsp = MockUspClient(); mockDevicesSvc = MockUspDevicesDataService(); + sampleFetchResult = DevicesDataFetchResult( + codegenContext: DevicesCodegenContext.empty, + hostNameByMac: {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, + meshNetwork: sampleMeshNetwork, + ); + when(() => mockDevicesSvc.fetch( wifiClientMap: any(named: 'wifiClientMap'), connectionDetailMap: any(named: 'connectionDetailMap'), @@ -80,9 +82,16 @@ void main() { meshTopology: any(named: 'meshTopology'), gatewayName: any(named: 'gatewayName'), systemInfo: any(named: 'systemInfo'), - )).thenReturn( - (deviceModels: sampleDeviceModels, nodeModels: sampleNodeModels), - ); + )).thenReturn(sampleMeshNetwork); + + when(() => mockDevicesSvc.rebuildWithMesh( + context: any(named: 'context'), + wifiClientMap: any(named: 'wifiClientMap'), + connectionDetailMap: any(named: 'connectionDetailMap'), + meshTopology: any(named: 'meshTopology'), + gatewayName: any(named: 'gatewayName'), + systemInfo: any(named: 'systemInfo'), + )).thenReturn(sampleMeshNetwork); }); setUpAll(() { @@ -104,7 +113,7 @@ void main() { )); registerFallbackValue({}); registerFallbackValue({}); - registerFallbackValue([]); + registerFallbackValue([]); }); ProviderContainer createContainer({ @@ -127,8 +136,8 @@ void main() { final container = createContainer(); final data = await container.read(devicesDataProvider.future); - expect(data.deviceModels, hasLength(2)); - expect(data.nodeModels, isEmpty); + expect(data.clientDevices, hasLength(2)); + expect(data.nodes, hasLength(1)); verify(() => mockDevicesSvc.fetch( wifiClientMap: any(named: 'wifiClientMap'), connectionDetailMap: any(named: 'connectionDetailMap'), @@ -179,7 +188,7 @@ void main() { ); final data = await container.read(devicesDataProvider.future); - expect(data.deviceModels, isNotEmpty); + expect(data.clientDevices, isNotEmpty); verify(() => mockDevicesSvc.fetch( wifiClientMap: any(named: 'wifiClientMap'), @@ -191,20 +200,37 @@ void main() { }); test('DevicesData copyWith works', () { - const data = DevicesData(); + final data = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test'), + ), + ); final updated = data.copyWith( hostNameByMac: {'AA:BB': 'Test'}, ); expect(updated.hostNameByMac, {'AA:BB': 'Test'}); - expect(updated.deviceModels, isEmpty); + expect(updated.clientDevices, isEmpty); }); test('DevicesData props for equality', () { - const a = DevicesData(); - const b = DevicesData(); + final a = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test'), + ), + ); + final b = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test'), + ), + ); expect(a, equals(b)); - final c = DevicesData(hostNameByMac: {'AA:BB': 'Test'}); + final c = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test'), + ), + hostNameByMac: {'AA:BB': 'Test'}, + ); expect(a, isNot(equals(c))); }); diff --git a/test/page/devices/services/usp_devices_data_service_test.dart b/test/page/devices/services/usp_devices_data_service_test.dart index 0a824886e..e6f443089 100644 --- a/test/page/devices/services/usp_devices_data_service_test.dart +++ b/test/page/devices/services/usp_devices_data_service_test.dart @@ -2,11 +2,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; import 'package:privacy_gui/page/devices/services/usp_devices_data_service.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; class MockUspClient extends Mock implements UspClient {} @@ -67,7 +67,7 @@ void main() { systemInfo: _sysInfo, ); - expect(result.deviceModels, hasLength(2)); + expect(result.meshNetwork.allClients, hasLength(2)); expect(result.codegenContext, isNot(DevicesCodegenContext.empty)); }); @@ -89,10 +89,10 @@ void main() { gatewayName: 'Router', ); - final wifi = - result.deviceModels.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); - final wired = - result.deviceModels.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:02'); + final wifi = result.meshNetwork.allClients + .firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); + final wired = result.meshNetwork.allClients + .firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:02'); expect(wifi.isWifi, isTrue); expect(wired.isWifi, isFalse); }); @@ -106,9 +106,9 @@ void main() { ); // Empty mesh → synthetic gateway node - expect(result.nodeModels, hasLength(1)); - expect(result.nodeModels.first.deviceId, 'gateway'); - expect(result.nodeModels.first.model, 'M60TB'); + expect(result.meshNetwork.allNodes, hasLength(1)); + expect(result.meshNetwork.master.deviceId, 'GATEWAY'); + expect(result.meshNetwork.master.model, 'M60TB'); }); test('skips node models when systemInfo is null', () async { @@ -119,7 +119,8 @@ void main() { systemInfo: null, ); - expect(result.nodeModels, isEmpty); + // Without systemInfo, still has a master node with default values + expect(result.meshNetwork.allNodes, hasLength(1)); }); test('maps USP error to ServiceError', () async { @@ -167,7 +168,7 @@ void main() { ); final wifi = - rebuilt.deviceModels.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); + rebuilt.allClients.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); expect(wifi.signalStrength, -50); expect(wifi.downlinkRate, 100); }); @@ -182,11 +183,11 @@ void main() { systemInfo: _sysInfo, ); - const mesh = MeshTopologyInfo( + final mesh = MeshTopologyInfo( nodes: [ - NodeUIModel(deviceId: 'NODE-A', model: 'M60'), + MasterNode(deviceId: 'NODE-A', model: 'M60'), ], - clientToNodeMap: {'AA:BB:CC:DD:EE:01': 'NODE-A'}, + clientToNodeMap: const {'AA:BB:CC:DD:EE:01': 'NODE-A'}, ); final rebuilt = svc.rebuildWithMesh( @@ -199,12 +200,12 @@ void main() { ); final wifi = - rebuilt.deviceModels.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); + rebuilt.allClients.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); expect(wifi.parentNodeId, 'NODE-A'); // Node models should reflect mesh - expect(rebuilt.nodeModels, hasLength(1)); - expect(rebuilt.nodeModels.first.isMaster, isTrue); + expect(rebuilt.allNodes, hasLength(1)); + expect(rebuilt.master.isMaster, isTrue); }); }); @@ -238,9 +239,9 @@ void main() { ); // Should be merged into 1 device - expect(result.deviceModels, hasLength(1)); - expect(result.deviceModels.first.hasMultipleInterfaces, isTrue); - expect(result.deviceModels.first.interfaceCount, 2); + expect(result.meshNetwork.allClients, hasLength(1)); + expect(result.meshNetwork.allClients.first.hasMultipleInterfaces, isTrue); + expect(result.meshNetwork.allClients.first.interfaceCount, 2); }); test('merged device includes all MAC addresses', () async { @@ -266,7 +267,7 @@ void main() { systemInfo: _sysInfo, ); - final device = result.deviceModels.first; + final device = result.meshNetwork.allClients.first; expect(device.allMacAddresses, hasLength(2)); expect( device.allMacAddresses, @@ -298,9 +299,10 @@ void main() { ); // Should remain as 2 separate devices - expect(result.deviceModels, hasLength(2)); - expect(result.deviceModels.first.hasMultipleInterfaces, isFalse); - expect(result.deviceModels.last.hasMultipleInterfaces, isFalse); + expect(result.meshNetwork.allClients, hasLength(2)); + expect( + result.meshNetwork.allClients.first.hasMultipleInterfaces, isFalse); + expect(result.meshNetwork.allClients.last.hasMultipleInterfaces, isFalse); }); test('mesh nodes (master/slave) are not merged', () async { @@ -328,15 +330,12 @@ void main() { systemInfo: _sysInfo, ); - // Mesh nodes should not be merged even with same hostname - // They should be excluded from deviceModels (client-only) or remain separate - final clientDevices = - result.deviceModels.where((d) => d.isClientDevice).toList(); - final meshDevices = - result.deviceModels.where((d) => !d.isClientDevice).toList(); + // Mesh nodes are separate from client devices in MeshNetwork + // allClients contains only client devices, allNodes contains mesh nodes + final clientDevices = result.meshNetwork.allClients; - // Mesh devices are not merged - expect(meshDevices, hasLength(2)); + // Mesh nodes with DeviceRole are filtered out from clients + // The test data has 2 devices both with mesh roles, so no client devices expect(clientDevices, isEmpty); }); @@ -364,7 +363,7 @@ void main() { systemInfo: _sysInfo, ); - final device = result.deviceModels.first; + final device = result.meshNetwork.allClients.first; // WiFi interface should be primary (isWifi=true) expect(device.isWifi, isTrue); expect(device.mac, 'AA:BB:CC:DD:EE:01'); // WiFi MAC @@ -393,7 +392,7 @@ void main() { systemInfo: _sysInfo, ); - final device = result.deviceModels.first; + final device = result.meshNetwork.allClients.first; expect(device.additionalInterfaces, hasLength(1)); final secondary = device.additionalInterfaces.first; @@ -427,9 +426,9 @@ void main() { ); // Should merge into 1 device despite different case - expect(result.deviceModels, hasLength(1)); - expect(result.deviceModels.first.hasMultipleInterfaces, isTrue); - expect(result.deviceModels.first.interfaceCount, 2); + expect(result.meshNetwork.allClients, hasLength(1)); + expect(result.meshNetwork.allClients.first.hasMultipleInterfaces, isTrue); + expect(result.meshNetwork.allClients.first.interfaceCount, 2); }); test('devices with mDNS suffix hostname merge correctly', () async { @@ -457,9 +456,9 @@ void main() { ); // Should merge: "MacBook._tcp.local" → "macbook", "MacBook" → "macbook" - expect(result.deviceModels, hasLength(1)); - expect(result.deviceModels.first.hasMultipleInterfaces, isTrue); - expect(result.deviceModels.first.interfaceCount, 2); + expect(result.meshNetwork.allClients, hasLength(1)); + expect(result.meshNetwork.allClients.first.hasMultipleInterfaces, isTrue); + expect(result.meshNetwork.allClients.first.interfaceCount, 2); }); test('inactive WiFi + active Ethernet selects Ethernet as primary', @@ -487,7 +486,7 @@ void main() { systemInfo: _sysInfo, ); - final device = result.deviceModels.first; + final device = result.meshNetwork.allClients.first; // Ethernet should be primary because it's active (active > WiFi preference) expect(device.isWifi, isFalse); expect(device.mac, 'AA:BB:CC:DD:EE:02'); // Ethernet MAC diff --git a/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart b/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart index 227077072..c92f585cf 100644 --- a/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart +++ b/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart @@ -3,8 +3,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/dhcp/providers/usp_dhcp_reservations_notifier.dart'; import 'package:privacy_gui/page/dhcp/services/usp_dhcp_service.dart'; @@ -376,8 +379,8 @@ void main() { test('deviceOptions maps client devices to pure data', () async { when(() => mockService.fetchReservations()).thenAnswer((_) async => []); - final container = createContainerWithDevices(DevicesData( - deviceModels: [ + final container = createContainerWithDevices(_devicesData( + clients: [ _device( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10', @@ -403,8 +406,8 @@ void main() { test('deviceOptions name falls back to hostName then mac', () async { when(() => mockService.fetchReservations()).thenAnswer((_) async => []); - final container = createContainerWithDevices(DevicesData( - deviceModels: [ + final container = createContainerWithDevices(_devicesData( + clients: [ _device(mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10', hostName: 'pc'), _device(mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.11'), ], @@ -422,18 +425,26 @@ void main() { test('deviceOptions excludes mesh nodes (master/slave)', () async { when(() => mockService.fetchReservations()).thenAnswer((_) async => []); - final container = createContainerWithDevices(DevicesData( - deviceModels: [ + // Clients (on master OR on a slave) surface as options; the mesh nodes + // themselves (master/slave NodeEntity identities) must NOT. Exclusion is + // structural: nodes live in MeshNetwork.master/.slaves, never in any + // node's connectedClients, so they can never leak into clientDevices. + const slaveNodeMac = 'AA:BB:CC:DD:EE:03'; // the slave NODE's own id + final container = createContainerWithDevices(_devicesData( + clients: [ _device( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.10', - deviceRole: 'client'), - _device( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.1', - deviceRole: 'master'), - _device( - mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.2', deviceRole: 'slave'), + mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10'), // master client + ], + slaves: [ + SlaveNode( + deviceId: slaveNodeMac, + model: 'TestExtender', + backhaul: const BackhaulInfo(mediaType: 'Wi-Fi'), + // A client connected TO the slave — this IS a client and must surface. + connectedClients: [ + _device(mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.20'), + ], + ), ], )); await container.read(devicesDataProvider.future); @@ -442,14 +453,17 @@ void main() { final options = container.read(uspDhcpReservationsProvider.notifier).deviceOptions(); - expect(options, hasLength(1)); - expect(options[0].mac, 'AA:BB:CC:DD:EE:01'); + final optionMacs = options.map((o) => o.mac).toSet(); + // Both the master client and the slave-connected client surface... + expect(optionMacs, {'AA:BB:CC:DD:EE:01', 'AA:BB:CC:DD:EE:02'}); + // ...but the slave NODE's own identity must never appear as an option. + expect(optionMacs, isNot(contains(slaveNodeMac))); container.dispose(); }); test('deviceOptions returns empty when no devices', () async { when(() => mockService.fetchReservations()).thenAnswer((_) async => []); - final container = createContainerWithDevices(const DevicesData()); + final container = createContainerWithDevices(_devicesData()); await container.read(devicesDataProvider.future); await Future.delayed(Duration.zero); @@ -472,22 +486,40 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { Future build() async => _data; } -DeviceUIModel _device({ +ClientDevice _device({ required String mac, required String ip, String hostName = '', String? friendlyName, bool isActive = true, - String? deviceRole, }) { - return DeviceUIModel( + return ClientDevice( mac: mac, ip: ip, hostName: hostName, isActive: isActive, - isWifi: false, + connectionType: ConnectionType.wired, friendlyName: friendlyName, - deviceRole: deviceRole, + ); +} + +/// Wraps client devices (and optional mesh nodes) into a [DevicesData] backed +/// by a [MeshNetwork]. Client devices are attached to the master node so they +/// surface via `devicesData.clientDevices`; mesh nodes are NOT clients and thus +/// are never offered as reservation options. +DevicesData _devicesData({ + List clients = const [], + List slaves = const [], +}) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'TestRouter', + connectedClients: clients, + ), + slaves: slaves, + ), ); } diff --git a/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart b/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart index fae2e996e..0edaa9b73 100644 --- a/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart +++ b/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart @@ -24,7 +24,7 @@ final _testTheme = AppTheme.create( designThemeBuilder: (c) => CustomDesignTheme.fromJson({'style': 'flat'}), ); -const _existing = [ +final _existing = [ DhcpReservationUIModel( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10', enable: true), DhcpReservationUIModel( @@ -37,8 +37,9 @@ const _existing = [ Future<({String mac, String ip, bool enable})?> _pumpAndOpen( WidgetTester tester, { DhcpReservationUIModel? reservation, - List existing = _existing, + List? existing, }) async { + final existingReservations = existing ?? _existing; ({String mac, String ip, bool enable})? result; bool popped = false; final router = GoRouter( @@ -55,7 +56,7 @@ Future<({String mac, String ip, bool enable})?> _pumpAndOpen( context: context, builder: (_) => DhcpReservationEditDialog( reservation: reservation, - existingReservations: existing, + existingReservations: existingReservations, ), ); popped = true; @@ -188,21 +189,20 @@ void main() { // value-equality (props include `enable`), a value-equality self-filter // would fail to exclude self and flag the user's own MAC/IP as a // duplicate. Self must be excluded by stable instancePath identity. - const frozen = DhcpReservationUIModel( + final frozen = DhcpReservationUIModel( instancePath: 'Device.DHCPv4.Server.Pool.1.StaticAddress.1', mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10', enable: true, ); // Same instancePath, but enable drifted true -> false (SSE update). - const drifted = DhcpReservationUIModel( + final drifted = DhcpReservationUIModel( instancePath: 'Device.DHCPv4.Server.Pool.1.StaticAddress.1', mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10', enable: false, ); - await _pumpAndOpen(tester, - reservation: frozen, existing: const [drifted]); + await _pumpAndOpen(tester, reservation: frozen, existing: [drifted]); // Change the value (so onChanged fires) then set it back to its own MAC, // forcing _validate() to run against the drifted self-entry. await _enterMac(tester, 'AA:BB:CC:DD:EE:09'); diff --git a/test/page/local_network/providers/dhcp_data_provider_test.dart b/test/page/local_network/providers/dhcp_data_provider_test.dart index dfbd35384..92cc48efd 100644 --- a/test/page/local_network/providers/dhcp_data_provider_test.dart +++ b/test/page/local_network/providers/dhcp_data_provider_test.dart @@ -9,7 +9,9 @@ import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/dhcp_data_provider.dart'; @@ -91,7 +93,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(devicesData ?? const DevicesData()), + () => _TestDevicesDataNotifier(devicesData ?? _emptyDevicesData()), ), ], ); @@ -120,7 +122,7 @@ void main() { test('hostname enrichment from devicesData', () async { final container = createContainer( - devicesData: const DevicesData( + devicesData: _emptyDevicesData( hostNameByMac: {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, ), ); @@ -135,26 +137,32 @@ void main() { container.dispose(); }); - test('isOnline enrichment from devicesData deviceModels', () async { + test('isOnline enrichment from devicesData client devices', () async { final container = createContainer( - devicesData: const DevicesData( - hostNameByMac: {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, - deviceModels: [ - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.101', - hostName: 'MyLaptop', - isActive: true, - isWifi: true, - ), - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.102', - hostName: '', - isActive: false, - isWifi: false, + devicesData: DevicesData( + hostNameByMac: const {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'TestRouter', + connectedClients: [ + ClientDevice( + mac: 'AA:BB:CC:DD:EE:01', + ip: '192.168.1.101', + hostName: 'MyLaptop', + isActive: true, + connectionType: ConnectionType.wifi, + ), + ClientDevice( + mac: 'AA:BB:CC:DD:EE:02', + ip: '192.168.1.102', + hostName: '', + isActive: false, + connectionType: ConnectionType.wired, + ), + ], ), - ], + ), ), ); await container.read(devicesDataProvider.future); @@ -168,12 +176,9 @@ void main() { }); test('isOnline is null when no matching device in devicesData', () async { - // Empty deviceModels - no Hosts data available + // No client devices - no Hosts data available final container = createContainer( - devicesData: const DevicesData( - hostNameByMac: {}, - deviceModels: [], - ), + devicesData: _emptyDevicesData(), ); await container.read(devicesDataProvider.future); final data = await container.read(dhcpDataProvider.future); @@ -189,7 +194,7 @@ void main() { overrides: [ uspClientProvider.overrideWithValue(null), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), ], ); @@ -236,7 +241,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), sseInvalidationProvider.overrideWith((ref) => sseController.stream), ], @@ -273,7 +278,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), sseInvalidationProvider.overrideWith((ref) => sseController.stream), ], @@ -305,7 +310,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), sseInvalidationProvider.overrideWith((ref) => sseController.stream), ], @@ -338,3 +343,13 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { @override Future build() async => _data; } + +/// Creates an empty DevicesData for testing. +DevicesData _emptyDevicesData({Map? hostNameByMac}) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + hostNameByMac: hostNameByMac ?? const {}, + ); +} diff --git a/test/page/local_network/providers/ethernet_data_provider_test.dart b/test/page/local_network/providers/ethernet_data_provider_test.dart index 03d181a92..4f6be73c6 100644 --- a/test/page/local_network/providers/ethernet_data_provider_test.dart +++ b/test/page/local_network/providers/ethernet_data_provider_test.dart @@ -3,8 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/ethernet_port_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/ethernet_data_provider.dart'; import 'package:privacy_gui/page/local_network/services/usp_ethernet_data_service.dart'; @@ -35,7 +37,7 @@ void main() { ]; setUpAll(() { - registerFallbackValue([]); + registerFallbackValue([]); }); setUp(() { @@ -55,7 +57,7 @@ void main() { overrides: [ uspEthernetDataServiceProvider.overrideWithValue(mockEthernetSvc), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(devicesData ?? const DevicesData()), + () => _TestDevicesDataNotifier(devicesData ?? _emptyDevicesData()), ), ], ); @@ -80,7 +82,7 @@ void main() { overrides: [ uspClientProvider.overrideWithValue(null), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), ], ); @@ -128,3 +130,12 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { @override Future build() async => _data; } + +/// Creates an empty DevicesData for testing. +DevicesData _emptyDevicesData() { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + ); +} diff --git a/test/page/local_network/services/usp_ethernet_data_service_test.dart b/test/page/local_network/services/usp_ethernet_data_service_test.dart index 63d6152f6..2dbe453e4 100644 --- a/test/page/local_network/services/usp_ethernet_data_service_test.dart +++ b/test/page/local_network/services/usp_ethernet_data_service_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/local_network/services/usp_ethernet_data_service.dart'; class MockUspClient extends Mock implements UspClient {} @@ -11,19 +11,19 @@ class MockUspClient extends Mock implements UspClient {} // Test helpers // --------------------------------------------------------------------------- -DeviceUIModel _device({ +ClientDevice _device({ String mac = 'AA:BB:CC:DD:EE:FF', String ip = '192.168.1.10', String hostName = 'laptop', bool isActive = true, bool isWifi = false, }) => - DeviceUIModel( + ClientDevice( mac: mac, ip: ip, hostName: hostName, isActive: isActive, - isWifi: isWifi, + connectionType: isWifi ? ConnectionType.wifi : ConnectionType.wired, ); /// Ethernet interfaces response (2 interfaces). diff --git a/test/page/topology/helpers/usp_topology_builder_test.dart b/test/page/topology/helpers/usp_topology_builder_test.dart index 8503f4446..388cc3c8c 100644 --- a/test/page/topology/helpers/usp_topology_builder_test.dart +++ b/test/page/topology/helpers/usp_topology_builder_test.dart @@ -1,17 +1,17 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/core/utils/oui_lookup.dart'; -import 'package:privacy_gui/core/utils/wifi.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/helpers/usp_topology_builder.dart'; import 'package:ui_kit_library/ui_kit.dart'; +import '../../../mocks/test_data/devices_test_data.dart'; + void main() { - // OUI database for testing (minimal set for topology tests) + // OUI database for testing const testOuiDatabase = { '112233': 'Test Vendor', + 'AABBCC': 'Linksys', }; setUpAll(() { @@ -22,912 +22,534 @@ void main() { OuiLookup.reset(); }); - // --------------------------------------------------------------------------- - // Shared test data - // --------------------------------------------------------------------------- - const sysInfo = SystemInfoUIModel( manufacturer: 'Linksys', modelName: 'MR7500', - serialNumber: 'SN123', hardwareVersion: '1.0', - softwareVersion: '2.0.0', + serialNumber: 'SN123456', + softwareVersion: '1.0.16.26013014', uptime: 3600, totalMemory: 512000, freeMemory: 256000, - cpuUsage: 30, + cpuUsage: 25, ); - const meshGateway = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - isMaster: true, - ); + group('UspTopologyBuilder.buildFromMeshNetwork', () { + // ========================================================================= + // Basic Topology Structure + // ========================================================================= - const meshExtender = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - ); + group('basic structure', () { + test('creates gateway node for single-node network', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - const wifiDevice = DeviceUIModel( - mac: '11:22:33:44:55:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - signalStrength: -55, - parentNodeId: 'AA:BB:CC:DD:EE:01', - ); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - const ethernetDevice = DeviceUIModel( - mac: '11:22:33:44:55:02', - ip: '192.168.1.101', - hostName: 'Desktop', - isActive: true, - isWifi: false, - parentNodeId: 'AA:BB:CC:DD:EE:01', - ); + expect(topology.nodes, isNotEmpty); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.id, 'gateway'); + expect(gateway.status, MeshNodeStatus.online); + }); - const offlineDevice = DeviceUIModel( - mac: '11:22:33:44:55:03', - ip: '192.168.1.102', - hostName: 'Tablet', - isActive: false, - isWifi: true, - signalStrength: -80, - parentNodeId: 'AA:BB:CC:DD:EE:02', - ); + test('creates extender nodes for mesh network', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); - // --------------------------------------------------------------------------- - // Non-mesh topology (single router) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - non-mesh', () { - test('builds gateway node with synthetic id when no mesh nodes', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); - - expect(topo.nodes.where((n) => n.type == MeshNodeType.gateway), - hasLength(1)); - final gateway = topo.nodes.first; - expect(gateway.id, 'gateway'); - expect(gateway.name, 'MR7500'); - expect(gateway.metadata?['deviceId'], 'gateway'); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('all client nodes link to gateway when no mesh', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice, ethernetDevice], - nodeModels: [], - ); + final extenders = + topology.nodes.where((n) => n.type == MeshNodeType.extender); + expect(extenders, hasLength(1)); + expect(extenders.first.id, startsWith('extender-')); + }); - final clientLinks = - topo.links.where((l) => l.sourceId == 'gateway').toList(); - expect(clientLinks, hasLength(2)); - }); + test('creates client nodes for connected devices', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - test('no extender nodes when mesh is empty', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - final extenders = - topo.nodes.where((n) => n.type == MeshNodeType.extender); - expect(extenders, isEmpty); - }); - }); + final clients = + topology.nodes.where((n) => n.type == MeshNodeType.client); + expect(clients, hasLength(2)); // WiFi + Wired from test data + }); - // --------------------------------------------------------------------------- - // Mesh topology (gateway + extenders) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - mesh', () { - test('builds gateway with real deviceId from first mesh node', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, meshExtender], - ); - - final gateway = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.gateway); - expect(gateway.metadata?['deviceId'], 'AA:BB:CC:DD:EE:01'); - }); + test('creates links between nodes', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); - test('builds extender nodes from mesh nodes (skip first)', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, meshExtender], - ); - - final extenders = - topo.nodes.where((n) => n.type == MeshNodeType.extender).toList(); - expect(extenders, hasLength(1)); - expect(extenders.first.name, 'MX5500'); - expect(extenders.first.metadata?['deviceId'], 'AA:BB:CC:DD:EE:02'); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('extender links to gateway', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, meshExtender], - ); - - final extenderLink = - topo.links.where((l) => l.targetId.startsWith('extender-')).toList(); - expect(extenderLink, hasLength(1)); - expect(extenderLink.first.sourceId, 'gateway'); - expect(extenderLink.first.connectionType, ConnectionType.wifi); + expect(topology.links, isNotEmpty); + // Should have link from gateway to extender (sourceId=parent, targetId=child) + final extenderLink = topology.links + .where((l) => l.targetId.startsWith('extender-')) + .firstOrNull; + expect(extenderLink, isNotNull); + expect(extenderLink?.sourceId, 'gateway'); + }); }); - test('client node links to correct extender based on parentNodeId', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [offlineDevice], - nodeModels: [meshGateway, meshExtender], - ); - - final clientLink = topo.links - .where((l) => l.targetId == 'client-${offlineDevice.mac}') - .first; - expect(clientLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); + // ========================================================================= + // Gateway Node Properties + // ========================================================================= - test('client node links to gateway when parentNodeId is not an extender', - () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [meshGateway, meshExtender], - ); - - final clientLink = topo.links - .where((l) => l.targetId == 'client-${wifiDevice.mac}') - .first; - // wifiDevice's parentNodeId is AA:BB:CC:DD:EE:01 which is the gateway, - // not in extenderNodeIds set, so falls back to gateway - expect(clientLink.sourceId, 'gateway'); - }); + group('gateway node', () { + test('uses master displayName when available', () { + final master = DevicesTestData.createMaster( + friendlyName: 'My Router', + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + master: master, + ); - test('client links to extender when parentNodeId matches dataElementsId', - () { - // Slave's Hosts MAC differs from its DataElements ID — clientToNodeMap - // uses the DataElements ID (no colons). - const slaveWithDifferentDeId = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', // Hosts MAC - dataElementsId: '11:11:11:22:22:22', // DataElements node id - model: 'MX5500', - isMaster: false, - ); - const clientWithDeParent = DeviceUIModel( - mac: '99:88:77:66:55:44', - ip: '192.168.1.150', - hostName: 'LivingRoomTV', - isActive: true, - isWifi: true, - signalStrength: -60, - // parentNodeId from clientToNodeMap is normalized (no colons, upper) - parentNodeId: '111111222222', - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [clientWithDeParent], - nodeModels: [meshGateway, slaveWithDifferentDeId], - ); - - final clientLink = topo.links - .where((l) => l.targetId == 'client-${clientWithDeParent.mac}') - .first; - // Should attach to the extender (built from Hosts deviceId), not gateway - expect(clientLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test( - 'client links to extender when parentNodeId matches Hosts MAC ' - '(normalized)', () { - // parentNodeId in normalized form (no colons) should still match - // against the slave's Hosts deviceId. - const clientWithNormalizedParent = DeviceUIModel( - mac: '99:88:77:66:55:55', - ip: '192.168.1.151', - hostName: 'Phone', - isActive: true, - isWifi: true, - signalStrength: -60, - parentNodeId: 'AABBCCDDEE02', // no colons - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [clientWithNormalizedParent], - nodeModels: [meshGateway, meshExtender], - ); - - final clientLink = topo.links - .where( - (l) => l.targetId == 'client-${clientWithNormalizedParent.mac}') - .first; - expect(clientLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); - }); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.name, 'My Router'); + }); - // --------------------------------------------------------------------------- - // Client node properties - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - client nodes', () { - test('wifi client has wifi connection type', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); - - final link = - topo.links.where((l) => l.targetId.startsWith('client-')).first; - expect(link.connectionType, ConnectionType.wifi); - expect(link.rssi, -55); - }); + test('falls back to systemInfo gatewayName', () { + final master = DevicesTestData.createMaster( + friendlyName: null, + hostName: null, + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + master: master.copyWith(connectedClients: []), + ); - test('ethernet client has ethernet connection type', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [ethernetDevice], - nodeModels: [], - ); - - final link = - topo.links.where((l) => l.targetId.startsWith('client-')).first; - expect(link.connectionType, ConnectionType.ethernet); - expect(link.rssi, isNull); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('online client has online status', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + // Falls back to model when displayName empty, or gatewayName from sysInfo + expect(gateway.name, isNotEmpty); + }); - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.status, MeshNodeStatus.online); - }); + test('includes metadata with deviceId and model', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - test('offline client has offline status', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [offlineDevice], - nodeModels: [], - ); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.status, MeshNodeStatus.offline); - }); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.metadata?['deviceId'], isNotNull); + expect(gateway.metadata?['isMaster'], isTrue); + }); - test('client name uses displayName (hostName if available)', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); + test('has level 1.0', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.name, 'iPhone'); - }); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - // --------------------------------------------------------------------------- - // Signal quality and level mapping - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - signal mapping', () { - test('strong wifi signal maps to high level', () { - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'Strong', - isActive: true, - isWifi: true, - signalStrength: -45, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.9); - expect(client.linkQuality, LinkQuality.excellent); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.level, 1.0); + }); }); - test('medium wifi signal maps to medium level', () { - // wifi.dart thresholds: [-65, -71, -78] - // -75 is >= -78 (fair) → level 0.4, LinkQuality.fair - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'Medium', - isActive: true, - isWifi: true, - signalStrength: -75, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.4); - expect(client.linkQuality, LinkQuality.fair); - }); + // ========================================================================= + // Extender Node Properties + // ========================================================================= - test('good wifi signal (-68) maps to level 0.65', () { - // wifi.dart thresholds: [-65, -71, -78] - // -68 is in (-71, -65] → good → level 0.65, LinkQuality.good - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AB', - ip: '192.168.1.1', - hostName: 'Good', - isActive: true, - isWifi: true, - signalStrength: -68, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.65); - expect(client.linkQuality, LinkQuality.good); - }); + group('extender nodes', () { + test('uses slave displayName', () { + final slave = DevicesTestData.createWifiSlave( + friendlyName: 'Living Room Extender', + ); + final meshNetwork = MeshNetwork( + master: DevicesTestData.createMaster(), + slaves: [slave], + ); - test('weak wifi signal maps to low level', () { - // wifi.dart thresholds: [-65, -71, -78] - // -80 is < -78 (poor) → LinkQuality.unknown - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'Weak', - isActive: true, - isWifi: true, - signalStrength: -80, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.1); - expect(client.linkQuality, LinkQuality.unknown); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('ethernet device maps to wired signal quality and level 1.0', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [ethernetDevice], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 1.0); - expect(client.linkQuality, LinkQuality.stable); - }); + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + expect(extender.name, 'Living Room Extender'); + }); + + test('includes backhaul metadata', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + expect(extender.metadata?['backhaulLinkType'], isNotNull); + expect(extender.metadata?['isMaster'], isFalse); + }); + + test('WiFi backhaul has level based on signal strength', () { + final slave = DevicesTestData.createWifiSlave( + backhaul: DevicesTestData.createWifiBackhaul(signalStrength: -50), + ); + final meshNetwork = MeshNetwork( + master: DevicesTestData.createMaster(), + slaves: [slave], + ); - test('wifi device with null RSSI maps to unknown quality', () { - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'NoRSSI', - isActive: true, - isWifi: true, - signalStrength: null, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.0); - expect(client.linkQuality, LinkQuality.unknown); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + // Excellent signal (-50) should have high level (0.9) + expect(extender.level, 0.9); + }); + + test('Ethernet backhaul has default level', () { + final slave = DevicesTestData.createEthernetSlave(); + final meshNetwork = MeshNetwork( + master: DevicesTestData.createMaster(), + slaves: [slave], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + // No signal → 0.5 default + expect(extender.level, 0.5); + }); + + test('parentId defaults to gateway', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + expect(extender.parentId, 'gateway'); + }); }); - test('LinkQuality mapping is consistent with getWifiSignalLevel SSoT', () { - // Defense test: ensures topology LinkQuality stays in sync with - // getWifiSignalLevel() from wifi.dart — the single source of truth. - // If this test fails, someone changed the mapping without updating - // both places, causing #1024-like inconsistencies. - - // Test boundary RSSI values for each threshold - const testCases = [ - // (rssi, expectedLevel, expectedLinkQuality) - (-64, NodeSignalLevel.excellent, LinkQuality.excellent), // >= -65 - (-65, NodeSignalLevel.excellent, LinkQuality.excellent), // exactly -65 - (-66, NodeSignalLevel.good, LinkQuality.good), // < -65, >= -71 - (-71, NodeSignalLevel.good, LinkQuality.good), // exactly -71 - (-72, NodeSignalLevel.fair, LinkQuality.fair), // < -71, >= -78 - (-78, NodeSignalLevel.fair, LinkQuality.fair), // exactly -78 - (-79, NodeSignalLevel.poor, LinkQuality.unknown), // < -78 - (-90, NodeSignalLevel.poor, LinkQuality.unknown), // very weak - ]; - - for (final (rssi, expectedSignalLevel, expectedLinkQuality) - in testCases) { - // Verify getWifiSignalLevel returns expected level - final actualSignalLevel = getWifiSignalLevel(rssi); - expect(actualSignalLevel, expectedSignalLevel, - reason: 'getWifiSignalLevel($rssi) should be $expectedSignalLevel'); - - // Verify topology builder produces consistent LinkQuality - final device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'Test', - isActive: true, - isWifi: true, - signalStrength: rssi, - ); - - final topo = UspTopologyBuilder.build( + // ========================================================================= + // Client Node Properties + // ========================================================================= + + group('client nodes', () { + test('creates client node with correct id format', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final clients = + topology.nodes.where((n) => n.type == MeshNodeType.client); + for (final client in clients) { + expect(client.id, startsWith('client-')); + } + }); + + test('WiFi client has level based on signal strength', () { + final wifiClient = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createExcellentSignal(), + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [wifiClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, info: sysInfo, - devices: [device], - nodeModels: [], ); final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.linkQuality, expectedLinkQuality, - reason: 'RSSI $rssi (${actualSignalLevel.name}) should map to ' - '$expectedLinkQuality, but got ${client.linkQuality}'); - } - }); - }); + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + // Excellent signal should have high level (0.9) + expect(client.level, 0.9); + }); + + test('wired client has level 1.0', () { + final wiredClient = DevicesTestData.createWiredClient(); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [wiredClient], + ); - // --------------------------------------------------------------------------- - // Total node/link counts - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - totals', () { - test('correct total nodes and links for mesh + clients', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice, ethernetDevice, offlineDevice], - nodeModels: [meshGateway, meshExtender], - ); - - // 1 gateway + 1 extender + 3 clients = 5 nodes - expect(topo.nodes, hasLength(5)); - // 1 gateway→extender link + 3 client links = 4 links - expect(topo.links, hasLength(4)); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('empty devices produces only infrastructure nodes', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, meshExtender], - ); - - // 1 gateway + 1 extender = 2 nodes - expect(topo.nodes, hasLength(2)); - // 1 gateway→extender link - expect(topo.links, hasLength(1)); - }); - }); + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + expect(client.level, 1.0); + }); + + test('offline client has offline status', () { + final offlineClient = DevicesTestData.createOfflineClient(); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [offlineClient], + ); - // --------------------------------------------------------------------------- - // Gateway metadata (enriched in topology enhancements) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - gateway metadata', () { - test('gateway metadata includes all system info fields', () { - const meshGatewayWithFullInfo = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - ); - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGatewayWithFullInfo], - ); - - final gateway = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.gateway); - expect(gateway.metadata?['model'], 'MR7500'); - expect(gateway.metadata?['manufacturer'], 'Linksys'); - expect(gateway.metadata?['serialNumber'], 'SN123'); - expect(gateway.metadata?['softwareVersion'], '2.0.0'); - expect(gateway.metadata?['isMaster'], isTrue); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('gateway metadata deviceId uses mesh node deviceId when available', - () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway], - ); - - final gateway = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.gateway); - expect(gateway.metadata?['deviceId'], 'AA:BB:CC:DD:EE:01'); - }); + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + expect(client.status, MeshNodeStatus.offline); + }); - test('gateway metadata deviceId is "gateway" when no mesh nodes', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [], - ); + test('client parentId points to correct node', () { + final slaveClient = DevicesTestData.createSlaveConnectedClient( + parentNodeId: DevicesTestData.slaveMac1, + ); + final meshNetwork = DevicesTestData.createMeshNetwork( + slaveClients: [slaveClient], + ); - final gateway = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.gateway); - expect(gateway.metadata?['deviceId'], 'gateway'); - }); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - // --------------------------------------------------------------------------- - // Extender metadata - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - extender metadata', () { - const extenderWithFullInfo = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - manufacturer: 'Linksys', - serialNumber: 'SN456', - softwareVersion: '1.5.0', - isMaster: false, - ); - - test('extender metadata includes all mesh node fields', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, extenderWithFullInfo], - ); - - final extender = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.extender); - expect(extender.metadata?['deviceId'], 'AA:BB:CC:DD:EE:02'); - expect(extender.metadata?['model'], 'MX5500'); - expect(extender.metadata?['manufacturer'], 'Linksys'); - expect(extender.metadata?['serialNumber'], 'SN456'); - expect(extender.metadata?['softwareVersion'], '1.5.0'); - expect(extender.metadata?['isMaster'], isFalse); - }); + final client = topology.nodes + .where((n) => + n.type == MeshNodeType.client && + n.metadata?['mac'] == DevicesTestData.clientMac5) + .firstOrNull; + expect(client, isNotNull); + expect(client?.parentId, startsWith('extender-')); + }); - test('extender metadata includes backhaul fields', () { - const extenderWithBackhaul = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulLinkType: 'Wi-Fi', - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:01', - backhaulSignalStrength: -45, - backhaulUplinkRate: 500000, - backhaulDownlinkRate: 600000, - lastContactTime: '2026-06-01T10:00:00Z', - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, extenderWithBackhaul], - ); - - final extender = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.extender); - expect(extender.metadata?['backhaulLinkType'], 'Wi-Fi'); - expect(extender.metadata?['backhaulParentDeviceId'], 'AA:BB:CC:DD:EE:01'); - expect(extender.metadata?['backhaulSignalStrength'], -45); - expect(extender.metadata?['backhaulUplinkRate'], 500000); - expect(extender.metadata?['backhaulDownlinkRate'], 600000); - expect(extender.metadata?['lastContactTime'], '2026-06-01T10:00:00Z'); - }); - }); + test('includes MAC in metadata', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - // --------------------------------------------------------------------------- - // Multi-layer mesh (Slave → Slave → Master) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - multi-layer mesh', () { - test( - 'slave links to another slave when backhaulParentDeviceId points to slave', - () { - const slaveA = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:01', // Points to master - ); - const slaveB = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:03', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:02', // Points to slaveA - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slaveA, slaveB], - ); - - // SlaveA should link to gateway - final slaveALink = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(slaveALink.sourceId, 'gateway'); - - // SlaveB should link to slaveA - final slaveBLink = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:03'); - expect(slaveBLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('slave links to gateway when backhaulParentDeviceId is null', () { - const slaveWithoutParent = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: null, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slaveWithoutParent], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.sourceId, 'gateway'); + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + expect(client.metadata?['mac'], isNotNull); + }); }); - test('slave links to gateway when backhaulParentDeviceId matches master', - () { - const slaveConnectedToMaster = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:01', // Points to master - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slaveConnectedToMaster], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.sourceId, 'gateway'); - }); + // ========================================================================= + // Link Properties + // ========================================================================= - test( - 'slave links via dataElementsId when backhaulParentDeviceId uses DE ID', - () { - const slaveAWithDeId = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - dataElementsId: '11:11:11:22:22:22', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:01', - ); - const slaveBPointingToDeId = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:03', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: '11:11:11:22:22:22', // Points to slaveA's DE ID - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slaveAWithDeId, slaveBPointingToDeId], - ); - - final slaveBLink = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:03'); - expect(slaveBLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); - }); + group('links', () { + test('creates link from extender to gateway', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); - // --------------------------------------------------------------------------- - // Backhaul link type - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - backhaul link type', () { - test('Ethernet backhaul uses ethernet connection type', () { - const ethernetSlave = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulLinkType: 'Ethernet', - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, ethernetSlave], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.connectionType, ConnectionType.ethernet); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('Wi-Fi backhaul uses wifi connection type', () { - const wifiSlave = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulLinkType: 'Wi-Fi', - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, wifiSlave], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.connectionType, ConnectionType.wifi); - }); + // Link direction: sourceId=parent, targetId=child + // extender → gateway means link with sourceId='gateway', targetId='extender-*' + final extenderLinks = topology.links + .where((l) => l.targetId.startsWith('extender-')) + .toList(); + expect(extenderLinks, isNotEmpty); + expect(extenderLinks.first.sourceId, 'gateway'); + }); - test('null backhaul link type defaults to wifi connection type', () { - const slavWithoutLinkType = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulLinkType: null, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slavWithoutLinkType], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.connectionType, ConnectionType.wifi); - }); - }); + test('creates links from clients to parent nodes', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - // --------------------------------------------------------------------------- - // Client node icons (DeviceClassifier integration) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - client icons', () { - test('iPhone hostname gets phone icon', () { - const device = DeviceUIModel( - mac: '11:22:33:44:55:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.iconData, DeviceCategory.phone.icon); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('MacBook hostname gets computer icon', () { - const device = DeviceUIModel( - mac: '11:22:33:44:55:02', - ip: '192.168.1.101', - hostName: 'MacBook-Pro', - isActive: true, - isWifi: true, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.iconData, DeviceCategory.computer.icon); - }); + // Link direction: sourceId=parent, targetId=child + final clientLinks = topology.links + .where((l) => l.targetId.startsWith('client-')) + .toList(); + expect(clientLinks, hasLength(2)); // 2 clients in test data + for (final link in clientLinks) { + expect(link.sourceId, 'gateway'); + } + }); + + test('WiFi link has quality based on signal', () { + final wifiClient = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createExcellentSignal(), + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [wifiClient], + ); - test('PlayStation hostname gets game console icon', () { - const device = DeviceUIModel( - mac: '11:22:33:44:55:03', - ip: '192.168.1.102', - hostName: 'PlayStation5', - isActive: true, - isWifi: false, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.iconData, DeviceCategory.gameConsole.icon); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + // Link direction: sourceId=parent, targetId=child (client) + final link = + topology.links.where((l) => l.targetId.startsWith('client-')).first; + expect(link.linkQuality, LinkQuality.excellent); + }); - test('unknown hostname with unknown OUI gets unknown icon', () { - // Use universally administered MAC (bit 1 of first byte = 0) - // that's not in our test OUI database - const device = DeviceUIModel( - mac: '00:FF:FF:44:55:04', - ip: '192.168.1.103', - hostName: 'device-12345', - isActive: true, - isWifi: true, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.iconData, DeviceCategory.unknown.icon); + test('wired link has stable quality', () { + final wiredClient = DevicesTestData.createWiredClient(); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [wiredClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + // Link direction: sourceId=parent, targetId=child (client) + final link = + topology.links.where((l) => l.targetId.startsWith('client-')).first; + expect(link.linkQuality, LinkQuality.stable); + }); }); - test('client metadata includes mac address', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); + // ========================================================================= + // Multi-Slave Network + // ========================================================================= + + group('multi-slave network', () { + test('creates all extender nodes', () { + final meshNetwork = DevicesTestData.createMultiSlaveMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extenders = + topology.nodes.where((n) => n.type == MeshNodeType.extender); + expect(extenders, hasLength(2)); + }); - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.metadata?['mac'], '11:22:33:44:55:01'); + test('clients connect to correct parent nodes', () { + final meshNetwork = DevicesTestData.createMultiSlaveMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + // Master client should connect to gateway + final masterClients = topology.nodes + .where( + (n) => n.type == MeshNodeType.client && n.parentId == 'gateway') + .toList(); + expect(masterClients, isNotEmpty); + + // Slave clients should connect to extenders + final slaveClients = topology.nodes + .where((n) => + n.type == MeshNodeType.client && + n.parentId != null && + n.parentId!.startsWith('extender-')) + .toList(); + expect(slaveClients, hasLength(2)); // One per slave + }); + }); + + // ========================================================================= + // Edge Cases + // ========================================================================= + + group('edge cases', () { + test('handles empty network (no clients)', () { + final meshNetwork = DevicesTestData.createEmptyNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + expect(topology.nodes, hasLength(1)); // Gateway only + final clients = + topology.nodes.where((n) => n.type == MeshNodeType.client); + expect(clients, isEmpty); + }); + + test('handles network with unassigned clients', () { + final meshNetwork = + DevicesTestData.createNetworkWithUnassignedClients(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final clients = + topology.nodes.where((n) => n.type == MeshNodeType.client); + expect(clients, hasLength(2)); + // Unassigned clients should connect to gateway + for (final client in clients) { + expect(client.parentId, 'gateway'); + } + }); + + test('handles client with poor signal', () { + final poorSignalClient = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createPoorSignal(), + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [poorSignalClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + // Poor signal (-85) should have low level (0.1) + expect(client.level, 0.1); + + // Link direction: sourceId=parent, targetId=child (client) + final link = + topology.links.where((l) => l.targetId.startsWith('client-')).first; + // Poor signal maps to unknown quality + expect(link.linkQuality, LinkQuality.unknown); + }); }); }); } diff --git a/test/page/topology/models/node_ui_model_test.dart b/test/page/topology/models/node_ui_model_test.dart deleted file mode 100644 index 382c7c695..000000000 --- a/test/page/topology/models/node_ui_model_test.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; - -void main() { - // --------------------------------------------------------------------------- - // NodeUIModel — displayName priority - // --------------------------------------------------------------------------- - - group('NodeUIModel — displayName', () { - test('displayName prefers friendlyName when available', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: 'Living Room Router', - hostName: 'linksys-router', - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'Living Room Router'); - }); - - test('displayName uses hostName when friendlyName is null', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: null, - hostName: 'linksys-router', - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'linksys-router'); - }); - - test('displayName uses hostName when friendlyName is empty', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: '', - hostName: 'linksys-router', - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'linksys-router'); - }); - - test('displayName uses model when friendlyName and hostName are empty', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: '', - hostName: '', - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'MR7500'); - }); - - test('displayName uses model when friendlyName and hostName are null', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: null, - hostName: null, - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'MR7500'); - }); - - test('displayName falls back to deviceId when all names are empty', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: '', - hostName: '', - model: '', - isMaster: true, - ); - expect(node.displayName, 'AA:BB:CC:DD:EE:01'); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModel — roleLabel - // --------------------------------------------------------------------------- - - group('NodeUIModel — roleLabel', () { - test('roleLabel is Master for isMaster=true', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - isMaster: true, - ); - expect(node.roleLabel, 'Master'); - }); - - test('roleLabel is Slave for isMaster=false', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - ); - expect(node.roleLabel, 'Slave'); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModel — hasBackhaul - // --------------------------------------------------------------------------- - - group('NodeUIModel — hasBackhaul', () { - test('hasBackhaul is true when backhaulMediaType is set', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulMediaType: 'IEEE 802.11ax', - backhaulPhyRate: 1200, - ); - expect(node.hasBackhaul, isTrue); - }); - - test('hasBackhaul is false when backhaulMediaType is empty', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - isMaster: true, - ); - expect(node.hasBackhaul, isFalse); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModel — Equatable - // --------------------------------------------------------------------------- - - group('NodeUIModel — Equatable', () { - test('equality based on all fields', () { - const node1 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: 'Router', - hostName: 'linksys', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - connectedDeviceCount: 5, - backhaulMediaType: '', - backhaulPhyRate: 0, - ); - const node2 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: 'Router', - hostName: 'linksys', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - connectedDeviceCount: 5, - backhaulMediaType: '', - backhaulPhyRate: 0, - ); - const node3 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', // Different deviceId - friendlyName: 'Router', - hostName: 'linksys', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - connectedDeviceCount: 5, - ); - - expect(node1, equals(node2)); - expect(node1, isNot(equals(node3))); - }); - - test('props includes all fields', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: 'Router', - hostName: 'linksys', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - connectedDeviceCount: 5, - ipAddress: '192.168.1.1', - ipv6Addresses: ['fe80::1'], - wanIpAddress: '203.0.113.1', - backhaulMediaType: 'Ethernet', - backhaulPhyRate: 1000, - backhaulSignalStrength: -45, - backhaulUplinkRate: 500000, - backhaulLinkType: 'Wi-Fi', - backhaulDownlinkRate: 600000, - ); - - // 25 props total: base fields + network addresses + DataElements enrichment - expect(node.props, hasLength(25)); - expect(node.props, contains('AA:BB:CC:DD:EE:01')); - expect(node.props, contains('Router')); - expect(node.props, contains('linksys')); - expect(node.props, contains('MR7500')); - expect(node.props, contains('Linksys')); - expect(node.props, contains('SN123')); - expect(node.props, contains('2.0.0')); - expect(node.props, contains(true)); - expect(node.props, contains(5)); - expect(node.props, contains('192.168.1.1')); - // List uses reference equality, so check by finding the list element - expect(node.props.any((p) => p is List && p.contains('fe80::1')), isTrue); - expect(node.props, contains('203.0.113.1')); - expect(node.props, contains('Ethernet')); - expect(node.props, contains(1000)); - expect(node.props, contains(-45)); - expect(node.props, contains(500000)); - expect(node.props, contains('Wi-Fi')); - expect(node.props, contains(600000)); - // Remaining DataElements enrichment fields default to null: - // dataElementsId(1) + instancePath(1) + backhaulAlId(1) + - // backhaulMacAddress(1) + backhaulParentDeviceId(1) + backhaulParentBssid(1) + - // lastContactTime(1) = 7 nulls - expect(node.props.where((p) => p == null).length, 7); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModel — default values - // --------------------------------------------------------------------------- - - group('NodeUIModel — default values', () { - test('default values are applied correctly', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - ); - - expect(node.friendlyName, isNull); - expect(node.hostName, isNull); - expect(node.manufacturer, ''); - expect(node.serialNumber, ''); - expect(node.softwareVersion, ''); - expect(node.isMaster, isFalse); - expect(node.connectedDeviceCount, 0); - expect(node.backhaulMediaType, ''); - expect(node.backhaulPhyRate, 0); - expect(node.backhaulSignalStrength, isNull); - expect(node.backhaulUplinkRate, isNull); - // DataElements enrichment fields default to null - expect(node.instancePath, isNull); - expect(node.backhaulAlId, isNull); - expect(node.backhaulMacAddress, isNull); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModelListExt — extension methods - // --------------------------------------------------------------------------- - - group('NodeUIModelListExt', () { - const masterNode = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - isMaster: true, - ); - const slaveNode1 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - ); - const slaveNode2 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:03', - model: 'MX5500', - isMaster: false, - ); - - test('master returns the master node', () { - final nodes = [masterNode, slaveNode1, slaveNode2]; - expect(nodes.master, equals(masterNode)); - }); - - test('master returns null when no master exists', () { - final nodes = [slaveNode1, slaveNode2]; - expect(nodes.master, isNull); - }); - - test('slaves returns all non-master nodes', () { - final nodes = [masterNode, slaveNode1, slaveNode2]; - expect(nodes.slaves, equals([slaveNode1, slaveNode2])); - }); - - test('slaves returns empty list when all nodes are master', () { - final nodes = [masterNode]; - expect(nodes.slaves, isEmpty); - }); - - test('hasMesh returns true when slaves exist', () { - final nodes = [masterNode, slaveNode1]; - expect(nodes.hasMesh, isTrue); - }); - - test('hasMesh returns false when no slaves', () { - final nodes = [masterNode]; - expect(nodes.hasMesh, isFalse); - }); - - test('hasMesh returns false for empty list', () { - final List nodes = []; - expect(nodes.hasMesh, isFalse); - }); - }); -} diff --git a/test/page/topology/providers/node_detail_provider_test.dart b/test/page/topology/providers/node_detail_provider_test.dart index 7d9b98c95..31451a662 100644 --- a/test/page/topology/providers/node_detail_provider_test.dart +++ b/test/page/topology/providers/node_detail_provider_test.dart @@ -1,9 +1,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/providers/node_detail_provider.dart'; void main() { @@ -11,58 +13,64 @@ void main() { // Shared test data // --------------------------------------------------------------------------- - const masterNode = NodeUIModel( + final masterNode = MasterNode( deviceId: 'AA:BB:CC:DD:EE:01', model: 'MR7500', - isMaster: true, - connectedDeviceCount: 2, + connectedClients: [], ); - const extenderNode = NodeUIModel( + final extenderNode = SlaveNode( deviceId: 'AA:BB:CC:DD:EE:02', model: 'MX5500', - isMaster: false, - connectedDeviceCount: 1, + connectedClients: [], + backhaul: const BackhaulInfo(mediaType: 'Wi-Fi'), ); - const device1 = DeviceUIModel( + final device1 = ClientDevice( mac: '11:22:33:44:55:01', ip: '192.168.1.100', hostName: 'iPhone', isActive: true, - isWifi: true, + connectionType: ConnectionType.wifi, parentNodeId: 'AA:BB:CC:DD:EE:01', ); - const device2 = DeviceUIModel( + final device2 = ClientDevice( mac: '11:22:33:44:55:02', ip: '192.168.1.101', hostName: 'MacBook', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, parentNodeId: 'AA:BB:CC:DD:EE:01', ); - const device3 = DeviceUIModel( + final device3 = ClientDevice( mac: '11:22:33:44:55:03', ip: '192.168.1.102', hostName: 'iPad', isActive: false, - isWifi: true, + connectionType: ConnectionType.wifi, parentNodeId: 'AA:BB:CC:DD:EE:02', ); - const meshData = DevicesData( - nodeModels: [masterNode, extenderNode], - deviceModels: [device1, device2, device3], - meshTopology: MeshTopologyInfo( - nodes: [ - NodeUIModel(deviceId: 'AA:BB:CC:DD:EE:01', model: 'MR7500'), - NodeUIModel(deviceId: 'AA:BB:CC:DD:EE:02', model: 'MX5500'), - ], - clientToNodeMap: {}, - ), - ); + DevicesData createMeshData() { + return DevicesData( + meshNetwork: MeshNetwork( + master: masterNode.copyWith( + connectedClients: [device1, device2], + ), + slaves: [ + extenderNode.copyWith( + connectedClients: [device3], + ), + ], + ), + meshTopology: const MeshTopologyInfo( + nodes: [], + clientToNodeMap: {}, + ), + ); + } ProviderContainer createContainer({DevicesData? data}) { return ProviderContainer( @@ -78,27 +86,31 @@ void main() { // ----------------------------------------------------------------------- test('returns node and connected devices for master node', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); final detail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:01')); - expect(detail.node, masterNode); - expect(detail.connectedDevices, hasLength(2)); - expect(detail.connectedDevices, contains(device1)); - expect(detail.connectedDevices, contains(device2)); + expect(detail.node, isNotNull); + expect(detail.node!.deviceId, 'AA:BB:CC:DD:EE:01'); + expect(detail.connectedClients, hasLength(2)); + expect(detail.connectedClients.any((d) => d.mac == device1.mac), isTrue); + expect(detail.connectedClients.any((d) => d.mac == device2.mac), isTrue); container.dispose(); }); test('returns node and connected devices for extender node', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); final detail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:02')); - expect(detail.node, extenderNode); - expect(detail.connectedDevices, hasLength(1)); - expect(detail.connectedDevices.first.hostName, 'iPad'); + expect(detail.node, isNotNull); + expect(detail.node!.deviceId, 'AA:BB:CC:DD:EE:02'); + expect(detail.connectedClients, hasLength(1)); + expect(detail.connectedClients.first.hostName, 'iPad'); container.dispose(); }); @@ -107,13 +119,14 @@ void main() { // ----------------------------------------------------------------------- test('deviceId lookup is case-insensitive', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); final detail = container.read(uspNodeDetailProvider('aa:bb:cc:dd:ee:01')); - expect(detail.node, masterNode); - expect(detail.connectedDevices, hasLength(2)); + expect(detail.node, isNotNull); + expect(detail.connectedClients, hasLength(2)); container.dispose(); }); @@ -123,32 +136,31 @@ void main() { test('GATEWAY lookup treats null parentNodeId as connected to gateway', () async { - const nonMeshData = DevicesData( - nodeModels: [ - NodeUIModel( + final device1NoParent = ClientDevice( + mac: '11:22:33:44:55:01', + ip: '192.168.1.100', + hostName: 'iPhone', + isActive: true, + connectionType: ConnectionType.wifi, + parentNodeId: null, // non-mesh: no parent + ); + final device2NoParent = ClientDevice( + mac: '11:22:33:44:55:02', + ip: '192.168.1.101', + hostName: 'MacBook', + isActive: true, + connectionType: ConnectionType.wired, + parentNodeId: null, + ); + + final nonMeshData = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( deviceId: 'GATEWAY', model: 'MR7500', - isMaster: true, - ), - ], - deviceModels: [ - DeviceUIModel( - mac: '11:22:33:44:55:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - parentNodeId: null, // non-mesh: no parent - ), - DeviceUIModel( - mac: '11:22:33:44:55:02', - ip: '192.168.1.101', - hostName: 'MacBook', - isActive: true, - isWifi: false, - parentNodeId: null, + connectedClients: [device1NoParent, device2NoParent], ), - ], + ), ); final container = createContainer(data: nonMeshData); @@ -156,25 +168,28 @@ void main() { final detail = container.read(uspNodeDetailProvider('GATEWAY')); - expect(detail.connectedDevices, hasLength(2)); + expect(detail.connectedClients, hasLength(2)); container.dispose(); }); test('GATEWAY lookup is case-insensitive', () async { - const nonMeshData = DevicesData( - nodeModels: [ - NodeUIModel(deviceId: 'GATEWAY', model: 'MR7500', isMaster: true), - ], - deviceModels: [ - DeviceUIModel( - mac: '11:22:33:44:55:01', - ip: '192.168.1.100', - hostName: 'Phone', - isActive: true, - isWifi: true, - parentNodeId: null, + final phone = ClientDevice( + mac: '11:22:33:44:55:01', + ip: '192.168.1.100', + hostName: 'Phone', + isActive: true, + connectionType: ConnectionType.wifi, + parentNodeId: null, + ); + + final nonMeshData = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'MR7500', + connectedClients: [phone], ), - ], + ), ); final container = createContainer(data: nonMeshData); @@ -182,27 +197,28 @@ void main() { final detail = container.read(uspNodeDetailProvider('gateway')); - expect(detail.connectedDevices, hasLength(1)); + expect(detail.connectedClients, hasLength(1)); container.dispose(); }); // ----------------------------------------------------------------------- - // activeDeviceCount + // activeClientCount // ----------------------------------------------------------------------- - test('activeDeviceCount counts only active devices', () async { + test('activeClientCount counts only active devices', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); // Master has device1 (active) + device2 (active) = 2 final masterDetail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:01')); - expect(masterDetail.activeDeviceCount, 2); + expect(masterDetail.activeClientCount, 2); // Extender has device3 (inactive) = 0 final extenderDetail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:02')); - expect(extenderDetail.activeDeviceCount, 0); + expect(extenderDetail.activeClientCount, 0); container.dispose(); }); @@ -216,25 +232,31 @@ void main() { final detail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:01')); expect(detail.node, isNull); - expect(detail.connectedDevices, isEmpty); + expect(detail.connectedClients, isEmpty); container.dispose(); }); test('returns null node for unknown deviceId', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); final detail = container.read(uspNodeDetailProvider('UNKNOWN')); expect(detail.node, isNull); - expect(detail.connectedDevices, isEmpty); + expect(detail.connectedClients, isEmpty); container.dispose(); }); test('node with no connected devices returns empty list', () async { - const data = DevicesData( - nodeModels: [masterNode], - deviceModels: [], + final data = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'AA:BB:CC:DD:EE:01', + model: 'MR7500', + connectedClients: [], + ), + ), ); final container = createContainer(data: data); @@ -242,9 +264,9 @@ void main() { final detail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:01')); - expect(detail.node, masterNode); - expect(detail.connectedDevices, isEmpty); - expect(detail.activeDeviceCount, 0); + expect(detail.node, isNotNull); + expect(detail.connectedClients, isEmpty); + expect(detail.activeClientCount, 0); container.dispose(); }); }); @@ -260,7 +282,16 @@ class _FakeDevicesNotifier extends AsyncNotifier _FakeDevicesNotifier(this._data); @override - Future build() async => _data ?? const DevicesData(); + Future build() async { + if (_data == null) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Unknown'), + ), + ); + } + return _data; + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); diff --git a/test/page/wifi_settings/services/usp_wifi_data_service_test.dart b/test/page/wifi_settings/services/usp_wifi_data_service_test.dart index 33d19866e..cb1964bef 100644 --- a/test/page/wifi_settings/services/usp_wifi_data_service_test.dart +++ b/test/page/wifi_settings/services/usp_wifi_data_service_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/services/usp_client.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/page/wifi_settings/services/usp_wifi_data_service.dart'; class MockUspClient extends Mock implements UspClient {} @@ -306,4 +308,164 @@ void main() { expect(result.radioModels.single.possibleChannels, isEmpty); }); }); + + // ------------------------------------------------------------------------- + // buildBssidToBandMap + // ------------------------------------------------------------------------- + + group('buildBssidToBandMap', () { + test('maps BSSID to band via SSID.LowerLayers → Radio', () { + 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 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: '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: 2400, + autoChannelEnable: false, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: '20MHz,40MHz,80MHz', + ), + ]); + + final result = + UspWifiDataService.buildBssidToBandMap(ssids: ssids, radios: radios); + + expect(result, { + 'AA:BB:CC:DD:EE:01': '2.4GHz', + 'AA:BB:CC:DD:EE:02': '5GHz', + }); + }); + + test('normalizes BSSID to uppercase', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: 'aa:bb:cc:dd:ee:01', // lowercase + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + 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: '20MHz,40MHz', + ), + ]); + + final result = + UspWifiDataService.buildBssidToBandMap(ssids: ssids, radios: radios); + + expect(result.keys.single, 'AA:BB:CC:DD:EE:01'); + }); + + test('skips SSID with empty BSSID', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: '', // empty + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + 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: '20MHz,40MHz', + ), + ]); + + final result = + UspWifiDataService.buildBssidToBandMap(ssids: ssids, radios: radios); + + expect(result, isEmpty); + }); + + test('returns empty map when no radios', () { + 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.', + ), + ]); + final radios = WiFiRadios(items: []); + + final result = + UspWifiDataService.buildBssidToBandMap(ssids: ssids, radios: radios); + + expect(result, isEmpty); + }); + }); }