diff --git a/assets/resources/demo_usp_data.json b/assets/resources/demo_usp_data.json new file mode 100644 index 000000000..1bbf93d9a --- /dev/null +++ b/assets/resources/demo_usp_data.json @@ -0,0 +1,366 @@ +{ + "_comment": "Demo TR-181 mock data for USP pages. Values are strings (UspService coerces types).", + + "Device.DeviceInfo.Manufacturer": "Linksys", + "Device.DeviceInfo.ModelName": "M60TB-EU", + "Device.DeviceInfo.SerialNumber": "DEMO1234567890", + "Device.DeviceInfo.HardwareVersion": "1.0", + "Device.DeviceInfo.SoftwareVersion": "1.0.16.26013014", + "Device.DeviceInfo.UpTime": "86400", + "Device.DeviceInfo.MemoryStatus.Total": "524288", + "Device.DeviceInfo.MemoryStatus.Free": "262144", + "Device.DeviceInfo.ProcessStatus.CPUUsage": "23", + "Device.DeviceInfo.HostName": "LinksysRouter", + "Device.DeviceInfo.ActiveFirmwareImage": "Device.DeviceInfo.FirmwareImage.1.", + "Device.DeviceInfo.BootFirmwareImage": "Device.DeviceInfo.FirmwareImage.1.", + + "Device.DeviceInfo.FirmwareImage.1.Name": "firmware_1", + "Device.DeviceInfo.FirmwareImage.1.Version": "1.0.16.26013014", + "Device.DeviceInfo.FirmwareImage.1.Status": "Active", + "Device.DeviceInfo.FirmwareImage.1.Available": "true", + "Device.DeviceInfo.FirmwareImage.2.Name": "firmware_2", + "Device.DeviceInfo.FirmwareImage.2.Version": "1.0.14.25072101", + "Device.DeviceInfo.FirmwareImage.2.Status": "Inactive", + "Device.DeviceInfo.FirmwareImage.2.Available": "true", + + "Device.DeviceInfo.VendorLogFile.1.Name": "syslog", + "Device.DeviceInfo.VendorLogFile.1.MaximumSize": "1048576", + "Device.DeviceInfo.VendorLogFile.1.Persistent": "false", + "Device.DeviceInfo.VendorLogFile.2.Name": "messages", + "Device.DeviceInfo.VendorLogFile.2.MaximumSize": "524288", + "Device.DeviceInfo.VendorLogFile.2.Persistent": "true", + + "Device.Hosts.Host.1.PhysAddress": "AA:BB:CC:DD:EE:01", + "Device.Hosts.Host.1.IPAddress": "192.168.1.100", + "Device.Hosts.Host.1.HostName": "iPhone-Austin", + "Device.Hosts.Host.1.Active": "true", + "Device.Hosts.Host.1.Layer1Interface": "Device.WiFi.AccessPoint.1.", + "Device.Hosts.Host.1.AddressSource": "DHCP", + + "Device.Hosts.Host.2.PhysAddress": "AA:BB:CC:DD:EE:02", + "Device.Hosts.Host.2.IPAddress": "192.168.1.101", + "Device.Hosts.Host.2.HostName": "MacBook-Pro", + "Device.Hosts.Host.2.Active": "true", + "Device.Hosts.Host.2.Layer1Interface": "Device.WiFi.AccessPoint.2.", + "Device.Hosts.Host.2.AddressSource": "DHCP", + + "Device.Hosts.Host.3.PhysAddress": "AA:BB:CC:DD:EE:03", + "Device.Hosts.Host.3.IPAddress": "192.168.1.102", + "Device.Hosts.Host.3.HostName": "Smart-TV", + "Device.Hosts.Host.3.Active": "true", + "Device.Hosts.Host.3.Layer1Interface": "Device.Ethernet.Interface.1.", + "Device.Hosts.Host.3.AddressSource": "DHCP", + + "Device.Hosts.Host.4.PhysAddress": "AA:BB:CC:DD:EE:04", + "Device.Hosts.Host.4.IPAddress": "192.168.1.103", + "Device.Hosts.Host.4.HostName": "iPad-Air", + "Device.Hosts.Host.4.Active": "true", + "Device.Hosts.Host.4.Layer1Interface": "Device.WiFi.AccessPoint.3.", + "Device.Hosts.Host.4.AddressSource": "DHCP", + + "Device.Hosts.Host.5.PhysAddress": "AA:BB:CC:DD:EE:05", + "Device.Hosts.Host.5.IPAddress": "192.168.1.104", + "Device.Hosts.Host.5.HostName": "Gaming-PC", + "Device.Hosts.Host.5.Active": "false", + "Device.Hosts.Host.5.Layer1Interface": "Device.Ethernet.Interface.1.", + "Device.Hosts.Host.5.AddressSource": "Static", + + "Device.WiFi.Radio.1.Enable": "true", + "Device.WiFi.Radio.1.Status": "Up", + "Device.WiFi.Radio.1.Channel": "6", + "Device.WiFi.Radio.1.OperatingFrequencyBand": "2.4GHz", + "Device.WiFi.Radio.1.OperatingChannelBandwidth": "20MHz", + "Device.WiFi.Radio.1.PossibleChannels": "1-11", + "Device.WiFi.Radio.1.OperatingStandards": "n,ax", + "Device.WiFi.Radio.1.SupportedStandards": "b,g,n,ax", + "Device.WiFi.Radio.1.TransmitPower": "100", + "Device.WiFi.Radio.1.MaxBitRate": "574000", + "Device.WiFi.Radio.1.AutoChannelEnable": "true", + "Device.WiFi.Radio.1.IEEE80211hEnabled": "false", + "Device.WiFi.Radio.1.SupportedOperatingChannelBandwidths": "20MHz,40MHz", + + "Device.WiFi.Radio.2.Enable": "true", + "Device.WiFi.Radio.2.Status": "Up", + "Device.WiFi.Radio.2.Channel": "36", + "Device.WiFi.Radio.2.OperatingFrequencyBand": "5GHz", + "Device.WiFi.Radio.2.OperatingChannelBandwidth": "80MHz", + "Device.WiFi.Radio.2.PossibleChannels": "36,40,44,48,52,56,60,64,100,104,108,112,116,120,124,128,132,136,140,144,149,153,157,161,165", + "Device.WiFi.Radio.2.OperatingStandards": "ac,ax", + "Device.WiFi.Radio.2.SupportedStandards": "a,n,ac,ax", + "Device.WiFi.Radio.2.TransmitPower": "100", + "Device.WiFi.Radio.2.MaxBitRate": "2402000", + "Device.WiFi.Radio.2.AutoChannelEnable": "true", + "Device.WiFi.Radio.2.IEEE80211hEnabled": "true", + "Device.WiFi.Radio.2.SupportedOperatingChannelBandwidths": "20MHz,40MHz,80MHz,160MHz", + + "Device.WiFi.Radio.3.Enable": "true", + "Device.WiFi.Radio.3.Status": "Up", + "Device.WiFi.Radio.3.Channel": "5", + "Device.WiFi.Radio.3.OperatingFrequencyBand": "6GHz", + "Device.WiFi.Radio.3.OperatingChannelBandwidth": "160MHz", + "Device.WiFi.Radio.3.PossibleChannels": "1,5,9,13,17,21,25,29,33,37,41,45,49,53,57,61,65,69,73,77,81,85,89,93", + "Device.WiFi.Radio.3.OperatingStandards": "ax", + "Device.WiFi.Radio.3.SupportedStandards": "ax", + "Device.WiFi.Radio.3.TransmitPower": "100", + "Device.WiFi.Radio.3.MaxBitRate": "4804000", + "Device.WiFi.Radio.3.AutoChannelEnable": "true", + "Device.WiFi.Radio.3.IEEE80211hEnabled": "false", + "Device.WiFi.Radio.3.SupportedOperatingChannelBandwidths": "20MHz,40MHz,80MHz,160MHz", + + "Device.WiFi.SSID.1.SSID": "Linksys-Demo", + "Device.WiFi.SSID.1.Enable": "true", + "Device.WiFi.SSID.1.Status": "Up", + "Device.WiFi.SSID.1.BSSID": "AA:BB:CC:00:11:01", + "Device.WiFi.SSID.1.LowerLayers": "Device.WiFi.Radio.1.", + + "Device.WiFi.SSID.2.SSID": "Linksys-Demo", + "Device.WiFi.SSID.2.Enable": "true", + "Device.WiFi.SSID.2.Status": "Up", + "Device.WiFi.SSID.2.BSSID": "AA:BB:CC:00:11:02", + "Device.WiFi.SSID.2.LowerLayers": "Device.WiFi.Radio.2.", + + "Device.WiFi.SSID.3.SSID": "Linksys-Demo", + "Device.WiFi.SSID.3.Enable": "true", + "Device.WiFi.SSID.3.Status": "Up", + "Device.WiFi.SSID.3.BSSID": "AA:BB:CC:00:11:03", + "Device.WiFi.SSID.3.LowerLayers": "Device.WiFi.Radio.3.", + + "Device.WiFi.SSID.4.SSID": "Linksys-Guest", + "Device.WiFi.SSID.4.Enable": "false", + "Device.WiFi.SSID.4.Status": "Down", + "Device.WiFi.SSID.4.BSSID": "AA:BB:CC:00:11:04", + "Device.WiFi.SSID.4.LowerLayers": "Device.WiFi.Radio.1.", + + "Device.WiFi.AccessPoint.1.Enable": "true", + "Device.WiFi.AccessPoint.1.Status": "Enabled", + "Device.WiFi.AccessPoint.1.Security.ModesSupported": "WPA2-Personal,WPA3-Personal,WPA2-WPA3-Personal", + "Device.WiFi.AccessPoint.1.Security.ModeEnabled": "WPA2-WPA3-Personal", + "Device.WiFi.AccessPoint.1.Security.EncryptionMode": "AES", + "Device.WiFi.AccessPoint.1.Security.KeyPassphrase": "DemoPassword123", + "Device.WiFi.AccessPoint.1.SSIDAdvertisementEnabled": "true", + "Device.WiFi.AccessPoint.1.SSIDReference": "Device.WiFi.SSID.1.", + + "Device.WiFi.AccessPoint.2.Enable": "true", + "Device.WiFi.AccessPoint.2.Status": "Enabled", + "Device.WiFi.AccessPoint.2.Security.ModesSupported": "WPA2-Personal,WPA3-Personal,WPA2-WPA3-Personal", + "Device.WiFi.AccessPoint.2.Security.ModeEnabled": "WPA2-WPA3-Personal", + "Device.WiFi.AccessPoint.2.Security.EncryptionMode": "AES", + "Device.WiFi.AccessPoint.2.Security.KeyPassphrase": "DemoPassword123", + "Device.WiFi.AccessPoint.2.SSIDAdvertisementEnabled": "true", + "Device.WiFi.AccessPoint.2.SSIDReference": "Device.WiFi.SSID.2.", + + "Device.WiFi.AccessPoint.3.Enable": "true", + "Device.WiFi.AccessPoint.3.Status": "Enabled", + "Device.WiFi.AccessPoint.3.Security.ModesSupported": "WPA3-Personal", + "Device.WiFi.AccessPoint.3.Security.ModeEnabled": "WPA3-Personal", + "Device.WiFi.AccessPoint.3.Security.EncryptionMode": "AES", + "Device.WiFi.AccessPoint.3.Security.KeyPassphrase": "DemoPassword123", + "Device.WiFi.AccessPoint.3.SSIDAdvertisementEnabled": "true", + "Device.WiFi.AccessPoint.3.SSIDReference": "Device.WiFi.SSID.3.", + + "Device.WiFi.AccessPoint.4.Enable": "false", + "Device.WiFi.AccessPoint.4.Status": "Disabled", + "Device.WiFi.AccessPoint.4.Security.ModesSupported": "WPA2-Personal", + "Device.WiFi.AccessPoint.4.Security.ModeEnabled": "WPA2-Personal", + "Device.WiFi.AccessPoint.4.Security.EncryptionMode": "AES", + "Device.WiFi.AccessPoint.4.Security.KeyPassphrase": "GuestPass123", + "Device.WiFi.AccessPoint.4.SSIDAdvertisementEnabled": "false", + "Device.WiFi.AccessPoint.4.SSIDReference": "Device.WiFi.SSID.4.", + + "Device.WiFi.AccessPoint.1.AssociatedDevice.1.MACAddress": "AA:BB:CC:DD:EE:01", + "Device.WiFi.AccessPoint.1.AssociatedDevice.1.SignalStrength": "-42", + "Device.WiFi.AccessPoint.1.AssociatedDevice.1.Noise": "-95", + "Device.WiFi.AccessPoint.1.AssociatedDevice.1.LastDataDownlinkRate": "72200", + "Device.WiFi.AccessPoint.1.AssociatedDevice.1.LastDataUplinkRate": "43300", + "Device.WiFi.AccessPoint.1.AssociatedDevice.1.Active": "true", + + "Device.WiFi.AccessPoint.2.AssociatedDevice.1.MACAddress": "AA:BB:CC:DD:EE:02", + "Device.WiFi.AccessPoint.2.AssociatedDevice.1.SignalStrength": "-55", + "Device.WiFi.AccessPoint.2.AssociatedDevice.1.Noise": "-92", + "Device.WiFi.AccessPoint.2.AssociatedDevice.1.LastDataDownlinkRate": "866700", + "Device.WiFi.AccessPoint.2.AssociatedDevice.1.LastDataUplinkRate": "433300", + "Device.WiFi.AccessPoint.2.AssociatedDevice.1.Active": "true", + + "Device.WiFi.AccessPoint.3.AssociatedDevice.1.MACAddress": "AA:BB:CC:DD:EE:04", + "Device.WiFi.AccessPoint.3.AssociatedDevice.1.SignalStrength": "-38", + "Device.WiFi.AccessPoint.3.AssociatedDevice.1.Noise": "-90", + "Device.WiFi.AccessPoint.3.AssociatedDevice.1.LastDataDownlinkRate": "2402000", + "Device.WiFi.AccessPoint.3.AssociatedDevice.1.LastDataUplinkRate": "1201000", + "Device.WiFi.AccessPoint.3.AssociatedDevice.1.Active": "true", + + "Device.WiFi.DataElements.Network.Device.1.ID": "DEMO1234567890", + "Device.WiFi.DataElements.Network.Device.1.ManufacturerModel": "M60TB-EU", + "Device.WiFi.DataElements.Network.Device.1.Manufacturer": "Linksys", + "Device.WiFi.DataElements.Network.Device.1.SerialNumber": "DEMO1234567890", + "Device.WiFi.DataElements.Network.Device.1.SoftwareVersion": "1.0.16.26013014", + "Device.WiFi.DataElements.Network.Device.1.Radio.1.BSS.1.BSSID": "AA:BB:CC:00:11:01", + "Device.WiFi.DataElements.Network.Device.1.Radio.1.BSS.1.SSID": "Linksys-Demo", + "Device.WiFi.DataElements.Network.Device.1.Radio.2.BSS.1.BSSID": "AA:BB:CC:00:11:02", + "Device.WiFi.DataElements.Network.Device.1.Radio.2.BSS.1.SSID": "Linksys-Demo", + + "Device.Time.Enable": "true", + "Device.Time.Status": "Synchronized", + "Device.Time.NTPServer1": "pool.ntp.org", + "Device.Time.NTPServer2": "time.google.com", + "Device.Time.LocalTimeZone": "CST-8", + "Device.Time.CurrentLocalTime": "2026-03-16T14:30:00", + + "Device.DHCPv4.Server.Pool.1.Enable": "true", + "Device.DHCPv4.Server.Pool.1.MinAddress": "192.168.1.100", + "Device.DHCPv4.Server.Pool.1.MaxAddress": "192.168.1.199", + "Device.DHCPv4.Server.Pool.1.LeaseTime": "86400", + "Device.DHCPv4.Server.Pool.1.DNSServers": "192.168.1.1", + + "Device.DHCPv4.Server.Pool.1.Client.1.Chaddr": "AA:BB:CC:DD:EE:01", + "Device.DHCPv4.Server.Pool.1.Client.1.Active": "true", + "Device.DHCPv4.Server.Pool.1.Client.1.IPv4Address.1.IPAddress": "192.168.1.100", + "Device.DHCPv4.Server.Pool.1.Client.1.IPv4Address.1.LeaseTimeRemaining": "2026-03-17T00:00:00Z", + + "Device.DHCPv4.Server.Pool.1.Client.2.Chaddr": "AA:BB:CC:DD:EE:02", + "Device.DHCPv4.Server.Pool.1.Client.2.Active": "true", + "Device.DHCPv4.Server.Pool.1.Client.2.IPv4Address.1.IPAddress": "192.168.1.101", + "Device.DHCPv4.Server.Pool.1.Client.2.IPv4Address.1.LeaseTimeRemaining": "2026-03-17T12:00:00Z", + + "Device.DHCPv4.Server.Pool.1.Client.3.Chaddr": "AA:BB:CC:DD:EE:04", + "Device.DHCPv4.Server.Pool.1.Client.3.Active": "true", + "Device.DHCPv4.Server.Pool.1.Client.3.IPv4Address.1.IPAddress": "192.168.1.103", + "Device.DHCPv4.Server.Pool.1.Client.3.IPv4Address.1.LeaseTimeRemaining": "2026-03-16T18:00:00Z", + + "Device.DHCPv4.Server.Pool.1.StaticAddress.1.Enable": "true", + "Device.DHCPv4.Server.Pool.1.StaticAddress.1.Chaddr": "AA:BB:CC:DD:EE:03", + "Device.DHCPv4.Server.Pool.1.StaticAddress.1.Yiaddr": "192.168.1.102", + + "Device.DHCPv4.Server.Pool.1.StaticAddress.2.Enable": "true", + "Device.DHCPv4.Server.Pool.1.StaticAddress.2.Chaddr": "AA:BB:CC:DD:EE:05", + "Device.DHCPv4.Server.Pool.1.StaticAddress.2.Yiaddr": "192.168.1.104", + + "Device.NAT.PortMapping.1.Enable": "true", + "Device.NAT.PortMapping.1.ExternalPort": "8080", + "Device.NAT.PortMapping.1.ExternalPortEndRange": "8080", + "Device.NAT.PortMapping.1.InternalPort": "80", + "Device.NAT.PortMapping.1.InternalClient": "192.168.1.102", + "Device.NAT.PortMapping.1.Protocol": "TCP", + "Device.NAT.PortMapping.1.Description": "Smart TV Web Server", + + "Device.NAT.PortMapping.2.Enable": "false", + "Device.NAT.PortMapping.2.ExternalPort": "25565", + "Device.NAT.PortMapping.2.ExternalPortEndRange": "25565", + "Device.NAT.PortMapping.2.InternalPort": "25565", + "Device.NAT.PortMapping.2.InternalClient": "192.168.1.104", + "Device.NAT.PortMapping.2.Protocol": "TCP/UDP", + "Device.NAT.PortMapping.2.Description": "Minecraft Server", + + "Device.NAT.PortTrigger.1.Enable": "true", + "Device.NAT.PortTrigger.1.Description": "FTP Trigger", + "Device.NAT.PortTrigger.1.Port": "21", + "Device.NAT.PortTrigger.1.PortEndRange": "21", + "Device.NAT.PortTrigger.1.Protocol": "TCP", + "Device.NAT.PortTrigger.1.Rule.1.Port": "20", + "Device.NAT.PortTrigger.1.Rule.1.PortEndRange": "20", + "Device.NAT.PortTrigger.1.Rule.1.Protocol": "TCP", + + "Device.IP.Interface.1.IPv4Address.1.IPAddress": "192.168.1.1", + "Device.IP.Interface.1.IPv4Address.1.SubnetMask": "255.255.255.0", + "Device.IP.Interface.1.IPv6Enable": "false", + + "Device.IP.Interface.2.Status": "Up", + "Device.IP.Interface.2.IPv4Address.1.IPAddress": "100.64.12.34", + "Device.IP.Interface.2.IPv4Address.1.SubnetMask": "255.255.255.0", + "Device.IP.Interface.2.IPv4Address.1.AddressingType": "DHCP", + "Device.IP.Interface.2.MaxMTUSize": "1500", + "Device.IP.Interface.2.IPv6Enable": "false", + + "Device.IP.Interface.1.Stats.BytesSent": "1234567890", + "Device.IP.Interface.1.Stats.BytesReceived": "9876543210", + "Device.IP.Interface.1.Stats.PacketsSent": "1234567", + "Device.IP.Interface.1.Stats.PacketsReceived": "9876543", + "Device.IP.Interface.1.Stats.ErrorsSent": "12", + "Device.IP.Interface.1.Stats.ErrorsReceived": "34", + "Device.IP.Interface.1.Stats.DiscardPacketsSent": "5", + "Device.IP.Interface.1.Stats.DiscardPacketsReceived": "8", + + "Device.IP.Interface.2.Stats.BytesSent": "2345678901", + "Device.IP.Interface.2.Stats.BytesReceived": "8765432109", + "Device.IP.Interface.2.Stats.PacketsSent": "2345678", + "Device.IP.Interface.2.Stats.PacketsReceived": "8765432", + "Device.IP.Interface.2.Stats.ErrorsSent": "7", + "Device.IP.Interface.2.Stats.ErrorsReceived": "15", + "Device.IP.Interface.2.Stats.DiscardPacketsSent": "2", + "Device.IP.Interface.2.Stats.DiscardPacketsReceived": "4", + + "Device.Ethernet.Interface.1.Name": "eth1", + "Device.Ethernet.Interface.1.Status": "Up", + "Device.Ethernet.Interface.1.Upstream": "false", + "Device.Ethernet.Interface.1.CurrentBitRate": "1000", + "Device.Ethernet.Interface.1.MACAddress": "AA:BB:CC:00:00:01", + + "Device.Ethernet.Interface.2.Name": "eth0", + "Device.Ethernet.Interface.2.Status": "Up", + "Device.Ethernet.Interface.2.Upstream": "true", + "Device.Ethernet.Interface.2.CurrentBitRate": "1000", + + "Device.Routing.Router.1.IPv4Forwarding.1.Enable": "true", + "Device.Routing.Router.1.IPv4Forwarding.1.DestIPAddress": "0.0.0.0", + "Device.Routing.Router.1.IPv4Forwarding.1.DestSubnetMask": "0.0.0.0", + "Device.Routing.Router.1.IPv4Forwarding.1.GatewayIPAddress": "100.64.12.1", + "Device.Routing.Router.1.IPv4Forwarding.1.Interface": "Device.IP.Interface.2.", + "Device.Routing.Router.1.IPv4Forwarding.1.Origin": "DHCPv4", + "Device.Routing.Router.1.IPv4Forwarding.1.Alias": "default", + + "Device.Routing.Router.1.IPv4Forwarding.2.Enable": "true", + "Device.Routing.Router.1.IPv4Forwarding.2.DestIPAddress": "192.168.1.0", + "Device.Routing.Router.1.IPv4Forwarding.2.DestSubnetMask": "255.255.255.0", + "Device.Routing.Router.1.IPv4Forwarding.2.GatewayIPAddress": "0.0.0.0", + "Device.Routing.Router.1.IPv4Forwarding.2.Interface": "Device.IP.Interface.1.", + "Device.Routing.Router.1.IPv4Forwarding.2.Origin": "Static", + "Device.Routing.Router.1.IPv4Forwarding.2.Alias": "lan", + + "Device.Firewall.Chain.1.Rule.1.Enable": "true", + "Device.Firewall.Chain.1.Rule.1.Description": "Block External SSH", + "Device.Firewall.Chain.1.Rule.1.Target": "Drop", + "Device.Firewall.Chain.1.Rule.1.IPVersion": "4", + "Device.Firewall.Chain.1.Rule.1.DestIP": "", + "Device.Firewall.Chain.1.Rule.1.DestPort": "22", + "Device.Firewall.Chain.1.Rule.1.DestPortRangeMax": "22", + "Device.Firewall.Chain.1.Rule.1.Protocol": "6", + "Device.Firewall.Chain.1.Rule.1.CreationDate": "2026-01-15T10:00:00Z", + + "Device.Firewall.DMZ.1.Enable": "false", + "Device.Firewall.DMZ.1.DestIP": "", + "Device.Firewall.DMZ.1.SourcePrefix": "", + "Device.Firewall.DMZ.1.Interface": "Device.IP.Interface.2.", + "Device.Firewall.DMZ.1.Description": "DMZ Host", + "Device.Firewall.DMZ.1.Status": "Disabled", + + "Device.Bridging.Bridge.1.Port.1.LowerLayers": "Device.Ethernet.Interface.1.", + "Device.Bridging.Bridge.1.Port.2.LowerLayers": "Device.WiFi.SSID.1.", + "Device.Bridging.Bridge.1.Port.3.LowerLayers": "Device.WiFi.SSID.2.", + + "Device.DNS.Client.Server.1.DNSServer": "8.8.8.8", + "Device.DNS.Client.Server.2.DNSServer": "8.8.4.4", + "Device.DNS.Client.Server.3.DNSServer": "", + + "Device.PPP.Interface.1.Username": "", + "Device.PPP.Interface.1.Password": "", + "Device.PPP.Interface.1.PPPoE.ServiceName": "", + "Device.PPP.Interface.1.ConnectionTrigger": "AlwaysOn", + "Device.PPP.Interface.1.IdleDisconnectTime": "0", + "Device.PPP.Interface.1.LCPEcho": "30", + "Device.PPP.Interface.1.ConnectionStatus": "Disconnected", + + "Device.Ethernet.VLANTermination.1.Enable": "false", + "Device.Ethernet.VLANTermination.1.VLANID": "0", + "Device.Bridging.Bridge.1.Enable": "true", + "Device.Ethernet.Link.1.MACAddress": "AA:BB:CC:00:00:02", + + "Device.DHCPv6.Client.1.Enable": "false", + "Device.DHCPv6.Client.1.DUID": "", + "Device.IPv6rd.InterfaceSetting.1.Enable": "false", + "Device.IPv6rd.InterfaceSetting.1.SPIPv6Prefix": "", + "Device.IPv6rd.InterfaceSetting.1.IPv4MaskLength": "0", + "Device.IPv6rd.InterfaceSetting.1.BorderRelayIPv4Addresses": "", + + "Device.Users.User.1.Username": "admin", + "Device.Users.User.1.Password": "", + "Device.Users.User.1.Enable": "true" +} diff --git a/definitions/network/network_diagnostics.yaml b/definitions/network/network_diagnostics.yaml new file mode 100644 index 000000000..8b06e59a9 --- /dev/null +++ b/definitions/network/network_diagnostics.yaml @@ -0,0 +1,33 @@ +name: NetworkDiagnostics +description: Network diagnostic operations (Ping, Traceroute) +type: operate +category: network + +operations: + - name: ping + path: Device.IP.Diagnostics.IPPing() + description: Run ICMP ping diagnostic + inputs: + - path: Host + field: host + type: string + required: true + - path: NumberOfRepetitions + field: numberOfRepetitions + type: string + required: false + default: "3" + + - name: traceRoute + path: Device.IP.Diagnostics.TraceRoute() + description: Run traceroute diagnostic + inputs: + - path: Host + field: host + type: string + required: true + - path: MaxHopCount + field: maxHopCount + type: string + required: false + default: "30" diff --git a/definitions/wifi/wi_fi_radios.yaml b/definitions/wifi/wi_fi_radios.yaml index 9ab44b036..0e6c716eb 100644 --- a/definitions/wifi/wi_fi_radios.yaml +++ b/definitions/wifi/wi_fi_radios.yaml @@ -74,3 +74,8 @@ parameters: type: boolean writable: true description: Enables IEEE 802.11h on this radio, which activates both Dynamic Frequency Selection (DFS) and Transmit Power Control (TPC) + + - field_name: supportedOperatingChannelBandwidths + path: .SupportedOperatingChannelBandwidths + type: string + description: Comma-separated list of supported channel bandwidths (e.g. "Auto,20MHz,40MHz,80MHz") diff --git a/doc/usp/integration/roadmap_m2.md b/doc/usp/integration/roadmap_m2.md index 1a0842380..eb20a1db8 100644 --- a/doc/usp/integration/roadmap_m2.md +++ b/doc/usp/integration/roadmap_m2.md @@ -28,17 +28,17 @@ All features still dependent on JNAP are considered **Migration Gaps**, requirin ## Status Overview -| Category | Total | βœ… USP Ready | πŸ”§ Needs Code Fix | 🏭 Needs FW Team | πŸ”΄ USP Gap (JNAP dependency) | -|----------|-------|-------------|-------------------|-----------------|-------------------------------| -| M1 Pending Features | 8 | 6 | β€” | β€” | β€” | -| Internet Settings | 10 | β€” | 5 | 4 | 1 | -| WiFi Settings | 4 | β€” | 2 | 2 | β€” | -| WiFi Advanced / Security | 3 | 1 | 1 | β€” | 1 | -| DDNS | 1 | β€” | β€” | 1 | β€” | -| QoS | 4 | β€” | β€” | 1 | 3 | -| Parental Control | 3 | β€” | β€” | β€” | 2 | -| Remaining JNAP Dependencies | 7 | β€” | β€” | β€” | 7 | -| **Total** | **40** | **7** | **8** | **8** | **14** | +| Category | Total | βœ… M2 Done | βœ… USP Ready | πŸ”§ Needs Code Fix | 🏭 Needs FW Team | πŸ”΄ USP Gap (JNAP dependency) | +|----------|-------|-----------|-------------|-------------------|-----------------|-------------------------------| +| M1 Pending Features | 8 | 3 | 4 | β€” | β€” | 1 | +| Internet Settings | 10 | β€” | β€” | 5 | 4 | 1 | +| WiFi Settings | 4 | 2 | β€” | β€” | 2 | β€” | +| WiFi Advanced / Security | 3 | β€” | 1 | 1 | β€” | 1 | +| DDNS | 1 | β€” | β€” | β€” | 1 | β€” | +| QoS | 4 | β€” | β€” | β€” | 1 | 3 | +| Parental Control | 3 | β€” | β€” | β€” | β€” | 2 | +| Remaining JNAP Dependencies | 7 | β€” | β€” | β€” | β€” | 7 | +| **Total** | **40** | **5** | **5** | **6** | **8** | **15** | > πŸ”΄ **USP Gap** = Feature currently only supported by JNAP, no corresponding USP path. Requires firmware team to provide vendor extension or bbfdm plugin to complete migration. @@ -50,24 +50,21 @@ These features have verified TR-181 support and are ready for implementation. ### F-001: WiFi SSID / Password / Security Management -**Priority:** P0 | **Effort:** Small | **Feasibility:** βœ… USP Ready (SSH-verified 2026-03-16) +**Priority:** P0 | **Effort:** Small | **Status:** βœ… **Implemented (M2-A)** -- `Device.WiFi.AccessPoint.{i}.Security.KeyPassphrase` β€” validated SET-able -- `Device.WiFi.AccessPoint.{i}.Security.SAEPassphrase` β€” WPA3 password (writable) -- `Device.WiFi.AccessPoint.{i}.Security.ModeEnabled` β€” writable, supports: `None, WPA2-Personal, WPA3-Personal, WPA3-Personal-Transition, Enhanced-Open` -- `Device.WiFi.AccessPoint.{i}.Security.MFPConfig` β€” Management Frame Protection (writable) -- `Device.WiFi.AccessPoint.{i}.Security.Reset()` β€” Operate command -- Add `writable: true` to `wi_fi_access_points.yaml`, re-run codegen -- New dialog: WiFi password edit per AP/SSID + security mode selector +- Password edit per AP/SSID, security mode selector (WPA2/WPA3/Enhanced-Open) +- 6GHz auto-override: forces WPA3-Personal + SAEPassphrase when band is 6GHz +- `wi_fi_access_points.yaml` updated with `writable: true`, codegen re-run +- `KeyPassphrase`, `SAEPassphrase`, `ModeEnabled`, `MFPConfig` β€” all SET-able - **JNAP `setWPSServerSessionStatus` can be fully migrated to USP** ### F-004: WiFi Channel Width Edit -**Priority:** P1 | **Effort:** Small | **Feasibility:** βœ… USP Ready +**Priority:** P1 | **Effort:** Small | **Status:** βœ… **Implemented (M2-A)** -- `Device.WiFi.Radio.{i}.OperatingChannelBandwidth` β€” available in codegen -- Mark as `writable` in `wi_fi_radios.yaml` -- Expand `wifi_channel_dialog.dart` with bandwidth selector +- Channel Width selector in WiFi network card, reads `SupportedOperatingChannelBandwidths` dynamically +- `wi_fi_radios.yaml` updated with `writable: true` for `OperatingChannelBandwidth` +- Fallback to hardcoded band-based options when firmware doesn't return supported bandwidths ### F-007: Guest Network Management @@ -79,11 +76,12 @@ These features have verified TR-181 support and are ready for implementation. ### F-011: Network Diagnostics (Ping / Traceroute) -**Priority:** P1 | **Effort:** Medium | **Feasibility:** βœ… SSE Infrastructure Ready +**Priority:** P1 | **Effort:** Medium | **Status:** βœ… **Implemented (M2-A)** -- Backend complete: `SseOperationAwaiter`, `PingResult`, `TracerouteResult` -- Remaining: UI page only (models, notifier, view, routing) -- Plan file: `noble-tickling-pumpkin.md` +- Full UI: `UspNetworkDiagnosticsView` with Ping + Traceroute tabs +- `SseOperationAwaiter` for async Operate + OperationComplete via SSE +- Route: `/uspAdvancedSettings/uspNetworkDiagnostics` +- YAML definitions: `network_diagnostics.yaml` + `wan_operations.yaml` (codegen) ### F-025: Historical Trend Analysis @@ -110,28 +108,22 @@ These features have verified TR-181 support and are ready for implementation. - Verified: AP.1 βœ… AP.2 βœ… AP.3 (guest, disabled) βœ… AP.4 βœ… - **JNAP `setWPSServerSessionStatus` can be fully migrated to USP** -### F-028: WiFi Advanced Radio Settings +### F-028: WiFi Advanced Settings -**Priority:** P2 | **Effort:** Medium | **Feasibility:** βœ… USP Ready (SSH-verified 2026-03-16) +**Priority:** P2 | **Effort:** Small | **Feasibility:** ⚠️ Mostly USP Gap (1/4 USP Ready) -All 12 Radio parameters below are writable (schema `data:"1"`), partially replacing JNAP `setAdvancedRadioSettings`: +**Scope correction (2026-03-16):** The original assessment listed 12 low-level Radio parameters (TransmitPower, GuardInterval, BeaconPeriod, etc.) as F-028 scope. These TR-181 paths are writable but were **never part of the JNAP Advanced WiFi UI**. The actual JNAP implementation (`wifi_advanced_settings_view.dart`) has 4 toggles (IPTV excluded β€” not needed): -| Parameter | Path | Current Value | Notes | -|-----------|------|--------------|-------| -| Transmit Power | `Radio.{i}.TransmitPower` | (supported: -1,25,50,75,100) | % or auto(-1) | -| Guard Interval | `Radio.{i}.GuardInterval` | `"Auto"` | Auto/Short/Long | -| Beacon Period | `Radio.{i}.BeaconPeriod` | 100 | ms | -| DTIM Period | `Radio.{i}.DTIMPeriod` | 2 | beacon count | -| RTS Threshold | `Radio.{i}.RTSThreshold` | 2347 | bytes | -| Fragmentation | `Radio.{i}.FragmentationThreshold` | 2346 | bytes | -| Preamble Type | `Radio.{i}.PreambleType` | `"long"` | long/short | -| MCS Index | `Radio.{i}.MCS` | 0 | modulation scheme | -| 802.11h (DFS) | `Radio.{i}.IEEE80211hEnabled` | | radar detection | -| Extension Channel | `Radio.{i}.ExtensionChannel` | | HT40 secondary | -| Operating Standards | `Radio.{i}.OperatingStandards` | | 802.11 a/b/g/n/ac/ax | -| Auto Channel Refresh | `Radio.{i}.AutoChannelRefreshPeriod` | | seconds | +| Toggle | JNAP Action | USP Path | Status | +|--------|-------------|----------|--------| +| DFS (802.11h) | `setAdvancedRadioSettings` | `Radio.{i}.IEEE80211hEnabled` | βœ… USP Ready β€” writable, SSH verified | +| Client Steering | `setSmartConnectSettings` | β€” | πŸ”΄ USP Gap β€” = Smart Connect, requires `X_LINKSYS_SmartConnect.*` (Β§7) | +| Node Steering | `setSmartConnectSettings` | β€” | πŸ”΄ USP Gap β€” Mesh node steering, requires `X_LINKSYS_SmartConnect.*` (Β§7) | +| MLO | `setMLOSettings` | β€” | πŸ”΄ USP Gap β€” = WiFi 6E/7 MLO, requires `X_LINKSYS_MLO.*` (Β§7) | -**Most of JNAP `setAdvancedRadioSettings` can be migrated to USP.** Remaining JNAP-only items in Β§7. +**Only DFS can be migrated immediately.** Client Steering, Node Steering, and MLO depend on firmware team vendor extensions already tracked in Β§7 (Smart Connect, MLO). + +> **Note:** The 12 low-level Radio parameters (TransmitPower, GuardInterval, etc.) are available as writable TR-181 paths for future use, but are NOT in the current JNAP migration scope since they were never exposed in the JNAP UI. --- @@ -204,10 +196,11 @@ Phase 3 β€” Re-test: **Reference:** [wifi-settings-tr181-limitations.md](../issues/wifi-settings-tr181-limitations.md) **SSH Re-verified:** 2026-03-16 -### πŸ”§ ISS-2: Channel Width β€” βœ… Standard Path Exists (Code Fix Only) +### βœ… ISS-2: Channel Width β€” Implemented (M2-A) **Original Assessment:** Vendor extension needed (`X_LINKSYS_PossibleChannelBandwidths`) **SSH Verification:** `Device.WiFi.Radio.{i}.SupportedOperatingChannelBandwidths` path exists and returns correct data +**Implementation:** Completed β€” dynamic bandwidth from `SupportedOperatingChannelBandwidths` with hardcoded fallback ``` Radio.1 (2.4 GHz): "Auto,20MHz" @@ -216,29 +209,25 @@ Radio.2 (5 GHz): "Auto,20MHz,40MHz,80MHz" ← SupportedBandwidths max 80MHz > ⚠️ **Data Inconsistency (2026-03-16):** Radio.2's `CurrentOperatingChannelBandwidth` reports `160MHz`, but `SupportedOperatingChannelBandwidths` only lists up to `80MHz`. This contradiction needs FW team clarification β€” `SupportedOperatingChannelBandwidths` return value may be incomplete, or `CurrentOperatingChannelBandwidth` is reporting incorrectly. -**Fix:** Remove UI hardcoded values, read `SupportedOperatingChannelBandwidths` for dynamic rendering. Update `wifi-settings-tr181-limitations.md` accordingly. +**Changes:** +- YAML: Added `supportedOperatingChannelBandwidths` field to `wi_fi_radios.yaml`, re-ran codegen +- Model: `WifiNetworkUIModel.supportedBandwidths` field populated from TR-181 +- UI: `wifi_network_card.dart` `_editChannelWidth()` reads `supportedBandwidths` dynamically, falls back to band-based hardcoded list if empty -### πŸ”§ ISS-1: Channel-per-Width β€” Client-Side Computable (Code Fix) +### βœ… ISS-1: Channel-per-Width β€” Implemented (M2-A) **Original Assessment:** Vendor extension needed (`X_LINKSYS_AvailableChannels`) -**Revised Assessment:** **Client-side computable** β€” downgraded from FW Team dependency to Code Fix - -IEEE 802.11 channel bonding rules are spec-defined constants. The app can derive `Map>` by combining: -1. `Device.WiFi.Radio.{i}.PossibleChannels` β€” regulatory-filtered flat channel list from TR-181 -2. `Device.WiFi.Radio.{i}.SupportedOperatingChannelBandwidths` β€” supported widths per radio -3. IEEE 802.11 bonding rules β€” deterministic primary channel selection per width - -**Existing App Code:** - -| Component | Location | Description | -|-----------|----------|-------------| -| Channel/frequency/DFS/UNII data | `lib/page/wifi_settings/models/channel_constants.dart` | 1302-line static table: 2.4GHz (14 ch), 5GHz (~60 ch), 6GHz (~90 ch) | -| Mode β†’ max width mapping | `lib/page/wifi_settings/models/wifi_enums.dart` `WifiWirelessMode.maxSupportedWidth` | e.g., acβ†’80MHz, axβ†’160MHz, beβ†’320MHz | -| JNAP channel-per-width mapping | `lib/page/wifi_settings/services/wifi_settings_mapper.dart:32-39` | Maps `supportedChannelsForChannelWidths` β†’ `Map>` | -| Target data structure | `lib/page/wifi_settings/providers/wifi_item.dart:22` | `Map> availableChannels` | -| Channel selection by width | `lib/page/wifi_settings/providers/wifi_bundle_provider.dart:262` | `setChannelWidth` uses `availableChannels[channelWidth]` | - -**USP Migration Fix:** Implement a utility that parses `PossibleChannels` string β†’ applies IEEE 802.11 bonding rules per width β†’ produces `Map>` to populate `WiFiItem.availableChannels`. This replaces the JNAP `supportedChannelsForChannelWidths` without FW dependency. +**Revised Assessment:** Client-side computable using IEEE 802.11 bonding rules +**Implementation:** Completed β€” bonding utility + UI channel filtering + auto-reset + +**Changes:** +- New utility: `lib/usp_page/wifi_settings/services/wifi_channel_bonding.dart` β€” `computeChannelsPerBandwidth()` pure function implementing IEEE 802.11 bonding rules for 2.4GHz/5GHz/6GHz +- Model: `WifiNetworkUIModel.availableChannelsPerBandwidth` (`Map>`) computed at fetch time +- Service: `usp_wifi_settings_service.dart` calls bonding utility with `PossibleChannels` + `SupportedOperatingChannelBandwidths` +- UI: `wifi_network_card.dart` `_editChannel()` filters channel list by current bandwidth +- Provider: `updateNetworkField()` auto-resets to `autoChannelEnable: true` when bandwidth change invalidates current channel +- Tests: `wifi_channel_bonding_test.dart` covers 2.4G/5G/6G bonding groups, empty input, edge cases +- Regional filtering: Firmware handles via `PossibleChannels` (SSH verified: EU Radio.2 only returns ch 36-140, no ch 149-165) ### 🏭 Needs FW Team (1 item) @@ -246,6 +235,26 @@ IEEE 802.11 channel bonding rules are spec-defined constants. The app can derive |----|-------|--------|-----------------| | ISS-4 | Guest network detection β€” no field distinguishes Guest/Primary | SSH verified 4 APs: SSIDReference/IsolationEnable/MultiAPMode identical across all, only Enable and Security.ModeEnabled differ (AP.3/4 disabled + None) | `X_LINKSYS_NetworkType` on `Device.WiFi.AccessPoint.{i}` | +**ISS-4 Additional SSH Findings (2026-03-16):** + +**Bridge/VLAN approach NOT viable through TR-181:** + +| Layer | Linux Reality | TR-181 Exposure | +|-------|-------------|-----------------| +| WiFi β†’ Bridge | `ath0` β†’ `br-lan` (main), guest β†’ `br-guest` | SSID.LowerLayers only points to **Radio**, not Bridge | +| Bridge Ports | br-lan contains ath0, ath10, eth1 | Bridge.{i}.Port only lists **Ethernet**, WiFi not included | +| AccessPoint | β€” | No bridge-related field (no LowerLayers to Bridge) | + +Bridge structure exists at the OS level (`brctl show`), but bbfdm does not map WiFi interfaces as Bridge Ports in TR-181. This means Guest detection via bridge membership is impossible through USP. + +**Dynamic SSID Add NOT functional:** +- `Device.WiFi.SSID.` Add succeeds but creates dmmap stub only (no `__section_name__`, no real UCI wireless config) +- Set on the new instance is silently ignored (no backend to write to) +- No new WiFi interface appears at the OS level +- Conclusion: Cannot dynamically create/delete SSIDs through TR-181 on current firmware + +**Current mitigation:** Case-insensitive `"guest"` substring match on SSID name. Reliable detection requires `X_LINKSYS_IsGuest` or `X_LINKSYS_NetworkType` vendor extension from FW team. + ### 🏭 ISS-3: MAC Filtering β€” Issue More Severe Than Documented **Original Assessment:** TR-181 only supports allow-list, vendor extension needed for deny-list @@ -261,10 +270,10 @@ IEEE 802.11 channel bonding rules are spec-defined constants. The app can derive **Revised Assessment:** Not only is the deny-list missing, but the allow-list multi-instance table is also not implemented. The entire MAC address list mechanism is broken at the bbfdm level. FW team needs to fix `AllowedMACAddress.{i}` multi-instance support **and** add deny-list vendor extension. **Current Mitigations:** -- ISS-1: ~~Hardcoded channel lists~~ β†’ **Client-side computable** using `PossibleChannels` + `SupportedOperatingChannelBandwidths` + IEEE 802.11 bonding rules (see ISS-1 section above) -- ISS-2: ~~Hardcoded width lists~~ β†’ **Can use `SupportedOperatingChannelBandwidths` instead** +- ISS-1: βœ… **Implemented** β€” Client-side bonding computation using `PossibleChannels` + `SupportedOperatingChannelBandwidths` + IEEE 802.11 bonding rules +- ISS-2: βœ… **Implemented** β€” Dynamic bandwidth from `SupportedOperatingChannelBandwidths` with hardcoded fallback - ISS-3: MAC Filtering tab removed from WiFi Settings page -- ISS-4: Case-insensitive `"guest"` substring match on SSID name +- ISS-4: Case-insensitive `"guest"` substring match on SSID name (Bridge/VLAN approach confirmed NOT viable via TR-181) --- @@ -277,7 +286,7 @@ IEEE 802.11 channel bonding rules are spec-defined constants. The app can derive | Feature | JNAP Action | USP Path | Status | |---------|-------------|----------|--------| | **WPS** | `setWPSServerSessionStatus` | `AP.{i}.WPS.Enable` + `InitiateWPSPBC()` | βœ… Full support (see Β§1 F-027) | -| **Advanced Radio (partial)** | `setAdvancedRadioSettings` | 12 writable Radio parameters | βœ… Mostly migratable (see Β§1 F-028) | +| **Advanced Radio** | `setAdvancedRadioSettings` | DFS toggle only (`IEEE80211hEnabled`) | ⚠️ DFS only USP Ready β€” 3/4 toggles are USP Gap (see Β§1 F-028) | | **Security Mode** | `setWirelessNetworkSettings` | `AP.{i}.Security.ModeEnabled` + WPA3 | βœ… Includes WPA3 + MFP (see Β§1 F-001) | ### πŸ”§ Code Fix (YAML/codegen update needed) @@ -432,7 +441,7 @@ QoS is implemented via Qualcomm hardware layer (SAL) + ebtables bridging rules. The following features currently only have JNAP implementations, with no standard TR-181 paths. **Each requires firmware team vendor extension to complete migration.** > ~~WPS~~ β€” βœ… Confirmed USP Ready (2026-03-16), moved to Β§1 F-027 -> ~~WiFi Advanced Settings~~ β€” βœ… Mostly confirmed USP Ready, moved to Β§1 F-028 / Β§3b +> ~~WiFi Advanced Settings~~ β€” ⚠️ Reclassified: only DFS is USP Ready (Β§1 F-028); Client Steering/Node Steering (= Smart Connect) and MLO remain in Β§7 | Feature | JNAP Action | Category | Required Vendor Extension | Priority | |---------|-------------|----------|--------------------------|----------| @@ -467,7 +476,7 @@ The following features currently only have JNAP implementations, with no standar β”‚ β”‚ β”‚ F-001 WiFi PW/Security F-004 Channel Width β”‚ β”‚ F-011 Ping UI F-026 Prefetch Cache β”‚ -β”‚ F-027 WPS F-028 Advanced Radio β”‚ +β”‚ F-027 WPS F-028 DFS toggle β”‚ β”‚ WiFi ISS-1 (channel-per-width client computation) β”‚ β”‚ WiFi ISS-2 (use SupportedBandwidths) β”‚ β”‚ Internet ISS-3/4 Vendor Ext ISS-6 YAML Fix β”‚ @@ -524,19 +533,19 @@ The following features currently only have JNAP implementations, with no standar ### Phase M2-A: Immediate (no external dependency) -| # | Feature | Effort | Rationale | -|---|---------|--------|-----------| -| 1 | F-001 WiFi Password/Security | Small | P0 daily WiFi management + WPA3 security modes | -| 2 | F-004 Channel Width | Small | P1 paired with WiFi, switch to `SupportedOperatingChannelBandwidths` | -| 3 | WiFi ISS-1 fix | Medium | Channel-per-width client-side computation (IEEE 802.11 bonding rules + `PossibleChannels`) | -| 4 | WiFi ISS-2 fix | Trivial | Remove hardcoded bandwidth, read from TR-181 | -| 5 | F-027 WPS | Small | P2 βœ… SSH verified full support | -| 6 | F-028 Advanced Radio | Medium | P2 12 writable parameters, partially replaces JNAP | -| 7 | F-011 Ping/Traceroute UI | Medium | SSE infrastructure ready, UI only | -| 8 | Internet ISS-3/4 | Small | Vendor extension path swap | -| 9 | Internet ISS-6 | Trivial | YAML writable flag removal | -| 10 | Internet ISS-2/8 | Medium | PPP/VLAN Add/Delete lifecycle | -| 11 | F-026 Prefetch Cache | Medium | Dashboard performance optimization | +| # | Feature | Effort | Status | Rationale | +|---|---------|--------|--------|-----------| +| 1 | F-001 WiFi Password/Security | Small | βœ… Done | P0 daily WiFi management + WPA3 security modes | +| 2 | F-004 Channel Width | Small | βœ… Done | P1 paired with WiFi, switch to `SupportedOperatingChannelBandwidths` | +| 3 | WiFi ISS-1 fix | Medium | βœ… Done | Channel-per-width client-side computation (IEEE 802.11 bonding rules + `PossibleChannels`) | +| 4 | WiFi ISS-2 fix | Trivial | βœ… Done | Remove hardcoded bandwidth, read from TR-181 | +| 5 | F-011 Ping/Traceroute UI | Medium | βœ… Done | SSE infrastructure ready, UI + notifier + routing | +| 6 | F-027 WPS | Small | Pending | P2 βœ… SSH verified full support | +| 7 | F-028 DFS Toggle | Small | Pending | P2 only DFS (`IEEE80211hEnabled`) is USP Ready; Client Steering/Node Steering/MLO need vendor ext (Β§7) | +| 8 | Internet ISS-3/4 | Small | Pending | Vendor extension path swap | +| 9 | Internet ISS-6 | Trivial | Pending | YAML writable flag removal | +| 10 | Internet ISS-2/8 | Medium | Pending | PPP/VLAN Add/Delete lifecycle | +| 11 | F-026 Prefetch Cache | Medium | Pending | Dashboard performance optimization | ### Phase M2-B: FW Team Coordination @@ -583,9 +592,10 @@ Based on USP Features Matrix (72 features total): | Status | Count | Percentage | Description | |--------|-------|-----------|-------------| -| βœ… USP Complete | 45 | 63% | M1 migration completed | -| βœ… USP Ready (verified) | 3 | 4% | SSH verified available: WPS, Advanced Radio, Security Mode | -| πŸ”§ Code Fix Only | 8 | 11% | Vendor extension path swap, YAML fix, bandwidth fix, channel-per-width computation, scheduling workaround | +| βœ… USP Complete (M1) | 45 | 63% | M1 migration completed | +| βœ… USP Complete (M2-A) | 5 | 7% | F-001, F-004, F-011, WiFi ISS-1, WiFi ISS-2 | +| βœ… USP Ready (verified) | 2 | 3% | SSH verified available: WPS, Security Mode (F-028 reclassified: only DFS toggle is USP Ready) | +| πŸ”§ Code Fix Only | 3 | 4% | Vendor extension path swap (ISS-3/4), YAML fix (ISS-6), scheduling workaround | | 🏭 FW Bug Fix | 5 | 7% | Existing TR-181 paths need repair (ISS-1/5/7/9/10) | | 🏭 FW bbfdm Plugin | 2 | 3% | New bbfdm plugins needed (DDNS, QoS Basic) | | πŸ”΄ FW Vendor Extension | 9 | 13% | FW team must provide new vendor extensions (JNAP migration blockers) | @@ -595,7 +605,8 @@ Based on USP Features Matrix (72 features total): | Milestone | Scope | USP Coverage | JNAP Dependencies | |-----------|-------|-------------|-------------------| | **M1 Done** | 45 features implemented | 45/72 (63%) | 27 remaining | -| **M2-A** (code fix) | F-001/004/011/026/027/028 + WiFi ISS-1/2 + Internet ISS-3/4/6/2/8 | 56/72 (78%) | 16 remaining | +| **M2-A partial** βœ… | F-001, F-004, F-011, WiFi ISS-1, WiFi ISS-2 | **50/72 (69%)** | 22 remaining | +| **M2-A full** (remaining code fix) | F-026/027 + F-028 DFS + Internet ISS-3/4/6/2/8 | 55/72 (76%) | 17 remaining | | **M2-B** (FW bug fix) | ISS-1/5/7/9/10 + F-007 | 61/72 (85%) | 11 remaining | | **M2-C** (bbfdm plugins) | DDNS, QoS Basic | 63/72 (88%) | 9 remaining | | **M2-D** (vendor extensions) | Smart Connect, Parental Control, PPTP/L2TP, FW Update, etc. | **72/72 (100%)** | **0 β€” JNAP migration complete** | @@ -628,5 +639,5 @@ Completing 100% migration requires the firmware team to provide: --- -**Last Updated:** 2026-03-16 +**Last Updated:** 2026-03-16 (M2-A partial: 5/11 items completed) **Next Review:** After FW team response on ISS-1 / bbfdm plugin feasibility diff --git a/doc/usp/usp_pages_architecture_v2.1.0.md b/doc/usp/usp_pages_architecture_v2.1.0.md new file mode 100644 index 000000000..fdf59a227 --- /dev/null +++ b/doc/usp/usp_pages_architecture_v2.1.0.md @@ -0,0 +1,343 @@ +# USP Pages Architecture Analysis β€” v2.1.0 + +> **Scope**: `lib/usp_page/` β€” 23 feature modules, 85+ Dart files +> **Date**: 2026-03-16 + +--- + +## 1. High-Level Architecture + +```mermaid +graph TD + subgraph Shell["Shell Layer"] + S1["UspDashboardShell"] + S2["UspTopBar"] + end + + subgraph Pages["Feature Modules (23)"] + P1["dashboard"] + P2["wifi_settings"] + P3["firewall"] + P4["internet_settings"] + P5["admin"] + P6["...17 more"] + end + + subgraph Core["Core Infrastructure"] + C1["UspService (WASM USP Client)"] + C2["Codegen .g.dart Models"] + C3["SSE Invalidation"] + C4["Preservable / FeatureState"] + end + + Shell --> Pages + Pages --> Core +``` + +USP Pages replaces the legacy JNAP pages with a new module group that communicates with the router via the **USP (User Services Platform)** protocol. The codebase follows a **Feature-First** directory structure with a consistent 4-layer design within each module. + +--- + +## 2. Module Inventory + +| Category | Modules | Structure | +|----------|---------|-----------| +| **Shell** | `shell/`, `menu/` | App shell & navigation | +| **Full CRUD** | `wifi_settings`, `firewall`, `internet_settings`, `admin`, `dmz`, `instant_safety`, `ipv6_port_service`, `local_network`, `static_routing` | models + services + providers + views | +| **Dashboard** | `dashboard/` | Largest module (17 data sources, 20+ UI models) | +| **Read-only / View-only** | `advanced_settings`, `dhcp`, `devices`, `network_diagnostics`, `statistics`, `support`, `system_log`, `test_console`, `port_forwarding` | Views only (or partial providers) | +| **Presentational** | `topology/` | helpers, models, providers, views | +| **Shared** | `components/` | Cross-module UI components | + +--- + +## 3. Standard Module Layout + +``` +usp_page// +β”œβ”€β”€ models/ # UI Models (Equatable, copyWith, toMap/fromMap) +β”œβ”€β”€ services/ # Stateless data transformers (codegen β†’ UI Model) +β”œβ”€β”€ providers/ # Riverpod Notifiers + State definitions +└── views/ + β”œβ”€β”€ components/ # Sub-components (cards, panels) + β”œβ”€β”€ dialogs/ # Edit / confirmation dialogs + └── sections/ # Page sections (for complex pages) +``` + +Not all modules have all four layers. `statistics/` and `advanced_settings/` contain only `views/`, indicating they are purely presentational or borrow data from the Dashboard provider. + +--- + +## 4. Core Architectural Patterns + +### 4.1 Provider Patterns β€” Three Variants + +#### Variant A: `PreservableAutoDisposeNotifierMixin` + +**Used by**: `wifi_settings`, `admin` + +```dart +class UspWifiSettingsNotifier + extends AutoDisposeNotifier + with PreservableAutoDisposeNotifierMixin< + WifiSettingsSettings, WifiSettingsStatus, UspWifiSettingsState> { + + @override + Future<(WifiSettingsSettings?, WifiSettingsStatus?)> performFetch(...) async { ... } + + @override + Future performSave() async { ... } +} +``` + +- State extends `FeatureState` +- `Preservable` wraps `original` / `current` for automatic dirty-checking +- Mixin provides `fetch()`, `save()`, `revert()`, `markAsSaved()` +- Best suited for "fetch β†’ edit β†’ save β†’ re-fetch" workflows + +#### Variant B: `AutoDisposeAsyncNotifier` + Manual Original/Pending + +**Used by**: `firewall`, `internet_settings`, `dmz`, `local_network`, `instant_safety`, `ipv6_port_service` + +```dart +class UspFirewallNotifier extends AutoDisposeAsyncNotifier { + @override + Future build() async { ... } + + void updateSetting(FirewallUIModel Function(FirewallUIModel) updater) { ... } + Future save() async { ... } +} +``` + +- State maintains `original` / `pending` snapshots manually +- `isDirty => original != pending` +- Uses `_withLock()` to prevent concurrent mutations +- Bridges to `PreservableContract` via `_UspPreservableAdapter` + +#### Variant C: `AsyncNotifier` (Non-AutoDispose) + +**Used by**: `dashboard` + +```dart +class UspDashboardNotifier extends AsyncNotifier { ... } +``` + +- **Not** auto-disposed β€” data persists across tab switches +- 17 parallel `Future.wait` fetches with progress reporting +- Supports SSE domain-specific incremental updates +- `_withLock()` serializes mutations (WASM constraint) + +### 4.2 Preservable / Dirty Guard System + +```mermaid +classDiagram + class Preservable~T~ { + +T original + +T current + +bool isDirty + +update(T) Preservable + +saved() Preservable + } + + class FeatureState~TSettings_TStatus~ { + +Preservable settings + +TStatus status + +bool isDirty + } + + class PreservableContract { + <> + +bool isDirty() + +void revert() + +performFetch() + +performSave() + } + + FeatureState --> Preservable + PreservableContract <|.. PreservableAutoDisposeNotifierMixin + PreservableContract <|.. _UspPreservableAdapter +``` + +- `Preservable` uses `Equatable` to compare `original` vs. `current` +- `FeatureState` separates **editable settings** (`TSettings`) from **read-only status** (`TStatus`) +- `PreservableContract` interface enables `LinksysRoute.onExit` dirty-check prompts + +### 4.3 Data Flow: Codegen β†’ Service β†’ Provider β†’ View + +```mermaid +flowchart LR + A["Router (TR-181 DM)"] -->|"USP/WASM"| B["UspService.get/set"] + B -->|".fetch(usp)"| C["Codegen .g.dart\n(WiFiSsids, FirewallChainRules, ...)"] + C -->|"Transform"| D["Service Layer\n(buildUIModel, parseRules)"] + D -->|"UI Models"| E["Notifier / State"] + E -->|"Riverpod watch"| F["View Widgets"] + F -->|"User Action"| E + E -->|".update() / .updateMany()"| B +``` + +**Key Design Decisions**: + +- **Codegen types** (e.g., `WiFiSsids`, `FirewallChainRules`) are generated by the `usp-codegen` tool from YAML definitions. They provide static `fetch()`, `update()`, and `updateMany()` methods. +- **Service layer is stateless**: performs data transformation only (codegen type β†’ UI Model), holds no state. +- **Provider layer owns all state**: including loading, saving, and dirty flags. + +### 4.4 SSE (Server-Sent Events) Invalidation + +```dart +ref.listen(sseInvalidationProvider, (prev, next) { + if (next.valueOrNull == InvalidationDomain.firewallRules) { + if (!s.isDirty && !s.isSaving) { + ref.invalidateSelf(); // re-trigger build() + } + } +}); +``` + +| Strategy | Description | +|----------|-------------| +| **Domain-scoped** | `InvalidationDomain` enum differentiates data sources | +| **Debounced** | Dashboard debounces 500ms, batching rapid SSE events | +| **Dirty-safe** | Does **not** auto-refetch when user has unsaved changes | +| **Incremental** | Dashboard `_handleInvalidation()` re-fetches only affected subsets (not all 17 classes) | + +### 4.5 Mutation Lock Pattern + +```dart +Future _withLock(Future Function() action) async { + if (_mutating) throw StateError('Another mutation is in progress'); + _mutating = true; + try { return await action(); } + finally { _mutating = false; } +} +``` + +Used by `dashboard`, `internet_settings`, and `firewall` save operations to prevent concurrent mutations due to WASM client limitations. + +--- + +## 5. Dashboard Module Deep Dive + +The Dashboard is the largest and most complex module: + +| Aspect | Detail | +|--------|--------| +| **Data sources** | 17 codegen types + 4 extra fetches (gateway, IPv6, firmware, bridge ports) | +| **UI components** | 21 card components (barrel export in `_components.dart`) | +| **UI models** | 15+ types (`DeviceUIModel`, `WanStatusUIModel`, `WifiRadioUIModel`, etc.) | +| **Progress tracking** | `UspLoadingProgress` provider reports real-time fetch progress | +| **Layout system** | `UspLayoutController` + `UspLayoutPreferences` for customizable card arrangement | +| **System monitor** | Independent `UspSystemMonitorNotifier` recording CPU/Memory history | +| **Analytics** | `UspDeviceAnalyticsNotifier` + `DeviceAnalyticsPersistence` (local persistence) | +| **Traffic** | `UspTrafficAnalysisNotifier` for real-time traffic analysis | +| **PDF export** | `UspPdfService` for report generation | + +### Dashboard State Structure + +```dart +class UspDashboardState { + // Raw codegen data (retained for mutation payloads) + final SystemInfo systemInfo; + final ConnectedDevices connectedDevices; + final WiFiRadios wifiRadios; + // ...13 more raw types + + // Pre-computed UI models (for View consumption) + final SystemInfoUIModel systemInfoModel; + final List deviceModels; + final List wifiRadioModels; + // ...10 more UI model types +} +``` + +> **Note**: The Dashboard retains both raw codegen data and computed UI models. Raw data is used for mutation payload construction and SSE incremental recomputation; UI models are consumed directly by Views. + +--- + +## 6. Model Layer Design + +All UI models share a consistent design: + +| Feature | Implementation | +|---------|---------------| +| **Value equality** | Extends `Equatable`, overrides `props` | +| **Immutability** | All fields `final`, provides `copyWith()` | +| **Serialization** | `toMap()` / `fromMap()` + `toJson()` / `fromJson()` | +| **TR-181 aware** | Field names map directly to TR-181 DM paths (e.g., `ssidInstancePath`, `accessPointInstancePath`) | + +**Example** β€” `WifiNetworkUIModel` merges three TR-181 collections: + +``` +SSID.{i} ─────────┐ +AccessPoint.{i} ──────→ WifiNetworkUIModel +Radio.{i} β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## 7. Service Layer Design + +| Principle | Description | +|-----------|-------------| +| **Stateless** | All services are stateless `Provider` instances (not Notifiers) | +| **Single responsibility** | Handles codegen β†’ UI model transformation + SET payload construction | +| **TR-181 knowledge encapsulation** | Path normalization (trailing dot), relationship resolution (SSID↔AP↔Radio) | +| **Business logic** | Firewall accept/drop rule inversion, WiFi 6 GHz WPA3 enforcement | + +--- + +## 8. View Layer Organization + +| Pattern | Description | Example | +|---------|-------------|---------| +| `usp_*_view.dart` | Root View widget per module | `usp_firewall_view.dart` | +| `components/` | Composable sub-components (cards, panels) | `usp_wifi_status_card.dart` | +| `dialogs/` | Modal dialogs | `change_password_dialog.dart` | +| `sections/` | Logical page sections | `usp_ipv4_section.dart` | +| Barrel exports | `_components.dart` | Dashboard's 21 card components | + +--- + +## 9. Module Dependency Graph + +```mermaid +graph TB + Shell["shell/"] --> Menu["menu/"] + Shell --> Dashboard + Shell --> AllPages["All Feature Pages"] + + Dashboard["dashboard/"] -->|"raw codegen types"| Codegen["generated/*.g.dart"] + AllPages --> Codegen + AllPages --> UspService["usp/services/usp_service.dart"] + AllPages --> SSE["usp/providers/sse_*"] + AllPages --> Preservable["providers/preservable.dart"] + AllPages --> Auth["usp/providers/usp_auth_coordinator.dart"] + + Codegen --> UspService +``` + +All feature modules depend on `UspService` (WASM USP client) and codegen `.g.dart` types, but have **no lateral dependencies** on each other β€” each module is a self-contained feature slice. + +--- + +## 10. Strengths & Areas for Improvement + +### Strengths + +| Area | Description | +|------|-------------| +| **Consistent module structure** | Easy onboarding β€” learn one module design, apply everywhere | +| **Dirty Guard** | `Preservable` + `PreservableContract` unifies unsaved-changes protection | +| **SSE incremental updates** | Dashboard avoids full refetch, reducing latency | +| **Codegen abstraction** | `.g.dart` types encapsulate USP communication details; modules only call `fetch()` / `update()` | +| **Testable services** | Stateless + pure transformations make unit testing straightforward | +| **Mutation lock** | Prevents WASM client concurrency issues | + +### Areas for Improvement + +| Area | Description | +|------|-------------| +| **Inconsistent provider patterns** | Three notifier variants are mixed (Preservable mixin / manual original-pending / async-only); consider unifying | +| **Oversized Dashboard** | A single Notifier manages 17 data sources + 21 UI models; `UspDashboardState` is excessively large and could be split into sub-providers | +| **Missing service layers** | `port_forwarding/` and `dhcp/` contain only views/models without services, suggesting business logic may be embedded in views | +| **Hardcoded guest detection** | `isGuest` relies on SSID name containing "guest" (marked with TODO); not reliable long-term | +| **Inconsistent state file placement** | Firewall's `UspFirewallState` is defined inline in the notifier file vs. WiFi's separate state file | diff --git a/lib/demo/providers/demo_overrides.dart b/lib/demo/providers/demo_overrides.dart index ca59ec9d6..b409f53c6 100644 --- a/lib/demo/providers/demo_overrides.dart +++ b/lib/demo/providers/demo_overrides.dart @@ -8,10 +8,15 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:privacy_gui/constants/build_config.dart'; import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_provider.dart'; import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_state.dart'; +import 'package:privacy_gui/core/protocol/protocol_resolver.dart'; import 'package:privacy_gui/page/instant_setup/models/pnp_ui_models.dart'; import 'package:privacy_gui/demo/jnap/demo_router_repository.dart'; +import 'package:privacy_gui/demo/usp/demo_usp_data_loader.dart'; +import 'package:privacy_gui/demo/usp/demo_usp_service.dart'; import 'package:privacy_gui/core/data/providers/polling_provider.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/page/instant_setup/providers/pnp_provider.dart'; @@ -20,6 +25,9 @@ import 'package:privacy_gui/providers/connectivity/connectivity_info.dart'; import 'package:privacy_gui/providers/connectivity/connectivity_provider.dart'; import 'package:privacy_gui/providers/connectivity/connectivity_state.dart'; import 'package:privacy_gui/route/router_provider.dart'; +import 'package:privacy_gui/usp/providers/sse_providers.dart'; +import 'package:privacy_gui/usp/providers/usp_auth_coordinator.dart'; +import 'package:privacy_gui/usp/providers/usp_service_provider.dart'; import 'demo_router_provider.dart'; /// Demo provider overrides for the Demo application. @@ -36,29 +44,53 @@ import 'demo_router_provider.dart'; /// pipeline. class DemoProviders { /// Returns all provider overrides needed for demo mode. - static List get allOverrides => [ - // 1. Auth: Always logged in - authProvider.overrideWith(() => _DemoAuthNotifier()), + static List get allOverrides { + final demoUsp = DemoUspService(DemoUspDataLoader.instance); + return [ + // 1. Auth: Always logged in + authProvider.overrideWith(() => _DemoAuthNotifier()), - // 2. Connectivity: Always online - connectivityProvider.overrideWith(() => _DemoConnectivityNotifier()), + // 2. Connectivity: Always online + connectivityProvider.overrideWith(() => _DemoConnectivityNotifier()), - // 3. Router Repository: Intercept JNAP traffic - routerRepositoryProvider - .overrideWith((ref) => DemoRouterRepository(ref)), + // 3. Router Repository: Intercept JNAP traffic + routerRepositoryProvider.overrideWith((ref) => DemoRouterRepository(ref)), - // 4. Polling: Auto-start - pollingProvider.overrideWith(() => _DemoPollingNotifier()), + // 4. Polling: Auto-start + pollingProvider.overrideWith(() => _DemoPollingNotifier()), - // 5. Router: Wrap with ShellRoute for Theme Panel Overlay - routerProvider.overrideWithProvider(demoRouterProvider), + // 5. Router: Wrap with ShellRoute for Theme Panel Overlay + routerProvider.overrideWithProvider(demoRouterProvider), - // 5. Geolocation: Bypass cloud service call - geolocationProvider.overrideWith(() => _DemoGeolocationNotifier()), + // 6. Geolocation: Bypass cloud service call + geolocationProvider.overrideWith(() => _DemoGeolocationNotifier()), - // 6. PnP: Bypass setup wizard - pnpProvider.overrideWith(() => _DemoPnpNotifier()), - ]; + // 7. PnP: Bypass setup wizard + pnpProvider.overrideWith(() => _DemoPnpNotifier()), + + // --- USP Provider overrides --- + + // 8. USP Service: Mock data from demo_usp_data.json + uspServiceProvider.overrideWith((ref) => demoUsp), + + // 9. SSE Bootstrap: No-op (no SSE in demo) + sseBootstrapProvider.overrideWith((ref) async {}), + + // 10. SSE Manager: Null (no SSE in demo) + sseManagerProvider.overrideWith((ref) => null), + + // 11. USP Bridge Client: Null (no bridge in demo) + uspBridgeClientProvider.overrideWith((ref) => null), + + // 12. Protocol Resolver: Force USP-only mode β†’ routes to /uspDashboard + protocolResolverProvider.overrideWith( + (ref) => ProtocolResolver(demoUsp, ProtocolPreference.uspOnly)), + + // 13. USP Auth Coordinator: Uses DemoUspService (always authenticated) + uspAuthCoordinatorProvider.overrideWith( + (ref) => UspAuthCoordinator(demoUsp, const FlutterSecureStorage())), + ]; + } } class _DemoGeolocationNotifier extends GeolocationNotifier { diff --git a/lib/demo/providers/demo_router_provider.dart b/lib/demo/providers/demo_router_provider.dart index 391206dca..883edac7e 100644 --- a/lib/demo/providers/demo_router_provider.dart +++ b/lib/demo/providers/demo_router_provider.dart @@ -77,6 +77,8 @@ final demoRouterProvider = Provider((ref) { return router.goPnpPath(state); } else if (state.matchedLocation.startsWith('/autoParentFirstLogin')) { return state.uri.toString(); + } else if (state.matchedLocation.startsWith('/usp')) { + return state.uri.toString(); } return router.redirectLogic(state); }, diff --git a/lib/demo/usp/demo_usp_data_loader.dart b/lib/demo/usp/demo_usp_data_loader.dart new file mode 100644 index 000000000..370cf2ccd --- /dev/null +++ b/lib/demo/usp/demo_usp_data_loader.dart @@ -0,0 +1,114 @@ +/// TR-181 Mock Data Loader for Demo Mode. +/// +/// Loads `demo_usp_data.json` and provides wildcard-aware path lookups +/// that mirror how the real USP agent resolves TR-181 GET requests. +library; + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class DemoUspDataLoader { + DemoUspDataLoader._(); + + static DemoUspDataLoader? _instance; + static DemoUspDataLoader get instance => _instance ??= DemoUspDataLoader._(); + + /// In-memory TR-181 data: path β†’ string value. + /// Mutable β€” `set`, `add`, `delete` operations modify this map. + final Map _data = {}; + + bool get isLoaded => _data.isNotEmpty; + + /// Load mock data from asset. + Future load() async { + if (_data.isNotEmpty) return; + + try { + final jsonString = await rootBundle.loadString( + 'assets/resources/demo_usp_data.json', + ); + final raw = json.decode(jsonString) as Map; + for (final entry in raw.entries) { + if (entry.key.startsWith('_')) continue; // skip comments + _data[entry.key] = entry.value?.toString() ?? ''; + } + debugPrint('[DemoUsp] Loaded ${_data.length} TR-181 paths'); + } catch (e) { + debugPrint('[DemoUsp] Failed to load demo_usp_data.json: $e'); + } + } + + /// Resolve a list of TR-181 paths (may contain wildcards) into a flat map. + /// + /// Supports three query patterns: + /// 1. **Exact**: `Device.DeviceInfo.Manufacturer` β†’ direct lookup + /// 2. **Wildcard**: `Device.WiFi.Radio.*.Enable` β†’ regex `\d+` for each `*` + /// 3. **Prefix**: `Device.IP.Interface.1.IPv6Address.` β†’ all children + Map resolve(List paths) { + final result = {}; + for (final path in paths) { + if (path.contains('*')) { + _resolveWildcard(path, result); + } else if (path.endsWith('.')) { + _resolvePrefix(path, result); + } else { + final val = _data[path]; + if (val != null) result[path] = val; + } + } + return result; + } + + /// Expand embedded wildcards: `Device.WiFi.Radio.*.Enable` + /// matches `Device.WiFi.Radio.1.Enable`, `Device.WiFi.Radio.2.Enable`, etc. + void _resolveWildcard(String pattern, Map out) { + final escaped = RegExp.escape(pattern).replaceAll(r'\*', r'\d+'); + final regex = RegExp('^$escaped\$'); + for (final entry in _data.entries) { + if (regex.hasMatch(entry.key)) { + out[entry.key] = entry.value; + } + } + } + + /// Expand prefix query: `Device.Hosts.Host.` β†’ all paths that start with it. + void _resolvePrefix(String prefix, Map out) { + for (final entry in _data.entries) { + if (entry.key.startsWith(prefix)) { + out[entry.key] = entry.value; + } + } + } + + // --------------------------------------------------------------------------- + // Mutation helpers (for interactive demo) + // --------------------------------------------------------------------------- + + void setValue(String path, String value) { + _data[path] = value; + } + + void removeByPrefix(String prefix) { + _data.removeWhere((key, _) => key.startsWith(prefix)); + } + + /// Returns the next available numeric instance ID for `objectPath`. + /// + /// Scans existing keys to find the highest ID and returns ID + 1. + /// Example: `Device.NAT.PortMapping.` with existing .1. and .2. β†’ returns 3. + int nextInstanceId(String objectPath) { + final normalized = objectPath.endsWith('.') ? objectPath : '$objectPath.'; + int maxId = 0; + for (final key in _data.keys) { + if (!key.startsWith(normalized)) continue; + final rest = key.substring(normalized.length); + final dot = rest.indexOf('.'); + if (dot <= 0) continue; + final id = int.tryParse(rest.substring(0, dot)); + if (id != null && id > maxId) maxId = id; + } + return maxId + 1; + } +} diff --git a/lib/demo/usp/demo_usp_service.dart b/lib/demo/usp/demo_usp_service.dart new file mode 100644 index 000000000..c1afdead6 --- /dev/null +++ b/lib/demo/usp/demo_usp_service.dart @@ -0,0 +1,322 @@ +/// Mock UspService for Demo mode. +/// +/// Extends [UspService] and overrides all public methods to return data +/// from [DemoUspDataLoader] instead of making real WASM/HTTP calls. +/// +/// Includes a [_DynamicSimulator] that injects time-varying values for +/// traffic counters, CPU usage, and memory β€” making statistics charts +/// display realistic, animated data. +library; + +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:privacy_gui/demo/usp/demo_usp_data_loader.dart'; +import 'package:privacy_gui/usp/services/usp_service.dart'; + +class DemoUspService extends UspService { + final DemoUspDataLoader _loader; + final _DynamicSimulator _sim = _DynamicSimulator(); + + DemoUspService(this._loader) : super('https://localhost'); + + @override + bool get isAuthenticated => true; + + @override + String? get sessionToken => 'demo-session-token'; + + // --------------------------------------------------------------------------- + // Auth (no-op) + // --------------------------------------------------------------------------- + + @override + Future login(String password) async {} + + @override + Future logout() async {} + + @override + Future refreshToken() async {} + + @override + Future reauth() async {} + + // --------------------------------------------------------------------------- + // GET β€” wildcard expansion + dynamic simulation + value coercion + // --------------------------------------------------------------------------- + + @override + Future> get(List paths) async { + await Future.delayed(const Duration(milliseconds: 40)); + + final raw = _loader.resolve(paths); + + // Inject dynamic values for statistics charts + _sim.apply(raw); + + // Coerce string values to proper Dart types + final result = {}; + for (final entry in raw.entries) { + result[entry.key] = _coerce(entry.key, entry.value); + } + + // Ensure non-wildcard paths have an entry (null for missing) + for (final path in paths) { + if (path.contains('*') || path.endsWith('.')) continue; + result.putIfAbsent(path, () => null); + } + + return result; + } + + // --------------------------------------------------------------------------- + // SET β€” update in-memory data + // --------------------------------------------------------------------------- + + @override + Future set(Map parameters, + {bool allowPartial = false}) async { + await Future.delayed(const Duration(milliseconds: 20)); + for (final entry in parameters.entries) { + _loader.setValue(entry.key, entry.value.toString()); + } + } + + // --------------------------------------------------------------------------- + // ADD β€” create new instance + // --------------------------------------------------------------------------- + + @override + Future add(String objectPath, Map parameters) async { + await Future.delayed(const Duration(milliseconds: 20)); + final nextId = _loader.nextInstanceId(objectPath); + final normalized = objectPath.endsWith('.') ? objectPath : '$objectPath.'; + final instancePath = '$normalized$nextId.'; + for (final entry in parameters.entries) { + _loader.setValue('$instancePath${entry.key}', entry.value.toString()); + } + debugPrint('[DemoUsp] ADD $instancePath'); + return instancePath; + } + + @override + Future> addMultiple(List> objects, + {bool allowPartial = false}) async { + final results = []; + for (final obj in objects) { + final path = obj['path'] as String? ?? ''; + final params = obj['parameters'] as Map? ?? {}; + results.add(await add(path, params)); + } + return results; + } + + // --------------------------------------------------------------------------- + // DELETE β€” remove from in-memory data + // --------------------------------------------------------------------------- + + @override + Future delete(String path) async { + await Future.delayed(const Duration(milliseconds: 20)); + _loader.removeByPrefix(path); + } + + @override + Future deleteMultiple(List paths, + {bool allowPartial = false}) async { + for (final path in paths) { + await delete(path); + } + } + + // --------------------------------------------------------------------------- + // OPERATE β€” mock results for Ping / Traceroute / DHCP Renew + // --------------------------------------------------------------------------- + + @override + Future> operate(String command, + {Map args = const {}}) async { + await Future.delayed(const Duration(milliseconds: 150)); + final ts = DateTime.now().millisecondsSinceEpoch; + + if (command.contains('IPPing')) { + return { + 'commandKey': 'demo-ping-$ts', + 'Status': 'Complete', + 'SuccessCount': args['NumberOfRepetitions'] ?? '4', + 'FailureCount': '0', + 'AverageResponseTime': '${8 + Random().nextInt(20)}', + 'MinimumResponseTime': '${5 + Random().nextInt(5)}', + 'MaximumResponseTime': '${20 + Random().nextInt(30)}', + }; + } + if (command.contains('TraceRoute')) { + return { + 'commandKey': 'demo-trace-$ts', + 'Status': 'Complete', + 'ResponseTime': '${10 + Random().nextInt(15)}', + 'NumberOfRouteHops': '${2 + Random().nextInt(4)}', + }; + } + if (command.contains('Renew') || command.contains('Reboot')) { + return {'commandKey': 'demo-op-$ts'}; + } + + debugPrint('[DemoUsp] OPERATE $command (unhandled)'); + return {}; + } + + // --------------------------------------------------------------------------- + // Subscription β€” no-op (no SSE in demo) + // --------------------------------------------------------------------------- + + @override + Future> createNotifySubscription({ + required String notifType, + required String referenceList, + }) async => + {'instancePath': 'Device.LocalAgent.Subscription.demo.'}; + + @override + Future deleteNotifySubscription(String instancePath) async {} + + @override + Future>> listSubscriptions() async => []; + + @override + Future purgeAllSubscriptions() async => 0; + + @override + Future> subscribe({ + required String id, + required NotifType notifType, + required List paths, + required T Function(Map) parser, + Duration interval = const Duration(seconds: 5), + }) async { + final controller = StreamController(); + return Subscription( + id: id, + notifType: notifType, + stream: controller.stream, + cancel: () async => controller.close(), + ); + } + + // --------------------------------------------------------------------------- + // Dispose β€” skip WASM free() + // --------------------------------------------------------------------------- + + @override + void dispose() {} + + // --------------------------------------------------------------------------- + // Value coercion (matches UspService._coerceValue) + // --------------------------------------------------------------------------- + + dynamic _coerce(String path, String? raw) { + if (raw == null) return null; + if (raw.isEmpty) return ''; + final lower = raw.toLowerCase(); + if (lower == 'true') return true; + if (lower == 'false') return false; + final isBoolPath = path.endsWith('Enable') || + path.endsWith('Active') || + path.endsWith('Upstream'); + if (isBoolPath) { + if (raw == '1') return true; + if (raw == '0') return false; + } + return raw; + } +} + +// ============================================================================= +// Dynamic Simulator β€” injects time-varying values for statistics charts +// ============================================================================= + +/// Modifies traffic counters, CPU usage, and memory on each [apply] call +/// so that the statistics charts display realistic animated data. +class _DynamicSimulator { + final _rng = Random(); + final _startTime = DateTime.now(); + + // Cumulative traffic counters (bytes / packets) β€” only increase. + final Map _counters = {}; + + /// Apply dynamic overrides to the resolved path map **in place**. + void apply(Map data) { + final elapsed = DateTime.now().difference(_startTime).inSeconds; + + for (final key in data.keys.toList()) { + // --- Traffic byte/packet counters --- + if (_isTrafficCounter(key)) { + _counters.putIfAbsent(key, () => int.tryParse(data[key] ?? '0') ?? 0); + // Bytes: 50-500 KB/s per 5-second poll β‰ˆ 250-2500 KB increment + // Packets: 100-1000 per poll + final isByte = key.contains('Bytes'); + final increment = isByte + ? 250000 + _rng.nextInt(2250000) // 250KB–2.5MB + : 100 + _rng.nextInt(900); // 100–1000 packets + _counters[key] = _counters[key]! + increment; + data[key] = _counters[key].toString(); + continue; + } + + // --- Error/Discard counters (low frequency) --- + if (_isErrorCounter(key)) { + _counters.putIfAbsent(key, () => int.tryParse(data[key] ?? '0') ?? 0); + // ~5% chance to increment by 1 each poll + if (_rng.nextDouble() < 0.05) { + _counters[key] = _counters[key]! + 1; + } + data[key] = _counters[key].toString(); + continue; + } + + // --- CPU usage: sine wave + noise (15–45%) --- + if (key == 'Device.DeviceInfo.ProcessStatus.CPUUsage') { + final base = 30.0; + final wave = 15.0 * sin(elapsed * 2 * pi / 60); // 60s period + final noise = (_rng.nextDouble() - 0.5) * 6; // Β±3 + final cpu = (base + wave + noise).clamp(5, 85).round(); + data[key] = cpu.toString(); + continue; + } + + // --- Memory free: Β±5% around baseline --- + if (key == 'Device.DeviceInfo.MemoryStatus.Free') { + const baseline = 262144; // 256 MB + final jitter = + (((_rng.nextDouble() - 0.5) * 2) * baseline * 0.05).round(); + data[key] = (baseline + jitter).toString(); + continue; + } + + // --- UpTime: initial + real elapsed seconds --- + if (key == 'Device.DeviceInfo.UpTime') { + final initial = int.tryParse(data[key] ?? '86400') ?? 86400; + data[key] = (initial + elapsed).toString(); + continue; + } + } + } + + bool _isTrafficCounter(String key) { + if (!key.startsWith('Device.IP.Interface.')) return false; + return key.endsWith('.BytesSent') || + key.endsWith('.BytesReceived') || + key.endsWith('.PacketsSent') || + key.endsWith('.PacketsReceived'); + } + + bool _isErrorCounter(String key) { + if (!key.startsWith('Device.IP.Interface.')) return false; + return key.endsWith('.ErrorsSent') || + key.endsWith('.ErrorsReceived') || + key.endsWith('.DiscardPacketsSent') || + key.endsWith('.DiscardPacketsReceived'); + } +} diff --git a/lib/generated/index.dart b/lib/generated/index.dart index a2a47ac53..96add82a8 100644 --- a/lib/generated/index.dart +++ b/lib/generated/index.dart @@ -19,6 +19,7 @@ export 'lan_network_info.g.dart'; export 'ipv6settings.g.dart'; export 'dhcp_clients.g.dart'; export 'wan_traffic_stats.g.dart'; +export 'network_diagnostics.g.dart'; export 'dhcp_reservations.g.dart'; export 'wan_operations.g.dart'; export 'multi_interface_traffic_stats.g.dart'; diff --git a/lib/generated/network_diagnostics.g.dart b/lib/generated/network_diagnostics.g.dart new file mode 100644 index 000000000..dda01a0bc --- /dev/null +++ b/lib/generated/network_diagnostics.g.dart @@ -0,0 +1,34 @@ +// AUTO-GENERATED CODE - DO NOT EDIT +// This file was generated by usp-codegen +// Any modifications will be overwritten on next generation + +import 'package:privacy_gui/usp/services/usp_service.dart'; + +/// Network diagnostic operations (Ping, Traceroute) +class NetworkDiagnostics { + /// Run ICMP ping diagnostic + static Future> ping( + UspService client, { + required String host, + String? numberOfRepetitions, + }) async { + final inputs = {}; + inputs['Host'] = host; + if (numberOfRepetitions != null) + inputs['NumberOfRepetitions'] = numberOfRepetitions; + return await client.operate('Device.IP.Diagnostics.IPPing()', args: inputs); + } + + /// Run traceroute diagnostic + static Future> traceRoute( + UspService client, { + required String host, + String? maxHopCount, + }) async { + final inputs = {}; + inputs['Host'] = host; + if (maxHopCount != null) inputs['MaxHopCount'] = maxHopCount; + return await client.operate('Device.IP.Diagnostics.TraceRoute()', + args: inputs); + } +} diff --git a/lib/generated/wan_operations.g.dart b/lib/generated/wan_operations.g.dart index 59e4f7662..205e95b4c 100644 --- a/lib/generated/wan_operations.g.dart +++ b/lib/generated/wan_operations.g.dart @@ -7,12 +7,13 @@ import 'package:privacy_gui/usp/services/usp_service.dart'; /// WAN DHCP lease renewal operations class WanOperations { /// Renew DHCPv4 WAN lease - static Future renewDhcpLease(UspService client) async { - await client.operate('Device.DHCPv4.Client.1.Renew()'); + static Future> renewDhcpLease(UspService client) async { + return await client.operate('Device.DHCPv4.Client.1.Renew()'); } /// Renew DHCPv6 WAN lease - static Future renewDhcpv6Lease(UspService client) async { - await client.operate('Device.DHCPv6.Client.1.Renew()'); + static Future> renewDhcpv6Lease( + UspService client) async { + return await client.operate('Device.DHCPv6.Client.1.Renew()'); } } diff --git a/lib/generated/wi_fi_radios.g.dart b/lib/generated/wi_fi_radios.g.dart index 7add014fd..3a4d2fa40 100644 --- a/lib/generated/wi_fi_radios.g.dart +++ b/lib/generated/wi_fi_radios.g.dart @@ -19,6 +19,7 @@ class WiFiRadio { final int maxBitRate; final bool autoChannelEnable; final bool ieee80211hEnabled; + final String supportedOperatingChannelBandwidths; const WiFiRadio({ required this.instancePath, @@ -34,6 +35,7 @@ class WiFiRadio { required this.maxBitRate, required this.autoChannelEnable, required this.ieee80211hEnabled, + required this.supportedOperatingChannelBandwidths, }); } @@ -77,6 +79,7 @@ class WiFiRadios { 'Device.WiFi.Radio.*.MaxBitRate', 'Device.WiFi.Radio.*.AutoChannelEnable', 'Device.WiFi.Radio.*.IEEE80211hEnabled', + 'Device.WiFi.Radio.*.SupportedOperatingChannelBandwidths', ]; /// Fetch all instances via USP Get message @@ -112,7 +115,8 @@ class WiFiRadios { response['${p}TransmitPower'], response['${p}MaxBitRate'], response['${p}AutoChannelEnable'], - response['${p}IEEE80211hEnabled'] + response['${p}IEEE80211hEnabled'], + response['${p}SupportedOperatingChannelBandwidths'] ].every((v) => v == null || v == '' || @@ -146,6 +150,9 @@ class WiFiRadios { ieee80211hEnabled: response['${p}IEEE80211hEnabled'] == true || response['${p}IEEE80211hEnabled'] == 'true' || response['${p}IEEE80211hEnabled'] == '1', + supportedOperatingChannelBandwidths: + (response['${p}SupportedOperatingChannelBandwidths'] ?? '') + as String, )); } return WiFiRadios(items: items); diff --git a/lib/main_demo.dart b/lib/main_demo.dart index 166a7572c..237019f5a 100644 --- a/lib/main_demo.dart +++ b/lib/main_demo.dart @@ -9,6 +9,7 @@ import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'demo/data/demo_cache_data.dart'; import 'demo/demo_app.dart'; import 'demo/providers/demo_overrides.dart'; +import 'demo/usp/demo_usp_data_loader.dart'; /// Demo mode entry point. /// @@ -42,8 +43,9 @@ void main() async { debugPrint('No .env file found, using defaults'); } - // Load demo cache data + // Load demo cache data (JNAP + USP) await DemoCacheDataLoader.instance.load(); + await DemoUspDataLoader.instance.load(); // Load theme configuration handled by themeConfigProvider diff --git a/lib/page/usp_test/usp_test_page.dart b/lib/page/usp_test/usp_test_page.dart index 993054537..3e8f153ef 100644 --- a/lib/page/usp_test/usp_test_page.dart +++ b/lib/page/usp_test/usp_test_page.dart @@ -193,11 +193,11 @@ class _UspTestPageState extends State { try { final args = Map.from(jsonDecode(argsJson) as Map? ?? {}); final response = await _service!.operate(command, args: args); - _log(' commandKey = ${response.commandKey}'); - if (response.data.isEmpty) { + _log(' commandKey = ${response['commandKey']}'); + if (response.isEmpty) { _log('OPERATE OK (no output)'); } else { - for (final entry in response.data.entries) { + for (final entry in response.entries) { _log(' ${entry.key} = ${entry.value}'); } } diff --git a/lib/providers/preservable_notifier_mixin.dart b/lib/providers/preservable_notifier_mixin.dart index 540f247df..7baf7e931 100644 --- a/lib/providers/preservable_notifier_mixin.dart +++ b/lib/providers/preservable_notifier_mixin.dart @@ -36,6 +36,10 @@ mixin PreservableNotifierMixin< settings: Preservable(original: newSettings, current: newSettings), status: newStatus ?? state.status, ) as TState; + } else if (newStatus != null) { + // Settings unavailable but status returned (e.g. error) β€” apply status + // so the UI can exit the loading state and display the error. + state = state.copyWith(status: newStatus) as TState; } } return state; @@ -104,6 +108,10 @@ mixin PreservableAutoDisposeNotifierMixin< settings: Preservable(original: newSettings, current: newSettings), status: newStatus ?? state.status, ) as TState; + } else if (newStatus != null) { + // Settings unavailable but status returned (e.g. error) β€” apply status + // so the UI can exit the loading state and display the error. + state = state.copyWith(status: newStatus) as TState; } } return state; diff --git a/lib/usp/models/usp_response.dart b/lib/usp/models/usp_response.dart deleted file mode 100644 index dda43b0a4..000000000 --- a/lib/usp/models/usp_response.dart +++ /dev/null @@ -1,20 +0,0 @@ -/// Unified response wrapper for all USP operations. -/// -/// Wraps the operation-specific [data] result with optional protocol-level -/// metadata. Currently only [commandKey] is populated (for Operate responses), -/// but additional fields can be added as USP integration evolves. -class UspResponse { - /// The operation result data. - final T data; - - /// UUID correlator assigned by the USP agent. - /// - /// Only present for Operate responses β€” used to correlate with - /// OperationComplete SSE events. `null` for GET/SET/ADD/DELETE. - final String? commandKey; - - const UspResponse({required this.data, this.commandKey}); - - @override - String toString() => 'UspResponse(commandKey=$commandKey, data=$data)'; -} diff --git a/lib/usp/providers/sse_providers.dart b/lib/usp/providers/sse_providers.dart index d73886881..d9e144540 100644 --- a/lib/usp/providers/sse_providers.dart +++ b/lib/usp/providers/sse_providers.dart @@ -80,38 +80,29 @@ final sseBootstrapProvider = FutureProvider((ref) async { final usp = ref.watch(uspServiceProvider); if (usp == null || !usp.isAuthenticated) return; - // Step 1: Purge stale OBUSPA subscriptions from previous sessions. - // Browser refresh doesn't trigger dispose(), so old subscriptions - // accumulate on the router and cause duplicate SSE notifications. + final bridge = ref.watch(uspBridgeClientProvider); + if (bridge == null) return; + + // Step 0: Health check β€” best-effort, non-fatal. + // If the bridge is busy (504) or slow, we still attempt SSE connection + // because SseConnectionManager has its own retry/backoff logic. try { - final purged = await usp.purgeAllSubscriptions(); - if (purged > 0) { - logger.d('[SSE Bootstrap] Purged $purged stale OBUSPA subscriptions'); - } + await bridge.health().timeout(const Duration(seconds: 5)); + logger.d('[USP][SSE][Bootstrap]Bridge health check passed'); } catch (e) { - logger.w('[SSE Bootstrap] Failed to purge stale subscriptions: $e'); + logger + .w('[USP][SSE][Bootstrap]Bridge health check failed: $e β€” continuing'); } - // Step 2: Connect SSE - await manager.connect(); - - // Step 3: Register core subscriptions (non-fatal on failure) - // - // Subscriptions auto-generated from YAML subscribe: blocks. + // Step 1: Set core subscriptions β€” they will be registered from the + // onConnected callback after the first heartbeat, reducing the HTTP + // request burst on the embedded router during bootstrap. // See subscriptions.g.dart (generated by usp-codegen). - for (final (id, notifType, path) in coreSubscriptions) { - try { - await manager.registry.register( - subscriptionId: id, - notifType: notifType, - referenceList: path, - ); - } catch (e) { - logger.w('[SSE Bootstrap] Failed to register $id: $e'); - // Non-fatal: SSE is additive, app continues with polling - } - } + manager.setCoreSubscriptions(coreSubscriptions); + + // Step 2: Connect SSE β€” subscriptions fire from onConnected callback + await manager.connect(); - logger.d('[SSE Bootstrap] Complete β€” ' - '${manager.registry.activeIds.length} core subscriptions registered'); + logger.d('[USP][SSE][Bootstrap]Complete β€” SSE connected, ' + 'core subscriptions will register on first heartbeat'); }); diff --git a/lib/usp/providers/usp_auth_coordinator.dart b/lib/usp/providers/usp_auth_coordinator.dart index 9931d576b..14641ce9b 100644 --- a/lib/usp/providers/usp_auth_coordinator.dart +++ b/lib/usp/providers/usp_auth_coordinator.dart @@ -31,11 +31,11 @@ class UspAuthCoordinator { if (_usp == null) return; try { await _usp.login(password); - logger.d('[UspAuth] USP login synced successfully'); + logger.d('[USP][Auth]USP login synced successfully'); } catch (e) { // USP login failure does not affect JNAP β€” ProtocolResolver // will fall back to JNAP when isAuthenticated=false - logger.w('[UspAuth] USP login failed after JNAP login: $e'); + logger.w('[USP][Auth]USP login failed after JNAP login: $e'); } } @@ -44,9 +44,9 @@ class UspAuthCoordinator { if (_usp == null || !_usp.isAuthenticated) return; try { await _usp.logout(); - logger.d('[UspAuth] USP logout synced successfully'); + logger.d('[USP][Auth]USP logout synced successfully'); } catch (e) { - logger.w('[UspAuth] USP logout failed: $e'); + logger.w('[USP][Auth]USP logout failed: $e'); } } @@ -56,24 +56,24 @@ class UspAuthCoordinator { /// stored password from FlutterSecureStorage and re-authenticates USP. Future restoreSession() async { if (_usp == null) { - logger.w('[UspAuth] restoreSession skipped: UspService is null'); + logger.w('[USP][Auth]restoreSession skipped: UspService is null'); return; } if (_usp.isAuthenticated) { - logger.d('[UspAuth] restoreSession skipped: already authenticated'); + logger.d('[USP][Auth]restoreSession skipped: already authenticated'); return; } final password = await _storage.read(key: pLocalPassword); if (password == null || password.isEmpty) { - logger.w('[UspAuth] restoreSession skipped: no stored password'); + logger.w('[USP][Auth]restoreSession skipped: no stored password'); return; } try { await _usp.login(password); logger.d( - '[UspAuth] restoreSession login done, isAuthenticated=${_usp.isAuthenticated}'); + '[USP][Auth]restoreSession login done, isAuthenticated=${_usp.isAuthenticated}'); } catch (e) { - logger.w('[UspAuth] restoreSession login failed: $e'); + logger.w('[USP][Auth]restoreSession login failed: $e'); } } @@ -83,18 +83,18 @@ class UspAuthCoordinator { /// Returns true if USP login succeeds and is authenticated. Future tryUspLogin(String password) async { if (_usp == null) { - logger.w('[UspAuth] tryUspLogin skipped: UspService is null'); + logger.w('[USP][Auth]tryUspLogin skipped: UspService is null'); return false; } try { await _usp.login(password); final authenticated = _usp.isAuthenticated; if (authenticated) { - logger.d('[UspAuth] USP standalone login succeeded'); + logger.d('[USP][Auth]USP standalone login succeeded'); } return authenticated; } catch (e) { - logger.w('[UspAuth] USP standalone login failed: $e'); + logger.w('[USP][Auth]USP standalone login failed: $e'); return false; } } @@ -105,7 +105,7 @@ class UspAuthCoordinator { try { await _usp.refreshToken(); } catch (e) { - logger.w('[UspAuth] USP token refresh failed, attempting restore: $e'); + logger.w('[USP][Auth]USP token refresh failed, attempting restore: $e'); await restoreSession(); } } diff --git a/lib/usp/services/sse_connection_manager.dart b/lib/usp/services/sse_connection_manager.dart index e2df2101c..08a854d34 100644 --- a/lib/usp/services/sse_connection_manager.dart +++ b/lib/usp/services/sse_connection_manager.dart @@ -52,6 +52,7 @@ class SseConnectionManager { StreamSubscription? _sseSubscription; Timer? _heartbeatWatchdog; + Completer? _connectInProgress; int _reconnectAttempt = 0; bool _disposed = false; bool _intentionalDisconnect = false; @@ -74,27 +75,41 @@ class SseConnectionManager { // Connect / Disconnect // ══════════════════════════════════════════════════════════════════════════ - /// Opens the SSE connection. Safe to call multiple times β€” disconnects - /// any existing connection first. + /// Opens the SSE connection. Safe to call multiple times β€” if a connect + /// is already in progress, subsequent calls await the existing attempt. Future connect() async { if (_disposed) return; - await _cancelExistingStream(); - _intentionalDisconnect = false; - connectionState.value = SseConnectionState.connecting; - - logger.d('[SSE] Connecting...'); + // Lock: if connect is already in progress, await it and return. + if (_connectInProgress != null) { + logger.d('[USP][SSE]connect() already in progress β€” awaiting'); + await _connectInProgress!.future; + return; + } + _connectInProgress = Completer(); try { + await _cancelExistingStream(); + _intentionalDisconnect = false; + connectionState.value = SseConnectionState.connecting; + + logger.d('[USP][SSE]Connecting...'); + final stream = _bridge.notifications(); _sseSubscription = stream.listen( _onEvent, onError: _onError, onDone: _onDone, ); + _connectInProgress!.complete(); } catch (e) { - logger.w('[SSE] Failed to open stream: $e'); + logger.w('[USP][SSE]Failed to open stream: $e'); + if (!_connectInProgress!.isCompleted) { + _connectInProgress!.completeError(e); + } _scheduleReconnect(); + } finally { + _connectInProgress = null; } } @@ -110,7 +125,7 @@ class SseConnectionManager { onDisconnected?.call(); } connectionState.value = SseConnectionState.disconnected; - logger.d('[SSE] Disconnected (intentional)'); + logger.d('[USP][SSE]Disconnected (intentional)'); } /// Attempts to reconnect from [SseConnectionState.suspended] or @@ -125,7 +140,7 @@ class SseConnectionManager { state == SseConnectionState.reconnecting) { return false; } - logger.d('[SSE] Manual reconnect requested (was: ${state.name})'); + logger.d('[USP][SSE]Manual reconnect requested (was: ${state.name})'); _reconnectAttempt = 0; await connect(); return true; @@ -156,7 +171,7 @@ class SseConnectionManager { connectionState.value != SseConnectionState.connected; connectionState.value = SseConnectionState.connected; _reconnectAttempt = 0; - logger.d('[SSE] Connected (event: ${event.event})'); + logger.d('[USP][SSE]Connected (event: ${event.event})'); if (wasDisconnected) { onConnected?.call(); } @@ -164,7 +179,7 @@ class SseConnectionManager { // Skip debug events from UspBridgeClient internal diagnostics if (event.event == '_debug') { - logger.d('[SSE] debug: ${event.data}'); + logger.d('[USP][SSE]debug: ${event.data}'); return; } @@ -173,12 +188,12 @@ class SseConnectionManager { } void _onError(Object error) { - logger.w('[SSE] Stream error: $error'); + logger.w('[USP][SSE]Stream error: $error'); _handleStreamEnd(); } void _onDone() { - logger.d('[SSE] Stream done (server closed connection)'); + logger.d('[USP][SSE]Stream done (server closed connection)'); _handleStreamEnd(); } @@ -204,7 +219,7 @@ class SseConnectionManager { void _resetHeartbeatWatchdog() { _heartbeatWatchdog?.cancel(); _heartbeatWatchdog = Timer(_heartbeatTimeout, () { - logger.w('[SSE] Heartbeat timeout (${_heartbeatTimeout.inSeconds}s) ' + logger.w('[USP][SSE]Heartbeat timeout (${_heartbeatTimeout.inSeconds}s) ' 'β€” connection may be stale'); _sseSubscription?.cancel(); _handleStreamEnd(); @@ -228,7 +243,7 @@ class SseConnectionManager { if (_reconnectAttempt > _maxRetries) { connectionState.value = SseConnectionState.suspended; - logger.w('[SSE] Max retries ($_maxRetries) reached β€” suspended. ' + logger.w('[USP][SSE]Max retries ($_maxRetries) reached β€” suspended. ' 'Call tryReconnect() or wait for lifecycle resume.'); return; } @@ -236,11 +251,11 @@ class SseConnectionManager { connectionState.value = SseConnectionState.reconnecting; final delay = _nextBackoff; - logger.d('[SSE] Reconnecting in ${delay.inSeconds}s ' + logger.d('[USP][SSE]Reconnecting in ${delay.inSeconds}s ' '(attempt #$_reconnectAttempt/$_maxRetries)'); Timer(delay, () { - if (!_disposed && !_intentionalDisconnect) { + if (!_disposed && !_intentionalDisconnect && _connectInProgress == null) { connect(); } }); diff --git a/lib/usp/services/sse_event_router.dart b/lib/usp/services/sse_event_router.dart index 14dfd08f3..e0eaa454a 100644 --- a/lib/usp/services/sse_event_router.dart +++ b/lib/usp/services/sse_event_router.dart @@ -62,10 +62,10 @@ class SseEventRouter { break; case 'turbo_channel': // Future: route to turbo channel coordinator - logger.d('[SSE Router] turbo_channel event: ${event.data}'); + logger.d('[USP][SSE][Router]turbo_channel event: ${event.data}'); break; default: - logger.d('[SSE Router] Unknown event type: ${event.event}'); + logger.d('[USP][SSE][Router]Unknown event type: ${event.event}'); break; } } @@ -75,7 +75,7 @@ class SseEventRouter { try { json = jsonDecode(event.data) as Map; } catch (e) { - logger.w('[SSE Router] Failed to parse notification JSON: $e'); + logger.w('[USP][SSE][Router]Failed to parse notification JSON: $e'); return; } @@ -83,8 +83,9 @@ class SseEventRouter { final type = json['type'] as String?; if (subscriptionId == null || type == null) { - logger.w('[SSE Router] Notification missing subscription_id or type: ' - '${event.data}'); + logger + .w('[USP][SSE][Router]Notification missing subscription_id or type: ' + '${event.data}'); return; } @@ -94,7 +95,7 @@ class SseEventRouter { payload: json, ); - logger.d('[SSE Router] Routing: $notification'); + logger.d('[USP][SSE][Router]Routing: $notification'); // Route to subscription-specific handlers final handlers = _handlers[subscriptionId]; @@ -103,7 +104,7 @@ class SseEventRouter { try { handler(notification); } catch (e) { - logger.w('[SSE Router] Handler error for $subscriptionId: $e'); + logger.w('[USP][SSE][Router]Handler error for $subscriptionId: $e'); } } } @@ -113,7 +114,7 @@ class SseEventRouter { try { handler(notification); } catch (e) { - logger.w('[SSE Router] Wildcard handler error: $e'); + logger.w('[USP][SSE][Router]Wildcard handler error: $e'); } } } diff --git a/lib/usp/services/sse_manager.dart b/lib/usp/services/sse_manager.dart index bb031649e..33ed30e69 100644 --- a/lib/usp/services/sse_manager.dart +++ b/lib/usp/services/sse_manager.dart @@ -6,6 +6,7 @@ import 'package:privacy_gui/core/utils/logger.dart'; import 'sse_connection_manager.dart'; import 'sse_event_router.dart'; import 'sse_subscription_registry.dart'; +import 'sse_unload_handler.dart'; import 'usp_bridge_client.dart'; import 'usp_service.dart'; @@ -35,33 +36,51 @@ import 'usp_service.dart'; /// ``` class SseManager { final UspService _usp; + final UspBridgeClient _bridge; final SseConnectionManager connection; final SseSubscriptionRegistry registry; final SseEventRouter router; + final SseUnloadHandler _unloadHandler = SseUnloadHandler(); + List<(String id, String notifType, String referenceList)> _coreSubscriptions = + []; + bool _coreSubsDeferred = false; SseManager({ required UspService usp, required UspBridgeClient bridge, }) : _usp = usp, + _bridge = bridge, connection = SseConnectionManager(bridge), registry = SseSubscriptionRegistry(bridge), router = SseEventRouter() { // Wire connection events to router connection.onEvent = router.routeEvent; - // Wire reconnection to bridge-only re-registration + // On connect (first or reconnect): register/re-register subscriptions. + // First connect: registers core subscriptions set via setCoreSubscriptions(). + // Reconnect: re-registers existing subscriptions on the bridge. connection.onConnected = () { - logger - .d('[SseManager] Connected β€” re-registering subscriptions on bridge'); - registry.resubscribeAll(); + logger.d('[USP][SSE]Connected β€” registering/re-registering ' + 'subscriptions on bridge'); + _registerOrResubscribe(); }; connection.onDisconnected = () { - logger.d('[SseManager] Disconnected'); + logger.d('[USP][SSE]Disconnected'); }; // Inject SSE delegate so codegen subscribe() routes through SSE _usp.onSseSubscribe = _handleSseSubscribe; + + // Register browser unload handler to abort SSE on page refresh/close. + // abortSse() is synchronous β€” critical because `beforeunload` does NOT + // wait for async operations. disconnect() is best-effort async cleanup. + _unloadHandler.onUnload = () { + logger.d('[USP][SSE]Page unload β€” aborting SSE'); + _bridge.abortSse(); + connection.disconnect(); + }; + _unloadHandler.register(); } /// Registers a subscription and adds a notification handler in one call. @@ -154,6 +173,62 @@ class SseManager { } } + /// Sets the core subscriptions to register when SSE first connects. + /// + /// Called once from [sseBootstrapProvider]. Subscriptions are registered + /// from the [onConnected] callback after the first heartbeat, rather than + /// during bootstrap, to reduce the HTTP request burst on the bridge. + void setCoreSubscriptions(List<(String, String, String)> subscriptions) { + _coreSubscriptions = subscriptions; + } + + /// Handles subscription registration on SSE connect/reconnect. + /// + /// - Reconnect (registry has entries): re-registers immediately + /// - First connect (registry empty): **defers** registration to avoid + /// competing with dashboard HTTP requests for the browser's 6-connection + /// HTTP/1.1 pool. Call [registerDeferredSubscriptions] after dashboard + /// initial load completes. + Future _registerOrResubscribe() async { + if (registry.activeIds.isNotEmpty) { + // Reconnect path: re-register existing subscriptions on bridge + await registry.resubscribeAll(); + } else if (_coreSubscriptions.isNotEmpty) { + // First connect: defer to avoid HTTP/1.1 connection contention + _coreSubsDeferred = true; + logger.d('[USP][SSE]Core subscriptions deferred β€” ' + 'waiting for dashboard load to complete'); + } + } + + /// Registers deferred core subscriptions. + /// + /// Call after dashboard initial load completes to avoid HTTP/1.1 + /// connection pool contention (browser limits 6 connections per host). + /// + /// Pass [force] = true to skip the deferral check and register immediately. + /// Used for the post-reload bootstrap where SSE onConnected hasn't fired yet. + Future registerDeferredSubscriptions({bool force = false}) async { + if (!force && !_coreSubsDeferred) return; + _coreSubsDeferred = false; + + for (final (id, notifType, referenceList) in _coreSubscriptions) { + try { + await registry.register( + subscriptionId: id, + notifType: notifType, + referenceList: referenceList, + ); + // Small breathing room for embedded router between requests + await Future.delayed(const Duration(milliseconds: 50)); + } catch (e) { + logger.w('[USP][SSE]Failed to register core sub $id: $e'); + } + } + logger.d('[USP][SSE]Registered ${registry.activeIds.length} ' + 'core subscriptions (deferred)'); + } + /// Starts the SSE connection. Future connect() => connection.connect(); @@ -170,6 +245,7 @@ class SseManager { /// Clean shutdown: disconnect SSE, unregister all subscriptions, dispose. Future dispose() async { + _unloadHandler.unregister(); _usp.onSseSubscribe = null; await connection.disconnect(); await registry.unregisterAll(); diff --git a/lib/usp/services/sse_operation_awaiter.dart b/lib/usp/services/sse_operation_awaiter.dart index 2a1193fc3..722e40174 100644 --- a/lib/usp/services/sse_operation_awaiter.dart +++ b/lib/usp/services/sse_operation_awaiter.dart @@ -87,9 +87,9 @@ class SseOperationAwaiter { // Step 2: Fire the operate command and capture commandKey for correlation final operateResponse = await _usp.operate(operateCommand, args: args); - final expectedKey = operateResponse.commandKey; + final expectedKey = operateResponse['commandKey'] as String?; - logger.d('[SSE Operate] Starting $operateCommand ' + logger.d('[USP][SSE][Operate]Starting $operateCommand ' '(sub=$subscriptionId, commandKey=$expectedKey)'); // Step 3: Wildcard handler matches by commandKey (primary) or @@ -105,7 +105,8 @@ class SseOperationAwaiter { : result.commandName == expectedCmd; if (matched) { - logger.d('[SSE Operate] Matched OperationComplete for $expectedCmd ' + logger.d( + '[USP][SSE][Operate]Matched OperationComplete for $expectedCmd ' '(commandKey=${result.commandKey}, ' 'sub=${notification.subscriptionId})'); completer.complete(result); @@ -122,7 +123,8 @@ class SseOperationAwaiter { ), ); - logger.d('[SSE Operate] Completed $operateCommand: ${result.status}'); + logger + .d('[USP][SSE][Operate]Completed $operateCommand: ${result.status}'); return result; } finally { // Always cleanup: wildcard handler + subscription @@ -131,7 +133,7 @@ class SseOperationAwaiter { try { await cleanupSubscription(); } catch (e) { - logger.w('[SSE Operate] Cleanup failed for $subscriptionId: $e'); + logger.w('[USP][SSE][Operate]Cleanup failed for $subscriptionId: $e'); } } } @@ -154,7 +156,7 @@ class SseOperationAwaiter { Map args, Duration timeout, ) async { - logger.d('[SSE Operate] SSE disconnected, using polling fallback ' + logger.d('[USP][SSE][Operate]SSE disconnected, using polling fallback ' 'for $operateCommand'); // Fire the operate command @@ -198,7 +200,7 @@ class SseOperationAwaiter { } } } catch (e) { - logger.w('[SSE Operate] Poll error: $e'); + logger.w('[USP][SSE][Operate]Poll error: $e'); } } diff --git a/lib/usp/services/sse_subscription_registry.dart b/lib/usp/services/sse_subscription_registry.dart index e16ab131d..826bc49cd 100644 --- a/lib/usp/services/sse_subscription_registry.dart +++ b/lib/usp/services/sse_subscription_registry.dart @@ -40,12 +40,13 @@ class SseSubscriptionRegistry { }) async { // Check for duplicate if (_subscriptions.containsKey(subscriptionId)) { - logger.d('[SSE Registry] Subscription $subscriptionId already exists, ' - 'skipping duplicate registration'); + logger + .d('[USP][SSE][Registry]Subscription $subscriptionId already exists, ' + 'skipping duplicate registration'); return _subscriptions[subscriptionId]!; } - logger.d('[SSE Registry] Registering $subscriptionId ' + logger.d('[USP][SSE][Registry]Registering $subscriptionId ' '(type=$notifType, ref=$referenceList)'); await _bridge.subscribe( @@ -62,7 +63,7 @@ class SseSubscriptionRegistry { ); _subscriptions[subscriptionId] = record; - logger.d('[SSE Registry] Registered $subscriptionId'); + logger.d('[USP][SSE][Registry]Registered $subscriptionId'); return record; } @@ -72,17 +73,17 @@ class SseSubscriptionRegistry { Future unregister(String subscriptionId) async { final record = _subscriptions.remove(subscriptionId); if (record == null) { - logger - .d('[SSE Registry] Unregister $subscriptionId: not found, skipping'); + logger.d( + '[USP][SSE][Registry]Unregister $subscriptionId: not found, skipping'); return; } - logger.d('[SSE Registry] Unregistering $subscriptionId'); + logger.d('[USP][SSE][Registry]Unregistering $subscriptionId'); try { await _bridge.unsubscribe(subscriptionId: subscriptionId); } catch (e) { - logger.w('[SSE Registry] Bridge unsubscribe failed for ' + logger.w('[USP][SSE][Registry]Bridge unsubscribe failed for ' '$subscriptionId: $e'); } } @@ -94,12 +95,12 @@ class SseSubscriptionRegistry { /// previous session. Future resubscribeAll() async { if (_subscriptions.isEmpty) { - logger - .d('[SSE Registry] resubscribeAll: no subscriptions to re-register'); + logger.d( + '[USP][SSE][Registry]resubscribeAll: no subscriptions to re-register'); return; } - logger.d('[SSE Registry] Re-registering ${_subscriptions.length} ' + logger.d('[USP][SSE][Registry]Re-registering ${_subscriptions.length} ' 'subscriptions on bridge'); for (final record in _subscriptions.values) { @@ -109,9 +110,9 @@ class SseSubscriptionRegistry { path: record.referenceList, notifType: _notifTypeToInt(record.notifType), ); - logger.d('[SSE Registry] Re-registered ${record.subscriptionId}'); + logger.d('[USP][SSE][Registry]Re-registered ${record.subscriptionId}'); } catch (e) { - logger.w('[SSE Registry] Failed to re-register ' + logger.w('[USP][SSE][Registry]Failed to re-register ' '${record.subscriptionId}: $e'); } } diff --git a/lib/usp/services/sse_unload_handler.dart b/lib/usp/services/sse_unload_handler.dart new file mode 100644 index 000000000..b131e9561 --- /dev/null +++ b/lib/usp/services/sse_unload_handler.dart @@ -0,0 +1,2 @@ +export 'sse_unload_handler_base.dart' + if (dart.library.js_interop) 'sse_unload_handler_web.dart'; diff --git a/lib/usp/services/sse_unload_handler_base.dart b/lib/usp/services/sse_unload_handler_base.dart new file mode 100644 index 000000000..ec996d899 --- /dev/null +++ b/lib/usp/services/sse_unload_handler_base.dart @@ -0,0 +1,12 @@ +import 'dart:ui'; + +/// No-op stub for non-Web platforms (Dart VM / tests). +/// +/// Selected by conditional export when `dart.library.js_interop` is unavailable. +class SseUnloadHandler { + VoidCallback? onUnload; + + void register() {} + + void unregister() {} +} diff --git a/lib/usp/services/sse_unload_handler_web.dart b/lib/usp/services/sse_unload_handler_web.dart new file mode 100644 index 000000000..7a468c80a --- /dev/null +++ b/lib/usp/services/sse_unload_handler_web.dart @@ -0,0 +1,38 @@ +import 'dart:js_interop'; +import 'dart:ui'; + +import 'package:web/web.dart' as web; + +/// Registers browser `beforeunload` and `pagehide` listeners to trigger +/// SSE cleanup when the page is refreshed or closed. +/// +/// - `beforeunload`: reliable on desktop browsers +/// - `pagehide`: reliable on mobile browsers (Safari/iOS) where +/// `beforeunload` may not fire +class SseUnloadHandler { + VoidCallback? onUnload; + JSFunction? _beforeUnloadJs; + JSFunction? _pageHideJs; + + void register() { + _beforeUnloadJs = ((web.Event event) { + onUnload?.call(); + }).toJS; + _pageHideJs = ((web.Event event) { + onUnload?.call(); + }).toJS; + web.window.addEventListener('beforeunload', _beforeUnloadJs!); + web.window.addEventListener('pagehide', _pageHideJs!); + } + + void unregister() { + if (_beforeUnloadJs != null) { + web.window.removeEventListener('beforeunload', _beforeUnloadJs!); + _beforeUnloadJs = null; + } + if (_pageHideJs != null) { + web.window.removeEventListener('pagehide', _pageHideJs!); + _pageHideJs = null; + } + } +} diff --git a/lib/usp/services/usp_bridge_client_base.dart b/lib/usp/services/usp_bridge_client_base.dart index c7996ab63..8c6339e5d 100644 --- a/lib/usp/services/usp_bridge_client_base.dart +++ b/lib/usp/services/usp_bridge_client_base.dart @@ -40,6 +40,9 @@ class UspBridgeClient { Future> turboRelease() => throw UnsupportedError('UspBridgeClient is only available on Web'); + + /// Synchronously abort the active SSE stream. No-op on non-Web platforms. + void abortSse() {} } /// A parsed Server-Sent Event. diff --git a/lib/usp/services/usp_bridge_client_web.dart b/lib/usp/services/usp_bridge_client_web.dart index 881a158b3..737994e77 100644 --- a/lib/usp/services/usp_bridge_client_web.dart +++ b/lib/usp/services/usp_bridge_client_web.dart @@ -20,6 +20,10 @@ class UspBridgeClient { UspBridgeClient(this._usp); + /// Active SSE AbortController β€” stored so [abortSse] can cancel + /// synchronously from a `beforeunload` handler. + web.AbortController? _sseAbortController; + String get _baseUrl => _usp.baseUrl; String get _token { @@ -72,21 +76,31 @@ class UspBridgeClient { // SSE Notifications // ══════════════════════════════════════════════════════════════════════════ + /// Synchronously abort the active SSE Fetch stream. + /// + /// Called from `beforeunload` / `pagehide` handlers where only synchronous + /// code is guaranteed to execute. This ensures the browser's TCP socket to + /// lighttpd is released immediately on page refresh/close. + void abortSse() { + _sseAbortController?.abort(); + _sseAbortController = null; + } + /// Opens an SSE connection to GET /api/v1/notifications. /// /// Browser-native EventSource does not support custom headers, so we use /// the Fetch API with ReadableStream to parse the text/event-stream. Stream notifications() { final controller = StreamController(); - web.AbortController? abortController; controller.onListen = () { - abortController = web.AbortController(); - _startSseStream(controller, abortController!); + _sseAbortController = web.AbortController(); + _startSseStream(controller, _sseAbortController!); }; controller.onCancel = () { - abortController?.abort(); + _sseAbortController?.abort(); + _sseAbortController = null; }; return controller.stream; @@ -115,8 +129,9 @@ class UspBridgeClient { Future _startSseStream( StreamController controller, - web.AbortController abortController, - ) async { + web.AbortController abortController, { + int authRetryCount = 0, + }) async { void debug(String msg) { if (!controller.isClosed) { controller.add(SseEvent(event: '_debug', data: msg)); @@ -147,11 +162,18 @@ class UspBridgeClient { if (!response.ok) { if (response.status == 401) { + if (authRetryCount >= 1) { + debug('401 retry limit reached (max 1 retry)'); + controller.addError('SSE 401 after reauth retry'); + await controller.close(); + return; + } debug('401 detected, attempting reauth and reconnect...'); try { await _usp.reauth(); debug('Reauth succeeded, reconnecting SSE...'); - await _startSseStream(controller, abortController); + await _startSseStream(controller, abortController, + authRetryCount: authRetryCount + 1); } catch (e) { debug('Reauth failed: $e'); controller.addError('SSE 401 reauth failed: $e'); diff --git a/lib/usp/services/usp_service.dart b/lib/usp/services/usp_service.dart index 54ab6e730..cd7801d6a 100644 --- a/lib/usp/services/usp_service.dart +++ b/lib/usp/services/usp_service.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:privacy_gui/core/utils/logger.dart'; -import 'package:privacy_gui/usp/models/usp_response.dart'; // Conditional import: use WASM client on Web, stub on other platforms (VM/tests). import '../stub/usp_client_stub.dart' @@ -123,17 +122,17 @@ class UspService { // Stage 1: quick token refresh (no password needed) try { await refreshToken(); - logger.d('[UspService]:Token refreshed successfully'); + logger.d('[USP][Service]Token refreshed successfully'); _reauthInProgress!.complete(); return; } catch (e) { - logger.w('[UspService]:Token refresh failed: $e'); + logger.w('[USP][Service]Token refresh failed: $e'); } // Stage 2: full re-login via stored password final reauth = onReauthRequired; if (reauth != null) { await reauth(); - logger.d('[UspService]:Full re-login succeeded'); + logger.d('[USP][Service]Full re-login succeeded'); } _reauthInProgress!.complete(); } catch (e) { @@ -152,7 +151,7 @@ class UspService { return await action(); } catch (e) { if (!_isAuthError(e)) rethrow; - logger.w('[UspService]:401 detected, attempting reauth...'); + logger.w('[USP][Service]401 detected, attempting reauth...'); await reauth(); return await action(); } @@ -177,12 +176,12 @@ class UspService { final rawMap = await _withAuthRetry(() => _client.getMultiple(paths)); sw.stop(); - logger.d('[UspService]:#$id GET ${_pathSummary(paths)} ' + logger.d('[USP][Service]#$id GET ${_pathSummary(paths)} ' '${paths.length} paths β†’ ${rawMap.length} keys (${sw.elapsedMilliseconds}ms)'); if (rawMap.isEmpty) { - logger.w('[UspService]:#$id GET response EMPTY for paths: $paths'); + logger.w('[USP][Service]#$id GET response EMPTY for paths: $paths'); } else { - logger.d('[UspService]:#$id ← ${_mapSummary(rawMap)}'); + logger.d('[USP][Service]#$id ← ${_mapSummary(rawMap)}'); } final Map result = {}; @@ -199,7 +198,7 @@ class UspService { for (final path in paths) { if (path.contains('*')) continue; if (!result.containsKey(path)) { - logger.w('[UspService]:GET missing path in response: "$path"'); + logger.w('[USP][Service]GET missing path in response: "$path"'); } result.putIfAbsent(path, () => null); } @@ -243,7 +242,7 @@ class UspService { await _withAuthRetry( () => _client.setMultiple(stringParams, allowPartial: allowPartial)); sw.stop(); - logger.d('[UspService]:#$id SET ${_paramSummary(parameters)} ' + logger.d('[USP][Service]#$id SET ${_paramSummary(parameters)} ' '${parameters.length} params' '${allowPartial ? ' (allowPartial)' : ''} (${sw.elapsedMilliseconds}ms)'); } @@ -277,7 +276,7 @@ class UspService { sw.stop(); final shortPath = objectPath.startsWith('Device.') ? objectPath.substring(7) : objectPath; - logger.d('[UspService]:#$id ADD $shortPath β€” ' + logger.d('[USP][Service]#$id ADD $shortPath β€” ' '${parameters.length} params β†’ $result (${sw.elapsedMilliseconds}ms)'); return result; } @@ -296,7 +295,7 @@ class UspService { final result = await _withAuthRetry( () => _client.addMultiple(objects, allowPartial: allowPartial)); sw.stop(); - logger.d('[UspService]:#$id ADD_MULTI ${objects.length} objects ' + logger.d('[USP][Service]#$id ADD_MULTI ${objects.length} objects ' 'β†’ ${result.length} created' '${allowPartial ? ' (allowPartial)' : ''} (${sw.elapsedMilliseconds}ms)'); return result; @@ -315,8 +314,8 @@ class UspService { await _withAuthRetry(() => _client.delete(path)); sw.stop(); final shortPath = path.startsWith('Device.') ? path.substring(7) : path; - logger - .d('[UspService]:#$id DELETE $shortPath (${sw.elapsedMilliseconds}ms)'); + logger.d( + '[USP][Service]#$id DELETE $shortPath (${sw.elapsedMilliseconds}ms)'); } /// Deletes multiple object instances in a single operation. @@ -327,7 +326,7 @@ class UspService { await _withAuthRetry( () => _client.deleteMultiple(paths, allowPartial: allowPartial)); sw.stop(); - logger.d('[UspService]:#$id DELETE_MULTI ${_pathSummary(paths)} ' + logger.d('[USP][Service]#$id DELETE_MULTI ${_pathSummary(paths)} ' '${paths.length} paths' '${allowPartial ? ' (allowPartial)' : ''} (${sw.elapsedMilliseconds}ms)'); } @@ -341,20 +340,21 @@ class UspService { /// [command] is the command path (e.g., "Device.Reboot()" or /// "Device.IP.Diagnostics.Ping()"). /// [args] are the input arguments for the command. - /// Returns [UspResponse] with commandKey (for SSE correlation) and output arguments. - Future>> operate(String command, + /// Returns a flat map containing `commandKey` (for SSE correlation) and + /// all output arguments from the Operate response. + Future> operate(String command, {Map args = const {}}) async { final id = ++_reqId; final sw = Stopwatch()..start(); final response = await _withAuthRetry(() => _client.operate(command, args: args)); sw.stop(); - logger.d('[UspService]:#$id OPERATE $command' + logger.d('[USP][Service]#$id OPERATE $command' '${args.isNotEmpty ? ' β€” ${args.length} args' : ''}' - ' β†’ key=${response.commandKey}, ${response.data.length} output keys' + ' β†’ key=${response['commandKey']}, ${response.length} output keys' ' (${sw.elapsedMilliseconds}ms)'); - if (response.data.isNotEmpty) { - logger.d('[UspService]:#$id ← ${_mapSummary(response.data)}'); + if (response.isNotEmpty) { + logger.d('[USP][Service]#$id ← ${_mapSummary(response)}'); } return response; } @@ -445,7 +445,7 @@ class UspService { sw.stop(); final recipient = verify['${instancePath}Recipient'] ?? ''; - logger.d('[UspService]:#$id CREATE_SUBSCRIPTION $instancePath ' + logger.d('[USP][Service]#$id CREATE_SUBSCRIPTION $instancePath ' 'type=$notifType ref=$referenceList β†’ Recipient=$recipient ' '(${sw.elapsedMilliseconds}ms)'); @@ -464,7 +464,7 @@ class UspService { final shortPath = instancePath.startsWith('Device.') ? instancePath.substring(7) : instancePath; - logger.d('[UspService]:#$id DELETE_SUBSCRIPTION $shortPath ' + logger.d('[USP][Service]#$id DELETE_SUBSCRIPTION $shortPath ' '(${sw.elapsedMilliseconds}ms)'); } @@ -477,7 +477,7 @@ class UspService { final sw = Stopwatch()..start(); final subs = await _withAuthRetry(() => _client.listSubscriptions()); sw.stop(); - logger.d('[UspService]:#$id LIST_SUBSCRIPTIONS β†’ ${subs.length} entries ' + logger.d('[USP][Service]#$id LIST_SUBSCRIPTIONS β†’ ${subs.length} entries ' '(${sw.elapsedMilliseconds}ms)'); return subs; } @@ -515,7 +515,7 @@ class UspService { if (instanceIds.isEmpty) { sw.stop(); - logger.d('[UspService]:#$id PURGE_SUBSCRIPTIONS β†’ 0 (none found, ' + logger.d('[USP][Service]#$id PURGE_SUBSCRIPTIONS β†’ 0 (none found, ' '${sw.elapsedMilliseconds}ms)'); return 0; } @@ -525,7 +525,7 @@ class UspService { final prefix = '$objectPath$instId.'; final notifType = allParams['${prefix}NotifType'] ?? '?'; final refList = allParams['${prefix}ReferenceList'] ?? '?'; - logger.d('[UspService]:#$id PURGE: $prefix ' + logger.d('[USP][Service]#$id PURGE: $prefix ' '(type=$notifType, ref=$refList)'); } @@ -536,13 +536,13 @@ class UspService { await _withAuthRetry(() => _client.delete(instancePath)); deleted++; } catch (e) { - logger.w('[UspService]:#$id PURGE failed to delete ' + logger.w('[USP][Service]#$id PURGE failed to delete ' '$instancePath: $e'); } } sw.stop(); - logger.d('[UspService]:#$id PURGE_SUBSCRIPTIONS β†’ deleted $deleted/' + logger.d('[USP][Service]#$id PURGE_SUBSCRIPTIONS β†’ deleted $deleted/' '${instanceIds.length} (${sw.elapsedMilliseconds}ms)'); return deleted; } @@ -601,7 +601,8 @@ class UspService { controller.add(parsed); } } catch (e) { - logger.w('[UspService]:SSE subscribe re-fetch error for "$id": $e'); + logger + .w('[USP][Service]SSE subscribe re-fetch error for "$id": $e'); } }); }, @@ -615,7 +616,7 @@ class UspService { controller.add(parsed); } } catch (e) { - logger.w('[UspService]:SSE subscribe initial fetch error for "$id": $e'); + logger.w('[USP][Service]SSE subscribe initial fetch error for "$id": $e'); } return Subscription( @@ -652,7 +653,7 @@ class UspService { controller.add(parsed); } } catch (e) { - logger.w('[UspService]:Subscribe poll error for "$id": $e'); + logger.w('[USP][Service]Subscribe poll error for "$id": $e'); } }); }, diff --git a/lib/usp/stub/usp_client_stub.dart b/lib/usp/stub/usp_client_stub.dart index a3b0de0b8..359f355fa 100644 --- a/lib/usp/stub/usp_client_stub.dart +++ b/lib/usp/stub/usp_client_stub.dart @@ -1,5 +1,3 @@ -import 'package:privacy_gui/usp/models/usp_response.dart'; - /// Stub implementation of UspClientWeb for non-Web platforms (Dart VM / tests). /// /// This file is selected by conditional import when dart.library.js_interop @@ -54,7 +52,7 @@ class UspClientWeb { {bool allowPartial = false}) => throw UnsupportedError('USP is only available on Web'); - Future>> operate(String command, + Future> operate(String command, {Map args = const {}}) => throw UnsupportedError('USP is only available on Web'); diff --git a/lib/usp/web/usp_client_wasm.dart b/lib/usp/web/usp_client_wasm.dart index 0865bf284..3e3290014 100644 --- a/lib/usp/web/usp_client_wasm.dart +++ b/lib/usp/web/usp_client_wasm.dart @@ -3,8 +3,6 @@ library usp_client; import 'dart:js_interop'; -import 'package:privacy_gui/usp/models/usp_response.dart'; - // Bind to the UspClient class exported in usp_client.js @JS('UspClient') extension type UspClientJS._(JSObject _) implements JSObject { @@ -157,26 +155,29 @@ class UspClientWeb { } /// Executes a USP Operate command. - /// Returns [UspResponse] with commandKey and output arguments. - Future>> operate(String command, + /// + /// Returns a flat map containing: + /// - `commandKey`: UUID correlator from the USP agent (may be absent) + /// - all output arguments from the Operate response + Future> operate(String command, {Map args = const {}}) async { final result = await _client.operate(command, args.jsify()!).toDart; - if (result == null || result.isUndefinedOrNull) { - return UspResponse(data: {}); - } + if (result == null || result.isUndefinedOrNull) return {}; final map = result.dartify() as Map?; - if (map == null) return UspResponse(data: {}); + if (map == null) return {}; + final output = {}; final commandKey = map['commandKey']?.toString(); + if (commandKey != null && commandKey.isNotEmpty) { + output['commandKey'] = commandKey; + } final rawOutputArgs = map['outputArgs']; - final outputArgs = {}; if (rawOutputArgs is Map) { for (final entry in rawOutputArgs.entries) { - outputArgs[entry.key.toString()] = entry.value.toString(); + output[entry.key.toString()] = entry.value.toString(); } } - - return UspResponse(data: outputArgs, commandKey: commandKey); + return output; } /// Lists all active OBUSPA subscriptions on the router. diff --git a/lib/usp_page/components/select_auto_complete.dart b/lib/usp_page/components/select_auto_complete.dart deleted file mode 100644 index d1acdfb09..000000000 --- a/lib/usp_page/components/select_auto_complete.dart +++ /dev/null @@ -1,290 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:ui_kit_library/ui_kit.dart'; - -/// A single autocomplete suggestion. -/// -/// Domain-decoupled: convert your domain models to [AutoCompleteOption] -/// at the call site. -class AutoCompleteOption { - /// Primary display text (e.g., device name). - final String label; - - /// The value written to the controller on selection. - final String value; - - /// Secondary info displayed on the trailing side (e.g., MAC address). - final String? subtitle; - - /// Whether the item is active/online. Controls the status indicator dot. - final bool isActive; - - const AutoCompleteOption({ - required this.label, - required this.value, - this.subtitle, - this.isActive = true, - }); -} - -/// Builds the visual content of a single option row in the dropdown. -/// -/// The tap handling (selection + controller update) is managed internally; -/// this builder only controls the visual appearance. -typedef AutoCompleteOptionBuilder = Widget Function( - BuildContext context, - AutoCompleteOption option, -); - -/// Decorator widget that adds autocomplete overlay to any text input child. -/// -/// Monitors the [controller] for text changes and displays a filtered dropdown -/// of matching [options] below the [child] widget. On selection, the chosen -/// value is written back to the [controller]. -/// -/// The [child] can be any widget containing a text input β€” e.g., -/// `AppTextField`, `AppIpv4TextField` (4-segment), `AppIPv6TextField`, or any -/// custom input field β€” as long as they share the same [controller]. -/// -/// ```dart -/// SelectAutoComplete( -/// options: myOptions, -/// controller: _ipController, -/// child: AppIpv4TextField(controller: _ipController), -/// ) -/// ``` -class SelectAutoComplete extends StatefulWidget { - final List options; - final TextEditingController controller; - final ValueChanged? onSelected; - final AutoCompleteOptionBuilder? optionBuilder; - final int maxSuggestions; - final Widget child; - - const SelectAutoComplete({ - super.key, - required this.options, - required this.controller, - required this.child, - this.onSelected, - this.optionBuilder, - this.maxSuggestions = 30, - }); - - @override - State createState() => _SelectAutoCompleteState(); -} - -class _SelectAutoCompleteState extends State { - final LayerLink _layerLink = LayerLink(); - OverlayEntry? _overlayEntry; - bool _hasFocus = false; - bool _suppressUpdate = false; - List _filteredOptions = []; - - @override - void initState() { - super.initState(); - widget.controller.addListener(_onTextChanged); - } - - @override - void didUpdateWidget(SelectAutoComplete oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_onTextChanged); - widget.controller.addListener(_onTextChanged); - } - if (oldWidget.options != widget.options) { - _updateOptions(); - } - } - - @override - void dispose() { - widget.controller.removeListener(_onTextChanged); - _hideOverlay(); - super.dispose(); - } - - // --------------------------------------------------------------------------- - // Filter & overlay lifecycle - // --------------------------------------------------------------------------- - - void _onTextChanged() { - if (_suppressUpdate) return; - _updateOptions(); - } - - void _updateOptions() { - final query = widget.controller.text.trim(); - if (query.isEmpty || !_hasFocus) { - _hideOverlay(); - return; - } - - final lq = query.toLowerCase(); - _filteredOptions = widget.options - .where((o) => - o.label.toLowerCase().contains(lq) || - o.value.toLowerCase().contains(lq) || - (o.subtitle?.toLowerCase().contains(lq) ?? false)) - .take(widget.maxSuggestions) - .toList(); - - if (_filteredOptions.isEmpty) { - _hideOverlay(); - } else { - _showOrUpdateOverlay(); - } - } - - void _showOrUpdateOverlay() { - if (_overlayEntry != null) { - _overlayEntry!.markNeedsBuild(); - return; - } - _overlayEntry = OverlayEntry(builder: (_) => _buildOverlay()); - Overlay.of(context).insert(_overlayEntry!); - } - - void _hideOverlay() { - _overlayEntry?.remove(); - _overlayEntry?.dispose(); - _overlayEntry = null; - } - - void _selectOption(AutoCompleteOption option) { - _suppressUpdate = true; - widget.controller.text = option.value; - widget.controller.selection = TextSelection.collapsed( - offset: option.value.length, - ); - _suppressUpdate = false; - _hideOverlay(); - widget.onSelected?.call(option.value); - } - - // --------------------------------------------------------------------------- - // Overlay - // --------------------------------------------------------------------------- - - Widget _buildOverlay() { - final targetWidth = _getTargetWidth(); - - return CompositedTransformFollower( - link: _layerLink, - targetAnchor: Alignment.bottomLeft, - followerAnchor: Alignment.topLeft, - showWhenUnlinked: false, - child: TextFieldTapRegion( - child: Align( - alignment: Alignment.topLeft, - child: AppSurface( - variant: SurfaceVariant.elevated, - borderRadius: 8, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: 280, - maxWidth: targetWidth ?? 480, - ), - child: ListView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - itemCount: _filteredOptions.length, - itemBuilder: (context, index) { - final option = _filteredOptions[index]; - return InkWell( - onTap: () => _selectOption(option), - child: widget.optionBuilder?.call(context, option) ?? - _buildDefaultOptionTile(context, option), - ); - }, - ), - ), - ), - ), - ), - ); - } - - Widget _buildDefaultOptionTile( - BuildContext context, AutoCompleteOption option) { - final colorScheme = Theme.of(context).extension(); - final statusColor = option.isActive - ? colorScheme?.semanticSuccess - : colorScheme?.onSurface.withValues(alpha: 0.38); - final secondaryColor = colorScheme?.onSurface.withValues(alpha: 0.6); - - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - child: Row( - children: [ - AppIcon.font( - option.isActive ? Icons.circle : Icons.circle_outlined, - size: 8, - color: statusColor, - ), - AppGap.sm(), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - AppText.labelMedium( - option.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - AppText.bodySmall( - option.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - color: secondaryColor, - ), - ], - ), - ), - if (option.subtitle != null) ...[ - AppGap.sm(), - AppText.bodyExtraSmall( - option.subtitle!, - color: secondaryColor, - ), - ], - ], - ), - ); - } - - double? _getTargetWidth() { - final renderBox = context.findRenderObject() as RenderBox?; - return renderBox?.size.width; - } - - // --------------------------------------------------------------------------- - // Build - // --------------------------------------------------------------------------- - - @override - Widget build(BuildContext context) { - return CompositedTransformTarget( - link: _layerLink, - child: Focus( - skipTraversal: true, - canRequestFocus: false, - onFocusChange: (hasFocus) { - _hasFocus = hasFocus; - if (!hasFocus) { - _hideOverlay(); - } else { - _updateOptions(); - } - }, - child: widget.child, - ), - ); - } -} diff --git a/lib/usp_page/dashboard/providers/mesh_node_enricher.dart b/lib/usp_page/dashboard/providers/mesh_node_enricher.dart index 6f7fce77e..043de5824 100644 --- a/lib/usp_page/dashboard/providers/mesh_node_enricher.dart +++ b/lib/usp_page/dashboard/providers/mesh_node_enricher.dart @@ -57,12 +57,13 @@ Future fetchMeshNodes(UspService client) async { try { final network = await DataElementsNetwork.fetch(client); if (network.items.isEmpty) { - logger.d('[USP] DataElements empty β€” not a mesh or unsupported'); + logger + .d('[USP][Dashboard]DataElements empty β€” not a mesh or unsupported'); return MeshTopologyInfo.empty; } return _buildTopologyInfo(network); } catch (e) { - logger.d('[USP] DataElements not supported or fetch failed: $e'); + logger.d('[USP][Dashboard]DataElements not supported or fetch failed: $e'); return MeshTopologyInfo.empty; } } @@ -103,7 +104,7 @@ MeshTopologyInfo _buildTopologyInfo(DataElementsNetwork network) { )); } - logger.d('[USP] Mesh nodes: ${nodes.length}, ' + logger.d('[USP][Dashboard]Mesh nodes: ${nodes.length}, ' 'clientβ†’node mappings: ${clientToNodeMap.length}'); return MeshTopologyInfo(nodes: nodes, clientToNodeMap: clientToNodeMap); } diff --git a/lib/usp_page/dashboard/providers/usp_dashboard_notifier.dart b/lib/usp_page/dashboard/providers/usp_dashboard_notifier.dart index 0d40e4603..f2d9e5c58 100644 --- a/lib/usp_page/dashboard/providers/usp_dashboard_notifier.dart +++ b/lib/usp_page/dashboard/providers/usp_dashboard_notifier.dart @@ -10,6 +10,7 @@ import 'package:privacy_gui/generated/ethernet_interfaces.g.dart'; import 'package:privacy_gui/generated/firewall_chain_rules.g.dart'; import 'package:privacy_gui/generated/firmware_images.g.dart'; import 'package:privacy_gui/generated/lan_network_info.g.dart'; +import 'package:privacy_gui/generated/wan_operations.g.dart'; import 'package:privacy_gui/generated/wan_status.g.dart'; import 'package:privacy_gui/generated/port_forwarding.g.dart'; import 'package:privacy_gui/generated/port_triggering.g.dart'; @@ -17,6 +18,7 @@ import 'package:privacy_gui/generated/system_info.g.dart'; import 'package:privacy_gui/generated/time_settings.g.dart'; import 'package:privacy_gui/generated/wi_fi_access_points.g.dart'; import 'package:privacy_gui/generated/wi_fi_radios.g.dart'; +import 'package:privacy_gui/generated/subscriptions.g.dart'; import 'package:privacy_gui/generated/wi_fi_ssids.g.dart'; import 'package:privacy_gui/usp_page/dashboard/providers/mesh_node_enricher.dart'; import 'package:privacy_gui/usp_page/dashboard/models/system_monitor_state.dart'; @@ -25,6 +27,7 @@ import 'package:privacy_gui/usp_page/dashboard/providers/usp_system_monitor_noti import 'package:privacy_gui/usp_page/dashboard/providers/wifi_client_enricher.dart'; import 'package:privacy_gui/usp_page/dashboard/services/usp_device_service.dart'; import 'package:privacy_gui/usp/providers/sse_invalidation_provider.dart'; +import 'package:privacy_gui/usp/providers/sse_providers.dart'; import 'package:privacy_gui/usp/providers/usp_auth_coordinator.dart'; import 'package:privacy_gui/usp/providers/usp_service_provider.dart'; import 'package:privacy_gui/usp/services/usp_service.dart'; @@ -79,16 +82,27 @@ class UspDashboardNotifier extends AsyncNotifier { @override Future build() async { + try { + return await _buildImpl(); + } catch (e, st) { + logger.e('[USP Dashboard] build() failed: $e\n$st'); + rethrow; + } + } + + Future _buildImpl() async { final usp = ref.watch(uspServiceProvider); if (usp == null) { throw StateError('USP service not available'); } // On page reload WASM state is lost β€” attempt session restore before giving up + bool authWasRestored = false; if (!usp.isAuthenticated) { await ref.read(uspAuthCoordinatorProvider).restoreSession(); if (!usp.isAuthenticated) { throw StateError('USP not authenticated after restore attempt'); } + authWasRestored = true; } // SSE invalidation: listen for domain-specific change signals. // When SSE delivers a notification (e.g., device connected, WiFi changed), @@ -127,97 +141,91 @@ class UspDashboardNotifier extends AsyncNotifier { ); } - // Parallel fetch β€” WASM client v0.6.1+ supports concurrent HTTP requests. + // Batched fetch β€” 3 requests per batch to stay within the browser's + // HTTP/1.1 connection pool limit (6 per host). With SSE occupying 1 + // persistent connection, 3 concurrent fetches use 4 total β€” safe margin. // Each fetch reports progress on completion for the loading indicator. - final results = await Future.wait([ - SystemInfo.fetch(usp).then((v) { - tick('System Info'); - return v; - }), - ConnectedDevices.fetch(usp).then((v) { - tick('Devices'); - return v; - }), - WiFiRadios.fetch(usp).then((v) { - tick('WiFi Radios'); - return v; - }), - WiFiSsids.fetch(usp).then((v) { - tick('WiFi SSIDs'); - return v; - }), - WiFiAccessPoints.fetch(usp).then((v) { - tick('Access Points'); - return v; - }), - TimeSettings.fetch(usp).then((v) { - tick('Time Settings'); - return v; - }), - DhcpClients.fetch(usp).then((v) { - tick('DHCP Clients'); - return v; - }), - DhcpReservations.fetch(usp).then((v) { - tick('DHCP Reservations'); - return v; - }), - PortForwarding.fetch(usp).then((v) { - tick('Port Forwarding'); - return v; - }), - PortTriggering.fetch(usp).then((v) { - tick('Port Triggering'); - return v; - }), - fetchWifiClients(usp).then((v) { - tick('WiFi Clients'); - return v; - }), - fetchMeshNodes(usp).then((v) { - tick('Mesh Nodes'); - return v; - }), - LanNetworkInfo.fetch(usp).then((v) { - tick('LAN Info'); - return v; - }), - EthernetInterfaces.fetch(usp).then((v) { - tick('Ethernet Ports'); - return v; - }), - WanStatus.fetch(usp).then((v) { - tick('WAN Status'); - return v; - }), - FirewallChainRules.fetch(usp).then((v) { - tick('Firewall Rules'); - return v; - }), - Dmz.fetch(usp).then((v) { - tick('DMZ'); - return v; - }), - ]); + final totalSw = Stopwatch()..start(); + + Future timed(String label, Future Function() fn) async { + final sw = Stopwatch()..start(); + final result = await fn().timeout( + const Duration(seconds: 30), + onTimeout: () => throw TimeoutException( + '$label timed out', const Duration(seconds: 30)), + ); + sw.stop(); + logger.d('[USP][Dashboard]$label β€” ${sw.elapsedMilliseconds}ms'); + tick(label); + return result; + } - final systemInfo = results[0] as SystemInfo; - final connectedDevices = results[1] as ConnectedDevices; - final wifiRadios = results[2] as WiFiRadios; - final wifiSsids = results[3] as WiFiSsids; - final wifiAccessPoints = results[4] as WiFiAccessPoints; - final timeSettings = results[5] as TimeSettings; - final dhcpClients = results[6] as DhcpClients; - final dhcpReservations = results[7] as DhcpReservations; - final portForwarding = results[8] as PortForwarding; - final portTriggering = results[9] as PortTriggering; - final wifiClientMap = results[10] as Map; - final meshTopology = results[11] as MeshTopologyInfo; - final lanNetworkInfo = results[12] as LanNetworkInfo; - final ethernetInterfaces = results[13] as EthernetInterfaces; - final wanStatus = results[14] as WanStatus; - final firewallRules = results[15] as FirewallChainRules; - final dmzEntries = results[16] as Dmz; - logger.d('[USP] Dashboard fetch complete β€” ' + // Batch 1: Core system info + final b1 = await Future.wait([ + timed('System Info', () => SystemInfo.fetch(usp)), + timed('Devices', () => ConnectedDevices.fetch(usp)), + timed('WAN Status', () => WanStatus.fetch(usp)), + ]); + final systemInfo = b1[0] as SystemInfo; + final connectedDevices = b1[1] as ConnectedDevices; + final wanStatus = b1[2] as WanStatus; + logger.d('[USP][Dashboard]Batch 1 done β€” ${totalSw.elapsedMilliseconds}ms'); + + // Batch 2: Network + WiFi radios + final b2 = await Future.wait([ + timed('LAN Info', () => LanNetworkInfo.fetch(usp)), + timed('WiFi Radios', () => WiFiRadios.fetch(usp)), + timed('WiFi SSIDs', () => WiFiSsids.fetch(usp)), + ]); + final lanNetworkInfo = b2[0] as LanNetworkInfo; + final wifiRadios = b2[1] as WiFiRadios; + final wifiSsids = b2[2] as WiFiSsids; + logger.d('[USP][Dashboard]Batch 2 done β€” ${totalSw.elapsedMilliseconds}ms'); + + // Batch 3: WiFi clients + access points + final b3 = await Future.wait([ + timed('Access Points', () => WiFiAccessPoints.fetch(usp)), + timed('WiFi Clients', () => fetchWifiClients(usp)), + timed('Ethernet Ports', () => EthernetInterfaces.fetch(usp)), + ]); + final wifiAccessPoints = b3[0] as WiFiAccessPoints; + final wifiClientMap = b3[1] as Map; + final ethernetInterfaces = b3[2] as EthernetInterfaces; + logger.d('[USP][Dashboard]Batch 3 done β€” ${totalSw.elapsedMilliseconds}ms'); + + // Batch 4: DHCP + time + final b4 = await Future.wait([ + timed('DHCP Clients', () => DhcpClients.fetch(usp)), + timed('DHCP Reservations', () => DhcpReservations.fetch(usp)), + timed('Time Settings', () => TimeSettings.fetch(usp)), + ]); + final dhcpClients = b4[0] as DhcpClients; + final dhcpReservations = b4[1] as DhcpReservations; + final timeSettings = b4[2] as TimeSettings; + logger.d('[USP][Dashboard]Batch 4 done β€” ${totalSw.elapsedMilliseconds}ms'); + + // Batch 5: Mesh + port rules + final b5 = await Future.wait([ + timed('Mesh Nodes', () => fetchMeshNodes(usp)), + timed('Port Forwarding', () => PortForwarding.fetch(usp)), + timed('Port Triggering', () => PortTriggering.fetch(usp)), + ]); + final meshTopology = b5[0] as MeshTopologyInfo; + final portForwarding = b5[1] as PortForwarding; + final portTriggering = b5[2] as PortTriggering; + logger.d('[USP][Dashboard]Batch 5 done β€” ${totalSw.elapsedMilliseconds}ms'); + + // Batch 6: Firewall + final b6 = await Future.wait([ + timed('Firewall Rules', () => FirewallChainRules.fetch(usp)), + timed('DMZ', () => Dmz.fetch(usp)), + ]); + final firewallRules = b6[0] as FirewallChainRules; + final dmzEntries = b6[1] as Dmz; + totalSw.stop(); + logger.d( + '[USP][Dashboard]Batch 6 done β€” total ${totalSw.elapsedMilliseconds}ms'); + logger.d('[USP][Dashboard]Dashboard fetch complete β€” ' 'devices: ${connectedDevices.items.length}, ' 'ethIfaces: ${ethernetInterfaces.items.length}, ' 'wifiClients: ${wifiClientMap.length}, ' @@ -236,7 +244,7 @@ class UspDashboardNotifier extends AsyncNotifier { _fetchIpv6Info(usp), _fetchFirmwareImages(usp), _fetchBridgePortMap(usp), - ]); + ]).timeout(const Duration(seconds: 30)); final wanGateway = extraResults[0] as String; final ipv6Info = extraResults[1] as ({ bool lanEnabled, @@ -251,6 +259,30 @@ class UspDashboardNotifier extends AsyncNotifier { }); final bridgePortMap = extraResults[3] as Map; + // All dashboard HTTP requests are done. OBUSPA pipeline is now free. + // + // No purge needed: bridge subscription API is idempotent by subscription_id. + // Re-registering the same codegen'd IDs overwrites existing entries without + // creating orphans. This saves 1+N serialized UDS requests per page load. + // + // SSE subscription setup + if (authWasRestored) { + // F5 reload: bootstrap connected SSE but deferred core subs. + // Also need to set core subs if bootstrap skipped them. + final manager = ref.read(sseManagerProvider); + if (manager != null) { + manager.setCoreSubscriptions(coreSubscriptions); + if (!manager.isConnected) { + await manager.connect(); + } + await manager.registerDeferredSubscriptions(force: true); + logger.d('[USP][Dashboard]Post-reload SSE setup complete'); + } + } else { + // Normal: SSE already connected via bootstrap, register deferred subs. + ref.read(sseManagerProvider)?.registerDeferredSubscriptions(); + } + // Data β†’ UI Model transformation (constitution Section 5.3) final svc = _svc; final gatewayName = @@ -368,7 +400,7 @@ class UspDashboardNotifier extends AsyncNotifier { 'Device.Routing.Router.1.IPv4Forwarding.*.DestIPAddress', 'Device.Routing.Router.1.IPv4Forwarding.*.GatewayIPAddress', 'Device.Routing.Router.1.IPv4Forwarding.*.Interface', - ]); + ]).timeout(const Duration(seconds: 10)); // Extract instance IDs from response keys const basePath = 'Device.Routing.Router.1.IPv4Forwarding.'; @@ -393,7 +425,7 @@ class UspDashboardNotifier extends AsyncNotifier { } return ''; } catch (e) { - logger.w('[USP] Failed to fetch default gateway: $e'); + logger.w('[USP][Dashboard]Failed to fetch default gateway: $e'); return ''; } } @@ -418,7 +450,7 @@ class UspDashboardNotifier extends AsyncNotifier { 'Device.IP.Interface.2.IPv6Enable', 'Device.IP.Interface.1.IPv6Address.', 'Device.IP.Interface.2.IPv6Address.', - ]); + ]).timeout(const Duration(seconds: 10)); final lanEnabled = resp['Device.IP.Interface.1.IPv6Enable'] == true; final wanEnabled = resp['Device.IP.Interface.2.IPv6Enable'] == true; @@ -437,9 +469,9 @@ class UspDashboardNotifier extends AsyncNotifier { .where((ip) => ip.isNotEmpty) .toList(); - logger.d('[USP] IPv6 LAN: enabled=$lanEnabled, ' + logger.d('[USP][Dashboard]IPv6 LAN: enabled=$lanEnabled, ' 'addresses=${lanAddresses.length}'); - logger.d('[USP] IPv6 WAN: enabled=$wanEnabled, ' + logger.d('[USP][Dashboard]IPv6 WAN: enabled=$wanEnabled, ' 'addresses=${wanAddresses.length}'); return ( @@ -449,7 +481,8 @@ class UspDashboardNotifier extends AsyncNotifier { wanAddresses: wanAddresses, ); } catch (e) { - logger.w('[USP] IPv6 fetch failed (router may not support IPv6): $e'); + logger.w( + '[USP][Dashboard]IPv6 fetch failed (router may not support IPv6): $e'); return ( lanEnabled: false, lanAddresses: [], @@ -474,18 +507,18 @@ class UspDashboardNotifier extends AsyncNotifier { 'Device.DeviceInfo.ActiveFirmwareImage', 'Device.DeviceInfo.BootFirmwareImage', ]), - ]); + ]).timeout(const Duration(seconds: 10)); final images = results[0] as FirmwareImages; final refs = results[1] as Map; final activeRef = refs['Device.DeviceInfo.ActiveFirmwareImage']?.toString() ?? ''; final bootRef = refs['Device.DeviceInfo.BootFirmwareImage']?.toString() ?? ''; - logger.d('[USP] Firmware images: ${images.items.length}, ' + logger.d('[USP][Dashboard]Firmware images: ${images.items.length}, ' 'active=$activeRef, boot=$bootRef'); return (images: images, activeRef: activeRef, bootRef: bootRef); } catch (e) { - logger.w('[USP] Firmware images fetch failed: $e'); + logger.w('[USP][Dashboard]Firmware images fetch failed: $e'); return ( images: FirmwareImages(items: const []), activeRef: '', @@ -508,7 +541,7 @@ class UspDashboardNotifier extends AsyncNotifier { try { final resp = await usp.get([ 'Device.Bridging.Bridge.*.Port.*.LowerLayers', - ]); + ]).timeout(const Duration(seconds: 10)); final map = {}; for (final entry in resp.entries) { if (!entry.key.endsWith('.LowerLayers')) continue; @@ -532,7 +565,7 @@ class UspDashboardNotifier extends AsyncNotifier { } return map; } catch (e) { - logger.w('[USP] Bridge port map fetch failed: $e'); + logger.w('[USP][Dashboard]Bridge port map fetch failed: $e'); return {}; } } @@ -547,7 +580,7 @@ class UspDashboardNotifier extends AsyncNotifier { final s = state.valueOrNull; if (s == null || _mutating) return; - logger.d('[USP] SSE invalidation: $domains'); + logger.d('[USP][Dashboard]SSE invalidation: $domains'); try { for (final domain in domains) { @@ -712,7 +745,7 @@ class UspDashboardNotifier extends AsyncNotifier { } } } catch (e) { - logger.w('[USP] SSE-triggered re-fetch failed: $e'); + logger.w('[USP][Dashboard]SSE-triggered re-fetch failed: $e'); // Non-fatal: data remains stale until next manual refresh } } @@ -737,7 +770,10 @@ class UspDashboardNotifier extends AsyncNotifier { Future renewWanLease() async { await _withLock(() async { - await _usp.operate('Device.DHCPv4.Client.1.Renew()'); + // DHCP Renew is fire-and-forget β€” firmware does NOT send + // OperationComplete for this operation (unlike Ping/Traceroute). + await WanOperations.renewDhcpLease(_usp); + await Future.delayed(const Duration(seconds: 2)); final wan = await WanStatus.fetch(_usp); final s = state.requireValue; state = AsyncData(s.copyWith( diff --git a/lib/usp_page/dashboard/providers/usp_device_analytics_notifier.dart b/lib/usp_page/dashboard/providers/usp_device_analytics_notifier.dart index 5d22f2676..4532f2b30 100644 --- a/lib/usp_page/dashboard/providers/usp_device_analytics_notifier.dart +++ b/lib/usp_page/dashboard/providers/usp_device_analytics_notifier.dart @@ -51,7 +51,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { ); } } catch (e) { - logger.w('[DeviceAnalytics] Failed to load persisted history: $e'); + logger.w('[USP][Monitor][Analytics]Failed to load persisted history: $e'); } } @@ -166,7 +166,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { try { await saveDeviceAnalytics(state); } catch (e) { - logger.w('[DeviceAnalytics] Failed to persist: $e'); + logger.w('[USP][Monitor][Analytics]Failed to persist: $e'); } } } diff --git a/lib/usp_page/dashboard/providers/usp_system_monitor_notifier.dart b/lib/usp_page/dashboard/providers/usp_system_monitor_notifier.dart index b6246af79..a606201b1 100644 --- a/lib/usp_page/dashboard/providers/usp_system_monitor_notifier.dart +++ b/lib/usp_page/dashboard/providers/usp_system_monitor_notifier.dart @@ -83,7 +83,7 @@ class UspSystemMonitorNotifier extends Notifier { isFetching: false, ); } catch (e) { - logger.w('[SystemMonitor] Fetch failed: $e'); + logger.w('[USP][Monitor][System]Fetch failed: $e'); state = state.copyWith(isFetching: false); } } diff --git a/lib/usp_page/dashboard/providers/usp_traffic_analysis_notifier.dart b/lib/usp_page/dashboard/providers/usp_traffic_analysis_notifier.dart index 9113e30c2..21e6a2ff1 100644 --- a/lib/usp_page/dashboard/providers/usp_traffic_analysis_notifier.dart +++ b/lib/usp_page/dashboard/providers/usp_traffic_analysis_notifier.dart @@ -118,7 +118,7 @@ class UspTrafficAnalysisNotifier extends Notifier { lastTimestamp: () => now, ); } catch (e) { - logger.w('[TrafficAnalysis] Fetch failed: $e'); + logger.w('[USP][Monitor][Traffic]Fetch failed: $e'); state = state.copyWith(isFetching: false); } } diff --git a/lib/usp_page/dashboard/providers/wifi_client_enricher.dart b/lib/usp_page/dashboard/providers/wifi_client_enricher.dart index 62a4e633a..55a097cbb 100644 --- a/lib/usp_page/dashboard/providers/wifi_client_enricher.dart +++ b/lib/usp_page/dashboard/providers/wifi_client_enricher.dart @@ -16,7 +16,7 @@ export 'package:privacy_gui/generated/wifi_clients.g.dart' show WifiClient; /// limitation), falls back to a broader parent-path fetch and manual parse. Future> fetchWifiClients(UspService client) async { final result = await WifiClients.fetch(client); - logger.d('[USP] WifiClients raw: ${result.items.length} items'); + logger.d('[USP][Dashboard]WifiClients raw: ${result.items.length} items'); if (result.items.isNotEmpty) { return { @@ -27,16 +27,17 @@ Future> fetchWifiClients(UspService client) async { // Fallback: selective-get with nested wildcards may not be supported by // some USP agents. Try fetching the whole AssociatedDevice subtree instead. - logger - .d('[USP] WifiClients selective-get empty, trying parent-path fallback'); + logger.d( + '[USP][Dashboard]WifiClients selective-get empty, trying parent-path fallback'); try { final fallback = await _fetchWifiClientsFallback(client); if (fallback.isNotEmpty) { - logger.d('[USP] WifiClients fallback: ${fallback.length} clients'); + logger.d( + '[USP][Dashboard]WifiClients fallback: ${fallback.length} clients'); } return fallback; } catch (e) { - logger.d('[USP] WifiClients fallback failed: $e'); + logger.d('[USP][Dashboard]WifiClients fallback failed: $e'); return {}; } } @@ -51,7 +52,8 @@ Future> _fetchWifiClientsFallback( final response = await client.get([ 'Device.WiFi.AccessPoint.*.AssociatedDevice.', ]); - logger.d('[USP] WifiClients fallback response: ${response.length} keys'); + logger.d( + '[USP][Dashboard]WifiClients fallback response: ${response.length} keys'); if (response.isEmpty) return {}; // Parse response keys to find AP and AssociatedDevice instance IDs. @@ -143,7 +145,7 @@ Map buildConnectionDetailMap({ _normalizeBand(r.operatingFrequencyBand), }; - logger.d('[USP] Connection detail: ' + logger.d('[USP][Dashboard]Connection detail: ' '${apByPath.length} APs, ${ssidByPath.length} SSIDs, ${bandByRadioPath.length} radios'); final result = {}; @@ -155,7 +157,7 @@ Map buildConnectionDetailMap({ final ap = apByPath[_ensureTrailingDot(client.parentPath)]; if (ap == null) { logger.d( - '[USP] Connection detail: no AP for parentPath=${client.parentPath}'); + '[USP][Dashboard]Connection detail: no AP for parentPath=${client.parentPath}'); continue; } @@ -168,7 +170,7 @@ Map buildConnectionDetailMap({ ? (bandByRadioPath[_ensureTrailingDot(ssid.lowerLayers)] ?? '') : ''; - logger.d('[USP] Connection detail: $mac β†’ ' + logger.d('[USP][Dashboard]Connection detail: $mac β†’ ' 'AP=${ap.instancePath}, ssidRef=${ap.ssidReference}, ' 'ssid=$ssidName, lowerLayers=${ssid?.lowerLayers}, band=$band'); diff --git a/lib/usp_page/dashboard/services/usp_pdf_service.dart b/lib/usp_page/dashboard/services/usp_pdf_service.dart index d51189dbf..ee13626eb 100644 --- a/lib/usp_page/dashboard/services/usp_pdf_service.dart +++ b/lib/usp_page/dashboard/services/usp_pdf_service.dart @@ -87,7 +87,7 @@ class UspPdfService { onLayout: (PdfPageFormat format) => doc.save(), ); } catch (e) { - logger.e('[UspPdfService] Print error', error: e); + logger.e('[USP][Dashboard][PDF]Print error', error: e); } } diff --git a/lib/usp_page/dashboard/views/dialogs/port_forwarding_dialog.dart b/lib/usp_page/dashboard/views/dialogs/port_forwarding_dialog.dart index 443fd4e87..2adec5bcd 100644 --- a/lib/usp_page/dashboard/views/dialogs/port_forwarding_dialog.dart +++ b/lib/usp_page/dashboard/views/dialogs/port_forwarding_dialog.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:privacy_gui/usp_page/components/select_auto_complete.dart'; import 'package:privacy_gui/usp_page/dashboard/models/port_forwarding_rule_ui_model.dart'; import 'package:privacy_gui/validator_rules/rules.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -28,7 +27,7 @@ class PortForwardingDialogResult { /// Pass [rule] to pre-fill for editing; omit for adding. class PortForwardingDialog extends StatefulWidget { final PortForwardingRuleUIModel? rule; - final List deviceOptions; + final List deviceOptions; const PortForwardingDialog({ super.key, @@ -170,7 +169,7 @@ class _PortForwardingDialogState extends State { onChanged: (_) => _validate(), ), AppGap.lg(), - SelectAutoComplete( + AppSelectAutoComplete( options: widget.deviceOptions, controller: _intClientController, onSelected: (_) => _validate(), diff --git a/lib/usp_page/dmz/providers/usp_dmz_notifier.dart b/lib/usp_page/dmz/providers/usp_dmz_notifier.dart index e23e13000..2c42c76c7 100644 --- a/lib/usp_page/dmz/providers/usp_dmz_notifier.dart +++ b/lib/usp_page/dmz/providers/usp_dmz_notifier.dart @@ -93,7 +93,7 @@ class UspDmzNotifier extends AutoDisposeAsyncNotifier { final instancePath = dmzData.items.isNotEmpty ? dmzData.items.first.instancePath : null; - logger.d('[USP] DMZ fetched β€” ' + logger.d('[USP][Firewall][DMZ]DMZ fetched β€” ' 'entries: ${dmzData.items.length}, ' 'enabled: ${uiModel.isEnabled}, ' 'instancePath: $instancePath'); @@ -139,7 +139,8 @@ class UspDmzNotifier extends AutoDisposeAsyncNotifier { sourcePrefix: sourcePrefix, description: 'DMZ', ); - logger.d('[USP] DMZ entry added β€” destIp: ${pending.destIp}'); + logger.d( + '[USP][Firewall][DMZ]DMZ entry added β€” destIp: ${pending.destIp}'); } else if (!s.isNewEntry) { // UPDATE existing entry final sourcePrefix = pending.sourceType == DmzSourceType.any @@ -154,7 +155,7 @@ class UspDmzNotifier extends AutoDisposeAsyncNotifier { sourcePrefix: sourcePrefix, ), ); - logger.d('[USP] DMZ entry updated β€” ' + logger.d('[USP][Firewall][DMZ]DMZ entry updated β€” ' 'enabled: ${pending.isEnabled}, destIp: ${pending.destIp}'); } diff --git a/lib/usp_page/firewall/providers/usp_firewall_notifier.dart b/lib/usp_page/firewall/providers/usp_firewall_notifier.dart index b6a8c8ffd..f0f584043 100644 --- a/lib/usp_page/firewall/providers/usp_firewall_notifier.dart +++ b/lib/usp_page/firewall/providers/usp_firewall_notifier.dart @@ -89,7 +89,7 @@ class UspFirewallNotifier extends AutoDisposeAsyncNotifier { final ruleMap = svc.parseFirewallRules(chainRules); final uiModel = svc.buildUIModel(rules: ruleMap); - logger.d('[USP] Firewall fetched β€” ' + logger.d('[USP][Firewall]Firewall fetched β€” ' 'rules: ${ruleMap.length}, ' 'spiV4: ${uiModel.isIPv4FirewallEnabled}, ' 'spiV6: ${uiModel.isIPv6FirewallEnabled}'); @@ -129,7 +129,8 @@ class UspFirewallNotifier extends AutoDisposeAsyncNotifier { await FirewallChainRules.updateMany(usp, updates); - logger.d('[USP] Firewall saved β€” ${updates.length} rules updated'); + logger + .d('[USP][Firewall]Firewall saved β€” ${updates.length} rules updated'); // Re-fetch to confirm changes took effect. ref.invalidateSelf(); diff --git a/lib/usp_page/instant_safety/providers/instant_safety_provider.dart b/lib/usp_page/instant_safety/providers/instant_safety_provider.dart index a958346f8..4d04528c7 100644 --- a/lib/usp_page/instant_safety/providers/instant_safety_provider.dart +++ b/lib/usp_page/instant_safety/providers/instant_safety_provider.dart @@ -74,7 +74,7 @@ class UspInstantSafetyNotifier extends AsyncNotifier { final svc = ref.read(uspInstantSafetyServiceProvider); final uiModel = svc.buildUIModel(data); - logger.d('[USP] Instant Safety fetched β€” ' + logger.d('[USP][Safety]Instant Safety fetched β€” ' 'dns: ${data.dnsServers}, type: ${uiModel.type}'); return UspInstantSafetyState( @@ -104,7 +104,7 @@ class UspInstantSafetyNotifier extends AsyncNotifier { final dnsValue = svc.dnsValueForType(s.pendingType); await LanNetworkInfo.save(usp, dnsServers: dnsValue); - logger.d('[USP] Instant Safety saved β€” dns: $dnsValue'); + logger.d('[USP][Safety]Instant Safety saved β€” dns: $dnsValue'); // Re-fetch to confirm the change took effect. ref.invalidateSelf(); diff --git a/lib/usp_page/internet_settings/providers/usp_internet_settings_notifier.dart b/lib/usp_page/internet_settings/providers/usp_internet_settings_notifier.dart index 29b31df5f..4b48a00f0 100644 --- a/lib/usp_page/internet_settings/providers/usp_internet_settings_notifier.dart +++ b/lib/usp_page/internet_settings/providers/usp_internet_settings_notifier.dart @@ -84,7 +84,7 @@ class UspInternetSettingsNotifier final (wan, ipv6) = await service.fetchSettings(); final form = UspInternetSettingsForm.fromGenerated(wan, ipv6); - logger.d('[USP InternetSettings] Fetched β€” ' + logger.d('[USP][Network][WAN]Fetched β€” ' 'raw addressingType: "${wan.addressingType}", ' 'bridgeEnabled: ${wan.bridgeEnabled}, ' 'detected type: ${UspWanConnectionType.fromWanSettings(wan).name}, ' @@ -151,14 +151,14 @@ class UspInternetSettingsNotifier final s = state.requireValue; final service = ref.read(uspInternetSettingsServiceProvider); - logger.d('[USP InternetSettings] Saving changes...'); + logger.d('[USP][Network][WAN]Saving changes...'); await service.saveAll(s.original, s.edited); // Re-fetch to get server-confirmed values final (wan, ipv6) = await service.fetchSettings(); final form = UspInternetSettingsForm.fromGenerated(wan, ipv6); - logger.d('[USP InternetSettings] Save complete, re-fetched'); + logger.d('[USP][Network][WAN]Save complete, re-fetched'); state = AsyncData(UspInternetSettingsState( wanSettings: wan, ipv6Settings: ipv6, @@ -176,7 +176,7 @@ class UspInternetSettingsNotifier Future renewDhcpLease() async { await _withLock(() async { final service = ref.read(uspInternetSettingsServiceProvider); - logger.d('[USP InternetSettings] Renewing DHCPv4 lease...'); + logger.d('[USP][Network][WAN]Renewing DHCPv4 lease...'); await service.renewDhcpLease(); }); } @@ -184,7 +184,7 @@ class UspInternetSettingsNotifier Future renewDhcpv6Lease() async { await _withLock(() async { final service = ref.read(uspInternetSettingsServiceProvider); - logger.d('[USP InternetSettings] Renewing DHCPv6 lease...'); + logger.d('[USP][Network][WAN]Renewing DHCPv6 lease...'); await service.renewDhcpv6Lease(); }); } diff --git a/lib/usp_page/ipv6_port_service/providers/usp_ipv6_port_service_notifier.dart b/lib/usp_page/ipv6_port_service/providers/usp_ipv6_port_service_notifier.dart index 1e8cb26ff..f4c677dfe 100644 --- a/lib/usp_page/ipv6_port_service/providers/usp_ipv6_port_service_notifier.dart +++ b/lib/usp_page/ipv6_port_service/providers/usp_ipv6_port_service_notifier.dart @@ -57,7 +57,7 @@ class UspIpv6PortServiceNotifier final svc = ref.read(uspIpv6PortServiceServiceProvider); final rules = svc.buildRuleUIModels(data); - logger.d('[USP] Ipv6PortService fetched β€” ' + logger.d('[USP][Firewall][IPv6Port]Ipv6PortService fetched β€” ' 'total: ${data.items.length}, ipv6: ${rules.length}'); return UspIpv6PortServiceState(rules: rules); @@ -96,7 +96,8 @@ class UspIpv6PortServiceNotifier protocol: protocol, target: 'Accept', ); - logger.d('[USP] Ipv6PortServiceRule added β€” $description'); + logger.d( + '[USP][Firewall][IPv6Port]Ipv6PortServiceRule added β€” $description'); await _refreshRules(); } catch (e) { state = AsyncData(s.copyWith(isMutating: false)); @@ -132,7 +133,8 @@ class UspIpv6PortServiceNotifier target: 'Accept', ), ); - logger.d('[USP] Ipv6PortServiceRule updated β€” $instancePath'); + logger.d( + '[USP][Firewall][IPv6Port]Ipv6PortServiceRule updated β€” $instancePath'); await _refreshRules(); } catch (e) { state = AsyncData(s.copyWith(isMutating: false)); @@ -151,7 +153,8 @@ class UspIpv6PortServiceNotifier usp, Ipv6PortServiceRuleUpdate(instancePath: instancePath, enable: enabled), ); - logger.d('[USP] Ipv6PortServiceRule toggled β€” $instancePath β†’ $enabled'); + logger.d( + '[USP][Firewall][IPv6Port]Ipv6PortServiceRule toggled β€” $instancePath β†’ $enabled'); await _refreshRules(); } catch (e) { state = AsyncData(s.copyWith(isMutating: false)); @@ -167,7 +170,8 @@ class UspIpv6PortServiceNotifier try { final usp = ref.read(uspServiceProvider)!; await Ipv6PortService.delete(usp, instancePath); - logger.d('[USP] Ipv6PortServiceRule deleted β€” $instancePath'); + logger.d( + '[USP][Firewall][IPv6Port]Ipv6PortServiceRule deleted β€” $instancePath'); await _refreshRules(); } catch (e) { state = AsyncData(s.copyWith(isMutating: false)); diff --git a/lib/usp_page/ipv6_port_service/views/dialogs/ipv6_port_service_rule_dialog.dart b/lib/usp_page/ipv6_port_service/views/dialogs/ipv6_port_service_rule_dialog.dart index d87c0de20..decfc5f95 100644 --- a/lib/usp_page/ipv6_port_service/views/dialogs/ipv6_port_service_rule_dialog.dart +++ b/lib/usp_page/ipv6_port_service/views/dialogs/ipv6_port_service_rule_dialog.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:privacy_gui/usp_page/components/select_auto_complete.dart'; import 'package:privacy_gui/usp_page/ipv6_port_service/models/ipv6_port_service_ui_model.dart'; import 'package:privacy_gui/usp_page/ipv6_port_service/services/usp_ipv6_port_service_service.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -28,7 +27,7 @@ class Ipv6PortServiceRuleDialogResult { /// Pass [rule] to pre-fill for editing; omit for adding. class Ipv6PortServiceRuleDialog extends StatefulWidget { final Ipv6PortServiceRuleUIModel? rule; - final List deviceOptions; + final List deviceOptions; const Ipv6PortServiceRuleDialog({ super.key, @@ -115,7 +114,7 @@ class _Ipv6PortServiceRuleDialogState extends State { onChanged: (_) => _validate(), ), AppGap.lg(), - SelectAutoComplete( + AppSelectAutoComplete( options: widget.deviceOptions, controller: _ipv6Controller, onSelected: (_) => _validate(), diff --git a/lib/usp_page/ipv6_port_service/views/usp_ipv6_port_service_view.dart b/lib/usp_page/ipv6_port_service/views/usp_ipv6_port_service_view.dart index 1785bfb8e..9849276ce 100644 --- a/lib/usp_page/ipv6_port_service/views/usp_ipv6_port_service_view.dart +++ b/lib/usp_page/ipv6_port_service/views/usp_ipv6_port_service_view.dart @@ -6,7 +6,6 @@ import 'package:privacy_gui/page/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/usp_page/ipv6_port_service/models/ipv6_port_service_ui_model.dart'; -import 'package:privacy_gui/usp_page/components/select_auto_complete.dart'; import 'package:privacy_gui/usp_page/ipv6_port_service/providers/usp_ipv6_port_service_notifier.dart'; import 'package:privacy_gui/usp_page/ipv6_port_service/services/usp_ipv6_port_service_service.dart'; import 'package:privacy_gui/usp_page/dashboard/providers/usp_dashboard_notifier.dart'; @@ -195,11 +194,11 @@ class UspIpv6PortServiceView extends ConsumerWidget { } } - List _buildIpv6DeviceOptions(WidgetRef ref) { + List _buildIpv6DeviceOptions(WidgetRef ref) { final devices = ref.read(uspDashboardProvider).valueOrNull?.deviceModels ?? []; return devices - .expand((d) => d.ipv6Addresses.map((addr) => AutoCompleteOption( + .expand((d) => d.ipv6Addresses.map((addr) => AppAutoCompleteOption( label: d.displayName, value: addr, subtitle: d.mac, diff --git a/lib/usp_page/local_network/providers/usp_local_network_notifier.dart b/lib/usp_page/local_network/providers/usp_local_network_notifier.dart index cb4cada5e..ba1409ced 100644 --- a/lib/usp_page/local_network/providers/usp_local_network_notifier.dart +++ b/lib/usp_page/local_network/providers/usp_local_network_notifier.dart @@ -81,7 +81,7 @@ class UspLocalNetworkNotifier final svc = ref.read(uspLocalNetworkServiceProvider); final uiModel = svc.buildUIModel(data); - logger.d('[USP] LocalNetwork fetched β€” ' + logger.d('[USP][Network][LAN]LocalNetwork fetched β€” ' 'ip: ${uiModel.ipAddress}, ' 'dhcp: ${uiModel.dhcpEnabled}, ' 'pool: ${uiModel.minAddress}-${uiModel.maxAddress}'); @@ -167,7 +167,7 @@ class UspLocalNetworkNotifier : null, ); - logger.d('[USP] LocalNetwork saved'); + logger.d('[USP][Network][LAN]LocalNetwork saved'); // Re-fetch to confirm changes took effect. ref.invalidateSelf(); diff --git a/lib/usp_page/network_diagnostics/providers/usp_network_diagnostics_notifier.dart b/lib/usp_page/network_diagnostics/providers/usp_network_diagnostics_notifier.dart index b7139de05..49a8894f2 100644 --- a/lib/usp_page/network_diagnostics/providers/usp_network_diagnostics_notifier.dart +++ b/lib/usp_page/network_diagnostics/providers/usp_network_diagnostics_notifier.dart @@ -90,7 +90,7 @@ class UspNetworkDiagnosticsNotifier final pingResult = PingResult.fromOperateResult(result, s.host); - logger.d('[Diagnostics] Ping complete β€” ' + logger.d('[USP][Diagnostics]Ping complete β€” ' 'avg=${pingResult.avgResponseTime}ms, ' '${pingResult.successCount}/${pingResult.totalCount} success'); @@ -99,13 +99,13 @@ class UspNetworkDiagnosticsNotifier pingResult: pingResult, )); } on TimeoutException catch (e) { - logger.w('[Diagnostics] Ping timeout: $e'); + logger.w('[USP][Diagnostics]Ping timeout: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, errorMessage: 'Ping timed out β€” no response from ${s.host}', )); } catch (e) { - logger.w('[Diagnostics] Ping failed: $e'); + logger.w('[USP][Diagnostics]Ping failed: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, errorMessage: 'Ping failed: $e', @@ -140,7 +140,7 @@ class UspNetworkDiagnosticsNotifier final traceResult = TracerouteResult.fromOperateResult(result, s.host); - logger.d('[Diagnostics] Traceroute complete β€” ' + logger.d('[USP][Diagnostics]Traceroute complete β€” ' '${traceResult.hops.length} hops'); state = AsyncData(state.requireValue.copyWith( @@ -148,13 +148,13 @@ class UspNetworkDiagnosticsNotifier tracerouteResult: traceResult, )); } on TimeoutException catch (e) { - logger.w('[Diagnostics] Traceroute timeout: $e'); + logger.w('[USP][Diagnostics]Traceroute timeout: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, errorMessage: 'Traceroute timed out β€” route to ${s.host} incomplete', )); } catch (e) { - logger.w('[Diagnostics] Traceroute failed: $e'); + logger.w('[USP][Diagnostics]Traceroute failed: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, errorMessage: 'Traceroute failed: $e', diff --git a/lib/usp_page/port_forwarding/views/components/usp_single_port_tab.dart b/lib/usp_page/port_forwarding/views/components/usp_single_port_tab.dart index b6cf31dbb..6d59fd0d1 100644 --- a/lib/usp_page/port_forwarding/views/components/usp_single_port_tab.dart +++ b/lib/usp_page/port_forwarding/views/components/usp_single_port_tab.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/usp_page/components/select_auto_complete.dart'; import 'package:privacy_gui/usp_page/dashboard/models/port_forwarding_rule_ui_model.dart'; import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/usp_page/dashboard/providers/usp_dashboard_notifier.dart'; @@ -96,12 +95,12 @@ class UspSinglePortTab extends ConsumerWidget { ); } - List _buildIpv4DeviceOptions(WidgetRef ref) { + List _buildIpv4DeviceOptions(WidgetRef ref) { final devices = ref.read(uspDashboardProvider).valueOrNull?.deviceModels ?? []; return devices .where((d) => d.ip.isNotEmpty) - .map((d) => AutoCompleteOption( + .map((d) => AppAutoCompleteOption( label: d.displayName, value: d.ip, subtitle: d.mac, diff --git a/lib/usp_page/static_routing/providers/usp_static_routing_notifier.dart b/lib/usp_page/static_routing/providers/usp_static_routing_notifier.dart index 7aa07f7b1..cd462a445 100644 --- a/lib/usp_page/static_routing/providers/usp_static_routing_notifier.dart +++ b/lib/usp_page/static_routing/providers/usp_static_routing_notifier.dart @@ -68,7 +68,7 @@ class UspStaticRoutingNotifier final svc = ref.read(uspStaticRoutingServiceProvider); final routes = svc.buildRouteUIModels(data); - logger.d('[USP] StaticRouting fetched β€” ' + logger.d('[USP][Network][Routing]StaticRouting fetched β€” ' 'total: ${data.items.length}, static: ${routes.length}'); return UspStaticRoutingState(routes: routes); @@ -105,7 +105,7 @@ class UspStaticRoutingNotifier interface_: interfacePath, alias: name, ); - logger.d('[USP] StaticRoute added β€” name: $name'); + logger.d('[USP][Network][Routing]StaticRoute added β€” name: $name'); await _refreshRoutes(); } catch (e) { state = AsyncData(s.copyWith(isMutating: false)); @@ -140,7 +140,7 @@ class UspStaticRoutingNotifier alias: name, ), ); - logger.d('[USP] StaticRoute updated β€” $instancePath'); + logger.d('[USP][Network][Routing]StaticRoute updated β€” $instancePath'); await _refreshRoutes(); } catch (e) { state = AsyncData(s.copyWith(isMutating: false)); @@ -159,7 +159,8 @@ class UspStaticRoutingNotifier usp, StaticRouteUpdate(instancePath: instancePath, enable: enabled), ); - logger.d('[USP] StaticRoute toggled β€” $instancePath β†’ $enabled'); + logger.d( + '[USP][Network][Routing]StaticRoute toggled β€” $instancePath β†’ $enabled'); await _refreshRoutes(); } catch (e) { state = AsyncData(s.copyWith(isMutating: false)); @@ -175,7 +176,7 @@ class UspStaticRoutingNotifier try { final usp = ref.read(uspServiceProvider)!; await StaticRouting.delete(usp, instancePath); - logger.d('[USP] StaticRoute deleted β€” $instancePath'); + logger.d('[USP][Network][Routing]StaticRoute deleted β€” $instancePath'); await _refreshRoutes(); } catch (e) { state = AsyncData(s.copyWith(isMutating: false)); diff --git a/lib/usp_page/test_console/views/usp_test_console_view.dart b/lib/usp_page/test_console/views/usp_test_console_view.dart index eef346f7e..4c801399c 100644 --- a/lib/usp_page/test_console/views/usp_test_console_view.dart +++ b/lib/usp_page/test_console/views/usp_test_console_view.dart @@ -9,6 +9,7 @@ import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/usp/providers/sse_providers.dart'; import 'package:privacy_gui/usp/providers/usp_service_provider.dart'; +import 'package:privacy_gui/usp/services/sse_connection_manager.dart'; import 'package:privacy_gui/usp/services/usp_bridge_client.dart'; import 'package:privacy_gui/usp/services/usp_service.dart'; import 'package:privacy_gui/usp/web/usp_wasm_init.dart'; @@ -270,11 +271,11 @@ class _UspTestConsoleViewState extends ConsumerState { try { final args = Map.from(jsonDecode(argsJson) as Map? ?? {}); final response = await _service!.operate(command, args: args); - _log(' commandKey = ${response.commandKey}'); - if (response.data.isEmpty) { + _log(' commandKey = ${response['commandKey']}'); + if (response.isEmpty) { _log('OPERATE OK (no output)'); } else { - for (final entry in response.data.entries) { + for (final entry in response.entries) { _log(' ${entry.key} = ${entry.value}'); } } @@ -656,15 +657,8 @@ class _UspTestConsoleViewState extends ConsumerState { ), ), // Status badges - if (_sseConnected) - Padding( - padding: const EdgeInsets.only(right: 8), - child: _buildStatusBadge( - context, - label: 'SSE', - color: Theme.of(context).colorScheme.primary, - ), - ), + _buildSseStateBadge(context), + const SizedBox(width: 8), _buildStatusBadge( context, label: _usingSharedSession @@ -681,6 +675,25 @@ class _UspTestConsoleViewState extends ConsumerState { ); } + Widget _buildSseStateBadge(BuildContext context) { + final sseState = ref.watch(sseConnectionStateProvider); + final state = sseState.valueOrNull ?? SseConnectionState.disconnected; + final colorScheme = Theme.of(context).colorScheme; + + final (String label, Color color) = switch (state) { + SseConnectionState.connected => ('SSE Connected', Colors.green), + SseConnectionState.connecting => ( + 'SSE Connecting...', + colorScheme.primary + ), + SseConnectionState.reconnecting => ('SSE Reconnecting', Colors.orange), + SseConnectionState.suspended => ('SSE Suspended', colorScheme.error), + SseConnectionState.disconnected => ('SSE Off', colorScheme.outline), + }; + + return _buildStatusBadge(context, label: label, color: color); + } + Widget _buildStatusBadge( BuildContext context, { required String label, diff --git a/lib/usp_page/wifi_settings/models/wifi_network_ui_model.dart b/lib/usp_page/wifi_settings/models/wifi_network_ui_model.dart index 09c856abe..24101d712 100644 --- a/lib/usp_page/wifi_settings/models/wifi_network_ui_model.dart +++ b/lib/usp_page/wifi_settings/models/wifi_network_ui_model.dart @@ -61,6 +61,17 @@ class WifiNetworkUIModel extends Equatable { /// e.g. "a,n,ac,ax" β€” read-only, used to derive available options. final String supportedStandards; + /// Supported channel bandwidths from Device.WiFi.Radio.{i}.SupportedOperatingChannelBandwidths. + /// e.g. ['Auto', '20MHz', '40MHz', '80MHz']. Empty list = firmware didn't provide data. + final List supportedBandwidths; + + /// Channels available for each bandwidth, computed from possibleChannels + /// using IEEE 802.11 bonding rules. + /// Key = bandwidth string ("Auto", "20MHz", "40MHz", etc.) + /// Value = sorted list of valid primary channel numbers. + /// Empty map = bonding data not computed (fallback to possibleChannels). + final Map> availableChannelsPerBandwidth; + const WifiNetworkUIModel({ required this.ssidInstancePath, this.accessPointInstancePath, @@ -79,6 +90,8 @@ class WifiNetworkUIModel extends Equatable { required this.possibleChannels, required this.operatingStandards, required this.supportedStandards, + this.supportedBandwidths = const [], + this.availableChannelsPerBandwidth = const {}, }); /// Display name for the band tab/header @@ -116,6 +129,8 @@ class WifiNetworkUIModel extends Equatable { List? possibleChannels, String? operatingStandards, String? supportedStandards, + List? supportedBandwidths, + Map>? availableChannelsPerBandwidth, }) { return WifiNetworkUIModel( ssidInstancePath: ssidInstancePath ?? this.ssidInstancePath, @@ -138,6 +153,9 @@ class WifiNetworkUIModel extends Equatable { possibleChannels: possibleChannels ?? this.possibleChannels, operatingStandards: operatingStandards ?? this.operatingStandards, supportedStandards: supportedStandards ?? this.supportedStandards, + supportedBandwidths: supportedBandwidths ?? this.supportedBandwidths, + availableChannelsPerBandwidth: + availableChannelsPerBandwidth ?? this.availableChannelsPerBandwidth, ); } @@ -160,6 +178,8 @@ class WifiNetworkUIModel extends Equatable { possibleChannels, operatingStandards, supportedStandards, + supportedBandwidths, + availableChannelsPerBandwidth, ]; Map toMap() => { @@ -180,6 +200,8 @@ class WifiNetworkUIModel extends Equatable { 'possibleChannels': possibleChannels, 'operatingStandards': operatingStandards, 'supportedStandards': supportedStandards, + 'supportedBandwidths': supportedBandwidths, + 'availableChannelsPerBandwidth': availableChannelsPerBandwidth, }; Map toJson() => toMap(); @@ -205,6 +227,13 @@ class WifiNetworkUIModel extends Equatable { possibleChannels: (map['possibleChannels'] as List?)?.cast() ?? [], operatingStandards: map['operatingStandards'] as String? ?? '', supportedStandards: map['supportedStandards'] as String? ?? '', + supportedBandwidths: + (map['supportedBandwidths'] as List?)?.cast() ?? [], + availableChannelsPerBandwidth: + (map['availableChannelsPerBandwidth'] as Map?)?.map( + (k, v) => MapEntry(k as String, (v as List).cast()), + ) ?? + {}, ); factory WifiNetworkUIModel.fromJson(Map json) => diff --git a/lib/usp_page/wifi_settings/providers/usp_wifi_advanced_provider.dart b/lib/usp_page/wifi_settings/providers/usp_wifi_advanced_provider.dart index 4cd968871..b663e8e15 100644 --- a/lib/usp_page/wifi_settings/providers/usp_wifi_advanced_provider.dart +++ b/lib/usp_page/wifi_settings/providers/usp_wifi_advanced_provider.dart @@ -30,7 +30,7 @@ class UspWifiAdvancedNotifier if (!usp.isAuthenticated) throw StateError('USP not authenticated'); } - logger.d('[WiFiAdvanced] Fetching advanced settings...'); + logger.d('[USP][WiFi][Advanced]Fetching advanced settings...'); final response = await usp.get([_ieee80211hPath]); @@ -46,7 +46,7 @@ class UspWifiAdvancedNotifier } } - logger.d('[WiFiAdvanced] radios=${ieee80211h.length}'); + logger.d('[USP][WiFi][Advanced]radios=${ieee80211h.length}'); return UspWifiAdvancedState(ieee80211hByRadio: ieee80211h); } diff --git a/lib/usp_page/wifi_settings/providers/usp_wifi_settings_provider.dart b/lib/usp_page/wifi_settings/providers/usp_wifi_settings_provider.dart index 686a0f87d..d1b751a3d 100644 --- a/lib/usp_page/wifi_settings/providers/usp_wifi_settings_provider.dart +++ b/lib/usp_page/wifi_settings/providers/usp_wifi_settings_provider.dart @@ -81,7 +81,7 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier } } - logger.d('[WiFiSettings] Fetching WiFi data...'); + logger.d('[USP][WiFi]Fetching WiFi data...'); final results = await Future.wait([ WiFiSsids.fetch(usp), @@ -101,7 +101,7 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier final quickSetup = _svc.buildQuickSetupNetworks(networks); - logger.d('[WiFiSettings] Loaded ${networks.length} networks, ' + logger.d('[USP][WiFi]Loaded ${networks.length} networks, ' 'isQuickSetup=${quickSetup.isQuickSetup}'); // Preserve the current quickSetupEnabled flag across re-fetches so the @@ -326,7 +326,7 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier final current = state.settings.current; final updatedNetworks = current.networks.map((n) { if (n.ssidInstancePath != ssidInstancePath) return n; - return n.copyWith( + var updated = n.copyWith( enabled: enabled, ssid: ssid, keyPassphrase: password, @@ -337,6 +337,20 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier channel: autoChannel == true ? n.channel : (channel ?? n.channel), autoChannelEnable: autoChannel, ); + + // Auto-reset channel to Auto when bandwidth changes and the current + // manual channel is no longer valid for the new bandwidth. + if (channelBandwidth != null && !updated.autoChannelEnable) { + final validChannels = + updated.availableChannelsPerBandwidth[channelBandwidth]; + if (validChannels != null && + validChannels.isNotEmpty && + !validChannels.contains(updated.channel)) { + updated = updated.copyWith(autoChannelEnable: true); + } + } + + return updated; }).toList(); state = state.copyWith( diff --git a/lib/usp_page/wifi_settings/services/usp_wifi_settings_service.dart b/lib/usp_page/wifi_settings/services/usp_wifi_settings_service.dart index bd1dd66c6..16ef90a5b 100644 --- a/lib/usp_page/wifi_settings/services/usp_wifi_settings_service.dart +++ b/lib/usp_page/wifi_settings/services/usp_wifi_settings_service.dart @@ -5,6 +5,7 @@ import 'package:privacy_gui/generated/wi_fi_radios.g.dart'; import 'package:privacy_gui/generated/wi_fi_ssids.g.dart'; import 'package:privacy_gui/usp_page/wifi_settings/models/wifi_network_ui_model.dart'; import 'package:privacy_gui/usp_page/wifi_settings/models/wifi_quick_setup_network.dart'; +import 'package:privacy_gui/usp_page/wifi_settings/services/wifi_channel_bonding.dart'; final uspWifiSettingsServiceProvider = Provider((_) => UspWifiSettingsService()); @@ -41,7 +42,7 @@ class UspWifiSettingsService { radioByPath[_ensureTrailingDot(r.instancePath)] = r; } - logger.d('[WiFiSettings] Building networks: ' + logger.d('[USP][WiFi]Building networks: ' '${ssids.items.length} SSIDs, ' '${accessPoints.items.length} APs, ' '${radios.items.length} radios'); @@ -57,7 +58,7 @@ class UspWifiSettingsService { final radioPath = _ensureTrailingDot(ssid.lowerLayers); final radio = radioByPath[radioPath]; - logger.d('[WiFiSettings] SSID ${ssid.ssid}: ' + logger.d('[USP][WiFi]SSID ${ssid.ssid}: ' 'AP=${ap?.instancePath ?? "none"}, ' 'radio=${radio?.operatingFrequencyBand ?? "none"}'); @@ -69,6 +70,18 @@ class UspWifiSettingsService { // e.g. "None, WPA2-Personal, WPA3-Personal" β†’ ['None', 'WPA2-Personal', 'WPA3-Personal'] final supportedModes = _parseModesSupported(ap?.modesSupported ?? ''); + final band = _normalizeBand(radio?.operatingFrequencyBand ?? ''); + final possibleChannels = + _parsePossibleChannels(radio?.possibleChannels ?? ''); + final supportedBandwidths = _parseSupportedBandwidths( + radio?.supportedOperatingChannelBandwidths ?? ''); + + final channelsPerBw = computeChannelsPerBandwidth( + band: band, + possibleChannels: possibleChannels, + supportedBandwidths: supportedBandwidths, + ); + networks.add(WifiNetworkUIModel( ssidInstancePath: ssid.instancePath, accessPointInstancePath: ap?.instancePath, @@ -80,13 +93,15 @@ class UspWifiSettingsService { securityMode: ap?.securityModeEnabled ?? '', keyPassphrase: ap?.keyPassphrase ?? '', isGuest: isGuest, - band: _normalizeBand(radio?.operatingFrequencyBand ?? ''), + band: band, channel: radio?.channel ?? 0, channelBandwidth: radio?.operatingChannelBandwidth ?? '', autoChannelEnable: radio?.autoChannelEnable ?? true, - possibleChannels: _parsePossibleChannels(radio?.possibleChannels ?? ''), + possibleChannels: possibleChannels, operatingStandards: radio?.operatingStandards ?? '', supportedStandards: radio?.supportedStandards ?? '', + supportedBandwidths: supportedBandwidths, + availableChannelsPerBandwidth: channelsPerBw, )); } @@ -203,6 +218,17 @@ String _ensureTrailingDot(String path) { return path.endsWith('.') ? path : '$path.'; } +/// Parses a TR-181 SupportedOperatingChannelBandwidths string. +/// e.g. "Auto,20MHz,40MHz,80MHz" β†’ ['Auto', '20MHz', '40MHz', '80MHz'] +List _parseSupportedBandwidths(String raw) { + if (raw.isEmpty) return []; + return raw + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); +} + /// Normalizes TR-181 OperatingFrequencyBand to display string. String _normalizeBand(String rawBand) { final lower = rawBand.toLowerCase(); diff --git a/lib/usp_page/wifi_settings/services/wifi_channel_bonding.dart b/lib/usp_page/wifi_settings/services/wifi_channel_bonding.dart new file mode 100644 index 000000000..115056306 --- /dev/null +++ b/lib/usp_page/wifi_settings/services/wifi_channel_bonding.dart @@ -0,0 +1,288 @@ +/// IEEE 802.11 channel bonding rules for computing valid primary channels +/// per bandwidth. +/// +/// This is a pure utility with no Flutter or provider dependencies. +/// All group tables are IEEE 802.11 standard constants. + +/// Computes a map of bandwidth -> valid primary channels, filtered by +/// what the router actually supports ([possibleChannels]). +/// +/// [band]: normalized band string ("2.4GHz", "5GHz", "6GHz") +/// [possibleChannels]: from Device.WiFi.Radio.{i}.PossibleChannels (parsed) +/// [supportedBandwidths]: from Device.WiFi.Radio.{i}.SupportedOperatingChannelBandwidths (parsed) +/// +/// Returns: { "20MHz": [1,2,...], "40MHz": [1,5,9], "80MHz": [...], ... } +/// The "Auto" key always maps to all possibleChannels. +Map> computeChannelsPerBandwidth({ + required String band, + required List possibleChannels, + required List supportedBandwidths, +}) { + if (possibleChannels.isEmpty) return {}; + + final result = >{}; + final possibleSet = possibleChannels.toSet(); + + // "Auto" always includes all possible channels. + result['Auto'] = List.of(possibleChannels); + + // Determine which bandwidths to compute. + // If supportedBandwidths is provided, use it; otherwise compute all known widths for the band. + final widths = supportedBandwidths.isNotEmpty + ? supportedBandwidths.where((bw) => bw != 'Auto').toList() + : _defaultWidthsForBand(band); + + for (final bw in widths) { + final channels = _channelsForBandwidth(band, bw, possibleSet); + if (channels.isNotEmpty) { + result[bw] = channels; + } + } + + return result; +} + +List _defaultWidthsForBand(String band) { + return switch (band) { + '2.4GHz' => ['20MHz', '40MHz'], + '5GHz' => ['20MHz', '40MHz', '80MHz', '160MHz'], + '6GHz' => ['20MHz', '40MHz', '80MHz', '160MHz'], + _ => ['20MHz'], + }; +} + +List _channelsForBandwidth(String band, String bw, Set possible) { + return switch (band) { + '2.4GHz' => _channels24(bw, possible), + '5GHz' => _channels5(bw, possible), + '6GHz' => _channels6(bw, possible), + _ => possible.toList()..sort(), + }; +} + +// --------------------------------------------------------------------------- +// 2.4 GHz bonding rules +// --------------------------------------------------------------------------- + +List _channels24(String bw, Set possible) { + return switch (bw) { + '20MHz' => possible.toList()..sort(), + '40MHz' => _filterByGroups(possible, _bondingGroups24_40), + _ => possible.toList()..sort(), + }; +} + +/// 2.4 GHz 40 MHz: HT40+ pairs. +/// A channel is a valid primary if its bonding partner (primary + 4) exists. +/// Standard pairs: (1,5), (2,6), (3,7), (4,8), (5,9), (6,10), (7,11), (8,12), (9,13). +const _bondingGroups24_40 = [ + [1, 5], + [2, 6], + [3, 7], + [4, 8], + [5, 9], + [6, 10], + [7, 11], + [8, 12], + [9, 13], +]; + +// --------------------------------------------------------------------------- +// 5 GHz bonding rules +// --------------------------------------------------------------------------- + +List _channels5(String bw, Set possible) { + return switch (bw) { + '20MHz' => possible.toList()..sort(), + '40MHz' => _filterByGroups(possible, _bondingGroups5_40), + '80MHz' => _filterByGroups(possible, _bondingGroups5_80), + '160MHz' => _filterByGroups(possible, _bondingGroups5_160), + _ => possible.toList()..sort(), + }; +} + +/// 5 GHz 40 MHz bonding pairs (IEEE 802.11n/ac/ax). +const _bondingGroups5_40 = [ + [36, 40], + [44, 48], + [52, 56], + [60, 64], + [100, 104], + [108, 112], + [116, 120], + [124, 128], + [132, 136], + [140, 144], + [149, 153], + [157, 161], + [165, 169], + [173, 177], +]; + +/// 5 GHz 80 MHz bonding groups (IEEE 802.11ac/ax). +const _bondingGroups5_80 = [ + [36, 40, 44, 48], + [52, 56, 60, 64], + [100, 104, 108, 112], + [116, 120, 124, 128], + [132, 136, 140, 144], + [149, 153, 157, 161], + [165, 169, 173, 177], +]; + +/// 5 GHz 160 MHz bonding groups (IEEE 802.11ac Wave 2 / ax). +const _bondingGroups5_160 = [ + [36, 40, 44, 48, 52, 56, 60, 64], + [100, 104, 108, 112, 116, 120, 124, 128], +]; + +// --------------------------------------------------------------------------- +// 6 GHz bonding rules +// --------------------------------------------------------------------------- + +List _channels6(String bw, Set possible) { + return switch (bw) { + '20MHz' => possible.toList()..sort(), + '40MHz' => _filterByGroups(possible, _build6GhzGroups(2)), + '80MHz' => _filterByGroups(possible, _build6GhzGroups(4)), + '160MHz' => _filterByGroups(possible, _build6GhzGroups(8)), + '320MHz' => _filterByGroups(possible, _build6GhzGroups(16)), + _ => possible.toList()..sort(), + }; +} + +/// Builds 6 GHz bonding groups dynamically. +/// +/// 6 GHz channels: 1, 5, 9, 13, 17, 21, ..., 229, 233 (step 4). +/// 20 MHz channels use all of them as primary. +/// 40 MHz pairs: [1,5], [9,13], [17,21], ... (groups of 2, step 8) +/// 80 MHz groups: [1,5,9,13], [17,21,25,29], ... (groups of 4, step 16) +/// 160 MHz groups: [1,5,9,13,17,21,25,29], ... (groups of 8, step 32) +/// 320 MHz groups: groups of 16, step 64 +List> _build6GhzGroups(int channelsPerGroup) { + const firstChannel = 1; + const channelStep = 4; + const lastChannel = 233; + final groupStep = channelsPerGroup * channelStep; + + final groups = >[]; + for (var start = firstChannel; start <= lastChannel; start += groupStep) { + final group = []; + for (var i = 0; i < channelsPerGroup; i++) { + final ch = start + i * channelStep; + if (ch <= lastChannel) group.add(ch); + } + if (group.length == channelsPerGroup) { + groups.add(group); + } + } + return groups; +} + +// --------------------------------------------------------------------------- +// Shared group filter +// --------------------------------------------------------------------------- + +/// Given a set of available channels and bonding group definitions, +/// returns all channels from [possible] that belong to a group where +/// at least 2 members (for pairs) or all members (for wider groups) are present. +/// +/// For 40 MHz (2-member groups): both channels in the pair must exist. +/// For 80/160/320 MHz (4+): all channels in the group must exist. +List _filterByGroups(Set possible, List> groups) { + final result = {}; + for (final group in groups) { + final presentInGroup = group.where(possible.contains).toList(); + // For a bonding group to be valid, all members must be present. + if (presentInGroup.length == group.length) { + result.addAll(presentInGroup); + } + } + final sorted = result.toList()..sort(); + return sorted; +} + +// --------------------------------------------------------------------------- +// Wireless mode ↔ bandwidth constraints +// --------------------------------------------------------------------------- + +/// Ordered bandwidth values from narrowest to widest. +const bandwidthOrder = ['20MHz', '40MHz', '80MHz', '160MHz', '320MHz']; + +/// Returns the numeric index of a bandwidth string in [bandwidthOrder]. +/// "Auto" returns the highest index (no filtering). Unknown values return -1. +int bandwidthIndex(String bw) { + if (bw == 'Auto') return bandwidthOrder.length; // Auto = no constraint + return bandwidthOrder.indexOf(bw); +} + +/// Maximum bandwidth supported by each IEEE 802.11 standard amendment. +const _maxBandwidthByStandard = { + 'b': '20MHz', + 'g': '20MHz', + 'a': '20MHz', + 'n': '40MHz', + 'ac': '160MHz', + 'ax': '160MHz', + 'be': '320MHz', +}; + +/// Returns the maximum bandwidth the given operating standards can support. +/// +/// [operatingStandards] can be comma-separated ("a,n,ac,ax") or concatenated +/// ("anacax"). Returns "20MHz" for empty/unknown input. +String maxBandwidthForStandards(String operatingStandards) { + if (operatingStandards.isEmpty) return '320MHz'; // "mixed" = no limit + + final standards = _parseStandardsSet(operatingStandards); + var maxIdx = 0; + for (final std in standards) { + final bw = _maxBandwidthByStandard[std]; + if (bw != null) { + final idx = bandwidthOrder.indexOf(bw); + if (idx > maxIdx) maxIdx = idx; + } + } + return bandwidthOrder[maxIdx]; +} + +/// Returns the minimum WiFi standard needed to support the given [bandwidth]. +/// +/// Used for bidirectional filtering: selecting 80MHz requires at least 802.11ac. +/// Returns null for "Auto" or "20MHz" (any standard works). +String? minStandardForBandwidth(String bandwidth) { + if (bandwidth == 'Auto' || bandwidth.isEmpty) return null; + final idx = bandwidthOrder.indexOf(bandwidth); + if (idx <= 0) return null; // 20MHz or unknown + if (idx == 1) return 'n'; // 40MHz + if (idx <= 3) return 'ac'; // 80MHz, 160MHz + return 'be'; // 320MHz +} + +/// Parses a wireless standards string into a normalized Set. +/// +/// Handles comma-separated ("a,n,ac,ax") and concatenated ("anacax"). +Set _parseStandardsSet(String raw) { + final lower = raw.toLowerCase().trim(); + if (lower == 'mixed') return _maxBandwidthByStandard.keys.toSet(); + + if (lower.contains(',')) { + return lower + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toSet(); + } + + // Concatenated form: try to parse known standard names + const known = ['be', 'ax', 'ac', 'n', 'g', 'b', 'a']; + final found = {}; + var remaining = lower; + for (final std in known) { + while (remaining.contains(std)) { + found.add(std); + remaining = remaining.replaceFirst(std, ''); + } + } + return found; +} diff --git a/lib/usp_page/wifi_settings/views/components/wifi_network_card.dart b/lib/usp_page/wifi_settings/views/components/wifi_network_card.dart index 8bbd2eb16..a48c795eb 100644 --- a/lib/usp_page/wifi_settings/views/components/wifi_network_card.dart +++ b/lib/usp_page/wifi_settings/views/components/wifi_network_card.dart @@ -5,6 +5,7 @@ import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/validator_rules/_validator_rules.dart'; import 'package:privacy_gui/usp_page/wifi_settings/models/wifi_network_ui_model.dart'; import 'package:privacy_gui/usp_page/wifi_settings/providers/usp_wifi_settings_provider.dart'; +import 'package:privacy_gui/usp_page/wifi_settings/services/wifi_channel_bonding.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// Card for a single WiFi network in Advanced mode. @@ -333,7 +334,20 @@ class WifiNetworkCard extends ConsumerWidget { Future _editWifiMode( BuildContext context, WidgetRef ref, WifiNetworkUIModel n) async { - final options = _wifiModeOptions(n.supportedStandards); + final allOptions = _wifiModeOptions(n.supportedStandards); + if (allOptions.isEmpty) return; + + // Filter by current channel width: e.g. 80MHz requires at least ac. + // "Auto" imposes no constraint (minStandardForBandwidth returns null). + final minStd = minStandardForBandwidth(n.channelBandwidth); + final bwIdx = bandwidthOrder.indexOf(n.channelBandwidth); // -1 for Auto + final options = minStd != null && bwIdx > 0 + ? allOptions.where((mode) { + final maxBw = maxBandwidthForStandards(mode); + return bandwidthOrder.indexOf(maxBw) >= bwIdx; + }).toList() + : allOptions; + if (options.isEmpty) return; final current = _toFirmwareMode(n.operatingStandards); @@ -371,11 +385,29 @@ class WifiNetworkCard extends ConsumerWidget { Future _editChannelWidth( BuildContext context, WidgetRef ref, WifiNetworkUIModel n) async { - final options = switch (n.band) { - '2.4GHz' => ['Auto', '20MHz', '40MHz'], - '6GHz' => ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], - _ => ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], - }; + var allOptions = n.supportedBandwidths.isNotEmpty + ? n.supportedBandwidths + : switch (n.band) { + '2.4GHz' => ['Auto', '20MHz', '40MHz'], + '6GHz' => ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], + _ => ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], + }; + + // Ensure "Auto" is always available (router may omit it from + // SupportedOperatingChannelBandwidths). + if (!allOptions.contains('Auto')) { + allOptions = ['Auto', ...allOptions]; + } + + // Filter by current WiFi mode's maximum supported bandwidth. + final maxBw = maxBandwidthForStandards(n.operatingStandards); + final maxIdx = bandwidthIndex(maxBw); + final options = allOptions.where((bw) { + if (bw == 'Auto') return true; // Auto is always valid + final idx = bandwidthOrder.indexOf(bw); + return idx >= 0 && idx <= maxIdx; + }).toList(); + final current = n.channelBandwidth.isNotEmpty ? n.channelBandwidth : 'Auto'; String selected = options.contains(current) ? current : options.first; @@ -385,9 +417,16 @@ class WifiNetworkCard extends ConsumerWidget { content: StatefulBuilder( builder: (ctx, setState) => AppRadioList( selected: selected, - items: options - .map((e) => AppRadioListItem(title: e, value: e)) - .toList(), + items: options.map((bw) { + final chCount = n.availableChannelsPerBandwidth[bw]?.length; + return AppRadioListItem( + title: bw, + value: bw, + descriptionWidget: chCount != null + ? AppText.bodySmall('$chCount channels available') + : null, + ); + }).toList(), onChanged: (_, value) { if (value != null) setState(() => selected = value); }, @@ -411,9 +450,17 @@ class WifiNetworkCard extends ConsumerWidget { const autoLabel = 'Auto'; final currentLabel = n.autoChannelEnable ? autoLabel : n.channel.toString(); + // Use per-bandwidth filtered channels when available; fall back to all. + final channelsForCurrentBw = + n.availableChannelsPerBandwidth[n.channelBandwidth]; + final effectiveChannels = + (channelsForCurrentBw != null && channelsForCurrentBw.isNotEmpty) + ? channelsForCurrentBw + : n.possibleChannels; + final channelItems = [ AppRadioListItem(title: autoLabel, value: autoLabel), - ...n.possibleChannels.map( + ...effectiveChannels.map( (ch) => AppRadioListItem( title: ch.toString(), value: ch.toString(), diff --git a/lib/usp_page/wifi_settings/views/tabs/wifi_list_tab.dart b/lib/usp_page/wifi_settings/views/tabs/wifi_list_tab.dart index 4794779fd..76adc5532 100644 --- a/lib/usp_page/wifi_settings/views/tabs/wifi_list_tab.dart +++ b/lib/usp_page/wifi_settings/views/tabs/wifi_list_tab.dart @@ -21,27 +21,8 @@ class UspWifiListTab extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(uspWifiSettingsProvider); - // Loading / error states - if (state.status.isLoading || state.settings.current.networks.isEmpty) { - if (state.status.errorMessage != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - color: Theme.of(context).colorScheme.error), - AppGap.md(), - AppText.bodyMedium( - 'Failed to load WiFi settings.', - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ); - } + // Loading state + if (state.status.isLoading) { return const Center( child: Padding( padding: EdgeInsets.all(AppSpacing.xxxl), @@ -50,6 +31,29 @@ class UspWifiListTab extends ConsumerWidget { ); } + // Error or empty state + if (state.status.errorMessage != null || + state.settings.current.networks.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon.font(Icons.error_outline, + color: Theme.of(context).colorScheme.error), + AppGap.md(), + AppText.bodyMedium( + state.status.errorMessage ?? + 'No WiFi networks found. Check router connection.', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } + final quickSetupEnabled = state.settings.current.quickSetupEnabled; final canSave = state.canSave; final isSaving = state.status.isSaving; diff --git a/test/usp_page/internet_settings/services/usp_internet_settings_service_test.dart b/test/usp_page/internet_settings/services/usp_internet_settings_service_test.dart index b20ff4b05..919584263 100644 --- a/test/usp_page/internet_settings/services/usp_internet_settings_service_test.dart +++ b/test/usp_page/internet_settings/services/usp_internet_settings_service_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:privacy_gui/usp/models/usp_response.dart'; import 'package:privacy_gui/usp/services/usp_service.dart'; import 'package:privacy_gui/usp_page/internet_settings/models/usp_internet_settings_form.dart'; import 'package:privacy_gui/usp_page/internet_settings/models/usp_wan_connection_type.dart'; @@ -114,7 +113,7 @@ void main() { group('renewDhcpLease', () { test('calls WanOperations.renewDhcpLease', () async { when(() => mockUsp.operate(any())) - .thenAnswer((_) async => UspResponse(data: {})); + .thenAnswer((_) async => {}); await service.renewDhcpLease(); @@ -125,7 +124,7 @@ void main() { group('renewDhcpv6Lease', () { test('calls WanOperations.renewDhcpv6Lease', () async { when(() => mockUsp.operate(any())) - .thenAnswer((_) async => UspResponse(data: {})); + .thenAnswer((_) async => {}); await service.renewDhcpv6Lease(); diff --git a/test/usp_page/wifi_settings/services/usp_wifi_settings_service_test.dart b/test/usp_page/wifi_settings/services/usp_wifi_settings_service_test.dart new file mode 100644 index 000000000..00ab65289 --- /dev/null +++ b/test/usp_page/wifi_settings/services/usp_wifi_settings_service_test.dart @@ -0,0 +1,577 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/generated/wi_fi_access_points.g.dart'; +import 'package:privacy_gui/generated/wi_fi_radios.g.dart'; +import 'package:privacy_gui/generated/wi_fi_ssids.g.dart'; +import 'package:privacy_gui/usp_page/wifi_settings/services/usp_wifi_settings_service.dart'; + +void main() { + late UspWifiSettingsService svc; + + setUp(() { + svc = UspWifiSettingsService(); + }); + + // ------------------------------------------------------------------------- + // buildWifiNetworks β€” supportedBandwidths & availableChannelsPerBandwidth + // ------------------------------------------------------------------------- + + group('buildWifiNetworks', () { + test('populates supportedBandwidths from radio field', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'MyNetwork', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'None,WPA2-Personal,WPA3-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'test1234', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48,52,56,60,64', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + expect(networks, hasLength(1)); + final n = networks.first; + + // supportedBandwidths parsed from comma-separated string + expect(n.supportedBandwidths, ['Auto', '20MHz', '40MHz', '80MHz']); + }); + + test('populates availableChannelsPerBandwidth with bonding rules', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'TestNet', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48,52,56,60,64', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: false, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz,160MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + final n = networks.first; + final bwMap = n.availableChannelsPerBandwidth; + + // Auto β†’ all channels + expect(bwMap['Auto'], [36, 40, 44, 48, 52, 56, 60, 64]); + + // 20MHz β†’ all channels + expect(bwMap['20MHz'], [36, 40, 44, 48, 52, 56, 60, 64]); + + // 40MHz β†’ valid pairs: (36,40), (44,48), (52,56), (60,64) + expect(bwMap['40MHz'], [36, 40, 44, 48, 52, 56, 60, 64]); + + // 80MHz β†’ valid groups: [36,40,44,48], [52,56,60,64] + expect(bwMap['80MHz'], [36, 40, 44, 48, 52, 56, 60, 64]); + + // 160MHz β†’ valid group: [36..64] + expect(bwMap['160MHz'], [36, 40, 44, 48, 52, 56, 60, 64]); + }); + + test('empty supportedOperatingChannelBandwidths falls back to defaults', + () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'FallbackNet', + enable: true, + status: 'Up', + bssid: '11:22:33:44:55:66', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,2,3,4,5,6,7,8,9,10,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: '', // empty β†’ fallback + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + final n = networks.first; + + // supportedBandwidths should be empty (raw value was empty) + expect(n.supportedBandwidths, isEmpty); + + // But availableChannelsPerBandwidth should still be computed with defaults + final bwMap = n.availableChannelsPerBandwidth; + expect(bwMap.containsKey('Auto'), isTrue); + expect(bwMap.containsKey('20MHz'), isTrue); + expect(bwMap.containsKey('40MHz'), isTrue); + }); + + test('multi-band networks each get correct bonding', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:01', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + WiFiSsid( + instancePath: 'Device.WiFi.SSID.2.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:02', + lowerLayers: 'Device.WiFi.Radio.2.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.2.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal,WPA3-Personal', + securityModeEnabled: 'WPA3-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.2.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,2,3,4,5,6,7,8,9,10,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz', + ), + WiFiRadio( + instancePath: 'Device.WiFi.Radio.2.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: false, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + expect(networks, hasLength(2)); + + // 2.4 GHz network + final n24 = networks[0]; + expect(n24.band, '2.4GHz'); + expect(n24.supportedBandwidths, ['Auto', '20MHz', '40MHz']); + expect(n24.availableChannelsPerBandwidth['20MHz'], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + // 40MHz bonding: all channels 1-11 should be present + // because pairs (1,5),(2,6),(3,7),...,(7,11) cover 1-11 + expect(n24.availableChannelsPerBandwidth['40MHz'], isNotEmpty); + + // 5 GHz network + final n5 = networks[1]; + expect(n5.band, '5GHz'); + expect(n5.supportedBandwidths, ['Auto', '20MHz', '40MHz', '80MHz']); + expect(n5.availableChannelsPerBandwidth['20MHz'], [36, 40, 44, 48]); + expect(n5.availableChannelsPerBandwidth['40MHz'], [36, 40, 44, 48]); + expect(n5.availableChannelsPerBandwidth['80MHz'], [36, 40, 44, 48]); + }); + + test('normalizes band strings correctly', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Test6GHz', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA3-Personal', + securityModeEnabled: 'WPA3-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 1, + operatingFrequencyBand: '6GHz', // already normalized + operatingChannelBandwidth: '80MHz', + possibleChannels: '1,5,9,13,17,21,25,29', + operatingStandards: 'ax', + supportedStandards: 'ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz,160MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + final n = networks.first; + expect(n.band, '6GHz'); + + // 6GHz bonding: [1,5,9,13,17,21,25,29] is a valid 160MHz group + expect(n.availableChannelsPerBandwidth['160MHz'], + [1, 5, 9, 13, 17, 21, 25, 29]); + + // 80MHz: [1,5,9,13] and [17,21,25,29] + expect(n.availableChannelsPerBandwidth['80MHz'], + [1, 5, 9, 13, 17, 21, 25, 29]); + + // 40MHz: [1,5],[9,13],[17,21],[25,29] + expect(n.availableChannelsPerBandwidth['40MHz'], + [1, 5, 9, 13, 17, 21, 25, 29]); + }); + + test('SSID without matching radio still builds network', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'NoRadio', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.99.', // no matching radio + ), + ]); + + final accessPoints = WiFiAccessPoints(items: []); + final radios = WiFiRadios(items: []); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + expect(networks, hasLength(1)); + final n = networks.first; + expect(n.supportedBandwidths, isEmpty); + expect(n.availableChannelsPerBandwidth, isEmpty); + expect(n.possibleChannels, isEmpty); + }); + + test('trailing dot normalization matches AP to SSID', () { + // AP ssidReference without trailing dot, SSID path with trailing dot + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', // with trailing dot + ssid: 'DotTest', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.1', // without trailing dot + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1', // without trailing dot + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1', // without trailing dot + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,6,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + expect(networks, hasLength(1)); + final n = networks.first; + // AP should be matched despite dot mismatch + expect(n.accessPointInstancePath, 'Device.WiFi.AccessPoint.1.'); + expect(n.securityMode, 'WPA2-Personal'); + // Radio should be matched + expect(n.band, '2.4GHz'); + expect(n.possibleChannels, [1, 6, 11]); + }); + }); + + // ------------------------------------------------------------------------- + // buildQuickSetupNetworks + // ------------------------------------------------------------------------- + + group('buildQuickSetupNetworks', () { + test('isQuickSetup true when all main networks share ssid and enabled', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: '01:01:01:01:01:01', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + WiFiSsid( + instancePath: 'Device.WiFi.SSID.2.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: '02:02:02:02:02:02', + lowerLayers: 'Device.WiFi.Radio.2.', + ), + ]); + + final accessPoints = WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal,WPA3-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.2.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal,WPA3-Personal', + securityModeEnabled: 'WPA3-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.2.', + ), + ]); + + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,6,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz', + ), + WiFiRadio( + instancePath: 'Device.WiFi.Radio.2.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: false, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz', + ), + ]); + + final networks = svc.buildWifiNetworks( + ssids: ssids, + accessPoints: accessPoints, + radios: radios, + ); + + final qs = svc.buildQuickSetupNetworks(networks); + expect(qs.isQuickSetup, isTrue); + expect(qs.main, isNotNull); + expect(qs.guest, isNull); + expect(qs.main!.ssid, 'Home'); + // Intersection of security modes + expect( + qs.main!.supportedSecurityModes, ['WPA2-Personal', 'WPA3-Personal']); + }); + }); +} diff --git a/test/usp_page/wifi_settings/services/wifi_channel_bonding_test.dart b/test/usp_page/wifi_settings/services/wifi_channel_bonding_test.dart new file mode 100644 index 000000000..435704288 --- /dev/null +++ b/test/usp_page/wifi_settings/services/wifi_channel_bonding_test.dart @@ -0,0 +1,268 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/usp_page/wifi_settings/services/wifi_channel_bonding.dart'; + +void main() { + group('computeChannelsPerBandwidth', () { + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + test('returns empty map when possibleChannels is empty', () { + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: [], + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + expect(result, isEmpty); + }); + + test('always includes Auto key with all possibleChannels', () { + final channels = [36, 40, 44, 48]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz'], + ); + expect(result['Auto'], channels); + }); + + // ----------------------------------------------------------------------- + // 2.4 GHz + // ----------------------------------------------------------------------- + + group('2.4 GHz', () { + test('20MHz returns all possible channels', () { + final channels = List.generate(13, (i) => i + 1); // 1-13 + final result = computeChannelsPerBandwidth( + band: '2.4GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + expect(result['20MHz'], channels); + }); + + test('40MHz returns valid HT40 primary channels', () { + final channels = List.generate(13, (i) => i + 1); // 1-13 + final result = computeChannelsPerBandwidth( + band: '2.4GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + // All channels 1-13 should be present since every pair (c, c+4) + // has both members in the 1-13 set. + final fortyMhz = result['40MHz']!; + expect(fortyMhz, isNotEmpty); + // Channels 1-9 can bond with +4 partner, channels 5-13 can bond with -4. + // Union of all valid pairs covers 1-13. + expect(fortyMhz, channels); + }); + + test('40MHz with limited channels filters correctly', () { + // Only channels 1 and 5 available β€” valid pair (1,5) + final result = computeChannelsPerBandwidth( + band: '2.4GHz', + possibleChannels: [1, 5], + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + expect(result['40MHz'], [1, 5]); + }); + + test('40MHz with no valid pairs returns empty', () { + // Only channels 1 and 3 β€” not a valid 40MHz pair + final result = computeChannelsPerBandwidth( + band: '2.4GHz', + possibleChannels: [1, 3], + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + expect(result.containsKey('40MHz'), isFalse); + }); + }); + + // ----------------------------------------------------------------------- + // 5 GHz + // ----------------------------------------------------------------------- + + group('5 GHz', () { + test('20MHz returns all possible channels', () { + final channels = [36, 40, 44, 48, 52, 56, 60, 64]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz', '80MHz', '160MHz'], + ); + expect(result['20MHz'], channels); + }); + + test('40MHz returns channels with valid bonding partner', () { + final channels = [36, 40, 44, 48]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + // Two pairs: (36,40), (44,48) β€” all channels present + expect(result['40MHz'], [36, 40, 44, 48]); + }); + + test('40MHz with incomplete pair filters out orphan', () { + // Channel 36 without its partner 40 + final channels = [36, 44, 48]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz'], + ); + // Only pair (44,48) is complete + expect(result['40MHz'], [44, 48]); + }); + + test('80MHz with full UNII-1 group', () { + final channels = [36, 40, 44, 48]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '40MHz', '80MHz'], + ); + expect(result['80MHz'], [36, 40, 44, 48]); + }); + + test('80MHz with incomplete group returns empty', () { + // Only 3 of 4 channels in the group + final channels = [36, 40, 44]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '80MHz'], + ); + expect(result.containsKey('80MHz'), isFalse); + }); + + test('160MHz with full 36-64 group', () { + final channels = [36, 40, 44, 48, 52, 56, 60, 64]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz', '160MHz'], + ); + expect(result['160MHz'], channels); + }); + + test('160MHz with partial 36-64 group returns empty', () { + // Missing channel 64 + final channels = [36, 40, 44, 48, 52, 56, 60]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '160MHz'], + ); + expect(result.containsKey('160MHz'), isFalse); + }); + + test('multiple 80MHz groups coexist', () { + final channels = [ + 36, 40, 44, 48, // UNII-1 + 100, 104, 108, 112, // UNII-2 extended + 149, 153, 157, 161, // UNII-3 + ]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '80MHz'], + ); + expect(result['80MHz'], channels); + }); + }); + + // ----------------------------------------------------------------------- + // 6 GHz + // ----------------------------------------------------------------------- + + group('6 GHz', () { + test('20MHz returns all possible channels', () { + final channels = [1, 5, 9, 13, 17, 21, 25, 29]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '20MHz'], + ); + expect(result['20MHz'], channels); + }); + + test('40MHz returns valid pairs', () { + final channels = [1, 5, 9, 13, 17, 21, 25, 29]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '40MHz'], + ); + // Pairs: [1,5], [9,13], [17,21], [25,29] + expect(result['40MHz'], channels); + }); + + test('80MHz returns valid groups of 4', () { + final channels = [1, 5, 9, 13, 17, 21, 25, 29]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '80MHz'], + ); + // Groups: [1,5,9,13], [17,21,25,29] + expect(result['80MHz'], channels); + }); + + test('160MHz returns valid groups of 8', () { + final channels = [1, 5, 9, 13, 17, 21, 25, 29]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '160MHz'], + ); + // Group: [1,5,9,13,17,21,25,29] + expect(result['160MHz'], channels); + }); + + test('160MHz with partial group returns empty', () { + // Only 6 of 8 channels + final channels = [1, 5, 9, 13, 17, 21]; + final result = computeChannelsPerBandwidth( + band: '6GHz', + possibleChannels: channels, + supportedBandwidths: ['Auto', '160MHz'], + ); + expect(result.containsKey('160MHz'), isFalse); + }); + }); + + // ----------------------------------------------------------------------- + // Default bandwidths (when supportedBandwidths is empty) + // ----------------------------------------------------------------------- + + group('default bandwidths fallback', () { + test('computes all known widths when supportedBandwidths is empty', () { + final channels = [36, 40, 44, 48, 52, 56, 60, 64]; + final result = computeChannelsPerBandwidth( + band: '5GHz', + possibleChannels: channels, + supportedBandwidths: [], + ); + expect(result.containsKey('Auto'), isTrue); + expect(result.containsKey('20MHz'), isTrue); + expect(result.containsKey('40MHz'), isTrue); + expect(result.containsKey('80MHz'), isTrue); + expect(result.containsKey('160MHz'), isTrue); + }); + + test('unknown band returns 20MHz only', () { + final channels = [1, 2, 3]; + final result = computeChannelsPerBandwidth( + band: 'unknown', + possibleChannels: channels, + supportedBandwidths: [], + ); + expect(result['Auto'], channels); + expect(result['20MHz'], channels); + expect(result.length, 2); // Only Auto + 20MHz + }); + }); + }); +}