Bladeren bron

Rework WiFi: native WLAN API, rich scan data, new UI

Replace Windows netsh parsing with native wlanapi.dll bindings (language and code page independent) and decode binary WLAN structures in portable, tested Go. Add 802.11 information element parsing (security, PHY/Wi-Fi generation, channel width, MIMO streams, estimated max rate, BSS load, WPS, roaming) shared by all platforms.

On Linux, replace the ad hoc iwlist parsing with tested parsers for iwlist and `iw link`, and add interface status with rfkill, IP and gateway info. Mark the connected BSS in scan results. Darwin and FreeBSD get stubs for the new methods.

Extend WiFiInfo with the new fields and add WiFiInterfaceStatus. wifi.go now accepts an `interface` parameter on scan, returns per-interface status plus capability flags from wifiinfo, and fixes a missing return in the remove handler.

Rewrite wifi.html and wifiinfo.html without jQuery: channel map, sortable and filterable network list with detail panels, live signal gauge and an interface selector.

File Manager: ignore stale search results after leaving search, navigating or starting a newer search. Fix upload retry appending the Firefox subpath twice.

Move Linux-only tests to wifi_linux_test.go and close the test DB so Windows can clean up temp files.
Toby Chui 17 uur geleden
bovenliggende
commit
369b05c9b1

+ 747 - 0
src/mod/network/wifi/ie.go

@@ -0,0 +1,747 @@
+package wifi
+
+/*
+	802.11 information element parsing
+
+	Beacons and probe responses carry a list of information elements (IEs)
+	describing what an access point can do. The OS hands them over as raw
+	bytes (the Windows WLAN API does, for every BSS of a scan), so they are
+	decoded here, in plain Go, on every platform. The input comes from the air
+	and must be treated as untrusted: every read is bounds checked.
+*/
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math"
+	"strconv"
+	"strings"
+)
+
+// Element IDs used below
+const (
+	ieSSID             = 0
+	ieSupportedRates   = 1
+	ieDSParameterSet   = 3
+	ieCountry          = 7
+	ieBSSLoad          = 11
+	ieHTCapabilities   = 45
+	ieRSN              = 48
+	ieExtSupportedRate = 50
+	ieMobilityDomain   = 54
+	ieHTOperation      = 61
+	ieRMEnabledCaps    = 70
+	ieExtCapabilities  = 127
+	ieVHTCapabilities  = 191
+	ieVHTOperation     = 192
+	ieVendorSpecific   = 221
+	ieExtension        = 255
+
+	ieExtHECapabilities  = 35
+	ieExtHEOperation     = 36
+	ieExtEHTOperation    = 106
+	ieExtEHTCapabilities = 108
+)
+
+// bssElements is what could be learnt from the IEs of one BSS
+type bssElements struct {
+	SSID       []byte
+	HasSSID    bool
+	DSChannel  int
+	Country    string
+	HasRSN     bool
+	HasWPA     bool
+	rsnAKMs    []uint32
+	wpaAKMs    []uint32
+	ciphers    []uint32
+	PMF        string
+	HT         bool
+	VHT        bool
+	HE         bool
+	EHT        bool
+	htWidth    int //20 or 40, 0 when no HT operation element
+	vhtWidth   int //80 or 160 when the VHT operation says so
+	sixGWidth  int //Width from the HE 6 GHz operation information
+	ehtWidth   int //Width from the EHT operation information
+	vhtCenter  int
+	sixGCenter int
+	ehtCenter  int
+	htCenter   int
+
+	htStreams  int
+	vhtStreams int
+	heStreams  int
+	vhtMaxMCS  int
+	heMaxMCS   int
+
+	StationCount       *int
+	ChannelUtilization *int
+	WPS                bool
+	Roaming            []string
+}
+
+// forEachIE walks a list of information elements and calls fn for each
+// complete element. A truncated trailing element is ignored.
+func forEachIE(b []byte, fn func(id byte, data []byte)) {
+	for len(b) >= 2 {
+		id := b[0]
+		l := int(b[1])
+		if len(b)-2 < l {
+			return
+		}
+		fn(id, b[2:2+l])
+		b = b[2+l:]
+	}
+}
+
+// parseIEs decodes the information elements of a beacon / probe response
+func parseIEs(b []byte) bssElements {
+	e := bssElements{}
+	roaming := map[string]bool{}
+	forEachIE(b, func(id byte, d []byte) {
+		switch id {
+		case ieSSID:
+			if !e.HasSSID {
+				e.HasSSID = true
+				e.SSID = append([]byte{}, d...)
+			}
+		case ieDSParameterSet:
+			if len(d) >= 1 {
+				e.DSChannel = int(d[0])
+			}
+		case ieCountry:
+			if len(d) >= 2 && isUpperLetter(d[0]) && isUpperLetter(d[1]) {
+				e.Country = string(d[:2])
+			}
+		case ieBSSLoad:
+			if len(d) >= 3 {
+				count := int(binary.LittleEndian.Uint16(d[0:2]))
+				util := int(math.Round(float64(d[2]) * 100 / 255))
+				e.StationCount = &count
+				e.ChannelUtilization = &util
+			}
+		case ieHTCapabilities:
+			e.HT = true
+			//Supported MCS set starts at offset 3, one byte per spatial stream
+			if len(d) >= 7 {
+				e.htStreams = countNonZero(d[3:7])
+			}
+		case ieHTOperation:
+			e.HT = true
+			if len(d) >= 2 {
+				primary := int(d[0])
+				offset := d[1] & 0x03
+				anyWidth := d[1]&0x04 != 0
+				e.htWidth = 20
+				if anyWidth && offset == 1 {
+					e.htWidth = 40
+					e.htCenter = primary + 2
+				} else if anyWidth && offset == 3 {
+					e.htWidth = 40
+					e.htCenter = primary - 2
+				}
+			}
+		case ieRSN:
+			akms, ciphers, caps, ok := parseCipherSuites(d, true)
+			if ok {
+				e.HasRSN = true
+				e.rsnAKMs = akms
+				e.ciphers = ciphers
+				if caps&0x0040 != 0 {
+					e.PMF = "Required"
+				} else if caps&0x0080 != 0 {
+					e.PMF = "Capable"
+				}
+			}
+		case ieMobilityDomain:
+			roaming["802.11r"] = true
+		case ieRMEnabledCaps:
+			roaming["802.11k"] = true
+		case ieExtCapabilities:
+			//Bit 19: BSS transition management
+			if len(d) >= 3 && d[2]&0x08 != 0 {
+				roaming["802.11v"] = true
+			}
+		case ieVHTCapabilities:
+			e.VHT = true
+			if len(d) >= 6 {
+				e.vhtStreams, e.vhtMaxMCS = decodeMCSMap(binary.LittleEndian.Uint16(d[4:6]), []int{7, 8, 9})
+			}
+		case ieVHTOperation:
+			e.VHT = true
+			if len(d) >= 3 {
+				seg0 := int(d[1])
+				seg1 := int(d[2])
+				switch d[0] {
+				case 1:
+					e.vhtWidth = 80
+					e.vhtCenter = seg0
+					if seg1 != 0 {
+						e.vhtWidth = 160
+						if absInt(seg1-seg0) == 8 {
+							e.vhtCenter = seg1
+						}
+					}
+				case 2, 3:
+					e.vhtWidth = 160
+					e.vhtCenter = seg0
+				}
+			}
+		case ieVendorSpecific:
+			if len(d) >= 4 && d[0] == 0x00 && d[1] == 0x50 && d[2] == 0xF2 {
+				switch d[3] {
+				case 1:
+					akms, ciphers, _, ok := parseCipherSuites(d[4:], false)
+					if ok {
+						e.HasWPA = true
+						e.wpaAKMs = akms
+						if !e.HasRSN {
+							e.ciphers = ciphers
+						}
+					}
+				case 4:
+					e.WPS = true
+				}
+			}
+		case ieExtension:
+			if len(d) < 1 {
+				return
+			}
+			ext := d[0]
+			d = d[1:]
+			switch ext {
+			case ieExtHECapabilities:
+				e.HE = true
+				//MAC caps (6) + PHY caps (11), then the Rx HE-MCS map for <= 80 MHz
+				if len(d) >= 19 {
+					e.heStreams, e.heMaxMCS = decodeMCSMap(binary.LittleEndian.Uint16(d[17:19]), []int{7, 9, 11})
+				}
+			case ieExtHEOperation:
+				e.HE = true
+				e.parseHEOperation(d)
+			case ieExtEHTCapabilities:
+				e.EHT = true
+			case ieExtEHTOperation:
+				e.EHT = true
+				//Parameters (1) + basic EHT-MCS set (4), then the optional operation info
+				if len(d) >= 8 && d[0]&0x01 != 0 {
+					e.ehtWidth = []int{20, 40, 80, 160, 320, 0, 0, 0}[d[5]&0x07]
+					e.ehtCenter = int(d[6])
+					if d[7] != 0 {
+						e.ehtCenter = int(d[7])
+					}
+				}
+			}
+		}
+	})
+
+	for _, r := range []string{"802.11k", "802.11v", "802.11r"} {
+		if roaming[r] {
+			e.Roaming = append(e.Roaming, r)
+		}
+	}
+	return e
+}
+
+// parseHEOperation reads the 6 GHz operation information of an HE operation element
+func (e *bssElements) parseHEOperation(d []byte) {
+	if len(d) < 6 {
+		return
+	}
+	params := uint32(d[0]) | uint32(d[1])<<8 | uint32(d[2])<<16
+	p := 6 //Parameters (3) + BSS color (1) + basic HE-MCS set (2)
+	if params&(1<<14) != 0 {
+		p += 3 //VHT operation information
+	}
+	if params&(1<<15) != 0 {
+		p++ //Co-hosted BSS
+	}
+	if params&(1<<17) == 0 || len(d) < p+4 {
+		return
+	}
+	e.sixGWidth = []int{20, 40, 80, 160}[d[p+1]&0x03]
+	e.sixGCenter = int(d[p+2])
+	if e.sixGWidth == 160 && d[p+3] != 0 {
+		e.sixGCenter = int(d[p+3])
+	}
+}
+
+// parseCipherSuites decodes an RSN element, or the body of a WPA (v1) vendor
+// element after its OUI and type. It returns the AKM suite types, the pairwise
+// cipher types and (RSN only) the capability field.
+func parseCipherSuites(d []byte, rsn bool) (akms []uint32, ciphers []uint32, caps uint16, ok bool) {
+	oui := []byte{0x00, 0x0F, 0xAC}
+	if !rsn {
+		oui = []byte{0x00, 0x50, 0xF2}
+	}
+	if len(d) < 2 {
+		return nil, nil, 0, false
+	}
+	d = d[2:] //Version
+	if len(d) < 4 {
+		//Only the version: the defaults (CCMP / 802.1X) for RSN
+		if rsn {
+			return []uint32{1}, []uint32{4}, 0, true
+		}
+		return nil, nil, 0, true
+	}
+	d = d[4:] //Group data cipher
+
+	readList := func() ([]uint32, bool) {
+		if len(d) < 2 {
+			return nil, false
+		}
+		n := int(binary.LittleEndian.Uint16(d[0:2]))
+		d = d[2:]
+		if n > len(d)/4 {
+			return nil, false
+		}
+		list := []uint32{}
+		for i := 0; i < n; i++ {
+			s := d[i*4 : i*4+4]
+			if s[0] == oui[0] && s[1] == oui[1] && s[2] == oui[2] {
+				list = append(list, uint32(s[3]))
+			}
+		}
+		d = d[n*4:]
+		return list, true
+	}
+
+	ciphers, okList := readList()
+	if !okList {
+		return nil, nil, 0, true
+	}
+	akms, okList = readList()
+	if !okList {
+		return nil, ciphers, 0, true
+	}
+	if rsn && len(d) >= 2 {
+		caps = binary.LittleEndian.Uint16(d[0:2])
+	}
+	return akms, ciphers, caps, true
+}
+
+// decodeMCSMap reads a VHT / HE style MCS map (2 bits per spatial stream,
+// 3 = not supported). It returns the stream count and the highest MCS of the
+// first stream, picked from maxByCode.
+func decodeMCSMap(m uint16, maxByCode []int) (streams int, maxMCS int) {
+	for i := 0; i < 8; i++ {
+		code := int(m>>(2*i)) & 0x03
+		if code == 3 {
+			continue
+		}
+		streams = i + 1
+		if i == 0 {
+			maxMCS = maxByCode[code]
+		}
+	}
+	return streams, maxMCS
+}
+
+// securityName describes the authentication an AP offers
+func (e *bssElements) securityName(privacy bool) string {
+	has := func(list []uint32, types ...uint32) bool {
+		for _, a := range list {
+			for _, t := range types {
+				if a == t {
+					return true
+				}
+			}
+		}
+		return false
+	}
+
+	if e.HasRSN {
+		sae := has(e.rsnAKMs, 8, 9, 24, 25)
+		psk := has(e.rsnAKMs, 2, 4, 6)
+		suiteB := has(e.rsnAKMs, 12, 13)
+		ent := has(e.rsnAKMs, 1, 3, 5)
+		owe := has(e.rsnAKMs, 18)
+		wpaPrefix := "WPA2"
+		if e.HasWPA {
+			wpaPrefix = "WPA/WPA2"
+		}
+		switch {
+		case sae && psk:
+			return "WPA2/WPA3-Personal"
+		case sae:
+			return "WPA3-Personal"
+		case psk:
+			return wpaPrefix + "-Personal"
+		case suiteB:
+			return "WPA3-Enterprise 192-bit"
+		case ent && e.PMF == "Required" && has(e.rsnAKMs, 5) && !has(e.rsnAKMs, 1, 3):
+			return "WPA3-Enterprise"
+		case ent:
+			return wpaPrefix + "-Enterprise"
+		case owe:
+			return "Enhanced Open (OWE)"
+		}
+		return "WPA2"
+	}
+	if e.HasWPA {
+		if has(e.wpaAKMs, 1) {
+			return "WPA-Enterprise"
+		}
+		return "WPA-Personal"
+	}
+	if privacy {
+		return "WEP"
+	}
+	return "Open"
+}
+
+// cipherName joins the pairwise ciphers an AP offers
+func (e *bssElements) cipherName(privacy bool) string {
+	names := []string{}
+	for _, c := range e.ciphers {
+		n := rsnCipherName(c)
+		if n != "" && !containsString(names, n) {
+			names = append(names, n)
+		}
+	}
+	if len(names) == 0 && privacy && !e.HasRSN && !e.HasWPA {
+		return "WEP"
+	}
+	return strings.Join(names, " + ")
+}
+
+func rsnCipherName(t uint32) string {
+	switch t {
+	case 1:
+		return "WEP-40"
+	case 2:
+		return "TKIP"
+	case 4:
+		return "AES-CCMP"
+	case 5:
+		return "WEP-104"
+	case 8:
+		return "AES-GCMP"
+	case 9:
+		return "AES-GCMP-256"
+	case 10:
+		return "AES-CCMP-256"
+	}
+	return ""
+}
+
+// phyInfo works out the 802.11 amendment, Wi-Fi generation, channel width and
+// center channel of a BSS from its elements and band.
+func (e *bssElements) phyInfo(band string, legacyMaxRate float64) (phy string, generation string, width int, center int) {
+	width = 20
+	if e.htWidth > 0 {
+		width = e.htWidth
+		center = e.htCenter
+	}
+	if band != "2.4 GHz" && e.vhtWidth > width {
+		width = e.vhtWidth
+		center = e.vhtCenter
+	}
+	if band == "6 GHz" && e.sixGWidth > 0 {
+		width = e.sixGWidth
+		center = e.sixGCenter
+	}
+	if e.ehtWidth > 0 {
+		width = e.ehtWidth
+		center = e.ehtCenter
+	}
+
+	switch {
+	case e.EHT:
+		return "802.11be", "Wi-Fi 7", width, center
+	case e.HE && band == "6 GHz":
+		return "802.11ax", "Wi-Fi 6E", width, center
+	case e.HE:
+		return "802.11ax", "Wi-Fi 6", width, center
+	case e.VHT && band != "2.4 GHz":
+		return "802.11ac", "Wi-Fi 5", width, center
+	case e.HT:
+		return "802.11n", "Wi-Fi 4", width, center
+	case band == "60 GHz":
+		return "802.11ad", "", width, center
+	case band == "5 GHz":
+		return "802.11a", "", width, center
+	case legacyMaxRate > 11:
+		return "802.11g", "", width, center
+	case legacyMaxRate > 0:
+		return "802.11b", "", width, center
+	}
+	return "", "", width, center
+}
+
+// spatialStreams returns the stream count of the newest PHY the AP supports
+func (e *bssElements) spatialStreams() int {
+	for _, s := range []int{e.heStreams, e.vhtStreams, e.htStreams} {
+		if s > 0 {
+			return s
+		}
+	}
+	return 0
+}
+
+// estimateMaxRate estimates the highest PHY rate (Mbps) an AP can offer from
+// its newest PHY, channel width and spatial streams, using the short guard
+// interval rates of the highest MCS it advertises.
+func estimateMaxRate(phy string, width int, streams int, maxMCS int, legacyMax float64) float64 {
+	if streams <= 0 {
+		streams = 1
+	}
+	col := map[int]int{20: 0, 40: 1, 80: 2, 160: 3, 320: 4}
+	c, ok := col[width]
+	if !ok {
+		c = 0
+	}
+	var perStream float64
+	switch phy {
+	case "802.11be":
+		perStream = []float64{172.059, 344.118, 720.588, 1441.176, 2882.353}[c]
+	case "802.11ax":
+		table := map[int][]float64{
+			7:  {86.029, 172.059, 360.294, 720.588, 720.588},
+			9:  {114.706, 229.412, 480.392, 960.784, 960.784},
+			11: {143.382, 286.765, 600.490, 1200.980, 1200.980},
+		}
+		row, ok := table[maxMCS]
+		if !ok {
+			row = table[11]
+		}
+		perStream = row[c]
+	case "802.11ac":
+		table := map[int][]float64{
+			7: {72.222, 150, 325, 650, 650},
+			8: {86.667, 180, 390, 780, 780},
+			9: {86.667, 200, 433.333, 866.667, 866.667},
+		}
+		row, ok := table[maxMCS]
+		if !ok {
+			row = table[9]
+		}
+		perStream = row[c]
+	case "802.11n":
+		if streams > 4 {
+			streams = 4
+		}
+		perStream = 72.222
+		if width >= 40 {
+			perStream = 150
+		}
+	default:
+		return legacyMax
+	}
+	return math.Round(perStream*float64(streams)*10) / 10
+}
+
+// legacyRates splits a rate set (units of 500 kbps, high bit = basic rate)
+// into the basic and the full list, formatted in Mbps, plus the highest rate
+func legacyRates(rates []uint16) (basic []string, all []string, max float64) {
+	basic = []string{}
+	all = []string{}
+	for _, r := range rates {
+		mbps := float64(r&0x7FFF) / 2
+		if mbps <= 0 {
+			continue
+		}
+		s := strconv.FormatFloat(mbps, 'f', -1, 64)
+		if !containsString(all, s) {
+			all = append(all, s)
+		}
+		if r&0x8000 != 0 && !containsString(basic, s) {
+			basic = append(basic, s)
+		}
+		if mbps > max {
+			max = mbps
+		}
+	}
+	return basic, all, max
+}
+
+// ratesFromIE reads the supported / extended supported rates elements, which
+// use the same encoding as legacyRates but with the basic flag in bit 7
+func ratesFromIE(b []byte) []uint16 {
+	rates := []uint16{}
+	forEachIE(b, func(id byte, d []byte) {
+		if id != ieSupportedRates && id != ieExtSupportedRate {
+			return
+		}
+		for _, r := range d {
+			v := uint16(r & 0x7F)
+			if r&0x80 != 0 {
+				v |= 0x8000
+			}
+			rates = append(rates, v)
+		}
+	})
+	return rates
+}
+
+/*
+	Channel and band helpers
+*/
+
+// bandOfFrequency returns the band a center frequency (MHz) belongs to
+func bandOfFrequency(mhz int) string {
+	switch {
+	case mhz >= 2400 && mhz < 2500:
+		return "2.4 GHz"
+	case mhz >= 4900 && mhz < 5925:
+		return "5 GHz"
+	case mhz >= 5925 && mhz <= 7125:
+		return "6 GHz"
+	case mhz >= 57000 && mhz <= 71000:
+		return "60 GHz"
+	}
+	return ""
+}
+
+// channelOfFrequency returns the channel number of a center frequency (MHz)
+func channelOfFrequency(mhz int) int {
+	switch {
+	case mhz == 2484:
+		return 14
+	case mhz >= 2412 && mhz <= 2472:
+		return (mhz - 2407) / 5
+	case mhz == 5935:
+		return 2
+	case mhz > 5950 && mhz <= 7115:
+		return (mhz - 5950) / 5
+	case mhz >= 4910 && mhz <= 4980:
+		return (mhz - 4000) / 5
+	case mhz >= 5000 && mhz < 5925:
+		return (mhz - 5000) / 5
+	case mhz >= 58320 && mhz <= 70200:
+		return (mhz - 56160) / 2160
+	}
+	return 0
+}
+
+// frequencyOfChannel guesses the center frequency (MHz) of a channel when
+// only the channel number is known. Channels that exist in both the 5 GHz
+// and the 6 GHz band resolve to 5 GHz unless sixGHz is set.
+func frequencyOfChannel(ch int, sixGHz bool) int {
+	switch {
+	case ch <= 0:
+		return 0
+	case sixGHz && ch == 2:
+		return 5935
+	case sixGHz && ch <= 233:
+		return 5950 + ch*5
+	case ch == 14:
+		return 2484
+	case ch < 14:
+		return 2407 + ch*5
+	case ch >= 182 && ch <= 196:
+		return 4000 + ch*5
+	case ch <= 177:
+		return 5000 + ch*5
+	}
+	return 0
+}
+
+// formatFrequency renders MHz as "5.180 GHz"
+func formatFrequency(mhz int) string {
+	if mhz <= 0 {
+		return ""
+	}
+	return fmt.Sprintf("%.3f GHz", float64(mhz)/1000)
+}
+
+// generationOfPHY maps an 802.11 amendment to its Wi-Fi generation name
+func generationOfPHY(phy string, band string) string {
+	switch phy {
+	case "802.11be":
+		return "Wi-Fi 7"
+	case "802.11ax":
+		if band == "6 GHz" {
+			return "Wi-Fi 6E"
+		}
+		return "Wi-Fi 6"
+	case "802.11ac":
+		return "Wi-Fi 5"
+	case "802.11n":
+		return "Wi-Fi 4"
+	}
+	return ""
+}
+
+// qualityFromRSSI maps an RSSI (dBm) to a 0 - 100 quality the way Windows
+// does: -100 dBm and below is 0 %, -50 dBm and above is 100 %
+func qualityFromRSSI(dbm int) int {
+	if dbm == 0 {
+		return -1
+	}
+	q := 2 * (dbm + 100)
+	if q < 0 {
+		return 0
+	}
+	if q > 100 {
+		return 100
+	}
+	return q
+}
+
+// rssiFromQuality is the inverse of qualityFromRSSI
+func rssiFromQuality(q int) int {
+	if q < 0 {
+		return 0
+	}
+	return q/2 - 100
+}
+
+/*
+	Small helpers
+*/
+
+// ssidString turns raw SSID bytes into a string, reporting hidden SSIDs
+// (empty or all zero) as hidden
+func ssidString(b []byte) (string, bool) {
+	allZero := true
+	for _, c := range b {
+		if c != 0 {
+			allZero = false
+			break
+		}
+	}
+	if len(b) == 0 || allZero {
+		return "", true
+	}
+	return strings.ToValidUTF8(string(b), "\uFFFD"), false
+}
+
+func formatMAC(b []byte) string {
+	parts := make([]string, len(b))
+	for i, c := range b {
+		parts[i] = fmt.Sprintf("%02x", c)
+	}
+	return strings.Join(parts, ":")
+}
+
+func isUpperLetter(c byte) bool {
+	return c >= 'A' && c <= 'Z'
+}
+
+func countNonZero(b []byte) int {
+	n := 0
+	for _, c := range b {
+		if c != 0 {
+			n++
+		}
+	}
+	return n
+}
+
+func absInt(v int) int {
+	if v < 0 {
+		return -v
+	}
+	return v
+}
+
+func containsString(list []string, s string) bool {
+	for _, v := range list {
+		if v == s {
+			return true
+		}
+	}
+	return false
+}

+ 382 - 0
src/mod/network/wifi/ie_test.go

@@ -0,0 +1,382 @@
+package wifi
+
+import (
+	"testing"
+)
+
+// ie builds one information element
+func ie(id byte, data ...byte) []byte {
+	return append([]byte{id, byte(len(data))}, data...)
+}
+
+// ieExt builds one extension element
+func ieExt(ext byte, data ...byte) []byte {
+	return ie(ieExtension, append([]byte{ext}, data...)...)
+}
+
+func concat(parts ...[]byte) []byte {
+	out := []byte{}
+	for _, p := range parts {
+		out = append(out, p...)
+	}
+	return out
+}
+
+// rsnIE builds an RSN element with the given pairwise ciphers, AKMs and capabilities
+func rsnIE(ciphers []byte, akms []byte, caps uint16) []byte {
+	d := []byte{1, 0, 0x00, 0x0F, 0xAC, 4}
+	d = append(d, byte(len(ciphers)), 0)
+	for _, c := range ciphers {
+		d = append(d, 0x00, 0x0F, 0xAC, c)
+	}
+	d = append(d, byte(len(akms)), 0)
+	for _, a := range akms {
+		d = append(d, 0x00, 0x0F, 0xAC, a)
+	}
+	d = append(d, byte(caps), byte(caps>>8))
+	return ie(ieRSN, d...)
+}
+
+func wpaIE(ciphers []byte, akms []byte) []byte {
+	d := []byte{0x00, 0x50, 0xF2, 1, 1, 0, 0x00, 0x50, 0xF2, 2}
+	d = append(d, byte(len(ciphers)), 0)
+	for _, c := range ciphers {
+		d = append(d, 0x00, 0x50, 0xF2, c)
+	}
+	d = append(d, byte(len(akms)), 0)
+	for _, a := range akms {
+		d = append(d, 0x00, 0x50, 0xF2, a)
+	}
+	return ie(ieVendorSpecific, d...)
+}
+
+// htCaps builds an HT capabilities element advertising n spatial streams
+func htCaps(streams int) []byte {
+	d := make([]byte, 26)
+	for i := 0; i < streams && i < 4; i++ {
+		d[3+i] = 0xFF
+	}
+	return ie(ieHTCapabilities, d...)
+}
+
+// vhtCaps builds a VHT capabilities element: n streams, MCS map code for all
+func vhtCaps(streams int, code uint16) []byte {
+	var m uint16 = 0xFFFF
+	for i := 0; i < streams; i++ {
+		m &^= 3 << (2 * i)
+		m |= code << (2 * i)
+	}
+	d := make([]byte, 12)
+	d[4] = byte(m)
+	d[5] = byte(m >> 8)
+	return ie(ieVHTCapabilities, d...)
+}
+
+func heCaps(streams int, code uint16) []byte {
+	var m uint16 = 0xFFFF
+	for i := 0; i < streams; i++ {
+		m &^= 3 << (2 * i)
+		m |= code << (2 * i)
+	}
+	d := make([]byte, 21)
+	d[17] = byte(m)
+	d[18] = byte(m >> 8)
+	return ieExt(ieExtHECapabilities, d...)
+}
+
+func TestForEachIEStopsOnTruncation(t *testing.T) {
+	cases := []struct {
+		name string
+		in   []byte
+		want int
+	}{
+		{"empty", nil, 0},
+		{"one byte", []byte{0}, 0},
+		{"two elements", concat(ie(0, 'a'), ie(3, 6)), 2},
+		{"truncated second", append(ie(0, 'a'), 3, 5, 1), 1},
+		{"zero length", ie(0), 1},
+	}
+	for _, tc := range cases {
+		n := 0
+		forEachIE(tc.in, func(id byte, d []byte) { n++ })
+		if n != tc.want {
+			t.Errorf("%s: got %d elements, want %d", tc.name, n, tc.want)
+		}
+	}
+}
+
+func TestSecurityName(t *testing.T) {
+	cases := []struct {
+		name       string
+		ies        []byte
+		privacy    bool
+		wantSec    string
+		wantCipher string
+		wantPMF    string
+	}{
+		{"open", nil, false, "Open", "", ""},
+		{"wep", nil, true, "WEP", "WEP", ""},
+		{"wpa2 personal", rsnIE([]byte{4}, []byte{2}, 0), true, "WPA2-Personal", "AES-CCMP", ""},
+		{"wpa3 personal", rsnIE([]byte{4}, []byte{8}, 0x00C0), true, "WPA3-Personal", "AES-CCMP", "Required"},
+		{"wpa2/wpa3 transition", rsnIE([]byte{4}, []byte{2, 8}, 0x0080), true, "WPA2/WPA3-Personal", "AES-CCMP", "Capable"},
+		{"wpa/wpa2 mixed", concat(rsnIE([]byte{2, 4}, []byte{2}, 0), wpaIE([]byte{2}, []byte{2})), true, "WPA/WPA2-Personal", "TKIP + AES-CCMP", ""},
+		{"wpa only", wpaIE([]byte{2}, []byte{2}), true, "WPA-Personal", "TKIP", ""},
+		{"wpa enterprise", wpaIE([]byte{2}, []byte{1}), true, "WPA-Enterprise", "TKIP", ""},
+		{"wpa2 enterprise", rsnIE([]byte{4}, []byte{1}, 0), true, "WPA2-Enterprise", "AES-CCMP", ""},
+		{"wpa3 enterprise", rsnIE([]byte{4}, []byte{5}, 0x00C0), true, "WPA3-Enterprise", "AES-CCMP", "Required"},
+		{"wpa3 192-bit", rsnIE([]byte{9}, []byte{12}, 0x00C0), true, "WPA3-Enterprise 192-bit", "AES-GCMP-256", "Required"},
+		{"owe", rsnIE([]byte{4}, []byte{18}, 0x00C0), false, "Enhanced Open (OWE)", "AES-CCMP", "Required"},
+		{"rsn version only", ie(ieRSN, 1, 0), true, "WPA2-Enterprise", "AES-CCMP", ""},
+	}
+	for _, tc := range cases {
+		e := parseIEs(tc.ies)
+		if got := e.securityName(tc.privacy); got != tc.wantSec {
+			t.Errorf("%s: security = %q, want %q", tc.name, got, tc.wantSec)
+		}
+		if got := e.cipherName(tc.privacy); got != tc.wantCipher {
+			t.Errorf("%s: cipher = %q, want %q", tc.name, got, tc.wantCipher)
+		}
+		if e.PMF != tc.wantPMF {
+			t.Errorf("%s: PMF = %q, want %q", tc.name, e.PMF, tc.wantPMF)
+		}
+	}
+}
+
+func TestPhyInfo(t *testing.T) {
+	cases := []struct {
+		name        string
+		ies         []byte
+		band        string
+		legacyMax   float64
+		wantPHY     string
+		wantGen     string
+		wantWidth   int
+		wantCenter  int
+		wantStreams int
+		wantMaxRate float64
+	}{
+		{"802.11b", nil, "2.4 GHz", 11, "802.11b", "", 20, 0, 0, 11},
+		{"802.11g", nil, "2.4 GHz", 54, "802.11g", "", 20, 0, 0, 54},
+		{"802.11a", nil, "5 GHz", 54, "802.11a", "", 20, 0, 0, 54},
+		{"n 20MHz 2ss", concat(htCaps(2), ie(ieHTOperation, 6, 0, 0, 0, 0)), "2.4 GHz", 54, "802.11n", "Wi-Fi 4", 20, 0, 2, 144.4},
+		{"n 40MHz above", concat(htCaps(2), ie(ieHTOperation, 36, 0x05, 0, 0, 0)), "5 GHz", 54, "802.11n", "Wi-Fi 4", 40, 38, 2, 300},
+		{"n 40MHz below", concat(htCaps(1), ie(ieHTOperation, 40, 0x07, 0, 0, 0)), "5 GHz", 54, "802.11n", "Wi-Fi 4", 40, 38, 1, 150},
+		{"ac 80MHz 3ss", concat(htCaps(3), ie(ieHTOperation, 149, 0x05), vhtCaps(3, 2), ie(ieVHTOperation, 1, 155, 0, 0, 0)), "5 GHz", 54, "802.11ac", "Wi-Fi 5", 80, 155, 3, 1300},
+		{"ac 160MHz", concat(htCaps(2), ie(ieHTOperation, 36, 0x05), vhtCaps(2, 2), ie(ieVHTOperation, 1, 42, 50, 0, 0)), "5 GHz", 54, "802.11ac", "Wi-Fi 5", 160, 50, 2, 1733.3},
+		{"vht on 2.4 is n", concat(htCaps(2), ie(ieHTOperation, 6, 0), vhtCaps(2, 2)), "2.4 GHz", 54, "802.11n", "Wi-Fi 4", 20, 0, 2, 144.4},
+		{"ax 80MHz 2ss", concat(htCaps(2), ie(ieHTOperation, 36, 0x05), vhtCaps(2, 2), ie(ieVHTOperation, 1, 42, 0), heCaps(2, 2)), "5 GHz", 54, "802.11ax", "Wi-Fi 6", 80, 42, 2, 1201},
+		{"ax 6GHz 160MHz", concat(heCaps(2, 2), ieExt(ieExtHEOperation, 0, 0, 0x02, 0, 0, 0, 37, 0x03, 39, 47, 0)), "6 GHz", 54, "802.11ax", "Wi-Fi 6E", 160, 47, 2, 2402},
+		{"be 320MHz", concat(heCaps(2, 2), ieExt(ieExtEHTCapabilities, 0), ieExt(ieExtEHTOperation, 0x01, 0, 0, 0, 0, 0x04, 31, 63)), "6 GHz", 54, "802.11be", "Wi-Fi 7", 320, 63, 2, 5764.7},
+	}
+	for _, tc := range cases {
+		e := parseIEs(tc.ies)
+		phy, gen, width, center := e.phyInfo(tc.band, tc.legacyMax)
+		if phy != tc.wantPHY || gen != tc.wantGen || width != tc.wantWidth || center != tc.wantCenter {
+			t.Errorf("%s: phyInfo = (%q, %q, %d, %d), want (%q, %q, %d, %d)", tc.name, phy, gen, width, center, tc.wantPHY, tc.wantGen, tc.wantWidth, tc.wantCenter)
+		}
+		streams := e.spatialStreams()
+		if streams != tc.wantStreams {
+			t.Errorf("%s: streams = %d, want %d", tc.name, streams, tc.wantStreams)
+		}
+		maxMCS := e.heMaxMCS
+		if phy == "802.11ac" {
+			maxMCS = e.vhtMaxMCS
+		}
+		if got := estimateMaxRate(phy, width, streams, maxMCS, tc.legacyMax); got != tc.wantMaxRate {
+			t.Errorf("%s: max rate = %v, want %v", tc.name, got, tc.wantMaxRate)
+		}
+	}
+}
+
+func TestParseIEsMisc(t *testing.T) {
+	blob := concat(
+		ie(ieSSID, 'H', 'o', 'm', 'e'),
+		ie(ieDSParameterSet, 11),
+		ie(ieCountry, 'T', 'W', ' '),
+		ie(ieBSSLoad, 5, 0, 128, 0, 0),
+		ie(ieMobilityDomain, 1, 2, 3),
+		ie(ieRMEnabledCaps, 0, 0, 0, 0, 0),
+		ie(ieExtCapabilities, 0, 0, 0x08),
+		ie(ieVendorSpecific, 0x00, 0x50, 0xF2, 4, 0x10),
+	)
+	e := parseIEs(blob)
+	if string(e.SSID) != "Home" || !e.HasSSID {
+		t.Errorf("SSID = %q", e.SSID)
+	}
+	if e.DSChannel != 11 {
+		t.Errorf("DSChannel = %d, want 11", e.DSChannel)
+	}
+	if e.Country != "TW" {
+		t.Errorf("Country = %q, want TW", e.Country)
+	}
+	if e.StationCount == nil || *e.StationCount != 5 {
+		t.Errorf("StationCount = %v, want 5", e.StationCount)
+	}
+	if e.ChannelUtilization == nil || *e.ChannelUtilization != 50 {
+		t.Errorf("ChannelUtilization = %v, want 50", e.ChannelUtilization)
+	}
+	if !e.WPS {
+		t.Error("WPS not detected")
+	}
+	want := []string{"802.11k", "802.11v", "802.11r"}
+	if len(e.Roaming) != len(want) {
+		t.Fatalf("Roaming = %v, want %v", e.Roaming, want)
+	}
+	for i := range want {
+		if e.Roaming[i] != want[i] {
+			t.Errorf("Roaming = %v, want %v", e.Roaming, want)
+		}
+	}
+
+	//An invalid country code is ignored
+	if c := parseIEs(ie(ieCountry, 0xFF, 'W', ' ')).Country; c != "" {
+		t.Errorf("invalid country parsed as %q", c)
+	}
+}
+
+// TestParseIEsTruncated feeds every prefix of a full IE blob, and every blob
+// with one length byte changed, to the parser: it must never panic.
+func TestParseIEsTruncated(t *testing.T) {
+	blob := concat(
+		ie(ieSSID, 'x'), htCaps(2), ie(ieHTOperation, 36, 0x05),
+		rsnIE([]byte{4, 2}, []byte{2, 8}, 0xC0), wpaIE([]byte{2}, []byte{2}),
+		vhtCaps(2, 2), ie(ieVHTOperation, 1, 42, 50), heCaps(2, 2),
+		ieExt(ieExtHEOperation, 0, 0xC0, 0x02, 0, 0, 0, 0, 0, 0, 37, 0x03, 39, 47, 0),
+		ieExt(ieExtEHTOperation, 0x01, 0, 0, 0, 0, 0x04, 31, 63),
+		ie(ieBSSLoad, 1, 0, 9, 0, 0), ie(ieCountry, 'U', 'S'),
+	)
+	for i := 0; i <= len(blob); i++ {
+		parseIEs(blob[:i])
+	}
+	for i := range blob {
+		for _, v := range []byte{0, 1, 2, 3, 4, 0x7F, 0xFF} {
+			mutated := append([]byte{}, blob...)
+			mutated[i] = v
+			parseIEs(mutated)
+			ratesFromIE(mutated)
+		}
+	}
+	//Element bodies on their own
+	for i := 0; i < 40; i++ {
+		body := make([]byte, i)
+		for j := range body {
+			body[j] = 0xFF
+		}
+		parseCipherSuites(body, true)
+		parseCipherSuites(body, false)
+		e := bssElements{}
+		e.parseHEOperation(body)
+	}
+}
+
+func TestLegacyRates(t *testing.T) {
+	basic, all, max := legacyRates([]uint16{0x8002, 0x8004, 0x800B, 0x8016, 12, 18, 0, 0x8002})
+	wantBasic := []string{"1", "2", "5.5", "11"}
+	wantAll := []string{"1", "2", "5.5", "11", "6", "9"}
+	if len(basic) != len(wantBasic) || len(all) != len(wantAll) {
+		t.Fatalf("legacyRates = %v / %v", basic, all)
+	}
+	for i := range wantBasic {
+		if basic[i] != wantBasic[i] {
+			t.Errorf("basic = %v, want %v", basic, wantBasic)
+		}
+	}
+	for i := range wantAll {
+		if all[i] != wantAll[i] {
+			t.Errorf("all = %v, want %v", all, wantAll)
+		}
+	}
+	if max != 11 {
+		t.Errorf("max = %v, want 11", max)
+	}
+
+	rates := ratesFromIE(concat(ie(ieSupportedRates, 0x82, 0x0C), ie(ieExtSupportedRate, 0x6C)))
+	if len(rates) != 3 || rates[0] != 0x8002 || rates[1] != 12 || rates[2] != 108 {
+		t.Errorf("ratesFromIE = %v", rates)
+	}
+}
+
+func TestChannelHelpers(t *testing.T) {
+	cases := []struct {
+		mhz     int
+		band    string
+		channel int
+	}{
+		{2412, "2.4 GHz", 1},
+		{2437, "2.4 GHz", 6},
+		{2472, "2.4 GHz", 13},
+		{2484, "2.4 GHz", 14},
+		{5180, "5 GHz", 36},
+		{5745, "5 GHz", 149},
+		{5885, "5 GHz", 177},
+		{4920, "5 GHz", 184},
+		{5935, "6 GHz", 2},
+		{5955, "6 GHz", 1},
+		{6135, "6 GHz", 37},
+		{7115, "6 GHz", 233},
+		{60480, "60 GHz", 2},
+		{1000, "", 0},
+	}
+	for _, tc := range cases {
+		if got := bandOfFrequency(tc.mhz); got != tc.band {
+			t.Errorf("bandOfFrequency(%d) = %q, want %q", tc.mhz, got, tc.band)
+		}
+		if got := channelOfFrequency(tc.mhz); got != tc.channel {
+			t.Errorf("channelOfFrequency(%d) = %d, want %d", tc.mhz, got, tc.channel)
+		}
+	}
+
+	freqCases := []struct {
+		ch     int
+		sixGHz bool
+		want   int
+	}{
+		{1, false, 2412}, {14, false, 2484}, {36, false, 5180}, {165, false, 5825},
+		{184, false, 4920}, {1, true, 5955}, {2, true, 5935}, {37, true, 6135}, {0, false, 0}, {300, false, 0},
+	}
+	for _, tc := range freqCases {
+		if got := frequencyOfChannel(tc.ch, tc.sixGHz); got != tc.want {
+			t.Errorf("frequencyOfChannel(%d, %v) = %d, want %d", tc.ch, tc.sixGHz, got, tc.want)
+		}
+	}
+}
+
+func TestQualityConversions(t *testing.T) {
+	cases := []struct {
+		dbm     int
+		quality int
+	}{
+		{0, -1}, {-30, 100}, {-50, 100}, {-60, 80}, {-75, 50}, {-100, 0}, {-110, 0},
+	}
+	for _, tc := range cases {
+		if got := qualityFromRSSI(tc.dbm); got != tc.quality {
+			t.Errorf("qualityFromRSSI(%d) = %d, want %d", tc.dbm, got, tc.quality)
+		}
+	}
+	if got := rssiFromQuality(80); got != -60 {
+		t.Errorf("rssiFromQuality(80) = %d, want -60", got)
+	}
+	if got := rssiFromQuality(-1); got != 0 {
+		t.Errorf("rssiFromQuality(-1) = %d, want 0", got)
+	}
+}
+
+func TestSSIDString(t *testing.T) {
+	cases := []struct {
+		in     []byte
+		want   string
+		hidden bool
+	}{
+		{nil, "", true},
+		{[]byte{0, 0, 0}, "", true},
+		{[]byte("Cafe"), "Cafe", false},
+		{[]byte{0xE5, 0xAE, 0xB6}, "\u5bb6", false},
+		{[]byte{'a', 0xFF}, "a\uFFFD", false},
+	}
+	for _, tc := range cases {
+		got, hidden := ssidString(tc.in)
+		if got != tc.want || hidden != tc.hidden {
+			t.Errorf("ssidString(%v) = (%q, %v), want (%q, %v)", tc.in, got, hidden, tc.want, tc.hidden)
+		}
+	}
+}

+ 416 - 0
src/mod/network/wifi/iwparse.go

@@ -0,0 +1,416 @@
+package wifi
+
+/*
+	Linux wireless tool output parsers
+
+	Pure text parsers for the output of "iwlist <if> scan" and
+	"iw dev <if> link", kept free of build tags so they are tested on every
+	platform. The commands themselves are run from wifi_linux.go.
+*/
+
+import (
+	"encoding/hex"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+var (
+	reIwQuality   = regexp.MustCompile(`Quality[=:](\d+)/(\d+)`)
+	reIwSignalDBm = regexp.MustCompile(`Signal level[=:](-?\d+(?:\.\d+)?) ?dBm`)
+	reIwSignalRel = regexp.MustCompile(`Signal level[=:](\d+)/(\d+)`)
+	reIwChannel   = regexp.MustCompile(`\(Channel (\d+)\)`)
+	reIwRate      = regexp.MustCompile(`([\d.]+) Mb/s`)
+	reIwSuiteNum  = regexp.MustCompile(`unknown \((\d+)\)`)
+	reIwWidth     = regexp.MustCompile(`(\d+)MHz`)
+	reLeadingInt  = regexp.MustCompile(`^\s*(-?\d+)`)
+)
+
+// iwlistCell collects one "Cell" of iwlist output
+type iwlistCell struct {
+	info     WiFiInfo
+	rawIEs   []byte
+	section  string //"rsn" or "wpa" while inside a decoded IE
+	hasRSN   bool
+	hasWPA   bool
+	rsnAKMs  []uint32
+	wpaAKMs  []uint32
+	rsnCiphs []uint32
+	wpaCiphs []uint32
+	rates    []uint16
+	inRates  bool
+	tsf      uint64
+}
+
+// parseIwlistScan parses the output of "iwlist <interface> scan"
+func parseIwlistScan(out string) []WiFiInfo {
+	results := []WiFiInfo{}
+	var cur *iwlistCell
+	flush := func() {
+		if cur != nil && cur.info.Address != "" {
+			results = append(results, cur.finish())
+		}
+		cur = nil
+	}
+
+	for _, raw := range strings.Split(out, "\n") {
+		line := strings.TrimSpace(raw)
+		if strings.HasPrefix(line, "Cell ") && strings.Contains(line, "Address:") {
+			flush()
+			cur = &iwlistCell{}
+			fields := strings.Fields(line)
+			cur.info.Address = strings.ToLower(fields[len(fields)-1])
+			cur.info.QualityPercent = -1
+			continue
+		}
+		if cur == nil || line == "" {
+			continue
+		}
+
+		//Bit rates continue on the following lines
+		if cur.inRates && !strings.Contains(line, ":") && reIwRate.MatchString(line) {
+			cur.addRates(line)
+			continue
+		}
+		cur.inRates = false
+
+		switch {
+		case strings.HasPrefix(line, "Channel:"):
+			if ch, err := strconv.Atoi(strings.TrimSpace(line[len("Channel:"):])); err == nil {
+				cur.info.Channel = ch
+			}
+		case strings.HasPrefix(line, "Frequency:"):
+			value := strings.TrimSpace(line[len("Frequency:"):])
+			cur.info.FrequencyMHz = parseFrequencyMHz(value)
+			if m := reIwChannel.FindStringSubmatch(value); m != nil {
+				cur.info.Channel, _ = strconv.Atoi(m[1])
+			}
+		case strings.HasPrefix(line, "Quality"):
+			if m := reIwQuality.FindStringSubmatch(line); m != nil {
+				cur.info.Quality = m[1] + "/" + m[2]
+				a, _ := strconv.Atoi(m[1])
+				b, _ := strconv.Atoi(m[2])
+				if b > 0 {
+					cur.info.QualityPercent = a * 100 / b
+				}
+			}
+			if m := reIwSignalDBm.FindStringSubmatch(line); m != nil {
+				dbm, _ := strconv.ParseFloat(m[1], 64)
+				cur.info.SignalDBm = int(dbm)
+				cur.info.SignalLevel = strconv.Itoa(cur.info.SignalDBm) + " dBm"
+			} else if m := reIwSignalRel.FindStringSubmatch(line); m != nil {
+				cur.info.SignalLevel = m[1] + "/" + m[2]
+			}
+		case strings.HasPrefix(line, "Encryption key:"):
+			cur.info.EncryptionKey = strings.TrimSpace(line[len("Encryption key:"):]) == "on"
+		case strings.HasPrefix(line, "ESSID:"):
+			value := strings.TrimSpace(line[len("ESSID:"):])
+			value = strings.TrimSuffix(strings.TrimPrefix(value, "\""), "\"")
+			ssid, hidden := ssidString(unescapeIwString(value))
+			cur.info.ESSID = ssid
+			cur.info.Hidden = hidden
+		case strings.HasPrefix(line, "Mode:"):
+			switch strings.TrimSpace(line[len("Mode:"):]) {
+			case "Master":
+				cur.info.BSSType = "Infrastructure"
+			case "Ad-Hoc":
+				cur.info.BSSType = "Ad hoc"
+			}
+		case strings.HasPrefix(line, "Bit Rates:"):
+			cur.inRates = true
+			cur.addRates(line)
+		case strings.HasPrefix(line, "IE: IEEE 802.11i/WPA2"):
+			cur.section = "rsn"
+			cur.hasRSN = true
+		case strings.HasPrefix(line, "IE: WPA Version"):
+			cur.section = "wpa"
+			cur.hasWPA = true
+		case strings.HasPrefix(line, "IE: Unknown:"):
+			cur.section = ""
+			if b, err := hex.DecodeString(strings.TrimSpace(line[len("IE: Unknown:"):])); err == nil {
+				cur.rawIEs = append(cur.rawIEs, b...)
+			}
+		case strings.HasPrefix(line, "IE:"):
+			cur.section = ""
+		case strings.HasPrefix(line, "Pairwise Ciphers"):
+			ciphers := parseIwSuiteList(line, map[string]uint32{"WEP-40": 1, "TKIP": 2, "WRAP": 3, "CCMP": 4, "WEP-104": 5})
+			if cur.section == "rsn" {
+				cur.rsnCiphs = ciphers
+			} else if cur.section == "wpa" {
+				cur.wpaCiphs = ciphers
+			}
+		case strings.HasPrefix(line, "Authentication Suites"):
+			akms := parseIwSuiteList(line, map[string]uint32{"802.1x": 1, "PSK": 2})
+			if cur.section == "rsn" {
+				cur.rsnAKMs = akms
+			} else if cur.section == "wpa" {
+				cur.wpaAKMs = akms
+			}
+		case strings.HasPrefix(line, "Extra:tsf="):
+			if v, err := strconv.ParseUint(strings.TrimSpace(line[len("Extra:tsf="):]), 16, 64); err == nil {
+				cur.tsf = v
+			}
+		}
+	}
+	flush()
+	return results
+}
+
+func (c *iwlistCell) addRates(line string) {
+	for _, m := range reIwRate.FindAllStringSubmatch(line, -1) {
+		if v, err := strconv.ParseFloat(m[1], 64); err == nil && v > 0 {
+			c.rates = append(c.rates, uint16(v*2))
+		}
+	}
+}
+
+// finish combines the decoded fields with what the raw IEs reveal
+func (c *iwlistCell) finish() WiFiInfo {
+	info := c.info
+	el := parseIEs(c.rawIEs)
+	if c.hasRSN {
+		el.HasRSN = true
+		el.rsnAKMs = c.rsnAKMs
+		el.ciphers = c.rsnCiphs
+	}
+	if c.hasWPA {
+		el.HasWPA = true
+		el.wpaAKMs = c.wpaAKMs
+		if !c.hasRSN {
+			el.ciphers = c.wpaCiphs
+		}
+	}
+	if info.ESSID == "" && !info.Hidden && el.HasSSID {
+		info.ESSID, info.Hidden = ssidString(el.SSID)
+	}
+
+	rates := c.rates
+	if len(rates) == 0 {
+		rates = ratesFromIE(c.rawIEs)
+	}
+	_, all, legacyMax := legacyRates(rates)
+	info.SupportedRates = all
+
+	normalizeWiFiInfo(&info)
+	phy, generation, width, center := el.phyInfo(info.Band, legacyMax)
+	info.PHYType = phy
+	info.Generation = generation
+	info.ChannelWidth = width
+	if width > 20 {
+		info.CenterChannel = center
+	}
+	streams := el.spatialStreams()
+	maxMCS := el.heMaxMCS
+	if phy == "802.11ac" {
+		maxMCS = el.vhtMaxMCS
+	}
+	info.SpatialStreams = streams
+	info.MaxRateMbps = estimateMaxRate(phy, width, streams, maxMCS, legacyMax)
+	info.Security = el.securityName(info.EncryptionKey)
+	info.Cipher = el.cipherName(info.EncryptionKey)
+	info.PMF = el.PMF
+	info.Country = el.Country
+	info.StationCount = el.StationCount
+	info.ChannelUtilization = el.ChannelUtilization
+	info.WPS = el.WPS
+	info.Roaming = el.Roaming
+	info.UptimeSeconds = int64(c.tsf / 1000000)
+	return info
+}
+
+// parseIwSuiteList reads the names after the colon of an iwlist
+// "Pairwise Ciphers (n) : ..." or "Authentication Suites (n) : ..." line
+func parseIwSuiteList(line string, names map[string]uint32) []uint32 {
+	i := strings.Index(line, ":")
+	if i < 0 {
+		return nil
+	}
+	value := line[i+1:]
+	list := []uint32{}
+	for _, m := range reIwSuiteNum.FindAllStringSubmatch(value, -1) {
+		if v, err := strconv.Atoi(m[1]); err == nil {
+			list = append(list, uint32(v))
+		}
+	}
+	value = reIwSuiteNum.ReplaceAllString(value, "")
+	for _, f := range strings.Fields(value) {
+		if v, ok := names[f]; ok {
+			list = append(list, v)
+		}
+	}
+	return list
+}
+
+// unescapeIwString decodes the \xNN escapes iw / iwlist use for SSID bytes
+func unescapeIwString(s string) []byte {
+	out := []byte{}
+	for i := 0; i < len(s); i++ {
+		if s[i] == '\\' && i+3 < len(s) && s[i+1] == 'x' {
+			if b, err := hex.DecodeString(s[i+2 : i+4]); err == nil {
+				out = append(out, b[0])
+				i += 3
+				continue
+			}
+		}
+		out = append(out, s[i])
+	}
+	return out
+}
+
+// parseFrequencyMHz reads "2.412 GHz", "5180 MHz", "5180" or "5180.0"
+func parseFrequencyMHz(s string) int {
+	fields := strings.Fields(strings.TrimSpace(s))
+	if len(fields) == 0 {
+		return 0
+	}
+	v, err := strconv.ParseFloat(fields[0], 64)
+	if err != nil || v <= 0 {
+		return 0
+	}
+	if len(fields) > 1 && strings.EqualFold(fields[1], "GHz") || len(fields) == 1 && v < 100 {
+		v *= 1000
+	}
+	return int(v + 0.5)
+}
+
+// iwLinkInfo is the parsed output of "iw dev <interface> link"
+type iwLinkInfo struct {
+	Connected  bool
+	BSSID      string
+	SSID       string
+	Hidden     bool
+	FreqMHz    int
+	SignalDBm  int
+	RxRateMbps float64
+	TxRateMbps float64
+	Width      int
+	PHYType    string
+	BeaconInt  int
+}
+
+// parseIwLink parses the output of "iw dev <interface> link"
+func parseIwLink(out string) iwLinkInfo {
+	link := iwLinkInfo{}
+	for _, raw := range strings.Split(out, "\n") {
+		line := strings.TrimSpace(raw)
+		key, value, found := strings.Cut(line, ":")
+		value = strings.TrimSpace(value)
+		switch {
+		case strings.HasPrefix(line, "Connected to "):
+			fields := strings.Fields(line)
+			if len(fields) >= 3 {
+				link.Connected = true
+				link.BSSID = strings.ToLower(fields[2])
+			}
+		case !found:
+			continue
+		case key == "SSID":
+			link.SSID, link.Hidden = ssidString(unescapeIwString(value))
+		case key == "freq":
+			link.FreqMHz = parseFrequencyMHz(value)
+		case key == "signal":
+			if m := reLeadingInt.FindStringSubmatch(value); m != nil {
+				link.SignalDBm, _ = strconv.Atoi(m[1])
+			}
+		case key == "rx bitrate" || key == "tx bitrate":
+			rate := 0.0
+			if f := strings.Fields(value); len(f) > 0 {
+				rate, _ = strconv.ParseFloat(f[0], 64)
+			}
+			if key == "rx bitrate" {
+				link.RxRateMbps = rate
+			} else {
+				link.TxRateMbps = rate
+			}
+			if m := reIwWidth.FindStringSubmatch(value); m != nil {
+				if w, _ := strconv.Atoi(m[1]); w > link.Width {
+					link.Width = w
+				}
+			}
+			if phy := phyOfBitrate(value); phy != "" {
+				link.PHYType = phy
+			}
+		case key == "beacon int":
+			link.BeaconInt, _ = strconv.Atoi(value)
+		}
+	}
+	return link
+}
+
+// phyOfBitrate maps the MCS type in an iw bitrate line to the 802.11 amendment
+func phyOfBitrate(s string) string {
+	switch {
+	case strings.Contains(s, "EHT-MCS"):
+		return "802.11be"
+	case strings.Contains(s, "HE-MCS"):
+		return "802.11ax"
+	case strings.Contains(s, "VHT-MCS"):
+		return "802.11ac"
+	case strings.Contains(s, "MCS"):
+		return "802.11n"
+	}
+	return ""
+}
+
+// parseProcRoute returns the default gateways of an interface from the
+// content of /proc/net/route
+func parseProcRoute(content string, iface string) []string {
+	gateways := []string{}
+	for _, line := range strings.Split(content, "\n") {
+		f := strings.Fields(line)
+		if len(f) < 3 || f[0] != iface || f[1] != "00000000" {
+			continue
+		}
+		b, err := hex.DecodeString(f[2])
+		if err != nil || len(b) != 4 {
+			continue
+		}
+		ip := strconv.Itoa(int(b[3])) + "." + strconv.Itoa(int(b[2])) + "." + strconv.Itoa(int(b[1])) + "." + strconv.Itoa(int(b[0]))
+		if ip != "0.0.0.0" && !containsString(gateways, ip) {
+			gateways = append(gateways, ip)
+		}
+	}
+	return gateways
+}
+
+// normalizeWiFiInfo fills the numeric fields of a scan result from the
+// string fields when the platform parser did not set them
+func normalizeWiFiInfo(info *WiFiInfo) {
+	if info.SignalDBm == 0 {
+		if m := reLeadingInt.FindStringSubmatch(info.SignalLevel); m != nil && strings.Contains(info.SignalLevel, "dBm") {
+			info.SignalDBm, _ = strconv.Atoi(m[1])
+		}
+	}
+	if info.QualityPercent <= 0 {
+		info.QualityPercent = -1
+		if m := reIwQuality.FindStringSubmatch("Quality=" + info.Quality); m != nil {
+			a, _ := strconv.Atoi(m[1])
+			b, _ := strconv.Atoi(m[2])
+			if b > 0 {
+				info.QualityPercent = a * 100 / b
+			}
+		}
+		if info.QualityPercent < 0 && info.SignalDBm != 0 {
+			info.QualityPercent = qualityFromRSSI(info.SignalDBm)
+		}
+	}
+	if info.FrequencyMHz == 0 {
+		info.FrequencyMHz = parseFrequencyMHz(info.Frequency)
+		if bandOfFrequency(info.FrequencyMHz) == "" {
+			info.FrequencyMHz = 0
+		}
+	}
+	if info.FrequencyMHz == 0 && info.Channel > 0 {
+		info.FrequencyMHz = frequencyOfChannel(info.Channel, false)
+	}
+	if info.Channel <= 0 && info.FrequencyMHz > 0 {
+		info.Channel = channelOfFrequency(info.FrequencyMHz)
+	}
+	if info.FrequencyMHz > 0 {
+		info.Band = bandOfFrequency(info.FrequencyMHz)
+		info.Frequency = formatFrequency(info.FrequencyMHz)
+	}
+	if info.Generation == "" {
+		info.Generation = generationOfPHY(info.PHYType, info.Band)
+	}
+}

+ 235 - 0
src/mod/network/wifi/iwparse_test.go

@@ -0,0 +1,235 @@
+package wifi
+
+import (
+	"strings"
+	"testing"
+)
+
+const sampleIwlist = `wlan0     Scan completed :
+          Cell 01 - Address: AA:BB:CC:DD:EE:01
+                    Channel:36
+                    Frequency:5.18 GHz (Channel 36)
+                    Quality=63/70  Signal level=-47 dBm
+                    Encryption key:on
+                    ESSID:"Home\xE5\xAE\xB6"
+                    Bit Rates:6 Mb/s; 9 Mb/s; 12 Mb/s; 18 Mb/s; 24 Mb/s
+                              36 Mb/s; 48 Mb/s; 54 Mb/s
+                    Mode:Master
+                    Extra:tsf=000000e8d4a51000
+                    Extra: Last beacon: 30ms ago
+                    IE: Unknown: 0007486F6D65E5AEB6
+                    IE: Unknown: 0703545720
+                    IE: Unknown: 2D1AEF0917FFFF000000000000000000000000000000000000000000
+                    IE: Unknown: 3D1624050000000000000000000000000000000000000000
+                    IE: Unknown: BF0CB259820FFAFF0000FAFF0000
+                    IE: Unknown: C005012A000000
+                    IE: IEEE 802.11i/WPA2 Version 1
+                        Group Cipher : CCMP
+                        Pairwise Ciphers (1) : CCMP
+                        Authentication Suites (2) : PSK unknown (8)
+          Cell 02 - Address: aa:bb:cc:dd:ee:02
+                    Channel:6
+                    Frequency:2.437 GHz (Channel 6)
+                    Quality=30/70  Signal level=-80 dBm
+                    Encryption key:on
+                    ESSID:""
+                    Bit Rates:1 Mb/s; 2 Mb/s; 5.5 Mb/s; 11 Mb/s
+                    Mode:Master
+                    IE: WPA Version 1
+                        Group Cipher : TKIP
+                        Pairwise Ciphers (1) : TKIP
+                        Authentication Suites (1) : PSK
+          Cell 03 - Address: AA:BB:CC:DD:EE:03
+                    Channel:11
+                    Frequency:2.462 GHz (Channel 11)
+                    Quality=70/70  Signal level=-30 dBm
+                    Encryption key:off
+                    ESSID:"Guest"
+                    Mode:Master
+`
+
+func TestParseIwlistScan(t *testing.T) {
+	results := parseIwlistScan(sampleIwlist)
+	if len(results) != 3 {
+		t.Fatalf("got %d cells, want 3 (the last cell must not be dropped)", len(results))
+	}
+
+	a := results[0]
+	checks := []struct {
+		name string
+		got  interface{}
+		want interface{}
+	}{
+		{"Address", a.Address, "aa:bb:cc:dd:ee:01"},
+		{"ESSID", a.ESSID, "Home\u5bb6"},
+		{"Channel", a.Channel, 36},
+		{"FrequencyMHz", a.FrequencyMHz, 5180},
+		{"Frequency", a.Frequency, "5.180 GHz"},
+		{"Band", a.Band, "5 GHz"},
+		{"Quality", a.Quality, "63/70"},
+		{"QualityPercent", a.QualityPercent, 90},
+		{"SignalDBm", a.SignalDBm, -47},
+		{"SignalLevel", a.SignalLevel, "-47 dBm"},
+		{"BSSType", a.BSSType, "Infrastructure"},
+		{"Security", a.Security, "WPA2/WPA3-Personal"},
+		{"Cipher", a.Cipher, "AES-CCMP"},
+		{"Country", a.Country, "TW"},
+		{"PHYType", a.PHYType, "802.11ac"},
+		{"ChannelWidth", a.ChannelWidth, 80},
+		{"CenterChannel", a.CenterChannel, 42},
+		{"SpatialStreams", a.SpatialStreams, 2},
+		{"SupportedRates", len(a.SupportedRates), 8},
+		{"UptimeSeconds", a.UptimeSeconds, int64(1000000)},
+	}
+	for _, c := range checks {
+		if c.got != c.want {
+			t.Errorf("cell 1 %s = %v, want %v", c.name, c.got, c.want)
+		}
+	}
+
+	b := results[1]
+	if !b.Hidden || b.ESSID != "" {
+		t.Errorf("cell 2: Hidden=%v ESSID=%q", b.Hidden, b.ESSID)
+	}
+	if b.Security != "WPA-Personal" || b.Cipher != "TKIP" || b.PHYType != "802.11b" {
+		t.Errorf("cell 2: Security=%q Cipher=%q PHY=%q", b.Security, b.Cipher, b.PHYType)
+	}
+
+	c := results[2]
+	if c.ESSID != "Guest" || c.Security != "Open" || c.EncryptionKey || c.QualityPercent != 100 {
+		t.Errorf("cell 3: %+v", c)
+	}
+
+	if got := parseIwlistScan("wlan0     No scan results\n"); len(got) != 0 {
+		t.Errorf("empty scan returned %d cells", len(got))
+	}
+}
+
+// TestParseIwlistScanTruncated parses every line prefix of the sample
+func TestParseIwlistScanTruncated(t *testing.T) {
+	lines := strings.Split(sampleIwlist, "\n")
+	for i := range lines {
+		parseIwlistScan(strings.Join(lines[:i], "\n"))
+	}
+}
+
+func TestParseIwLink(t *testing.T) {
+	connected := `Connected to aa:bb:cc:dd:ee:01 (on wlan0)
+	SSID: Home\xe5\xae\xb6
+	freq: 5180.0
+	RX: 123456 bytes (789 packets)
+	TX: 23456 bytes (123 packets)
+	signal: -52 dBm
+	rx bitrate: 433.3 MBit/s VHT-MCS 9 80MHz short GI VHT-NSS 1
+	tx bitrate: 390.0 MBit/s VHT-MCS 8 80MHz short GI VHT-NSS 1
+
+	bss flags:	short-slot-time
+	dtim period:	1
+	beacon int:	100
+`
+	link := parseIwLink(connected)
+	if !link.Connected || link.BSSID != "aa:bb:cc:dd:ee:01" || link.SSID != "Home\u5bb6" {
+		t.Errorf("link = %+v", link)
+	}
+	if link.FreqMHz != 5180 || link.SignalDBm != -52 || link.Width != 80 || link.BeaconInt != 100 {
+		t.Errorf("link = %+v", link)
+	}
+	if link.RxRateMbps != 433.3 || link.TxRateMbps != 390 || link.PHYType != "802.11ac" {
+		t.Errorf("link = %+v", link)
+	}
+
+	if link := parseIwLink("Not connected.\n"); link.Connected {
+		t.Error("Not connected parsed as connected")
+	}
+
+	he := parseIwLink("Connected to 00:11:22:33:44:55 (on wlp2s0)\n\tfreq: 2437\n\ttx bitrate: 286.7 MBit/s 40MHz HE-MCS 11 HE-NSS 2 HE-GI 0 HE-DCM 0\n")
+	if he.PHYType != "802.11ax" || he.Width != 40 || he.FreqMHz != 2437 {
+		t.Errorf("HE link = %+v", he)
+	}
+}
+
+func TestPhyOfBitrate(t *testing.T) {
+	cases := map[string]string{
+		"54.0 MBit/s":                         "",
+		"72.2 MBit/s MCS 7 short GI":          "802.11n",
+		"866.7 MBit/s VHT-MCS 9 160MHz":       "802.11ac",
+		"1201.0 MBit/s 80MHz HE-MCS 11":       "802.11ax",
+		"2882.4 MBit/s 320MHz EHT-MCS 13 EHT": "802.11be",
+	}
+	for in, want := range cases {
+		if got := phyOfBitrate(in); got != want {
+			t.Errorf("phyOfBitrate(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestParseFrequencyMHz(t *testing.T) {
+	cases := map[string]int{
+		"2.412 GHz":             2412,
+		"5.18 GHz (Channel 36)": 5180,
+		"5180":                  5180,
+		"5180.0":                5180,
+		"5180 MHz":              5180,
+		"":                      0,
+		"abc":                   0,
+		"-5":                    0,
+	}
+	for in, want := range cases {
+		if got := parseFrequencyMHz(in); got != want {
+			t.Errorf("parseFrequencyMHz(%q) = %d, want %d", in, got, want)
+		}
+	}
+}
+
+func TestParseProcRoute(t *testing.T) {
+	content := "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n" +
+		"wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n" +
+		"wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t00FFFFFF\t0\t0\t0\n" +
+		"eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n"
+	got := parseProcRoute(content, "wlan0")
+	if len(got) != 1 || got[0] != "192.168.0.1" {
+		t.Errorf("parseProcRoute(wlan0) = %v", got)
+	}
+	if got := parseProcRoute(content, "wlan1"); len(got) != 0 {
+		t.Errorf("parseProcRoute(wlan1) = %v", got)
+	}
+}
+
+func TestNormalizeWiFiInfo(t *testing.T) {
+	cases := []struct {
+		name    string
+		in      WiFiInfo
+		dbm     int
+		quality int
+		mhz     int
+		band    string
+		channel int
+	}{
+		{"iwlist strings", WiFiInfo{SignalLevel: "-60 dBm", Quality: "35/70", Frequency: "2.437 GHz"}, -60, 50, 2437, "2.4 GHz", 6},
+		{"nmcli estimate", WiFiInfo{SignalLevel: "-55 dBm[Estimated]", Quality: "80/100", Channel: 149, Frequency: "54 Mbit/s"}, -55, 80, 5745, "5 GHz", 149},
+		{"signal only", WiFiInfo{SignalLevel: "-75 dBm"}, -75, 50, 0, "", 0},
+		{"nothing", WiFiInfo{}, 0, -1, 0, "", 0},
+	}
+	for _, tc := range cases {
+		info := tc.in
+		normalizeWiFiInfo(&info)
+		if info.SignalDBm != tc.dbm || info.QualityPercent != tc.quality || info.FrequencyMHz != tc.mhz || info.Band != tc.band || info.Channel != tc.channel {
+			t.Errorf("%s: got dBm=%d quality=%d MHz=%d band=%q channel=%d", tc.name, info.SignalDBm, info.QualityPercent, info.FrequencyMHz, info.Band, info.Channel)
+		}
+	}
+}
+
+func TestUnescapeIwString(t *testing.T) {
+	cases := map[string]string{
+		`plain`:          "plain",
+		`a\x41b`:         "aAb",
+		`bad\xZZ`:        `bad\xZZ`,
+		`end\x4`:         `end\x4`,
+		`\xe5\xae\xb6!!`: "\u5bb6!!",
+	}
+	for in, want := range cases {
+		if got := string(unescapeIwString(in)); got != want {
+			t.Errorf("unescapeIwString(%q) = %q, want %q", in, got, want)
+		}
+	}
+}

+ 75 - 5
src/mod/network/wifi/types.go

@@ -1,14 +1,84 @@
 package wifi
 
+// WiFiInfo describes one access point (BSS) found by a scan. The first block
+// of fields is the original, string based format every platform fills; the
+// rest is filled as far as the platform can tell and left at its zero value
+// (or nil) otherwise.
 type WiFiInfo struct {
-	Address         string
+	Address         string //BSSID
 	Channel         int
-	Frequency       string
-	Quality         string
-	SignalLevel     string
+	Frequency       string //Human readable, e.g. "5.180 GHz"
+	Quality         string //e.g. "70/70" or "86/100"
+	SignalLevel     string //e.g. "-52 dBm"
 	EncryptionKey   bool
 	ESSID           string
-	ConnectedBefore bool
+	ConnectedBefore bool //A saved profile exists for this SSID
+
+	Interface          string   //Interface that scanned this BSS
+	Hidden             bool     //The AP does not broadcast its SSID
+	Connected          bool     //This is the BSS the interface is associated with
+	SignalDBm          int      //RSSI in dBm, 0 when unknown
+	QualityPercent     int      //Link quality 0 - 100, -1 when unknown
+	FrequencyMHz       int      //Center frequency of the primary channel
+	Band               string   //"2.4 GHz", "5 GHz", "6 GHz" or "60 GHz"
+	ChannelWidth       int      //Operating channel width in MHz, 0 when unknown
+	CenterChannel      int      //Center channel of a 40 MHz or wider channel
+	PHYType            string   //e.g. "802.11ax"
+	Generation         string   //e.g. "Wi-Fi 6"
+	BSSType            string   //"Infrastructure" or "Ad hoc"
+	Security           string   //e.g. "WPA2-Personal", "WPA3-Personal", "Open"
+	Cipher             string   //e.g. "AES-CCMP"
+	PMF                string   //Protected management frames: "Required", "Capable" or ""
+	SpatialStreams     int      //Spatial streams the AP advertises, 0 when unknown
+	MaxRateMbps        float64  //Estimated maximum PHY rate, 0 when unknown
+	BasicRates         []string //Legacy basic (mandatory) rates in Mbps
+	SupportedRates     []string //All legacy rates in Mbps
+	BeaconInterval     int      //Beacon interval in time units (1.024 ms)
+	UptimeSeconds      int64    //AP uptime from the beacon timestamp, 0 when unknown
+	Country            string   //Two letter regulatory country code
+	StationCount       *int     //Clients associated with the AP (BSS Load element)
+	ChannelUtilization *int     //Channel busy percentage (BSS Load element)
+	WPS                bool     //Wi-Fi Protected Setup is enabled
+	Roaming            []string //Fast roaming amendments: "802.11k", "802.11v", "802.11r"
+}
+
+// WiFiInterfaceStatus is the live state of one wireless interface.
+type WiFiInterfaceStatus struct {
+	Name          string //Friendly name, e.g. "Wi-Fi 2" or "wlan0"
+	Description   string //Adapter model
+	GUID          string //Windows interface GUID
+	MAC           string
+	State         string //"connected", "disconnected", "associating", ...
+	Connected     bool
+	SoftwareRadio string //"on", "off" or "unknown"
+	HardwareRadio string //"on", "off" or "unknown"
+
+	SSID           string
+	BSSID          string
+	ProfileName    string
+	ConnectionMode string
+	BSSType        string
+	PHYType        string
+	Generation     string
+	Channel        int
+	FrequencyMHz   int
+	Band           string
+	ChannelWidth   int
+	SignalQuality  int     //Percent, -1 when unknown
+	RSSI           int     //dBm, 0 when unknown
+	RxRateMbps     float64 //Receive link rate
+	TxRateMbps     float64 //Transmit link rate
+	Security       string
+	Cipher         string
+	OneX           bool //802.1X authentication in use
+
+	IPv4     []string
+	IPv6     []string
+	Gateways []string
+	DNS      []string
+	MTU      int
+
+	AP *WiFiInfo //Details of the connected access point, when known
 }
 
 type WiFiConnectionResult struct {

+ 15 - 0
src/mod/network/wifi/wifi_darwin.go

@@ -37,3 +37,18 @@ func (w *WiFiManager) GetConnectedWiFi() (string, string, error) {
 func (w *WiFiManager) RemoveWifi(ssid string) error {
 	return errors.New("Platform not supported")
 }
+
+// CanControlInterfacePower reports whether SetInterfacePower is supported
+func (w *WiFiManager) CanControlInterfacePower() bool {
+	return false
+}
+
+// CanConnect reports whether ConnectWiFi and RemoveWifi are supported
+func (w *WiFiManager) CanConnect() bool {
+	return false
+}
+
+// GetInterfaceStatus returns the live state of every wireless interface
+func (w *WiFiManager) GetInterfaceStatus() ([]WiFiInterfaceStatus, error) {
+	return []WiFiInterfaceStatus{}, nil
+}

+ 15 - 0
src/mod/network/wifi/wifi_freebsd.go

@@ -37,3 +37,18 @@ func (w *WiFiManager) GetConnectedWiFi() (string, string, error) {
 func (w *WiFiManager) RemoveWifi(ssid string) error {
 	return errors.New("Platform not supported")
 }
+
+// CanControlInterfacePower reports whether SetInterfacePower is supported
+func (w *WiFiManager) CanControlInterfacePower() bool {
+	return false
+}
+
+// CanConnect reports whether ConnectWiFi and RemoveWifi are supported
+func (w *WiFiManager) CanConnect() bool {
+	return false
+}
+
+// GetInterfaceStatus returns the live state of every wireless interface
+func (w *WiFiManager) GetInterfaceStatus() ([]WiFiInterfaceStatus, error) {
+	return []WiFiInterfaceStatus{}, nil
+}

+ 164 - 96
src/mod/network/wifi/wifi_linux.go

@@ -6,6 +6,7 @@ package wifi
 import (
 	"errors"
 	"fmt"
+	"net"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -147,9 +148,12 @@ func (w *WiFiManager) ScanNearbyWiFi(interfaceName string) ([]WiFiInfo, error) {
 						continue
 					}
 
+					thisWifiInfo.Interface = interfaceName
+					normalizeWiFiInfo(thisWifiInfo)
 					results = append(results, *thisWifiInfo)
 				}
 
+				markConnectedBSS(results, interfaceName)
 				return results, nil
 			} else {
 				logger.PrintAndLog("Wifi", fmt.Sprint("*WiFi* Scan Failed: ", err.Error()), nil)
@@ -160,106 +164,45 @@ func (w *WiFiManager) ScanNearbyWiFi(interfaceName string) ([]WiFiInfo, error) {
 	}
 
 	//parse the output of the WiFi Scan
-	lines := strings.Split(strings.TrimSpace(string(out)), "\n")
-	for i, thisline := range lines {
-		lines[i] = strings.TrimSpace(thisline)
+	results := parseIwlistScan(string(out))
+	useNmcliStore := pkg_exists("nmcli")
+	for i := range results {
+		results[i].Interface = interfaceName
+		if useNmcliStore {
+			//Make use of nmcli storage
+			results[i].ConnectedBefore = w.database.KeyExists("wifi", results[i].ESSID)
+		} else {
+			//Direct access to wpa_supplicant
+			results[i].ConnectedBefore = fileExists("./system/network/wifi/ap/" + results[i].ESSID + ".config")
+		}
 	}
+	markConnectedBSS(results, interfaceName)
+	return results, nil
+}
 
-	//Ignore first line if it contains "Scan completed"
-	if strings.Contains(lines[0], "Scan completed") {
-		lines = lines[1:]
+// markConnectedBSS flags the scan result the interface is associated with
+func markConnectedBSS(results []WiFiInfo, interfaceName string) {
+	link, err := readIwLink(interfaceName)
+	if err != nil || !link.Connected {
+		return
 	}
-
-	var results = []WiFiInfo{}
-	//Loop through each line and construct the WiFi Info slice
-
-	processingWiFiNode := new(WiFiInfo)
-	for _, line := range lines {
-		if strings.Contains(line, "Address: ") {
-			//Push the previous results into results and create a new Node
-			if processingWiFiNode.Address != "" {
-				//Check if the ESSID already exists
-				if pkg_exists("nmcli") {
-					//Make use of nmcli storage
-					if w.database.KeyExists("wifi", processingWiFiNode.ESSID) {
-						processingWiFiNode.ConnectedBefore = true
-					} else {
-						processingWiFiNode.ConnectedBefore = false
-					}
-				} else {
-					//Direct access to wpa_supplicant
-					if fileExists("./system/network/wifi/ap/" + processingWiFiNode.ESSID + ".config") {
-						processingWiFiNode.ConnectedBefore = true
-					} else {
-						processingWiFiNode.ConnectedBefore = false
-					}
-
-				}
-				results = append(results, *processingWiFiNode)
-				processingWiFiNode = new(WiFiInfo)
-			}
-			//Analysis this node
-			datachunk := strings.Split(line, " ")
-			if len(datachunk) > 0 {
-				processingWiFiNode.Address = datachunk[len(datachunk)-1]
-			}
-		} else if strings.Contains(line, "Channel") && strings.Contains(line, "Frequency") == false {
-			datachunk := strings.Split(line, ":")
-			if len(datachunk) > 0 {
-				channel, err := strconv.Atoi(datachunk[len(datachunk)-1])
-				if err != nil {
-					channel = -1
-				}
-				processingWiFiNode.Channel = channel
-
-			}
-
-		} else if strings.Contains(line, "Frequency") {
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 0 {
-				frequencyData := tmp[len(tmp)-1]
-				frequencyDataChunk := strings.Split(frequencyData, " ")
-				if len(frequencyDataChunk) > 1 {
-					frequencyString := frequencyDataChunk[:2]
-					processingWiFiNode.Frequency = strings.Join(frequencyString, " ")
-				}
-
-			}
-		} else if strings.Contains(line, "Quality=") {
-			//Need to seperate quality data from signal level. Example source: Quality=70/70  Signal level=-40 dBm
-			analysisItem := strings.Split(line, "  ")
-			if len(analysisItem) == 2 {
-				//Get the quality of connections
-				processingWiFiNode.Quality = analysisItem[0][8:]
-
-				//Get the signal level of the connections
-				processingWiFiNode.SignalLevel = analysisItem[1][13:]
-			}
-
-		} else if strings.Contains(line, "Encryption key") {
-			ek := strings.Split(line, ":")
-			if len(ek) > 0 {
-				status := ek[1]
-				if status == "on" {
-					processingWiFiNode.EncryptionKey = true
-				} else {
-					processingWiFiNode.EncryptionKey = false
-				}
-			}
-		} else if strings.Contains(line, "ESSID") {
-			iddata := strings.Split(line, ":")
-			if len(iddata) > 0 {
-				ESSID := iddata[1]
-				ESSID = strings.ReplaceAll(ESSID, "\"", "")
-				if ESSID == "" {
-					ESSID = "Hidden Network"
-				}
-				processingWiFiNode.ESSID = ESSID
-			}
+	for i := range results {
+		if strings.EqualFold(results[i].Address, link.BSSID) {
+			results[i].Connected = true
 		}
 	}
+}
 
-	return results, nil
+// readIwLink runs "iw dev <interface> link"
+func readIwLink(interfaceName string) (iwLinkInfo, error) {
+	if interfaceName == "" || strings.ContainsAny(interfaceName, " /\\") {
+		return iwLinkInfo{}, errors.New("invalid interface name")
+	}
+	out, err := exec.Command("iw", "dev", interfaceName, "link").CombinedOutput()
+	if err != nil {
+		return iwLinkInfo{}, errors.New(string(out))
+	}
+	return parseIwLink(string(out)), nil
 }
 
 // Hack the signal level out of the nmcli bars
@@ -555,8 +498,6 @@ func (w *WiFiManager) GetConnectedWiFi() (string, string, error) {
 
 			return currentSSID, interfaceName, nil
 
-		} else {
-			return "", "", errors.New(string(out))
 		}
 		return "", "", errors.New(string(out))
 	}
@@ -626,6 +567,133 @@ func (w *WiFiManager) RemoveWifi(ssid string) error {
 	return nil
 }
 
+// CanControlInterfacePower reports whether SetInterfacePower is supported
+func (w *WiFiManager) CanControlInterfacePower() bool {
+	return true
+}
+
+// CanConnect reports whether ConnectWiFi and RemoveWifi are supported
+func (w *WiFiManager) CanConnect() bool {
+	return true
+}
+
+// GetInterfaceStatus returns the live state of every wireless interface
+func (w *WiFiManager) GetInterfaceStatus() ([]WiFiInterfaceStatus, error) {
+	names, err := w.GetWirelessInterfaces()
+	if err != nil {
+		return []WiFiInterfaceStatus{}, err
+	}
+	results := []WiFiInterfaceStatus{}
+	for _, name := range names {
+		name = strings.TrimSpace(name)
+		if name == "" {
+			continue
+		}
+		results = append(results, w.interfaceStatus(name))
+	}
+	return results, nil
+}
+
+func (w *WiFiManager) interfaceStatus(name string) WiFiInterfaceStatus {
+	status := WiFiInterfaceStatus{
+		Name:          name,
+		State:         "disconnected",
+		SoftwareRadio: "unknown",
+		HardwareRadio: "unknown",
+		SignalQuality: -1,
+		IPv4:          []string{},
+		IPv6:          []string{},
+		Gateways:      []string{},
+		DNS:           []string{},
+	}
+
+	sysDir := filepath.Join("/sys", "class", "net", name)
+	if driver, err := filepath.EvalSymlinks(filepath.Join(sysDir, "device", "driver")); err == nil {
+		status.Description = "Driver: " + filepath.Base(driver)
+	}
+	if blocked, ok := readRfkill(sysDir, "soft"); ok {
+		status.SoftwareRadio = map[bool]string{true: "off", false: "on"}[blocked]
+	}
+	if blocked, ok := readRfkill(sysDir, "hard"); ok {
+		status.HardwareRadio = map[bool]string{true: "off", false: "on"}[blocked]
+	}
+
+	if nic, err := net.InterfaceByName(name); err == nil {
+		status.MAC = nic.HardwareAddr.String()
+		status.MTU = nic.MTU
+		if nic.Flags&net.FlagUp == 0 {
+			status.State = "down"
+		}
+		if addrs, err := nic.Addrs(); err == nil {
+			for _, addr := range addrs {
+				ipnet, ok := addr.(*net.IPNet)
+				if !ok {
+					continue
+				}
+				if ipnet.IP.To4() != nil {
+					status.IPv4 = append(status.IPv4, ipnet.String())
+				} else {
+					status.IPv6 = append(status.IPv6, ipnet.String())
+				}
+			}
+		}
+	}
+	if routes, err := os.ReadFile(filepath.Join("/proc", "net", "route")); err == nil {
+		status.Gateways = parseProcRoute(string(routes), name)
+	}
+
+	link, err := readIwLink(name)
+	if err != nil {
+		//No iw: fall back to the connected SSID from iwgetid / nmcli
+		ssid, iface, err := w.GetConnectedWiFi()
+		if err == nil && iface == name && ssid != "" && ssid != "OFFLINE" {
+			status.SSID = ssid
+			status.Connected = true
+			status.State = "connected"
+		}
+		return status
+	}
+	if !link.Connected {
+		return status
+	}
+	status.Connected = true
+	status.State = "connected"
+	status.SSID = link.SSID
+	status.BSSID = link.BSSID
+	status.BSSType = "Infrastructure"
+	status.FrequencyMHz = link.FreqMHz
+	status.Band = bandOfFrequency(link.FreqMHz)
+	status.Channel = channelOfFrequency(link.FreqMHz)
+	status.ChannelWidth = link.Width
+	status.RSSI = link.SignalDBm
+	status.SignalQuality = qualityFromRSSI(link.SignalDBm)
+	status.RxRateMbps = link.RxRateMbps
+	status.TxRateMbps = link.TxRateMbps
+	status.PHYType = link.PHYType
+	status.Generation = generationOfPHY(link.PHYType, status.Band)
+	return status
+}
+
+// readRfkill reports whether any rfkill switch of the interface's PHY is
+// blocked (kind is "soft" or "hard"). ok is false when there is no switch.
+func readRfkill(sysDir string, kind string) (blocked bool, ok bool) {
+	matches, err := filepath.Glob(filepath.Join(sysDir, "phy80211", "rfkill*", kind))
+	if err != nil || len(matches) == 0 {
+		return false, false
+	}
+	for _, m := range matches {
+		content, err := os.ReadFile(m)
+		if err != nil {
+			continue
+		}
+		ok = true
+		if strings.TrimSpace(string(content)) == "1" {
+			blocked = true
+		}
+	}
+	return blocked, ok
+}
+
 // Helper functions
 func fileInDir(filesourcepath string, directory string) bool {
 	filepathAbs, err := filepath.Abs(filesourcepath)

+ 88 - 0
src/mod/network/wifi/wifi_linux_test.go

@@ -0,0 +1,88 @@
+//go:build linux
+// +build linux
+
+package wifi
+
+import (
+	"runtime"
+	"testing"
+)
+
+// ---------------------------------------------------------------------------
+// Linux-specific helper function tests
+// ---------------------------------------------------------------------------
+
+// TestFileExistsLinux verifies the fileExists helper.
+func TestFileExistsLinux(t *testing.T) {
+	if runtime.GOOS != "linux" {
+		t.Skip("fileExists helper is in linux-only source; test runs on Linux")
+	}
+	// A path that exists.
+	if !fileExists("/proc/version") {
+		t.Error("fileExists(\"/proc/version\") should return true on Linux")
+	}
+	// A path that does not exist.
+	if fileExists("/nonexistent_path_xyz_12345") {
+		t.Error("fileExists(\"/nonexistent_path_xyz_12345\") should return false")
+	}
+}
+
+// TestFileInDirLinux verifies the fileInDir helper.
+func TestFileInDirLinux(t *testing.T) {
+	if runtime.GOOS != "linux" {
+		t.Skip("fileInDir helper is in linux-only source; test runs on Linux")
+	}
+	// The file is inside the directory.
+	if !fileInDir("/tmp/foo/bar.txt", "/tmp/foo") {
+		t.Error("fileInDir should return true when file is inside directory")
+	}
+	// The file is outside the directory.
+	if fileInDir("/proc/version", "/tmp") {
+		t.Error("fileInDir should return false when file is outside directory")
+	}
+}
+
+// TestPkgExistsLinux verifies the pkg_exists helper.
+func TestPkgExistsLinux(t *testing.T) {
+	if runtime.GOOS != "linux" {
+		t.Skip("pkg_exists helper is in linux-only source; test runs on Linux")
+	}
+	// "sh" should always exist on Linux.
+	if !pkg_exists("sh") {
+		t.Error("pkg_exists(\"sh\") should return true on Linux")
+	}
+	// A package that almost certainly doesn't exist.
+	if pkg_exists("this_package_does_not_exist_xyz") {
+		t.Error("pkg_exists(\"this_package_does_not_exist_xyz\") should return false")
+	}
+}
+
+// TestGetSignalLevelEstimation verifies the bar-to-dBm mapping.
+func TestGetSignalLevelEstimation(t *testing.T) {
+	if runtime.GOOS != "linux" {
+		t.Skip("getSignalLevelEstimation is in linux-only source; test runs on Linux")
+	}
+	database, cleanup := newTestDatabase(t)
+	defer cleanup()
+
+	wm := NewWiFiManager(database, false, "", "")
+
+	cases := []struct {
+		bar      string
+		expected string
+	}{
+		{"▂▄▆█", "-45 dBm[Estimated]"},
+		{"▂▄▆_", "-55 dBm[Estimated]"},
+		{"▂▄__", "-75 dBm[Estimated]"},
+		{"▂___", "-85 dBm[Estimated]"},
+		{"____", "-95 dBm[Estimated]"},
+		{"", "-95 dBm[Estimated]"},
+	}
+
+	for _, tc := range cases {
+		got := wm.getSignalLevelEstimation(tc.bar)
+		if got != tc.expected {
+			t.Errorf("getSignalLevelEstimation(%q) = %q, expected %q", tc.bar, got, tc.expected)
+		}
+	}
+}

+ 3 - 81
src/mod/network/wifi/wifi_test.go

@@ -29,8 +29,9 @@ func newTestDatabase(t *testing.T) (*db.Database, func()) {
 	}
 
 	cleanup := func() {
-		// Nothing extra needed; t.TempDir() handles directory removal.
-		_ = database
+		// Close the database so t.TempDir() can remove the file (Windows
+		// refuses to delete a file that is still open).
+		database.Close()
 	}
 	return database, cleanup
 }
@@ -255,82 +256,3 @@ func TestRemoveWifiUnsupported(t *testing.T) {
 		t.Errorf("RemoveWifi() on %s should return an error, got nil", runtime.GOOS)
 	}
 }
-
-// ---------------------------------------------------------------------------
-// Linux-specific helper function tests
-// ---------------------------------------------------------------------------
-
-// TestFileExistsLinux verifies the fileExists helper.
-func TestFileExistsLinux(t *testing.T) {
-	if runtime.GOOS != "linux" {
-		t.Skip("fileExists helper is in linux-only source; test runs on Linux")
-	}
-	// A path that exists.
-	if !fileExists("/proc/version") {
-		t.Error("fileExists(\"/proc/version\") should return true on Linux")
-	}
-	// A path that does not exist.
-	if fileExists("/nonexistent_path_xyz_12345") {
-		t.Error("fileExists(\"/nonexistent_path_xyz_12345\") should return false")
-	}
-}
-
-// TestFileInDirLinux verifies the fileInDir helper.
-func TestFileInDirLinux(t *testing.T) {
-	if runtime.GOOS != "linux" {
-		t.Skip("fileInDir helper is in linux-only source; test runs on Linux")
-	}
-	// The file is inside the directory.
-	if !fileInDir("/tmp/foo/bar.txt", "/tmp/foo") {
-		t.Error("fileInDir should return true when file is inside directory")
-	}
-	// The file is outside the directory.
-	if fileInDir("/etc/passwd", "/tmp") {
-		t.Error("fileInDir should return false when file is outside directory")
-	}
-}
-
-// TestPkgExistsLinux verifies the pkg_exists helper.
-func TestPkgExistsLinux(t *testing.T) {
-	if runtime.GOOS != "linux" {
-		t.Skip("pkg_exists helper is in linux-only source; test runs on Linux")
-	}
-	// "sh" should always exist on Linux.
-	if !pkg_exists("sh") {
-		t.Error("pkg_exists(\"sh\") should return true on Linux")
-	}
-	// A package that almost certainly doesn't exist.
-	if pkg_exists("this_package_does_not_exist_xyz") {
-		t.Error("pkg_exists(\"this_package_does_not_exist_xyz\") should return false")
-	}
-}
-
-// TestGetSignalLevelEstimation verifies the bar-to-dBm mapping.
-func TestGetSignalLevelEstimation(t *testing.T) {
-	if runtime.GOOS != "linux" {
-		t.Skip("getSignalLevelEstimation is in linux-only source; test runs on Linux")
-	}
-	database, cleanup := newTestDatabase(t)
-	defer cleanup()
-
-	wm := NewWiFiManager(database, false, "", "")
-
-	cases := []struct {
-		bar      string
-		expected string
-	}{
-		{"▂▄▆█", "-45 dBm[Estimated]"},
-		{"▂▄▆_", "-55 dBm[Estimated]"},
-		{"▂▄__", "-75 dBm[Estimated]"},
-		{"▂___", "-85 dBm[Estimated]"},
-		{"____", "-95 dBm[Estimated]"},
-		{"", "-95 dBm[Estimated]"},
-	}
-
-	for _, tc := range cases {
-		got := wm.getSignalLevelEstimation(tc.bar)
-		if got != tc.expected {
-			t.Errorf("getSignalLevelEstimation(%q) = %q, expected %q", tc.bar, got, tc.expected)
-		}
-	}
-}

+ 261 - 132
src/mod/network/wifi/wifi_windows.go

@@ -7,15 +7,17 @@ package wifi
 	WiFi connection module for Windows
 	author: tobychui
 
+	Reads interface state and scan results through the native WLAN API
+	(wlanapi_windows.go). Earlier versions parsed the output of
+	"chcp 65001 && netsh wlan ...", which depended on the OS language and
+	switched the code page of the console ArozOS was running in.
 */
 
 import (
+	"bytes"
 	"errors"
-	"os/exec"
-	"strconv"
+	"sort"
 	"strings"
-
-	"imuslab.com/arozos/mod/info/logger"
 )
 
 // Toggle WiFi On Off. Only allow on sudo mode
@@ -23,136 +25,273 @@ func (w *WiFiManager) SetInterfacePower(wlanInterface string, on bool) error {
 	return errors.New("Windows WiFi function is currently readonly")
 }
 
-func (w *WiFiManager) GetInterfacePowerStatuts(wlanInterface string) (bool, error) {
-	return false, errors.New("Platform not supported")
+// CanControlInterfacePower reports whether SetInterfacePower is supported
+func (w *WiFiManager) CanControlInterfacePower() bool {
+	return false
 }
 
-func (w *WiFiManager) ScanNearbyWiFi(interfaceName string) ([]WiFiInfo, error) {
-	cmd := exec.Command("cmd", "/c", "chcp 65001 && netsh WLAN show networks mode=bssid")
-	out, err := cmd.CombinedOutput()
+// CanConnect reports whether ConnectWiFi and RemoveWifi are supported
+func (w *WiFiManager) CanConnect() bool {
+	return false
+}
+
+func (w *WiFiManager) GetInterfacePowerStatuts(wlanInterface string) (bool, error) {
+	client, err := openWlanClient()
 	if err != nil {
-		//No interface found on the system
-		logger.PrintAndLog("Wifi", string(out), nil)
-		return []WiFiInfo{}, errors.New(string(out))
+		return false, err
 	}
+	defer client.Close()
 
-	//Filter the output
-	output := string(out)
-	results := []WiFiInfo{}
-	var currentWiFiInfo *WiFiInfo = nil
-	for _, line := range strings.Split(output, "\r\n") {
-		line = strings.TrimSpace(line)
-		for strings.Contains(line, "  ") {
-			line = strings.ReplaceAll(line, "  ", "")
+	iface, _, err := findWlanInterface(client, wlanInterface, false)
+	if err != nil {
+		return false, err
+	}
+	guid := guidFromBytes(iface.GUID)
+	states, err := client.radioState(&guid)
+	if err != nil {
+		return false, err
+	}
+	software, hardware := radioSummary(states)
+	return software == "on" && hardware != "off", nil
+}
+
+// radioSummary folds the per-PHY radio states into one software and one
+// hardware state: "on" when any PHY is on
+func radioSummary(states []wlanPhyRadioState) (string, string) {
+	software, hardware := "unknown", "unknown"
+	for _, s := range states {
+		if sw := dot11RadioStateName(s.Software); sw == "on" || software == "unknown" {
+			software = sw
+		}
+		if hw := dot11RadioStateName(s.Hardware); hw == "on" || hardware == "unknown" {
+			hardware = hw
 		}
+	}
+	return software, hardware
+}
 
-		line = strings.TrimSpace(line)
-		if len(line) == 0 {
-			//This is an empty line
-			continue
+// findWlanInterface picks the interface whose friendly name, description or
+// GUID matches name. When nothing matches (or name is empty) the first
+// interface is returned if fallback is set.
+func findWlanInterface(client *wlanClient, name string, fallback bool) (wlanInterfaceInfo, adapterInfo, error) {
+	ifaces, err := client.interfaces()
+	if err != nil {
+		return wlanInterfaceInfo{}, adapterInfo{}, err
+	}
+	if len(ifaces) == 0 {
+		return wlanInterfaceInfo{}, adapterInfo{}, errors.New("Wireless Interface Not Found")
+	}
+	adapters := adaptersByGUID()
+	name = strings.TrimSpace(name)
+	for _, iface := range ifaces {
+		guid := guidString(iface.GUID)
+		adapter := adapters[guid]
+		if name != "" && (strings.EqualFold(adapter.FriendlyName, name) || strings.EqualFold(iface.Description, name) || strings.EqualFold(guid, name)) {
+			return iface, adapter, nil
 		}
+	}
+	if !fallback {
+		return wlanInterfaceInfo{}, adapterInfo{}, errors.New("wlan Interface not exists")
+	}
+	return ifaces[0], adapters[guidString(ifaces[0].GUID)], nil
+}
 
-		if line[:4] == "SSID" {
-			//Starting a new WiFi Info
-			if currentWiFiInfo != nil {
-				currentWiFiInfo.Quality = "-"
-				results = append(results, *currentWiFiInfo)
-			}
+// interfaceDisplayName is the name the rest of ArozOS uses for an interface
+func interfaceDisplayName(iface wlanInterfaceInfo, adapter adapterInfo) string {
+	if adapter.FriendlyName != "" {
+		return adapter.FriendlyName
+	}
+	return iface.Description
+}
 
-			essid := ""
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 1 {
-				essid = tmp[1]
-			}
-			currentWiFiInfo = &WiFiInfo{
-				ESSID: strings.TrimSpace(essid),
-			}
-		} else if line[:5] == "BSSID" {
-			bssid := ""
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 1 {
-				tmp = tmp[1:]
-				bssid = strings.Join(tmp, ":")
-			}
-			currentWiFiInfo.Address = strings.TrimSpace(bssid)
+func (w *WiFiManager) ScanNearbyWiFi(interfaceName string) ([]WiFiInfo, error) {
+	client, err := openWlanClient()
+	if err != nil {
+		return []WiFiInfo{}, err
+	}
+	defer client.Close()
 
-		} else if line[:7] == "Channel" {
-			channel := ""
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 1 {
-				channel = tmp[1]
-			}
-			channel = strings.TrimSpace(channel)
-			channelInt, err := strconv.Atoi(channel)
-			if err != nil {
-				channelInt = -1
-			}
-			currentWiFiInfo.Channel = channelInt
-		} else if line[:6] == "Signal" {
-			signal := ""
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 1 {
-				signal = tmp[1]
-			}
+	iface, adapter, err := findWlanInterface(client, interfaceName, true)
+	if err != nil {
+		return []WiFiInfo{}, err
+	}
+	guid := guidFromBytes(iface.GUID)
+	displayName := interfaceDisplayName(iface, adapter)
 
-			signal = strings.TrimSpace(signal)
-			currentWiFiInfo.SignalLevel = signal
-		} else if line[:10] == "Encryption" {
-			encryp := ""
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 1 {
-				encryp = tmp[1]
-			}
+	//Ask the driver for fresh results. A failed scan (for example while the
+	//radio is off) still leaves the cached list to read.
+	scanErr := client.scan(guid)
 
-			encryp = strings.TrimSpace(encryp)
+	entries, err := client.bssList(&guid)
+	if err != nil {
+		if scanErr != nil {
+			return []WiFiInfo{}, scanErr
+		}
+		return []WiFiInfo{}, err
+	}
 
-			if encryp == "CCMO" || encryp == "TKIP" {
-				currentWiFiInfo.EncryptionKey = true
-			} else {
-				currentWiFiInfo.EncryptionKey = false
-			}
-		} else if line[:10] == "Radio type" {
-			radtype := ""
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 1 {
-				radtype = tmp[1]
-			}
+	//Saved profiles and the fallback security per SSID
+	networks, _ := client.availableNetworks(&guid)
+	networkBySSID := map[string]wlanAvailableNetwork{}
+	for _, n := range networks {
+		key := string(n.SSID)
+		if old, ok := networkBySSID[key]; !ok || n.Flags&wlanAvailableNetworkHasProfile != 0 && old.Flags&wlanAvailableNetworkHasProfile == 0 {
+			networkBySSID[key] = n
+		}
+	}
 
-			radtype = strings.TrimSpace(radtype)
-			currentWiFiInfo.Frequency = radtype
+	var connectedBSSID []byte
+	if iface.State == 1 {
+		if conn, err := client.connection(&guid); err == nil {
+			connectedBSSID = conn.BSSID[:]
 		}
+	}
 
+	results := []WiFiInfo{}
+	for _, entry := range entries {
+		info := bssInfoFromEntry(entry)
+		info.Interface = displayName
+		if n, ok := networkBySSID[string(entry.SSID)]; ok {
+			info.ConnectedBefore = n.Flags&wlanAvailableNetworkHasProfile != 0
+			if info.Security == "Open" && n.SecurityEnabled {
+				info.Security = dot11AuthName(n.AuthAlgorithm)
+				info.Cipher = dot11CipherName(n.CipherAlgorithm)
+				info.EncryptionKey = true
+			}
+		}
+		info.Connected = connectedBSSID != nil && bytes.Equal(connectedBSSID, entry.BSSID[:])
+		results = append(results, info)
 	}
 
+	sort.SliceStable(results, func(i, j int) bool {
+		return results[i].SignalDBm > results[j].SignalDBm
+	})
 	return results, nil
 }
 
 func (w *WiFiManager) GetWirelessInterfaces() ([]string, error) {
-	//Try to get wireless interface info from cmd
-	cmd := exec.Command("cmd", "/c", "chcp 65001 && netsh WLAN show drivers")
-	out, err := cmd.CombinedOutput()
+	client, err := openWlanClient()
 	if err != nil {
-		//No interface found on the system
-		logger.PrintAndLog("Wifi", string(out), nil)
 		return []string{}, err
 	}
-	output := string(out)
-	wlanInterfaces := []string{}
-	for _, line := range strings.Split(output, "\r\n") {
-		line = strings.TrimSpace(line)
-		for strings.Contains(line, "  ") {
-			line = strings.ReplaceAll(line, "  ", "")
-		}
+	defer client.Close()
+
+	ifaces, err := client.interfaces()
+	if err != nil {
+		return []string{}, err
+	}
+	adapters := adaptersByGUID()
+	names := []string{}
+	for _, iface := range ifaces {
+		names = append(names, interfaceDisplayName(iface, adapters[guidString(iface.GUID)]))
+	}
+	return names, nil
+}
+
+// GetInterfaceStatus returns the live state of every wireless interface
+func (w *WiFiManager) GetInterfaceStatus() ([]WiFiInterfaceStatus, error) {
+	client, err := openWlanClient()
+	if err != nil {
+		return []WiFiInterfaceStatus{}, err
+	}
+	defer client.Close()
+
+	ifaces, err := client.interfaces()
+	if err != nil {
+		return []WiFiInterfaceStatus{}, err
+	}
+	adapters := adaptersByGUID()
+	results := []WiFiInterfaceStatus{}
+	for _, iface := range ifaces {
+		results = append(results, interfaceStatus(client, iface, adapters[guidString(iface.GUID)]))
+	}
+	return results, nil
+}
+
+func interfaceStatus(client *wlanClient, iface wlanInterfaceInfo, adapter adapterInfo) WiFiInterfaceStatus {
+	guid := guidFromBytes(iface.GUID)
+	status := WiFiInterfaceStatus{
+		Name:          interfaceDisplayName(iface, adapter),
+		Description:   iface.Description,
+		GUID:          guidString(iface.GUID),
+		MAC:           adapter.MAC,
+		State:         wlanInterfaceStateName(iface.State),
+		Connected:     iface.State == 1,
+		SoftwareRadio: "unknown",
+		HardwareRadio: "unknown",
+		SignalQuality: -1,
+		IPv4:          nonNil(adapter.IPv4),
+		IPv6:          nonNil(adapter.IPv6),
+		Gateways:      nonNil(adapter.Gateways),
+		DNS:           nonNil(adapter.DNS),
+		MTU:           adapter.MTU,
+	}
+	if states, err := client.radioState(&guid); err == nil {
+		status.SoftwareRadio, status.HardwareRadio = radioSummary(states)
+	}
+	if !status.Connected {
+		return status
+	}
 
-		if strings.Contains(line, "Interface name: ") {
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 1 {
-				thisInterfaceName := tmp[1]
-				wlanInterfaces = append(wlanInterfaces, thisInterfaceName)
+	conn, err := client.connection(&guid)
+	if err != nil {
+		return status
+	}
+	status.SSID, _ = ssidString(conn.SSID)
+	status.BSSID = formatMAC(conn.BSSID[:])
+	status.ProfileName = conn.ProfileName
+	status.ConnectionMode = wlanConnectionModeName(conn.Mode)
+	status.BSSType = dot11BSSTypeName(conn.BSSType)
+	status.PHYType = dot11PhyName(conn.PhyType)
+	status.SignalQuality = int(conn.SignalQuality)
+	status.RxRateMbps = float64(conn.RxRateKbps) / 1000
+	status.TxRateMbps = float64(conn.TxRateKbps) / 1000
+	status.Security = dot11AuthName(conn.AuthAlgorithm)
+	if !conn.SecurityEnabled {
+		status.Security = "Open"
+	}
+	status.Cipher = dot11CipherName(conn.CipherAlgorithm)
+	status.OneX = conn.OneXEnabled
+
+	if ch, err := client.uint32Value(&guid, wlanIntfOpcodeChannelNumber); err == nil {
+		status.Channel = int(ch)
+	}
+	if rssi, err := client.uint32Value(&guid, wlanIntfOpcodeRSSI); err == nil && int32(rssi) < 0 {
+		status.RSSI = int(int32(rssi))
+	} else {
+		status.RSSI = rssiFromQuality(status.SignalQuality)
+	}
+
+	//The cached scan results describe the connected AP in detail. Reading
+	//them does not start a new scan.
+	if entries, err := client.bssList(&guid); err == nil {
+		for _, entry := range entries {
+			if !bytes.Equal(entry.BSSID[:], conn.BSSID[:]) {
+				continue
+			}
+			ap := bssInfoFromEntry(entry)
+			ap.Interface = status.Name
+			ap.Connected = true
+			ap.ConnectedBefore = true
+			status.AP = &ap
+			status.FrequencyMHz = ap.FrequencyMHz
+			status.Band = ap.Band
+			status.ChannelWidth = ap.ChannelWidth
+			if ap.Channel > 0 {
+				status.Channel = ap.Channel
+			}
+			if ap.PHYType != "" && status.PHYType == "" {
+				status.PHYType = ap.PHYType
 			}
+			break
 		}
 	}
-	return wlanInterfaces, nil
+	if status.FrequencyMHz == 0 {
+		status.FrequencyMHz = frequencyOfChannel(status.Channel, false)
+		status.Band = bandOfFrequency(status.FrequencyMHz)
+	}
+	status.Generation = generationOfPHY(status.PHYType, status.Band)
+	return status
 }
 
 func (w *WiFiManager) ConnectWiFi(ssid string, password string, connType string, identity string) (*WiFiConnectionResult, error) {
@@ -161,41 +300,31 @@ func (w *WiFiManager) ConnectWiFi(ssid string, password string, connType string,
 
 // Get connected wifi ssid, interface name and error if any
 func (w *WiFiManager) GetConnectedWiFi() (string, string, error) {
-	cmd := exec.Command("cmd", "/c", "chcp 65001 && netsh WLAN show interface")
-	out, err := cmd.CombinedOutput()
+	statuses, err := w.GetInterfaceStatus()
 	if err != nil {
-		//No interface found on the system
-		logger.PrintAndLog("Wifi", string(out), nil)
+		if err == errWlanUnavailable {
+			return "", "", nil
+		}
+		return "", "", err
+	}
+	if len(statuses) == 0 {
 		return "", "", nil
 	}
-	output := string(out)
-
-	//Things to be returned
-	interfaceName := ""
-	connectedSSID := ""
-
-	for _, line := range strings.Split(output, "\r\n") {
-		line = strings.TrimSpace(line)
-		for strings.Contains(line, "  ") {
-			line = strings.ReplaceAll(line, "  ", "")
-		}
-
-		if len(line) > 4 && line[:4] == "Name" {
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 1 {
-				interfaceName = tmp[1]
-			}
-		} else if len(line) > 4 && line[:4] == "SSID" {
-			tmp := strings.Split(line, ":")
-			if len(tmp) > 1 {
-				connectedSSID = tmp[1]
-			}
+	for _, s := range statuses {
+		if s.Connected {
+			return s.SSID, s.Name, nil
 		}
 	}
-
-	return connectedSSID, interfaceName, nil
+	return "", statuses[0].Name, nil
 }
 
 func (w *WiFiManager) RemoveWifi(ssid string) error {
 	return errors.New("Windows WiFi function is currently readonly")
 }
+
+func nonNil(list []string) []string {
+	if list == nil {
+		return []string{}
+	}
+	return list
+}

+ 375 - 0
src/mod/network/wifi/wlanapi_windows.go

@@ -0,0 +1,375 @@
+//go:build windows
+// +build windows
+
+package wifi
+
+/*
+	Native WLAN API bindings
+
+	Talks to the WLAN AutoConfig service (WlanSvc) through wlanapi.dll instead
+	of running netsh. The API returns binary structures, so the results do not
+	depend on the console code page or the language of the OS, and no child
+	process (or console code page switch) is involved.
+*/
+
+import (
+	"errors"
+	"net"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+	"unsafe"
+
+	"golang.org/x/sys/windows"
+)
+
+var (
+	modwlanapi = windows.NewLazySystemDLL("wlanapi.dll")
+
+	procWlanOpenHandle              = modwlanapi.NewProc("WlanOpenHandle")
+	procWlanCloseHandle             = modwlanapi.NewProc("WlanCloseHandle")
+	procWlanEnumInterfaces          = modwlanapi.NewProc("WlanEnumInterfaces")
+	procWlanQueryInterface          = modwlanapi.NewProc("WlanQueryInterface")
+	procWlanScan                    = modwlanapi.NewProc("WlanScan")
+	procWlanGetNetworkBssList       = modwlanapi.NewProc("WlanGetNetworkBssList")
+	procWlanGetAvailableNetworkList = modwlanapi.NewProc("WlanGetAvailableNetworkList")
+	procWlanFreeMemory              = modwlanapi.NewProc("WlanFreeMemory")
+	procWlanRegisterNotification    = modwlanapi.NewProc("WlanRegisterNotification")
+)
+
+// WLAN_INTF_OPCODE values
+const (
+	wlanIntfOpcodeRadioState        = 4
+	wlanIntfOpcodeCurrentConnection = 7
+	wlanIntfOpcodeChannelNumber     = 8
+	wlanIntfOpcodeRSSI              = 0x10000102
+)
+
+const (
+	wlanClientVersion            = 2 //Windows Vista and later
+	dot11BSSTypeAny              = 3
+	wlanNotificationSourceNone   = 0
+	wlanNotificationSourceACM    = 0x08
+	wlanNotificationACMScanDone  = 7
+	wlanNotificationACMScanFail  = 8
+	wlanScanTimeout              = 6 * time.Second
+	errorServiceNotActive        = 1062
+	errorAccessDenied            = 5
+	errorNotReady                = 21
+	errorNdisDot11PowerStateOff  = 0x80342002
+	errorInvalidState            = 5023
+	errorDeviceNotConnected      = 1167
+	errorNdisInterfaceNotPresent = 0x8034000E
+)
+
+var errWlanUnavailable = errors.New("the WLAN API (wlanapi.dll) is not available on this system")
+
+// wlanError turns a WLAN API return code into a readable error
+func wlanError(fn string, code uintptr) error {
+	switch code {
+	case errorServiceNotActive:
+		return errors.New("the WLAN AutoConfig service (WlanSvc) is not running")
+	case errorAccessDenied:
+		return errors.New("access to WiFi information was denied by Windows (allow desktop apps to use location in Privacy settings)")
+	case errorNotReady, errorNdisDot11PowerStateOff, errorInvalidState:
+		return errors.New("the wireless interface is not ready (the radio might be turned off)")
+	case errorDeviceNotConnected, errorNdisInterfaceNotPresent:
+		return errors.New("the wireless interface is not present")
+	}
+	return errors.New(fn + ": " + windows.Errno(code).Error())
+}
+
+type wlanClient struct {
+	handle uintptr
+}
+
+func openWlanClient() (*wlanClient, error) {
+	if err := modwlanapi.Load(); err != nil {
+		return nil, errWlanUnavailable
+	}
+	var negotiated uint32
+	var handle uintptr
+	r, _, _ := procWlanOpenHandle.Call(wlanClientVersion, 0, uintptr(unsafe.Pointer(&negotiated)), uintptr(unsafe.Pointer(&handle)))
+	if r != 0 {
+		return nil, wlanError("WlanOpenHandle", r)
+	}
+	return &wlanClient{handle: handle}, nil
+}
+
+func (c *wlanClient) Close() {
+	procWlanCloseHandle.Call(c.handle, 0)
+}
+
+// takeMemory copies size bytes of WLAN API allocated memory and frees it
+func takeMemory(p *byte, size int) []byte {
+	if p == nil {
+		return nil
+	}
+	defer procWlanFreeMemory.Call(uintptr(unsafe.Pointer(p)))
+	if size <= 0 {
+		return nil
+	}
+	return append([]byte{}, unsafe.Slice(p, size)...)
+}
+
+// readUint32At reads a DWORD from WLAN API allocated memory
+func readUint32At(p *byte, offset uintptr) uint32 {
+	return *(*uint32)(unsafe.Add(unsafe.Pointer(p), offset))
+}
+
+func (c *wlanClient) interfaces() ([]wlanInterfaceInfo, error) {
+	var p *byte
+	r, _, _ := procWlanEnumInterfaces.Call(c.handle, 0, uintptr(unsafe.Pointer(&p)))
+	if r != 0 {
+		return nil, wlanError("WlanEnumInterfaces", r)
+	}
+	if p == nil {
+		return []wlanInterfaceInfo{}, nil
+	}
+	n := int(readUint32At(p, 0))
+	return parseWlanInterfaceList(takeMemory(p, 8+n*wlanInterfaceInfoSize))
+}
+
+func (c *wlanClient) query(guid *windows.GUID, opcode uint32) ([]byte, error) {
+	var size uint32
+	var p *byte
+	r, _, _ := procWlanQueryInterface.Call(c.handle, uintptr(unsafe.Pointer(guid)), uintptr(opcode), 0,
+		uintptr(unsafe.Pointer(&size)), uintptr(unsafe.Pointer(&p)), 0)
+	if r != 0 {
+		return nil, wlanError("WlanQueryInterface", r)
+	}
+	return takeMemory(p, int(size)), nil
+}
+
+func (c *wlanClient) bssList(guid *windows.GUID) ([]wlanBSSEntry, error) {
+	var p *byte
+	r, _, _ := procWlanGetNetworkBssList.Call(c.handle, uintptr(unsafe.Pointer(guid)), 0, dot11BSSTypeAny, 0, 0, uintptr(unsafe.Pointer(&p)))
+	if r != 0 {
+		return nil, wlanError("WlanGetNetworkBssList", r)
+	}
+	if p == nil {
+		return []wlanBSSEntry{}, nil
+	}
+	total := int(readUint32At(p, 0))
+	return parseWlanBSSList(takeMemory(p, total))
+}
+
+func (c *wlanClient) availableNetworks(guid *windows.GUID) ([]wlanAvailableNetwork, error) {
+	var p *byte
+	r, _, _ := procWlanGetAvailableNetworkList.Call(c.handle, uintptr(unsafe.Pointer(guid)), 0, 0, uintptr(unsafe.Pointer(&p)))
+	if r != 0 {
+		return nil, wlanError("WlanGetAvailableNetworkList", r)
+	}
+	if p == nil {
+		return []wlanAvailableNetwork{}, nil
+	}
+	n := int(readUint32At(p, 0))
+	return parseWlanAvailableNetworkList(takeMemory(p, 8+n*wlanAvailableNetworkSize))
+}
+
+func (c *wlanClient) connection(guid *windows.GUID) (*wlanConnectionAttributes, error) {
+	b, err := c.query(guid, wlanIntfOpcodeCurrentConnection)
+	if err != nil {
+		return nil, err
+	}
+	return parseWlanConnectionAttributes(b)
+}
+
+func (c *wlanClient) radioState(guid *windows.GUID) ([]wlanPhyRadioState, error) {
+	b, err := c.query(guid, wlanIntfOpcodeRadioState)
+	if err != nil {
+		return nil, err
+	}
+	return parseWlanRadioState(b)
+}
+
+func (c *wlanClient) uint32Value(guid *windows.GUID, opcode uint32) (uint32, error) {
+	b, err := c.query(guid, opcode)
+	if err != nil {
+		return 0, err
+	}
+	if len(b) < 4 {
+		return 0, errWlanTruncated
+	}
+	return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24, nil
+}
+
+/*
+	Scanning
+
+	WlanScan only starts a scan. Completion is reported through a
+	notification callback; the callback is created once for the process
+	(callbacks are a limited resource) and wakes the goroutines waiting for
+	that interface.
+*/
+
+type wlanNotificationData struct {
+	Source        uint32
+	Code          uint32
+	InterfaceGUID windows.GUID
+	DataSize      uint32
+	Data          uintptr
+}
+
+var (
+	wlanNotifyOnce     sync.Once
+	wlanNotifyCallback uintptr
+	wlanScanMu         sync.Mutex
+	wlanScanWaiters    = map[windows.GUID]map[chan struct{}]bool{}
+)
+
+func wlanNotificationHandler(data *wlanNotificationData, context uintptr) uintptr {
+	if data == nil || data.Source != wlanNotificationSourceACM {
+		return 0
+	}
+	if data.Code != wlanNotificationACMScanDone && data.Code != wlanNotificationACMScanFail {
+		return 0
+	}
+	wlanScanMu.Lock()
+	for ch := range wlanScanWaiters[data.InterfaceGUID] {
+		select {
+		case ch <- struct{}{}:
+		default:
+		}
+	}
+	wlanScanMu.Unlock()
+	return 0
+}
+
+// scan triggers a scan on the interface and waits until it completes (or
+// times out). The results are then read with bssList.
+func (c *wlanClient) scan(guid windows.GUID) error {
+	wlanNotifyOnce.Do(func() {
+		wlanNotifyCallback = windows.NewCallback(wlanNotificationHandler)
+	})
+
+	done := make(chan struct{}, 1)
+	wlanScanMu.Lock()
+	if wlanScanWaiters[guid] == nil {
+		wlanScanWaiters[guid] = map[chan struct{}]bool{}
+	}
+	wlanScanWaiters[guid][done] = true
+	wlanScanMu.Unlock()
+	defer func() {
+		wlanScanMu.Lock()
+		delete(wlanScanWaiters[guid], done)
+		if len(wlanScanWaiters[guid]) == 0 {
+			delete(wlanScanWaiters, guid)
+		}
+		wlanScanMu.Unlock()
+	}()
+
+	registered := false
+	r, _, _ := procWlanRegisterNotification.Call(c.handle, wlanNotificationSourceACM, 1, wlanNotifyCallback, 0, 0, 0)
+	if r == 0 {
+		registered = true
+		defer procWlanRegisterNotification.Call(c.handle, wlanNotificationSourceNone, 1, 0, 0, 0, 0)
+	}
+
+	r, _, _ = procWlanScan.Call(c.handle, uintptr(unsafe.Pointer(&guid)), 0, 0, 0)
+	if r != 0 {
+		return wlanError("WlanScan", r)
+	}
+
+	if !registered {
+		//No notification: give the driver the 4 seconds it is allowed
+		time.Sleep(4 * time.Second)
+		return nil
+	}
+	select {
+	case <-done:
+	case <-time.After(wlanScanTimeout):
+	}
+	return nil
+}
+
+/*
+	Adapter (IP layer) information
+*/
+
+type adapterInfo struct {
+	FriendlyName string
+	Description  string
+	MAC          string
+	MTU          int
+	IPv4         []string
+	IPv6         []string
+	Gateways     []string
+	DNS          []string
+}
+
+// adaptersByGUID returns the IP layer view of every adapter, keyed by the
+// upper case "{GUID}" adapter name the WLAN API interface GUIDs map to
+func adaptersByGUID() map[string]adapterInfo {
+	result := map[string]adapterInfo{}
+	size := uint32(16 * 1024)
+	var buf []byte
+	for i := 0; i < 4; i++ {
+		buf = make([]byte, size)
+		err := windows.GetAdaptersAddresses(windows.AF_UNSPEC,
+			windows.GAA_FLAG_INCLUDE_GATEWAYS|windows.GAA_FLAG_SKIP_ANYCAST|windows.GAA_FLAG_SKIP_MULTICAST,
+			0, (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])), &size)
+		if err == nil {
+			break
+		}
+		if err != windows.ERROR_BUFFER_OVERFLOW {
+			return result
+		}
+		buf = nil
+	}
+	if buf == nil {
+		return result
+	}
+
+	for a := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])); a != nil; a = a.Next {
+		info := adapterInfo{
+			FriendlyName: windows.UTF16PtrToString(a.FriendlyName),
+			Description:  windows.UTF16PtrToString(a.Description),
+			MTU:          int(a.Mtu),
+			IPv4:         []string{},
+			IPv6:         []string{},
+			Gateways:     []string{},
+			DNS:          []string{},
+		}
+		if a.PhysicalAddressLength > 0 && a.PhysicalAddressLength <= uint32(len(a.PhysicalAddress)) {
+			info.MAC = net.HardwareAddr(a.PhysicalAddress[:a.PhysicalAddressLength]).String()
+		}
+		for u := a.FirstUnicastAddress; u != nil; u = u.Next {
+			ip := u.Address.IP()
+			if ip == nil {
+				continue
+			}
+			cidr := ip.String() + "/" + strconv.Itoa(int(u.OnLinkPrefixLength))
+			if ip.To4() != nil {
+				info.IPv4 = append(info.IPv4, cidr)
+			} else {
+				info.IPv6 = append(info.IPv6, cidr)
+			}
+		}
+		for g := a.FirstGatewayAddress; g != nil; g = g.Next {
+			if ip := g.Address.IP(); ip != nil {
+				info.Gateways = append(info.Gateways, ip.String())
+			}
+		}
+		for d := a.FirstDnsServerAddress; d != nil; d = d.Next {
+			if ip := d.Address.IP(); ip != nil {
+				info.DNS = append(info.DNS, ip.String())
+			}
+		}
+		name := strings.ToUpper(windows.BytePtrToString(a.AdapterName))
+		result[name] = info
+	}
+	return result
+}
+
+// guidFromBytes converts the in-memory GUID bytes into a windows.GUID
+func guidFromBytes(b [16]byte) windows.GUID {
+	g := windows.GUID{
+		Data1: uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24,
+		Data2: uint16(b[4]) | uint16(b[5])<<8,
+		Data3: uint16(b[6]) | uint16(b[7])<<8,
+	}
+	copy(g.Data4[:], b[8:16])
+	return g
+}

+ 533 - 0
src/mod/network/wifi/wlanstruct.go

@@ -0,0 +1,533 @@
+package wifi
+
+/*
+	Native WLAN API structures
+
+	The Windows WLAN API (wlanapi.dll) returns its results as C structures.
+	They are copied into Go byte slices by wlanapi_windows.go and decoded here
+	at fixed offsets, so the layout does not depend on Go's struct alignment
+	(which differs from the C layout on 32-bit targets) and the decoding can
+	be tested on every platform.
+*/
+
+import (
+	"encoding/binary"
+	"errors"
+	"strconv"
+	"strings"
+	"unicode/utf16"
+)
+
+// Sizes of the fixed-size WLAN API structures, in bytes
+const (
+	wlanInterfaceInfoSize    = 532 //WLAN_INTERFACE_INFO
+	wlanAvailableNetworkSize = 628 //WLAN_AVAILABLE_NETWORK
+	wlanBSSEntrySize         = 360 //WLAN_BSS_ENTRY
+	wlanConnectionAttrSize   = 604 //WLAN_CONNECTION_ATTRIBUTES
+	dot11SSIDSize            = 36  //DOT11_SSID
+)
+
+// WLAN_AVAILABLE_NETWORK flags
+const (
+	wlanAvailableNetworkConnected  = 0x1
+	wlanAvailableNetworkHasProfile = 0x2
+)
+
+var errWlanTruncated = errors.New("truncated WLAN API structure")
+
+type wlanInterfaceInfo struct {
+	GUID        [16]byte
+	Description string
+	State       uint32
+}
+
+type wlanAvailableNetwork struct {
+	ProfileName     string
+	SSID            []byte
+	BSSType         uint32
+	SignalQuality   uint32
+	SecurityEnabled bool
+	AuthAlgorithm   uint32
+	CipherAlgorithm uint32
+	Flags           uint32
+}
+
+type wlanBSSEntry struct {
+	SSID           []byte
+	PhyID          uint32
+	BSSID          [6]byte
+	BSSType        uint32
+	PhyType        uint32
+	RSSI           int32
+	LinkQuality    uint32
+	InRegDomain    bool
+	BeaconPeriod   uint16
+	Timestamp      uint64
+	HostTimestamp  uint64
+	Capability     uint16
+	CenterFreqKHz  uint32
+	Rates          []uint16
+	InformationElt []byte
+}
+
+type wlanConnectionAttributes struct {
+	State           uint32
+	Mode            uint32
+	ProfileName     string
+	SSID            []byte
+	BSSType         uint32
+	BSSID           [6]byte
+	PhyType         uint32
+	PhyIndex        uint32
+	SignalQuality   uint32
+	RxRateKbps      uint32
+	TxRateKbps      uint32
+	SecurityEnabled bool
+	OneXEnabled     bool
+	AuthAlgorithm   uint32
+	CipherAlgorithm uint32
+}
+
+type wlanPhyRadioState struct {
+	PhyIndex uint32
+	Software uint32
+	Hardware uint32
+}
+
+// utf16String decodes a fixed size, NUL terminated WCHAR array
+func utf16String(b []byte) string {
+	u := make([]uint16, 0, len(b)/2)
+	for i := 0; i+1 < len(b); i += 2 {
+		c := binary.LittleEndian.Uint16(b[i:])
+		if c == 0 {
+			break
+		}
+		u = append(u, c)
+	}
+	return string(utf16.Decode(u))
+}
+
+// dot11SSID decodes a DOT11_SSID (length + 32 bytes)
+func dot11SSID(b []byte) []byte {
+	if len(b) < dot11SSIDSize {
+		return nil
+	}
+	n := binary.LittleEndian.Uint32(b[0:4])
+	if n > 32 {
+		n = 32
+	}
+	return append([]byte{}, b[4:4+n]...)
+}
+
+// guidString formats a Windows GUID stored in its in-memory byte order
+func guidString(g [16]byte) string {
+	le := binary.LittleEndian
+	return strings.ToUpper(
+		"{" + hex32(le.Uint32(g[0:4])) + "-" + hex16(le.Uint16(g[4:6])) + "-" + hex16(le.Uint16(g[6:8])) + "-" +
+			hexBytes(g[8:10]) + "-" + hexBytes(g[10:16]) + "}")
+}
+
+func hex32(v uint32) string {
+	return hexBytes([]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
+}
+func hex16(v uint16) string { return hexBytes([]byte{byte(v >> 8), byte(v)}) }
+func hexBytes(b []byte) string {
+	const digits = "0123456789abcdef"
+	s := make([]byte, 0, len(b)*2)
+	for _, c := range b {
+		s = append(s, digits[c>>4], digits[c&0x0F])
+	}
+	return string(s)
+}
+
+// parseWlanInterfaceList decodes a WLAN_INTERFACE_INFO_LIST
+func parseWlanInterfaceList(b []byte) ([]wlanInterfaceInfo, error) {
+	if len(b) < 8 {
+		return nil, errWlanTruncated
+	}
+	n := int(binary.LittleEndian.Uint32(b[0:4]))
+	if n < 0 || n > (len(b)-8)/wlanInterfaceInfoSize {
+		return nil, errWlanTruncated
+	}
+	list := []wlanInterfaceInfo{}
+	for i := 0; i < n; i++ {
+		e := b[8+i*wlanInterfaceInfoSize : 8+(i+1)*wlanInterfaceInfoSize]
+		info := wlanInterfaceInfo{
+			Description: utf16String(e[16:528]),
+			State:       binary.LittleEndian.Uint32(e[528:532]),
+		}
+		copy(info.GUID[:], e[0:16])
+		list = append(list, info)
+	}
+	return list, nil
+}
+
+// parseWlanAvailableNetworkList decodes a WLAN_AVAILABLE_NETWORK_LIST
+func parseWlanAvailableNetworkList(b []byte) ([]wlanAvailableNetwork, error) {
+	if len(b) < 8 {
+		return nil, errWlanTruncated
+	}
+	n := int(binary.LittleEndian.Uint32(b[0:4]))
+	if n < 0 || n > (len(b)-8)/wlanAvailableNetworkSize {
+		return nil, errWlanTruncated
+	}
+	le := binary.LittleEndian
+	list := []wlanAvailableNetwork{}
+	for i := 0; i < n; i++ {
+		e := b[8+i*wlanAvailableNetworkSize : 8+(i+1)*wlanAvailableNetworkSize]
+		list = append(list, wlanAvailableNetwork{
+			ProfileName:     utf16String(e[0:512]),
+			SSID:            dot11SSID(e[512:548]),
+			BSSType:         le.Uint32(e[548:552]),
+			SignalQuality:   le.Uint32(e[604:608]),
+			SecurityEnabled: le.Uint32(e[608:612]) != 0,
+			AuthAlgorithm:   le.Uint32(e[612:616]),
+			CipherAlgorithm: le.Uint32(e[616:620]),
+			Flags:           le.Uint32(e[620:624]),
+		})
+	}
+	return list, nil
+}
+
+// parseWlanBSSList decodes a WLAN_BSS_LIST, including the information
+// elements that follow each entry
+func parseWlanBSSList(b []byte) ([]wlanBSSEntry, error) {
+	if len(b) < 8 {
+		return nil, errWlanTruncated
+	}
+	le := binary.LittleEndian
+	total := int(le.Uint32(b[0:4]))
+	if total < 8 || total > len(b) {
+		total = len(b)
+	}
+	b = b[:total]
+	n := int(le.Uint32(b[4:8]))
+	if n < 0 || n > (len(b)-8)/wlanBSSEntrySize {
+		return nil, errWlanTruncated
+	}
+	list := []wlanBSSEntry{}
+	for i := 0; i < n; i++ {
+		start := 8 + i*wlanBSSEntrySize
+		e := b[start : start+wlanBSSEntrySize]
+		entry := wlanBSSEntry{
+			SSID:          dot11SSID(e[0:36]),
+			PhyID:         le.Uint32(e[36:40]),
+			BSSType:       le.Uint32(e[48:52]),
+			PhyType:       le.Uint32(e[52:56]),
+			RSSI:          int32(le.Uint32(e[56:60])),
+			LinkQuality:   le.Uint32(e[60:64]),
+			InRegDomain:   e[64] != 0,
+			BeaconPeriod:  le.Uint16(e[66:68]),
+			Timestamp:     le.Uint64(e[72:80]),
+			HostTimestamp: le.Uint64(e[80:88]),
+			Capability:    le.Uint16(e[88:90]),
+			CenterFreqKHz: le.Uint32(e[92:96]),
+		}
+		copy(entry.BSSID[:], e[40:46])
+
+		//WLAN_RATE_SET: rate count (documented as a byte length, but
+		//wlansvc reports the number of rates), then up to 126 USHORT rates
+		rateCount := int(le.Uint32(e[96:100]))
+		if rateCount > 126 {
+			rateCount = 126
+		}
+		for r := 0; r < rateCount; r++ {
+			entry.Rates = append(entry.Rates, le.Uint16(e[100+r*2:]))
+		}
+
+		//Information elements, offset relative to the start of this entry
+		ieOffset := int64(le.Uint32(e[352:356]))
+		ieSize := int64(le.Uint32(e[356:360]))
+		ieStart := int64(start) + ieOffset
+		if ieSize > 0 && ieOffset >= wlanBSSEntrySize && ieStart+ieSize <= int64(len(b)) {
+			entry.InformationElt = append([]byte{}, b[ieStart:ieStart+ieSize]...)
+		}
+		list = append(list, entry)
+	}
+	return list, nil
+}
+
+// parseWlanConnectionAttributes decodes WLAN_CONNECTION_ATTRIBUTES
+func parseWlanConnectionAttributes(b []byte) (*wlanConnectionAttributes, error) {
+	if len(b) < wlanConnectionAttrSize {
+		return nil, errWlanTruncated
+	}
+	le := binary.LittleEndian
+	c := &wlanConnectionAttributes{
+		State:       le.Uint32(b[0:4]),
+		Mode:        le.Uint32(b[4:8]),
+		ProfileName: utf16String(b[8:520]),
+		//WLAN_ASSOCIATION_ATTRIBUTES at 520
+		SSID:          dot11SSID(b[520:556]),
+		BSSType:       le.Uint32(b[556:560]),
+		PhyType:       le.Uint32(b[568:572]),
+		PhyIndex:      le.Uint32(b[572:576]),
+		SignalQuality: le.Uint32(b[576:580]),
+		RxRateKbps:    le.Uint32(b[580:584]),
+		TxRateKbps:    le.Uint32(b[584:588]),
+		//WLAN_SECURITY_ATTRIBUTES at 588
+		SecurityEnabled: le.Uint32(b[588:592]) != 0,
+		OneXEnabled:     le.Uint32(b[592:596]) != 0,
+		AuthAlgorithm:   le.Uint32(b[596:600]),
+		CipherAlgorithm: le.Uint32(b[600:604]),
+	}
+	copy(c.BSSID[:], b[560:566])
+	return c, nil
+}
+
+// parseWlanRadioState decodes WLAN_RADIO_STATE
+func parseWlanRadioState(b []byte) ([]wlanPhyRadioState, error) {
+	if len(b) < 4 {
+		return nil, errWlanTruncated
+	}
+	le := binary.LittleEndian
+	n := int(le.Uint32(b[0:4]))
+	if n > 64 {
+		n = 64
+	}
+	if n < 0 || n > (len(b)-4)/12 {
+		return nil, errWlanTruncated
+	}
+	list := []wlanPhyRadioState{}
+	for i := 0; i < n; i++ {
+		e := b[4+i*12 : 4+(i+1)*12]
+		list = append(list, wlanPhyRadioState{
+			PhyIndex: le.Uint32(e[0:4]),
+			Software: le.Uint32(e[4:8]),
+			Hardware: le.Uint32(e[8:12]),
+		})
+	}
+	return list, nil
+}
+
+// bssInfoFromEntry turns one WLAN_BSS_ENTRY into a WiFiInfo
+func bssInfoFromEntry(e wlanBSSEntry) WiFiInfo {
+	ies := parseIEs(e.InformationElt)
+	privacy := e.Capability&0x0010 != 0
+
+	ssidBytes := e.SSID
+	if len(ssidBytes) == 0 && ies.HasSSID {
+		ssidBytes = ies.SSID
+	}
+	ssid, hidden := ssidString(ssidBytes)
+
+	mhz := int(e.CenterFreqKHz / 1000)
+	band := bandOfFrequency(mhz)
+	channel := channelOfFrequency(mhz)
+	if channel == 0 && ies.DSChannel > 0 {
+		channel = ies.DSChannel
+	}
+
+	rates := e.Rates
+	if len(rates) == 0 {
+		rates = ratesFromIE(e.InformationElt)
+	}
+	basic, all, legacyMax := legacyRates(rates)
+
+	phy, generation, width, center := ies.phyInfo(band, legacyMax)
+	if phy == "" || len(e.InformationElt) == 0 && dot11PhyName(e.PhyType) != "" {
+		//Without elements the PHY type reported by the driver is the better guess
+		phy = dot11PhyName(e.PhyType)
+		generation = generationOfPHY(phy, band)
+	}
+	streams := ies.spatialStreams()
+	maxMCS := ies.heMaxMCS
+	if phy == "802.11ac" {
+		maxMCS = ies.vhtMaxMCS
+	}
+	if width <= 20 {
+		center = 0
+	}
+
+	quality := int(e.LinkQuality)
+	if quality > 100 {
+		quality = 100
+	}
+
+	info := WiFiInfo{
+		Address:            formatMAC(e.BSSID[:]),
+		Channel:            channel,
+		Frequency:          formatFrequency(mhz),
+		Quality:            strconv.Itoa(quality) + "/100",
+		SignalLevel:        strconv.Itoa(int(e.RSSI)) + " dBm",
+		ESSID:              ssid,
+		Hidden:             hidden,
+		SignalDBm:          int(e.RSSI),
+		QualityPercent:     quality,
+		FrequencyMHz:       mhz,
+		Band:               band,
+		ChannelWidth:       width,
+		CenterChannel:      center,
+		PHYType:            phy,
+		Generation:         generation,
+		BSSType:            dot11BSSTypeName(e.BSSType),
+		Security:           ies.securityName(privacy),
+		Cipher:             ies.cipherName(privacy),
+		PMF:                ies.PMF,
+		SpatialStreams:     streams,
+		MaxRateMbps:        estimateMaxRate(phy, width, streams, maxMCS, legacyMax),
+		BasicRates:         basic,
+		SupportedRates:     all,
+		BeaconInterval:     int(e.BeaconPeriod),
+		UptimeSeconds:      int64(e.Timestamp / 1000000),
+		Country:            ies.Country,
+		StationCount:       ies.StationCount,
+		ChannelUtilization: ies.ChannelUtilization,
+		WPS:                ies.WPS,
+		Roaming:            ies.Roaming,
+	}
+	info.EncryptionKey = info.Security != "Open" && info.Security != ""
+	return info
+}
+
+/*
+	DOT11 / WLAN enumeration names
+*/
+
+func dot11PhyName(t uint32) string {
+	switch t {
+	case 1:
+		return "802.11 FHSS"
+	case 2:
+		return "802.11 DSSS"
+	case 3:
+		return "802.11 IR"
+	case 4:
+		return "802.11a"
+	case 5:
+		return "802.11b"
+	case 6:
+		return "802.11g"
+	case 7:
+		return "802.11n"
+	case 8:
+		return "802.11ac"
+	case 9:
+		return "802.11ad"
+	case 10:
+		return "802.11ax"
+	case 11:
+		return "802.11be"
+	}
+	return ""
+}
+
+func dot11BSSTypeName(t uint32) string {
+	switch t {
+	case 1:
+		return "Infrastructure"
+	case 2:
+		return "Ad hoc"
+	}
+	return ""
+}
+
+func dot11AuthName(t uint32) string {
+	switch t {
+	case 1:
+		return "Open"
+	case 2:
+		return "Shared Key (WEP)"
+	case 3:
+		return "WPA-Enterprise"
+	case 4:
+		return "WPA-Personal"
+	case 5:
+		return "WPA-None"
+	case 6:
+		return "WPA2-Enterprise"
+	case 7:
+		return "WPA2-Personal"
+	case 8:
+		return "WPA3-Enterprise 192-bit"
+	case 9:
+		return "WPA3-Personal"
+	case 10:
+		return "Enhanced Open (OWE)"
+	case 11:
+		return "WPA3-Enterprise"
+	}
+	if t >= 0x80000000 {
+		return "Vendor specific"
+	}
+	return ""
+}
+
+func dot11CipherName(t uint32) string {
+	switch t {
+	case 0x00:
+		return "None"
+	case 0x01:
+		return "WEP-40"
+	case 0x02:
+		return "TKIP"
+	case 0x04:
+		return "AES-CCMP"
+	case 0x05:
+		return "WEP-104"
+	case 0x06:
+		return "BIP"
+	case 0x08:
+		return "AES-GCMP"
+	case 0x09:
+		return "AES-GCMP-256"
+	case 0x0a:
+		return "AES-CCMP-256"
+	case 0x100:
+		return "Group cipher"
+	case 0x101:
+		return "WEP"
+	}
+	if t >= 0x80000000 {
+		return "Vendor specific"
+	}
+	return ""
+}
+
+func wlanInterfaceStateName(s uint32) string {
+	switch s {
+	case 0:
+		return "not ready"
+	case 1:
+		return "connected"
+	case 2:
+		return "ad hoc network formed"
+	case 3:
+		return "disconnecting"
+	case 4:
+		return "disconnected"
+	case 5:
+		return "associating"
+	case 6:
+		return "discovering"
+	case 7:
+		return "authenticating"
+	}
+	return "unknown"
+}
+
+func wlanConnectionModeName(m uint32) string {
+	switch m {
+	case 0:
+		return "Profile"
+	case 1:
+		return "Temporary profile"
+	case 2:
+		return "Secure discovery"
+	case 3:
+		return "Unsecure discovery"
+	case 4:
+		return "Automatic"
+	}
+	return ""
+}
+
+func dot11RadioStateName(s uint32) string {
+	switch s {
+	case 1:
+		return "on"
+	case 2:
+		return "off"
+	}
+	return "unknown"
+}

+ 327 - 0
src/mod/network/wifi/wlanstruct_test.go

@@ -0,0 +1,327 @@
+package wifi
+
+import (
+	"encoding/binary"
+	"testing"
+	"unicode/utf16"
+)
+
+func putUTF16(b []byte, s string) {
+	for i, c := range utf16.Encode([]rune(s)) {
+		binary.LittleEndian.PutUint16(b[i*2:], c)
+	}
+}
+
+func putSSID(b []byte, ssid string) {
+	binary.LittleEndian.PutUint32(b[0:4], uint32(len(ssid)))
+	copy(b[4:36], ssid)
+}
+
+type testBSS struct {
+	ssid    string
+	bssid   [6]byte
+	phy     uint32
+	rssi    int32
+	quality uint32
+	beacon  uint16
+	tsf     uint64
+	caps    uint16
+	freqKHz uint32
+	rates   []uint16
+	ies     []byte
+}
+
+// buildBSSList lays out a WLAN_BSS_LIST the way wlansvc does: the fixed
+// entries first, then the information elements of every entry
+func buildBSSList(entries []testBSS) []byte {
+	le := binary.LittleEndian
+	fixed := 8 + len(entries)*wlanBSSEntrySize
+	b := make([]byte, fixed)
+	le.PutUint32(b[4:8], uint32(len(entries)))
+	for i, e := range entries {
+		start := 8 + i*wlanBSSEntrySize
+		d := b[start : start+wlanBSSEntrySize]
+		putSSID(d[0:36], e.ssid)
+		copy(d[40:46], e.bssid[:])
+		le.PutUint32(d[48:52], 1)
+		le.PutUint32(d[52:56], e.phy)
+		le.PutUint32(d[56:60], uint32(e.rssi))
+		le.PutUint32(d[60:64], e.quality)
+		d[64] = 1
+		le.PutUint16(d[66:68], e.beacon)
+		le.PutUint64(d[72:80], e.tsf)
+		le.PutUint16(d[88:90], e.caps)
+		le.PutUint32(d[92:96], e.freqKHz)
+		le.PutUint32(d[96:100], uint32(len(e.rates)))
+		for r, rate := range e.rates {
+			le.PutUint16(d[100+r*2:], rate)
+		}
+		le.PutUint32(d[352:356], uint32(len(b)-start))
+		le.PutUint32(d[356:360], uint32(len(e.ies)))
+		b = append(b, e.ies...)
+		for len(b)%8 != 0 {
+			b = append(b, 0)
+		}
+	}
+	le.PutUint32(b[0:4], uint32(len(b)))
+	return b
+}
+
+func TestParseWlanBSSList(t *testing.T) {
+	list := buildBSSList([]testBSS{
+		{
+			ssid: "Home-5G", bssid: [6]byte{0xAE, 0x20, 0x2E, 0xF8, 0x39, 0x30}, phy: 8, rssi: -36, quality: 100,
+			beacon: 100, tsf: 1041878 * 1000000, caps: 0x0011, freqKHz: 5745000,
+			rates: []uint16{0x800C, 18, 0x8018, 36, 48, 72, 96, 108},
+			ies: concat(ie(ieSSID, []byte("Home-5G")...), ie(ieCountry, 'T', 'W', 4), htCaps(3), ie(ieHTOperation, 149, 0x05),
+				vhtCaps(3, 2), ie(ieVHTOperation, 1, 155, 0), rsnIE([]byte{4}, []byte{2}, 0)),
+		},
+		{
+			ssid: "", bssid: [6]byte{1, 2, 3, 4, 5, 6}, phy: 7, rssi: -80, quality: 40,
+			caps: 0x0001, freqKHz: 2437000, rates: []uint16{0x8002, 0x8004},
+		},
+	})
+
+	entries, err := parseWlanBSSList(list)
+	if err != nil {
+		t.Fatalf("parseWlanBSSList: %v", err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("got %d entries, want 2", len(entries))
+	}
+
+	info := bssInfoFromEntry(entries[0])
+	checks := []struct {
+		name string
+		got  interface{}
+		want interface{}
+	}{
+		{"ESSID", info.ESSID, "Home-5G"},
+		{"Address", info.Address, "ae:20:2e:f8:39:30"},
+		{"Channel", info.Channel, 149},
+		{"Band", info.Band, "5 GHz"},
+		{"Frequency", info.Frequency, "5.745 GHz"},
+		{"SignalDBm", info.SignalDBm, -36},
+		{"SignalLevel", info.SignalLevel, "-36 dBm"},
+		{"Quality", info.Quality, "100/100"},
+		{"ChannelWidth", info.ChannelWidth, 80},
+		{"CenterChannel", info.CenterChannel, 155},
+		{"PHYType", info.PHYType, "802.11ac"},
+		{"Generation", info.Generation, "Wi-Fi 5"},
+		{"Security", info.Security, "WPA2-Personal"},
+		{"Cipher", info.Cipher, "AES-CCMP"},
+		{"EncryptionKey", info.EncryptionKey, true},
+		{"SpatialStreams", info.SpatialStreams, 3},
+		{"MaxRateMbps", info.MaxRateMbps, 1300.0},
+		{"Country", info.Country, "TW"},
+		{"BeaconInterval", info.BeaconInterval, 100},
+		{"UptimeSeconds", info.UptimeSeconds, int64(1041878)},
+		{"BSSType", info.BSSType, "Infrastructure"},
+		{"BasicRates", len(info.BasicRates), 2},
+		{"SupportedRates", len(info.SupportedRates), 8},
+	}
+	for _, c := range checks {
+		if c.got != c.want {
+			t.Errorf("%s = %v, want %v", c.name, c.got, c.want)
+		}
+	}
+
+	hidden := bssInfoFromEntry(entries[1])
+	if !hidden.Hidden || hidden.ESSID != "" {
+		t.Errorf("hidden entry: Hidden=%v ESSID=%q", hidden.Hidden, hidden.ESSID)
+	}
+	if hidden.PHYType != "802.11n" || hidden.Security != "Open" || hidden.Channel != 6 {
+		t.Errorf("hidden entry: PHY=%q Security=%q Channel=%d", hidden.PHYType, hidden.Security, hidden.Channel)
+	}
+}
+
+// TestParseWlanBSSListHostile checks that wrong counts and IE offsets never
+// read out of bounds
+func TestParseWlanBSSListHostile(t *testing.T) {
+	list := buildBSSList([]testBSS{{ssid: "a", freqKHz: 2412000, ies: ie(ieSSID, 'a')}})
+	le := binary.LittleEndian
+
+	cases := []struct {
+		name   string
+		mutate func(b []byte)
+		ok     bool
+	}{
+		{"valid", func(b []byte) {}, true},
+		{"count too large", func(b []byte) { le.PutUint32(b[4:8], 1000) }, false},
+		{"total too large", func(b []byte) { le.PutUint32(b[0:4], 0xFFFFFFFF) }, true},
+		{"ie offset past end", func(b []byte) { le.PutUint32(b[8+352:], 0xFFFFFFF0) }, true},
+		{"ie size past end", func(b []byte) { le.PutUint32(b[8+356:], 0xFFFFFFF0) }, true},
+		{"ie offset inside entry", func(b []byte) { le.PutUint32(b[8+352:], 4) }, true},
+		{"rate count huge", func(b []byte) { le.PutUint32(b[8+96:], 0xFFFFFFFF) }, true},
+		{"ssid length huge", func(b []byte) { le.PutUint32(b[8:], 0xFFFFFFFF) }, true},
+	}
+	for _, tc := range cases {
+		b := append([]byte{}, list...)
+		tc.mutate(b)
+		entries, err := parseWlanBSSList(b)
+		if (err == nil) != tc.ok {
+			t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok)
+		}
+		for _, e := range entries {
+			bssInfoFromEntry(e)
+		}
+	}
+	for i := 0; i <= len(list); i++ {
+		entries, _ := parseWlanBSSList(list[:i])
+		for _, e := range entries {
+			bssInfoFromEntry(e)
+		}
+	}
+}
+
+func TestParseWlanInterfaceList(t *testing.T) {
+	b := make([]byte, 8+2*wlanInterfaceInfoSize)
+	binary.LittleEndian.PutUint32(b[0:4], 2)
+	guid := []byte{0x87, 0xAB, 0xF7, 0x0B, 0xB4, 0x4A, 0xFB, 0x42, 0xA6, 0x2E, 0xD7, 0xE7, 0x20, 0xF1, 0x5A, 0x7C}
+	copy(b[8:24], guid)
+	putUTF16(b[24:536], "Realtek 8811CU Wireless LAN 802.11ac USB NIC")
+	binary.LittleEndian.PutUint32(b[8+528:], 4)
+	binary.LittleEndian.PutUint32(b[8+wlanInterfaceInfoSize+528:], 1)
+
+	list, err := parseWlanInterfaceList(b)
+	if err != nil || len(list) != 2 {
+		t.Fatalf("parseWlanInterfaceList = %v, %v", list, err)
+	}
+	if list[0].Description != "Realtek 8811CU Wireless LAN 802.11ac USB NIC" {
+		t.Errorf("Description = %q", list[0].Description)
+	}
+	if g := guidString(list[0].GUID); g != "{0BF7AB87-4AB4-42FB-A62E-D7E720F15A7C}" {
+		t.Errorf("GUID = %s", g)
+	}
+	if wlanInterfaceStateName(list[0].State) != "disconnected" || wlanInterfaceStateName(list[1].State) != "connected" {
+		t.Errorf("States = %d, %d", list[0].State, list[1].State)
+	}
+
+	if _, err := parseWlanInterfaceList(b[:100]); err == nil {
+		t.Error("truncated interface list accepted")
+	}
+	if _, err := parseWlanInterfaceList(nil); err == nil {
+		t.Error("empty interface list accepted")
+	}
+}
+
+func TestParseWlanConnectionAttributes(t *testing.T) {
+	le := binary.LittleEndian
+	b := make([]byte, wlanConnectionAttrSize)
+	le.PutUint32(b[0:4], 1)
+	le.PutUint32(b[4:8], 0)
+	putUTF16(b[8:520], "Home-5G")
+	putSSID(b[520:556], "Home-5G")
+	le.PutUint32(b[556:560], 1)
+	copy(b[560:566], []byte{0xAE, 0x20, 0x2E, 0xF8, 0x39, 0x30})
+	le.PutUint32(b[568:572], 8)
+	le.PutUint32(b[576:580], 92)
+	le.PutUint32(b[580:584], 866700)
+	le.PutUint32(b[584:588], 650000)
+	le.PutUint32(b[588:592], 1)
+	le.PutUint32(b[596:600], 7)
+	le.PutUint32(b[600:604], 4)
+
+	c, err := parseWlanConnectionAttributes(b)
+	if err != nil {
+		t.Fatalf("parseWlanConnectionAttributes: %v", err)
+	}
+	if c.ProfileName != "Home-5G" || string(c.SSID) != "Home-5G" {
+		t.Errorf("profile / SSID = %q / %q", c.ProfileName, c.SSID)
+	}
+	if formatMAC(c.BSSID[:]) != "ae:20:2e:f8:39:30" {
+		t.Errorf("BSSID = %s", formatMAC(c.BSSID[:]))
+	}
+	if dot11PhyName(c.PhyType) != "802.11ac" || c.SignalQuality != 92 || c.RxRateKbps != 866700 || c.TxRateKbps != 650000 {
+		t.Errorf("association = %+v", c)
+	}
+	if !c.SecurityEnabled || dot11AuthName(c.AuthAlgorithm) != "WPA2-Personal" || dot11CipherName(c.CipherAlgorithm) != "AES-CCMP" {
+		t.Errorf("security = %+v", c)
+	}
+	if wlanConnectionModeName(c.Mode) != "Profile" {
+		t.Errorf("mode = %d", c.Mode)
+	}
+	if _, err := parseWlanConnectionAttributes(b[:600]); err == nil {
+		t.Error("truncated connection attributes accepted")
+	}
+}
+
+func TestParseWlanRadioState(t *testing.T) {
+	le := binary.LittleEndian
+	b := make([]byte, 4+64*12)
+	le.PutUint32(b[0:4], 2)
+	le.PutUint32(b[4:8], 0)
+	le.PutUint32(b[8:12], 2)
+	le.PutUint32(b[12:16], 1)
+	le.PutUint32(b[16:20], 1)
+	le.PutUint32(b[20:24], 1)
+	le.PutUint32(b[24:28], 1)
+
+	states, err := parseWlanRadioState(b)
+	if err != nil || len(states) != 2 {
+		t.Fatalf("parseWlanRadioState = %v, %v", states, err)
+	}
+	if dot11RadioStateName(states[0].Software) != "off" || dot11RadioStateName(states[1].Software) != "on" {
+		t.Errorf("states = %+v", states)
+	}
+	le.PutUint32(b[0:4], 1000)
+	if states, err := parseWlanRadioState(b); err != nil || len(states) != 64 {
+		t.Errorf("oversized count: %d states, %v", len(states), err)
+	}
+	if _, err := parseWlanRadioState(b[:2]); err == nil {
+		t.Error("truncated radio state accepted")
+	}
+}
+
+func TestParseWlanAvailableNetworkList(t *testing.T) {
+	le := binary.LittleEndian
+	b := make([]byte, 8+wlanAvailableNetworkSize)
+	le.PutUint32(b[0:4], 1)
+	e := b[8:]
+	putUTF16(e[0:512], "Home-5G")
+	putSSID(e[512:548], "Home-5G")
+	le.PutUint32(e[548:552], 1)
+	le.PutUint32(e[604:608], 88)
+	le.PutUint32(e[608:612], 1)
+	le.PutUint32(e[612:616], 9)
+	le.PutUint32(e[616:620], 4)
+	le.PutUint32(e[620:624], wlanAvailableNetworkConnected|wlanAvailableNetworkHasProfile)
+
+	list, err := parseWlanAvailableNetworkList(b)
+	if err != nil || len(list) != 1 {
+		t.Fatalf("parseWlanAvailableNetworkList = %v, %v", list, err)
+	}
+	n := list[0]
+	if n.ProfileName != "Home-5G" || string(n.SSID) != "Home-5G" || n.SignalQuality != 88 || !n.SecurityEnabled {
+		t.Errorf("network = %+v", n)
+	}
+	if dot11AuthName(n.AuthAlgorithm) != "WPA3-Personal" || n.Flags&wlanAvailableNetworkHasProfile == 0 {
+		t.Errorf("network = %+v", n)
+	}
+	if _, err := parseWlanAvailableNetworkList(b[:500]); err == nil {
+		t.Error("truncated network list accepted")
+	}
+}
+
+func TestEnumNames(t *testing.T) {
+	cases := []struct {
+		got  string
+		want string
+	}{
+		{dot11PhyName(10), "802.11ax"},
+		{dot11PhyName(99), ""},
+		{dot11BSSTypeName(2), "Ad hoc"},
+		{dot11AuthName(0x80000001), "Vendor specific"},
+		{dot11AuthName(10), "Enhanced Open (OWE)"},
+		{dot11CipherName(0x101), "WEP"},
+		{dot11CipherName(0), "None"},
+		{wlanInterfaceStateName(99), "unknown"},
+		{wlanConnectionModeName(4), "Automatic"},
+		{dot11RadioStateName(0), "unknown"},
+	}
+	for i, c := range cases {
+		if c.got != c.want {
+			t.Errorf("case %d: got %q, want %q", i, c.got, c.want)
+		}
+	}
+}

+ 23 - 1
src/web/SystemAO/file_system/js/explorer/search.js

@@ -53,6 +53,8 @@ function hideSearchBar(skipFilelistRefresh = false){
         return;
     }
 
+    //Any search still in flight now belongs to a search the user has left
+    searchRequestId++;
     $(".searchbar").hide();
     initWindowSizes(true);
     $("#searchInput").val("");
@@ -117,6 +119,16 @@ function handleHotSearch(starting, offset){
 
 }
 
+/*
+    A search on a large folder can take a while. If the user leaves search mode,
+    navigates to another folder or starts a newer search before it returns, the
+    late result must not paint over whatever they are looking at now.
+*/
+let searchRequestId = 0;
+function isStaleSearch(id, path){
+    return !searchMode || id != searchRequestId || path != currentPath;
+}
+
 function handleSearch(){
     var keyword = $("#searchInput").val();
 
@@ -139,10 +151,16 @@ function handleSearch(){
     $("#fileList").hide();
     $("#fileList").html("");
 
+    let thisSearchId = ++searchRequestId;
+    let searchPath = currentPath;
     $.ajax({
         url: "../../system/file_system/search",
-        data: {path: currentPath, keyword: keyword, casesensitive: searchCaseSensitive},
+        data: {path: searchPath, keyword: keyword, casesensitive: searchCaseSensitive},
         success: function(data){
+            if (isStaleSearch(thisSearchId, searchPath)){
+                console.log("[File Manager] Dropped stale search result for \"" + keyword + "\" in " + searchPath, data);
+                return;
+            }
             if (data.error !== undefined){
                 msgbox("red remove", applocale.getString("message/" + data.error, data.error));
                 hideSearchBar();
@@ -162,6 +180,10 @@ function handleSearch(){
                 
             }
         }, error: function(){
+            if (isStaleSearch(thisSearchId, searchPath)){
+                console.log("[File Manager] Dropped stale search error for \"" + keyword + "\" in " + searchPath);
+                return;
+            }
             $("#folderList").html(`<div class="ui basic segment">
             <div class="ui header">
                 <i class="remove icon"></i> Search Error

+ 10 - 3
src/web/SystemAO/file_system/js/explorer/upload.js

@@ -65,11 +65,10 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
 
         //Push to upload pending list if the max concurrent upload is reached
         if (uploadingFileCount >= maxConcurrentUpload){
+            //Queue the base directory; the Firefox subpath is added when it runs
             let uploadDir = currentPath;
             if (targetDir !== undefined){
                 uploadDir = targetDir;
-            }else if (isFirefox && uploadDir == currentPath && file.webkitRelativePath != ""){
-                uploadDir = targetDir;
             }
             uploadPendingList.push({
                 File: file,
@@ -101,6 +100,14 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
             uploadDir = targetDir;
         }
 
+        /*
+            Retry calls back into uploadFile with this as targetDir, so it must be
+            the directory before the Firefox subpath below is appended - storing
+            the final one makes a retry append the subpath a second time and
+            upload into "<folder><folder>".
+        */
+        let retryDir = JSON.parse(JSON.stringify(uploadDir));
+
         //Fixing Firefox path issues on or above FF48.0
         if (isFirefox && file.webkitRelativePath != ""){
             //Use the webkitRelativePath instead of the name, this is a folder upload
@@ -117,7 +124,7 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
         }
 
         // Store file reference in retry map so the user can retry on failure
-        uploadRetryMap.set(taskUUID, {file: file, targetDir: JSON.parse(JSON.stringify(uploadDir))});
+        uploadRetryMap.set(taskUUID, {file: file, targetDir: retryDir});
 
         let aborted = false;
         let completed = false;

+ 1055 - 329
src/web/SystemAO/network/wifi.html

@@ -1,376 +1,1102 @@
 <!DOCTYPE html>
 <html>
-
 <head>
     <meta charset="UTF-8">
-    <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
-    <script type="text/javascript" src="../../script/jquery.min.js"></script>
-    <script type="text/javascript" src="../../script/semantic/semantic.min.js"></script>
-    <title>WiFi Settings</title>
     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
-    <style>
-        #sys-setting-page { padding: 20px; }
-    </style>
+    <title>WiFi Settings</title>
+    <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
 </head>
 <body>
-    <div class="ui container" id="sys-setting-page">
-        <div class="ui horizontal segments">
-            <div class="ui segment">
-                <h4 class="ui header">
-                    <i class="signal icon"></i>
-                    <div class="content">
-                        <span id="SSID">Loading...</span>
-                        <div class="sub header">WiFi SSID</div>
-                    </div>
-                </h4>
-            </div>
-            <div class="ui segment">
-                <h4 class="ui header">
-                    <i class="globe icon"></i>
-                    <div class="content">
-                        <span id="Interface">Loading...</span>
-                        <div class="sub header">Interface</div>
-                    </div>
-                </h4>
+<!-- NOTE: <style> and <script> live in <body> so they survive being injected
+     into System Settings with jQuery .load(). No jQuery is required. -->
+<style>
+#ws-root {
+    --ws-card:      #ffffff;
+    --ws-border:    rgba(0,0,0,0.07);
+    --ws-shadow:    0 2px 8px rgba(0,0,0,0.06), 0 0 1px rgba(0,0,0,0.05);
+    --ws-text:      #1a1a1a;
+    --ws-dim:       #5a5a5a;
+    --ws-muted:     #949494;
+    --ws-sep:       rgba(0,0,0,0.07);
+    --ws-chip:      #f1f3f5;
+    --ws-chip-fg:   #3d3d3d;
+    --ws-accent:    #0f6cbd;
+    --ws-accent-fg: #ffffff;
+    --ws-hover:     rgba(0,0,0,0.035);
+    --ws-select:    rgba(15,108,189,0.07);
+    --ws-track:     rgba(0,0,0,0.09);
+    --ws-good:      #13a10e;
+    --ws-ok:        #0f9d8a;
+    --ws-fair:      #c58a00;
+    --ws-weak:      #d13438;
+    --ws-off:       #a8a8a8;
+    --ws-input:     #ffffff;
+    --ws-grid:      rgba(0,0,0,0.06);
+    --ws-overlay:   rgba(0,0,0,0.35);
+
+    font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+    font-size: 14px;
+    color: var(--ws-text);
+    -webkit-font-smoothing: antialiased;
+    max-width: 1200px;
+    padding: 4px 0 28px;
+    position: relative;
+}
+body.dark #ws-root {
+    --ws-card:      #2b2b2b;
+    --ws-border:    rgba(255,255,255,0.08);
+    --ws-shadow:    0 2px 10px rgba(0,0,0,0.3), 0 0 1px rgba(0,0,0,0.5);
+    --ws-text:      #f0f0f0;
+    --ws-dim:       #b4b4b4;
+    --ws-muted:     #828282;
+    --ws-sep:       rgba(255,255,255,0.07);
+    --ws-chip:      rgba(255,255,255,0.08);
+    --ws-chip-fg:   #d0d0d0;
+    --ws-accent:    #4cc2ff;
+    --ws-accent-fg: #0b1a26;
+    --ws-hover:     rgba(255,255,255,0.04);
+    --ws-select:    rgba(76,194,255,0.09);
+    --ws-track:     rgba(255,255,255,0.12);
+    --ws-good:      #6ccb5f;
+    --ws-ok:        #4fd1c5;
+    --ws-fair:      #f2c94c;
+    --ws-weak:      #ff6b6b;
+    --ws-off:       #6e6e6e;
+    --ws-input:     #1f1f1f;
+    --ws-grid:      rgba(255,255,255,0.07);
+    --ws-overlay:   rgba(0,0,0,0.55);
+}
+#ws-root * { box-sizing: border-box; }
+#ws-root .ws-wrap { container-type: inline-size; }
+#ws-root .ws-scroll { overflow-x: auto; }
+#ws-root i.icon { height: auto; }
+
+#ws-root .ws-card {
+    background: var(--ws-card); border: 1px solid var(--ws-border); border-radius: 12px;
+    box-shadow: var(--ws-shadow); margin-bottom: 14px;
+}
+#ws-root .ws-card-title {
+    display: flex; align-items: center; gap: 8px; padding: 11px 16px; flex-wrap: wrap;
+    font-size: 13px; font-weight: 600; color: var(--ws-dim); border-bottom: 1px solid var(--ws-sep);
+}
+#ws-root .ws-card-title i.icon { margin: 0; opacity: .75; }
+#ws-root .ws-card-title .ws-right { margin-left: auto; display: flex; gap: 6px; align-items: center; font-weight: 400; }
+
+/* Header: connection + radio */
+#ws-root .ws-head { display: grid; grid-template-columns: 1.5fr 1fr; gap: 14px; margin-bottom: 14px; }
+#ws-root .ws-head .ws-card { margin: 0; padding: 16px 18px; display: flex; align-items: center; gap: 14px; min-width: 0; }
+#ws-root .ws-head-icon {
+    width: 46px; height: 46px; border-radius: 12px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
+    background: color-mix(in srgb, var(--ws-lv, var(--ws-off)) 14%, transparent);
+}
+#ws-root .ws-head-icon svg { width: 28px; height: 28px; }
+#ws-root .ws-head-text { min-width: 0; flex: 1; }
+#ws-root .ws-head-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--ws-muted); font-weight: 600; }
+#ws-root .ws-head-value { font-size: 17px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 2px 0; }
+#ws-root .ws-head-sub { font-size: 12.5px; color: var(--ws-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+#ws-root select.ws-select {
+    font: inherit; font-size: 13px; padding: 5px 8px; border-radius: 6px; border: 1px solid var(--ws-border);
+    background: var(--ws-input); color: var(--ws-text); max-width: 180px;
+}
+
+/* Toggle switch */
+#ws-root .ws-switch { position: relative; width: 44px; height: 24px; flex-shrink: 0; }
+#ws-root .ws-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
+#ws-root .ws-switch span {
+    position: absolute; inset: 0; border-radius: 12px; background: var(--ws-track); cursor: pointer; transition: background .2s;
+}
+#ws-root .ws-switch span::after {
+    content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; border-radius: 50%;
+    background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .2s;
+}
+#ws-root .ws-switch input:checked + span { background: var(--ws-accent); }
+#ws-root .ws-switch input:checked + span::after { transform: translateX(20px); }
+#ws-root .ws-switch input:disabled + span { opacity: .5; cursor: default; }
+
+/* Pills / chips */
+#ws-root .ws-pill {
+    display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 20px; font-size: 11.5px;
+    font-weight: 600; background: var(--ws-chip); color: var(--ws-chip-fg); white-space: nowrap;
+}
+#ws-root .ws-pill.on { color: var(--ws-good); background: color-mix(in srgb, var(--ws-good) 14%, transparent); }
+#ws-root .ws-pill.off { color: var(--ws-weak); background: color-mix(in srgb, var(--ws-weak) 13%, transparent); }
+#ws-root .ws-pill.accent { color: var(--ws-accent); background: color-mix(in srgb, var(--ws-accent) 13%, transparent); }
+
+/* Toolbar */
+#ws-root .ws-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; }
+#ws-root .ws-btn {
+    display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: 13px; font-weight: 600;
+    padding: 7px 14px; border-radius: 7px; border: 1px solid var(--ws-border); background: var(--ws-card);
+    color: var(--ws-text); cursor: pointer; box-shadow: var(--ws-shadow);
+}
+#ws-root .ws-btn:hover { background: var(--ws-hover); }
+#ws-root .ws-btn.primary { background: var(--ws-accent); border-color: var(--ws-accent); color: var(--ws-accent-fg); }
+#ws-root .ws-btn.danger { color: var(--ws-weak); }
+#ws-root .ws-btn:disabled { opacity: .55; cursor: default; }
+#ws-root .ws-btn i.icon { margin: 0; }
+#ws-root .ws-seg { display: inline-flex; border: 1px solid var(--ws-border); border-radius: 7px; overflow: hidden; background: var(--ws-card); }
+#ws-root .ws-seg button {
+    font: inherit; font-size: 12.5px; padding: 6px 12px; border: 0; background: transparent; color: var(--ws-dim); cursor: pointer;
+    border-left: 1px solid var(--ws-border);
+}
+#ws-root .ws-seg button:first-child { border-left: 0; }
+#ws-root .ws-seg button.active { background: var(--ws-accent); color: var(--ws-accent-fg); font-weight: 600; }
+#ws-root .ws-search { position: relative; margin-left: auto; }
+#ws-root .ws-search input {
+    font: inherit; font-size: 13px; padding: 7px 10px 7px 30px; border-radius: 7px; border: 1px solid var(--ws-border);
+    background: var(--ws-input); color: var(--ws-text); width: 220px; outline: none;
+}
+#ws-root .ws-search input:focus { border-color: var(--ws-accent); }
+#ws-root .ws-search i.icon { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--ws-muted); margin: 0; }
+#ws-root .ws-meta { font-size: 12px; color: var(--ws-muted); }
+
+/* Channel map */
+#ws-root .ws-chart { padding: 6px 12px 10px; position: relative; }
+#ws-root .ws-chart svg { display: block; width: 100%; overflow: visible; }
+#ws-root .ws-chart .grid { stroke: var(--ws-grid); stroke-width: 1; }
+#ws-root .ws-chart .axis-label { fill: var(--ws-muted); font-size: 10.5px; }
+#ws-root .ws-chart .axis-title { fill: var(--ws-muted); font-size: 10.5px; font-weight: 600; }
+#ws-root .ws-chart .ap { cursor: pointer; transition: opacity .15s; }
+#ws-root .ws-chart .ap path { stroke-width: 1.6; }
+#ws-root .ws-chart .ap.connected path { stroke-width: 3; }
+#ws-root .ws-chart.has-hover .ap { opacity: .18; }
+#ws-root .ws-chart.has-hover .ap.hover { opacity: 1; }
+#ws-root .ws-chart .ap text { font-size: 11px; font-weight: 600; pointer-events: none; }
+#ws-root .ws-chart-empty { padding: 40px 0; text-align: center; color: var(--ws-muted); font-size: 13px; }
+#ws-root .ws-tip {
+    position: absolute; pointer-events: none; background: var(--ws-card); border: 1px solid var(--ws-border);
+    box-shadow: var(--ws-shadow); border-radius: 8px; padding: 7px 10px; font-size: 12px; white-space: nowrap; display: none; z-index: 2;
+}
+#ws-root .ws-tip b { display: block; font-size: 12.5px; }
+
+/* Network table */
+#ws-root .ws-table { width: 100%; border-collapse: collapse; }
+#ws-root .ws-table th {
+    text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--ws-muted);
+    font-weight: 600; padding: 9px 12px; border-bottom: 1px solid var(--ws-sep); cursor: pointer; user-select: none; white-space: nowrap;
+}
+#ws-root .ws-table th.nosort { cursor: default; }
+#ws-root .ws-table th .arrow { opacity: .8; margin-left: 3px; }
+#ws-root .ws-table td { padding: 9px 12px; border-bottom: 1px solid var(--ws-sep); vertical-align: middle; }
+#ws-root .ws-table tr.row { cursor: pointer; }
+#ws-root .ws-table tr.row:hover, #ws-root .ws-table tr.row.hover { background: var(--ws-hover); }
+#ws-root .ws-table tr.row.open { background: var(--ws-select); }
+#ws-root .ws-table tr.row.open td { border-bottom-color: transparent; }
+#ws-root .ws-table tr:last-child td { border-bottom: 0; }
+#ws-root .ws-net { display: flex; align-items: center; gap: 11px; min-width: 0; }
+#ws-root .ws-net svg.bars { width: 22px; height: 18px; flex-shrink: 0; }
+#ws-root .ws-net-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
+#ws-root .ws-net-name.hidden-ssid { font-style: italic; font-weight: 500; color: var(--ws-dim); }
+#ws-root .ws-net-sub { font-size: 12px; color: var(--ws-dim); display: flex; gap: 5px; flex-wrap: wrap; align-items: center; margin-top: 2px; }
+#ws-root .ws-swatch { width: 4px; align-self: stretch; border-radius: 2px; flex-shrink: 0; }
+#ws-root .ws-sig { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
+#ws-root .ws-sig-bar { width: 54px; height: 5px; border-radius: 3px; background: var(--ws-track); overflow: hidden; }
+#ws-root .ws-sig-bar span { display: block; height: 100%; border-radius: 3px; }
+#ws-root .ws-sig b { font-weight: 600; min-width: 64px; display: inline-block; }
+#ws-root .ws-mono { font-family: 'Cascadia Mono', Consolas, Menlo, monospace; font-size: 12.5px; color: var(--ws-dim); }
+#ws-root .ws-small { font-size: 12px; color: var(--ws-dim); }
+#ws-root .ws-sec { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
+#ws-root .ws-sec i.icon { margin: 0; color: var(--ws-muted); }
+#ws-root .ws-sec.open-net i.icon { color: var(--ws-fair); }
+#ws-root .ws-actions { white-space: nowrap; text-align: right; }
+#ws-root .ws-link {
+    font: inherit; font-size: 12.5px; font-weight: 600; background: none; border: 0; color: var(--ws-accent);
+    cursor: pointer; padding: 3px 6px; border-radius: 5px;
+}
+#ws-root .ws-link:hover { background: var(--ws-hover); }
+#ws-root .ws-link.danger { color: var(--ws-weak); }
+#ws-root .ws-link:disabled { opacity: .5; cursor: default; }
+#ws-root .ws-state { padding: 46px 20px; text-align: center; color: var(--ws-dim); }
+#ws-root .ws-state i.icon { display: block; margin: 0 auto 10px; }
+
+/* Detail panel */
+#ws-root .ws-detail td { padding: 0 12px 14px; background: var(--ws-select); }
+#ws-root .ws-detail-grid {
+    display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 1px; background: var(--ws-card);
+    border: 1px solid var(--ws-sep); border-radius: 9px; overflow: hidden;
+}
+#ws-root .ws-detail-grid > div { background: var(--ws-card); padding: 8px 12px; min-width: 0; box-shadow: 0 0 0 1px var(--ws-sep); }
+#ws-root .ws-detail-grid > div.wide { grid-column: span 2; }
+#ws-root .ws-detail-grid dt { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--ws-muted); font-weight: 600; margin-bottom: 2px; }
+#ws-root .ws-detail-grid dd { margin: 0; font-size: 13px; overflow-wrap: anywhere; }
+#ws-root .ws-tags { display: flex; flex-wrap: wrap; gap: 3px; }
+#ws-root .ws-tag { background: var(--ws-chip); color: var(--ws-chip-fg); border-radius: 4px; padding: 0 6px; font-size: 11.5px; }
+#ws-root .ws-tag.basic { outline: 1px solid var(--ws-accent); outline-offset: -1px; }
+
+/* Notice */
+#ws-root .ws-notice {
+    display: flex; gap: 10px; align-items: flex-start; padding: 10px 14px; border-radius: 10px; margin-bottom: 14px;
+    font-size: 13px; color: var(--ws-dim); background: color-mix(in srgb, var(--ws-accent) 8%, transparent);
+    border: 1px solid color-mix(in srgb, var(--ws-accent) 22%, transparent);
+}
+#ws-root .ws-notice.error { background: color-mix(in srgb, var(--ws-weak) 9%, transparent); border-color: color-mix(in srgb, var(--ws-weak) 25%, transparent); }
+#ws-root .ws-notice i.icon { margin: 1px 0 0; color: var(--ws-accent); }
+#ws-root .ws-notice.error i.icon { color: var(--ws-weak); }
+
+/* Dialog */
+#ws-root .ws-overlay {
+    position: fixed; inset: 0; background: var(--ws-overlay); display: none; align-items: center; justify-content: center; z-index: 1000; padding: 16px;
+}
+#ws-root .ws-overlay.show { display: flex; }
+#ws-root .ws-dialog {
+    background: var(--ws-card); color: var(--ws-text); border-radius: 14px; width: 100%; max-width: 420px;
+    box-shadow: 0 18px 50px rgba(0,0,0,.3); border: 1px solid var(--ws-border); overflow: hidden;
+}
+#ws-root .ws-dialog-head { display: flex; align-items: center; gap: 12px; padding: 18px 20px 6px; }
+#ws-root .ws-dialog-head svg { width: 30px; height: 26px; }
+#ws-root .ws-dialog-head h3 { margin: 0; font-size: 17px; font-weight: 600; }
+#ws-root .ws-dialog-head div { min-width: 0; }
+#ws-root .ws-dialog-body { padding: 10px 20px 4px; }
+#ws-root .ws-field { margin-bottom: 12px; }
+#ws-root .ws-field label { display: block; font-size: 12px; font-weight: 600; color: var(--ws-dim); margin-bottom: 5px; }
+#ws-root .ws-input-wrap { display: flex; border: 1px solid var(--ws-border); border-radius: 7px; background: var(--ws-input); overflow: hidden; }
+#ws-root .ws-input-wrap:focus-within { border-color: var(--ws-accent); }
+#ws-root .ws-input-wrap input { flex: 1; font: inherit; padding: 8px 10px; border: 0; background: transparent; color: var(--ws-text); outline: none; min-width: 0; }
+#ws-root .ws-input-wrap button { border: 0; background: transparent; color: var(--ws-muted); cursor: pointer; padding: 0 10px; }
+#ws-root .ws-dialog-msg { font-size: 12.5px; min-height: 18px; color: var(--ws-weak); }
+#ws-root .ws-dialog-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 18px; }
+#ws-root .ws-toast {
+    position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%) translateY(20px); opacity: 0; transition: all .25s;
+    background: #222; color: #fff; padding: 9px 16px; border-radius: 8px; font-size: 13px; z-index: 1001; pointer-events: none;
+}
+#ws-root .ws-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+
+@container (max-width: 860px) {
+    #ws-root .ws-col-bssid { display: none; }
+}
+@container (max-width: 700px) {
+    #ws-root .ws-head { grid-template-columns: 1fr; }
+}
+@container (max-width: 620px) {
+    #ws-root .ws-col-std, #ws-root .ws-col-sec { display: none; }
+    #ws-root .ws-search { margin-left: 0; width: 100%; }
+    #ws-root .ws-search input { width: 100%; }
+    #ws-root .ws-net-name { max-width: 150px; }
+    #ws-root .ws-sig-bar { display: none; }
+}
+</style>
+
+<div id="ws-root">
+  <div class="ws-wrap">
+    <div class="ws-head">
+        <div class="ws-card" id="ws-conn">
+            <div class="ws-head-icon" id="ws-conn-icon"></div>
+            <div class="ws-head-text">
+                <div class="ws-head-label">Current network</div>
+                <div class="ws-head-value" id="ws-conn-ssid">Loading&hellip;</div>
+                <div class="ws-head-sub" id="ws-conn-sub">&nbsp;</div>
             </div>
         </div>
-        <!-- WiFi Interface Toggle -->
-        <div class="ui segment">
-            <p>Wireless Interface Power Toggle</p>
-            <div class="ui green message" id="updateSucceedMessage" style="display:none;">
-                 <p><i class="checkmark icon"></i> Interface Status Updated</p>
+        <div class="ws-card" id="ws-radio">
+            <div class="ws-head-icon" style="--ws-lv: var(--ws-accent)"><i class="large wifi icon" style="margin:0;color:var(--ws-accent)"></i></div>
+            <div class="ws-head-text">
+                <div class="ws-head-label">Wireless interface</div>
+                <div class="ws-head-value" id="ws-radio-name">&mdash;</div>
+                <div class="ws-head-sub" id="ws-radio-sub">&nbsp;</div>
             </div>
-            <form class="ui form" id="wlanlist">
-                <p><i class="remove icon"></i> No wlan interface attached</p>
-            </form>
+            <div id="ws-radio-ctrl"></div>
         </div>
+    </div>
 
-        <!-- WiFi Scanning Results-->
-        <div>
-            <table class="ui very basic celled table">
-            <thead>
-                <tr>
-                    <th>Wifi Access Points</th>
-                    <th>Signal Level (Quality)</th>
-                    <th>Physical Address</th>
-                    <th>Connect</th>
-                    <th>Remove</th>
-                </tr>
-            </thead>
-            <div class="ui container">
-                <button class="ui small button" onclick="initWiFiScan();"><i class="refresh icon"></i> Rescan</button>
-            </div>
-            <tbody id="wifilist">
-                <tr>
-                    <td><i class="loading spinner icon"></i> Scanning Nearby WiFi Access Points</td>
-                </tr>
-            </tbody>
-            </table>
+    <div id="ws-notice"></div>
+
+    <div class="ws-toolbar">
+        <button class="ws-btn primary" id="ws-rescan"><i class="sync alternate icon"></i> Rescan</button>
+        <div class="ws-seg" id="ws-bands"></div>
+        <span class="ws-meta" id="ws-meta"></span>
+        <div class="ws-search"><i class="search icon"></i><input type="search" id="ws-filter" placeholder="Filter networks"></div>
+    </div>
+
+    <div class="ws-card">
+        <div class="ws-card-title"><i class="chart area icon"></i>Channel map
+            <div class="ws-right"><div class="ws-seg" id="ws-chart-bands"></div></div>
         </div>
-    <br><br><br>
+        <div class="ws-chart" id="ws-chart"><div class="ws-chart-empty">Waiting for scan results&hellip;</div></div>
     </div>
-    <!-- WiFi Connection Interface-->
-    <div class="ui modal">
-        <i class="close icon"></i>
-        <div class="header">
-            <span id="wifiName"></span>
+
+    <div class="ws-card">
+        <div class="ws-card-title"><i class="list icon"></i>Nearby networks
+            <div class="ws-right ws-meta" id="ws-count"></div>
         </div>
-        <div class="image content">
-            <div class="ui mini image">
-            <img id="wifiImage" src="">
+        <div id="ws-list" class="ws-scroll"><div class="ws-state"><i class="big circle notch loading icon"></i>Scanning nearby access points&hellip;</div></div>
+    </div>
+  </div>
+
+    <div class="ws-overlay" id="ws-dialog">
+        <div class="ws-dialog" role="dialog" aria-modal="true" aria-labelledby="ws-dialog-title">
+            <div class="ws-dialog-head" id="ws-dialog-icon">
+                <div><h3 id="ws-dialog-title">Connect</h3><div class="ws-small" id="ws-dialog-desc"></div></div>
             </div>
-            <div class="description" style="min-width:400px;">
-            <div class="ui header">Credential Required</div>
-                <div class="ui labeled fluid input" style="margin-top:12px;">
-                    <div class="ui teal label">
-                        SSID
+            <div class="ws-dialog-body">
+                <div class="ws-field" id="ws-pwd-field">
+                    <label for="ws-pwd">Password</label>
+                    <div class="ws-input-wrap">
+                        <input type="password" id="ws-pwd" autocomplete="new-password">
+                        <button type="button" id="ws-pwd-eye" title="Show password"><i class="eye icon"></i></button>
                     </div>
-                    <input type="text" value="" id="ssid" readonly=true>
                 </div>
-        
-                <div class="ui labeled action fluid input" style="margin-top:12px;">
-                    <div class="ui teal label">
-                        Password
-                    </div>
-                    <input type="password" id="pwd">
-                    <button class="ui icon black button"  onmousedown="showPassword();" onmouseup="hidePassword();" ontouchstart="showPassword();" ontouchend="hidePassword();">
-                        <i class="eye icon"></i>
-                    </button>
-                </div>
-            </div>
-        </div>
-        <div class="actions">
-            <div class="ui black deny button">
-            Cancel
+                <div class="ws-dialog-msg" id="ws-dialog-msg"></div>
             </div>
-            <div class="ui positive right labeled icon button" onclick="confirmConnection();">
-            Connect
-            <i class="checkmark icon"></i>
+            <div class="ws-dialog-foot">
+                <button class="ws-btn" id="ws-dialog-cancel">Cancel</button>
+                <button class="ws-btn primary" id="ws-dialog-ok">Connect</button>
             </div>
         </div>
     </div>
+    <div class="ws-toast" id="ws-toast"></div>
+</div>
 
-    <script>
-        var retryCount = 0;
-        //Delete all previous modal if exists
-        //Funny bug of Semantic ui
-        for (var i =1; i < $(".ui.modal").length; i++){
-            $($(".ui.modal")[i]).remove();
+<script>
+(function () {
+    var root = document.getElementById('ws-root');
+    function $id(id) { return document.getElementById(id); }
+
+    var state = {
+        info: null,          // /system/network/wifiinfo response
+        power: null,         // /system/network/power response, null when unavailable
+        iface: null,         // selected interface name
+        networks: [],        // last scan results
+        scannedAt: null,
+        scanning: false,
+        retries: 0,
+        band: 'all',         // list filter
+        chartBand: null,     // channel map band
+        filter: '',
+        sort: { key: 'signal', dir: -1 },
+        open: {},            // BSSID -> expanded
+        hover: null
+    };
+    var infoTimer = null;
+
+    /* ---------- helpers ---------- */
+    function esc(s) {
+        return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
+            return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
+        });
+    }
+    function alive() { return document.body.contains(root); }
+    function isDark() { return document.body.classList.contains('dark'); }
+
+    function api(url, params) {
+        var opt = { credentials: 'same-origin', cache: 'no-store' };
+        if (params) {
+            opt.method = 'POST';
+            opt.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
+            opt.body = new URLSearchParams(params).toString();
         }
-        initWiFiScan();
-        initWlanPower();
-        function initWiFiScan(){
-            $("#wifilist").html(` <tr>
-                <td><i class="loading spinner icon"></i> Scanning Nearby WiFi Access Points</td>
-            </tr>`);
-            $.get("../../system/network/scanWifi",function(data){
-                if (data.error != undefined){
-                    console.log(data.error);
-                    if (retryCount > 5){
-                        //Stop retrtying
-                        $("#wifilist").html(` <tr>
-                            <td><i class="remove icon"></i> WiFi Scanning Failed</td>
-                        </tr>`);
-                        return
-                    }
-                    //Unknown error. Rescan later
-                    setTimeout(function(){
-                        initWiFiScan();
-                    },3000);
-                    retryCount++;
-                }else{
-                    retryCount = 0;
-                    $("#wifilist").html("");
-                    for (var i = 0; i < data.length; i++){
-                        var thisWiFi = data[i];
-                        var signalLevel = thisWiFi.SignalLevel.split(" ").shift();
-                        var signalIcon = "signal_0.svg"
-                        var warninglevel = 0;
-                        try{
-                            signalLevel = parseInt(signalLevel);
-                            if (signalLevel > -50){
-                                signalIcon = "signal_5.svg";
-                            }else if (signalLevel > -60){
-                                signalIcon = "signal_4.svg";
-                            }else if (signalLevel > -70){
-                                signalIcon = "signal_3.svg";
-                            }else if (signalLevel > -80){
-                                signalIcon = "signal_2.svg";
-                                warninglevel = 1;
-                            }else{
-                                signalIcon = "signal_1.svg";
-                                warninglevel = 2;
-                            }
-                        }catch(ex){
-                             
-                        }
-
-                        //This WiFi might be too week to connect
-                        classname = "";
-                        if (warninglevel == 0){
-                            classname = "positive";
-                        }
-
-                        //Only add remove button if the WiFi is connected before
-                        removeHTML = "";
-                        if (thisWiFi.ConnectedBefore){
-                            removeHTML = `<a href="#">Remove</a>`;
-                        }
-
-                        //Check if this WiFi has been connected. If yes, hide the connect button
-                        var connBtn = ` <td class="selectable" conncetedBefore="${thisWiFi.ConnectedBefore}" image="${signalIcon}" targetESSID="${thisWiFi.ESSID}" onclick="connectThis(this, event);">
-                            <a href="#">Connect</a>
-                        </td>`;
-                        if (thisWiFi.ESSID == $("#SSID").text().trim()){
-                            connBtn = `<td conncetedBefore="${thisWiFi.ConnectedBefore}">
-                            <i class="checkmark icon"></i>
-                        </td>`;
-                        }
-
-                        $("#wifilist").append(`<tr class="${classname}">
-                        <td>
-                            <h4 class="ui image header">
-                                <img src="../network/img/${signalIcon}" class="ui mini rounded image">
-                                <div class="content">
-                                    ${thisWiFi.ESSID}
-                                    <div class="sub header"> ${thisWiFi.Frequency} (Channel ${thisWiFi.Channel})
-                                </div>
-                                </div>
-                            </h4>
-                        </td>
-                        <td>
-                            ${thisWiFi.SignalLevel} ( ${thisWiFi.Quality})
-                        </td>
-                        <td>
-                            ${thisWiFi.Address}
-                        </td>
-                        ${connBtn}
-                        <td class="selectable"  image="${signalIcon}" targetESSID="${thisWiFi.ESSID}" onclick="removeThis(this, event);">
-                            ${removeHTML}
-                        </td>
-                    </tr>`);
-                    }
-                   
-                }   
+        return fetch(url, opt).then(function (r) {
+            return r.text().then(function (t) {
+                try { return JSON.parse(t); } catch (e) { return { error: r.ok ? 'Unexpected response' : 'HTTP ' + r.status, notFound: r.status === 404 }; }
             });
+        });
+    }
+
+    function rssiOf(n) {
+        if (n.SignalDBm) return n.SignalDBm;
+        if (n.QualityPercent >= 0) return n.QualityPercent / 2 - 100;
+        return -100;
+    }
+    function level(rssi) {
+        if (rssi >= -55) return { bars: 4, name: 'Excellent', color: 'var(--ws-good)' };
+        if (rssi >= -67) return { bars: 3, name: 'Good', color: 'var(--ws-ok)' };
+        if (rssi >= -75) return { bars: 2, name: 'Fair', color: 'var(--ws-fair)' };
+        if (rssi > -100) return { bars: 1, name: 'Weak', color: 'var(--ws-weak)' };
+        return { bars: 0, name: 'Unknown', color: 'var(--ws-off)' };
+    }
+    function qualityOf(n) {
+        if (n.QualityPercent >= 0) return n.QualityPercent;
+        return Math.max(0, Math.min(100, 2 * (rssiOf(n) + 100)));
+    }
+
+    // Bars glyph: four rising bars, the first `bars` filled
+    function barsSVG(bars, color, cls) {
+        var html = '<svg class="' + (cls || 'bars') + '" viewBox="0 0 22 18" aria-hidden="true">';
+        for (var i = 0; i < 4; i++) {
+            var h = 5 + i * 4.3;
+            html += '<rect x="' + (i * 5.6) + '" y="' + (18 - h) + '" width="3.8" height="' + h + '" rx="1.2" fill="' +
+                (i < bars ? color : 'var(--ws-track)') + '"/>';
         }
+        return html + '</svg>';
+    }
 
-        function initWlanPower(){
-            $.get("../../system/network/power", function(data){
-                $("#wlanlist").html("");
-                if (data.error != undefined){
-                    console.log(data.error);
-                }else{
-                   
-                    data.forEach(interface => {
-                        var checked = "false";
-                        if (interface.Running){
-                            checked = "true";
-                        }
-                        $("#wlanlist").append(`<div class="field"><div class="ui toggle checkbox" id="${interface.Name}">
-                            <input type="checkbox" name="${interface.Name}" tabindex="0" class="hidden">
-                            <label><i class="wifi icon"></i> ${interface.Name}</label>
-                        </div></div>`);
-                        if (interface.Running){
-                            $("#" + interface.Name).checkbox("check");
-                        }else{
-                            $("#" + interface.Name).checkbox("uncheck");
-                        }
-                        
-                        $("#" + interface.Name).find("input").attr("onchange","handleInterfacePowerToggle(this);")
-                        
-                    });
+    // Stable colour per SSID, so a network looks the same on every band
+    function colorFor(n) {
+        var key = n.ESSID || n.Address || '';
+        var h = 0;
+        for (var i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
+        var hue = (h * 137.508) % 360;
+        return isDark() ? 'hsl(' + hue.toFixed(0) + ',72%,64%)' : 'hsl(' + hue.toFixed(0) + ',66%,44%)';
+    }
 
-                }
-            }); 
+    function fmtRate(mbps) {
+        if (!mbps) return '';
+        if (mbps >= 1000) return (mbps / 1000).toFixed(2).replace(/\.?0+$/, '') + ' Gbps';
+        return (Math.round(mbps * 10) / 10) + ' Mbps';
+    }
+    function fmtUptime(sec) {
+        if (!sec) return '';
+        var d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60);
+        return d > 0 ? d + 'd ' + h + 'h' : (h > 0 ? h + 'h ' + m + 'm' : m + 'm');
+    }
+    function bandOf(n) {
+        if (n.Band) return n.Band;
+        if (n.FrequencyMHz >= 5925) return '6 GHz';
+        if (n.FrequencyMHz >= 4900) return '5 GHz';
+        if (n.FrequencyMHz >= 2400 || (n.Channel > 0 && n.Channel <= 14)) return '2.4 GHz';
+        if (n.Channel > 14) return '5 GHz';
+        return '';
+    }
+    function securityOf(n) {
+        if (n.Security) return n.Security;
+        return n.EncryptionKey ? 'Secured' : 'Open';
+    }
+    function isConnected(n) {
+        if (n.Connected) return true;
+        var cur = currentInterface();
+        if (!cur || !cur.Connected) return false;
+        if (cur.BSSID) return (n.Address || '').toLowerCase() === cur.BSSID.toLowerCase();
+        return !!n.ESSID && n.ESSID === cur.SSID;
+    }
+    function currentInterface() {
+        var list = (state.info && state.info.Interfaces) || [];
+        for (var i = 0; i < list.length; i++) if (list[i].Name === state.iface) return list[i];
+        return list[0] || null;
+    }
+
+    function toast(msg) {
+        var t = $id('ws-toast');
+        t.textContent = msg;
+        t.classList.add('show');
+        clearTimeout(t._timer);
+        t._timer = setTimeout(function () { t.classList.remove('show'); }, 3200);
+    }
+
+    /* ---------- header ---------- */
+    function renderHeader() {
+        var info = state.info;
+        var cur = currentInterface();
+        var connIcon = $id('ws-conn-icon');
+        if (!info || !cur) {
+            connIcon.innerHTML = barsSVG(0, 'var(--ws-off)', 'bars');
+            $id('ws-conn-ssid').textContent = info ? 'No wireless interface' : 'Loading…';
+            $id('ws-conn-sub').innerHTML = '&nbsp;';
+            $id('ws-radio-name').textContent = '—';
+            $id('ws-radio-sub').innerHTML = '&nbsp;';
+            $id('ws-radio-ctrl').innerHTML = '';
+            return;
         }
 
-        function handleInterfacePowerToggle(object){
-            var toggleState = object.checked;
-            var interfaeName = $(object).attr("name");
-            var status = "on";
-            if (toggleState == true){
-                status = "on";
-            }else{
-                status = "off";
-            }
-            $.ajax({
-                url: "../../system/network/power",
-                data: {status: status, interface: interfaeName},
-                success: function(data){
-                    console.log(data);
-                    initWlanPower();
-                    $("#updateSucceedMessage").stop().finish().slideDown("fast").delay(5000).slideUp("fast");
-                }
-            })
+        if (cur.Connected) {
+            var lv = level(cur.RSSI || (cur.SignalQuality >= 0 ? cur.SignalQuality / 2 - 100 : -100));
+            $id('ws-conn').style.setProperty('--ws-lv', lv.color);
+            connIcon.innerHTML = barsSVG(lv.bars, lv.color, 'bars');
+            $id('ws-conn-ssid').textContent = cur.SSID || 'Hidden network';
+            var sub = [];
+            if (cur.RSSI) sub.push(cur.RSSI + ' dBm');
+            if (cur.Band) sub.push(cur.Band + (cur.Channel ? ' · ch ' + cur.Channel : ''));
+            if (cur.Generation || cur.PHYType) sub.push(cur.Generation || cur.PHYType);
+            if (cur.RxRateMbps) sub.push(fmtRate(cur.RxRateMbps));
+            $id('ws-conn-sub').textContent = sub.join('  ·  ') || 'Connected';
+        } else {
+            $id('ws-conn').style.setProperty('--ws-lv', 'var(--ws-off)');
+            connIcon.innerHTML = barsSVG(0, 'var(--ws-off)', 'bars');
+            $id('ws-conn-ssid').textContent = 'Not connected';
+            $id('ws-conn-sub').textContent = cur.State && cur.State !== 'disconnected' ? 'State: ' + cur.State : 'Choose a network below';
         }
 
-        function removeThis(obj, event){
-            event.preventDefault();
-            targetSSID = $(obj).attr("targetESSID");
-            $.ajax({
-                url: "../../system/network/removeWifi",
-                data: {ESSID: targetSSID},
-                success: function(data){
-                    if (data.error !== undefined){
-                        alert(data.error)
-                    }else{
-                        resetInterface();
-                    }
-                }
-            });
+        // Interface name, or a selector when there are several
+        var list = info.Interfaces || [];
+        var nameEl = $id('ws-radio-name');
+        if (list.length > 1) {
+            nameEl.innerHTML = '<select class="ws-select" id="ws-iface">' + list.map(function (i) {
+                return '<option value="' + esc(i.Name) + '"' + (i.Name === state.iface ? ' selected' : '') + '>' + esc(i.Name) + '</option>';
+            }).join('') + '</select>';
+        } else {
+            nameEl.textContent = cur.Name;
         }
+        var radioOn = cur.SoftwareRadio !== 'off' && cur.HardwareRadio !== 'off';
+        var radioKnown = cur.SoftwareRadio && cur.SoftwareRadio !== 'unknown';
+        var powerEntry = null;
+        (state.power || []).forEach(function (p) { if (p.Name === cur.Name) powerEntry = p; });
+        if (powerEntry && !radioKnown) radioOn = powerEntry.Running;
 
-        function resetInterface(obj=undefined){
-            $("#wifilist .selectable").removeClass("disabled");
-            if (obj !== undefined){
-                $(obj).html(`<a href="#">Connect</a>`);
-            }
-            initWiFiScan();
+        $id('ws-radio-sub').textContent = cur.Description || cur.MAC || '';
+        var ctrl = $id('ws-radio-ctrl');
+        if (info.CanControlPower && powerEntry && powerEntry.Controllable) {
+            ctrl.innerHTML = '<label class="ws-switch" title="Turn the wireless radio on or off"><input type="checkbox" id="ws-power"' +
+                (powerEntry.Running ? ' checked' : '') + '><span></span></label>';
+        } else if (cur.HardwareRadio === 'off') {
+            ctrl.innerHTML = '<span class="ws-pill off" title="The radio is disabled by a hardware switch">Radio off (switch)</span>';
+        } else if (radioKnown || powerEntry) {
+            ctrl.innerHTML = '<span class="ws-pill ' + (radioOn ? 'on' : 'off') + '">Radio ' + (radioOn ? 'on' : 'off') + '</span>';
+        } else {
+            ctrl.innerHTML = '';
         }
+    }
 
-        function connectThis(obj, event){
-            $("#pwd").val("");
-            targetSSID = $(obj).attr("targetESSID");
-            event.preventDefault();
-            //Create DOM elements
-            $("#wifiName").text(targetSSID);
-            $("#wifiImage").attr('src',"../network/img/" + $(obj).attr("image"));
-            $("#ssid").val(targetSSID);
-            
-            //Check if this is connected before. If yes, send out AP switch request
-            if ($(obj).attr("conncetedBefore") == "true"){
-                var ESSID = $(obj).attr("targetESSID");
-                $("#wifilist .selectable").addClass("disabled");
-                $(obj).html(`<div class="ui active centered inline loader"></div>`);
-                $.ajax({
-                    url: "../../system/network/connectWifi",
-                    data: {"ESSID": ESSID, "ConnType":"switch"},
-                    method: "POST",
-                    success: function(data){
-                        console.log(data);
-                        initWifiInfo();
-                        resetInterface(obj);
-                    },
-                    error: function(){
-                        $("#wifilist .selectable").removeClass("disabled");
-                        resetInterface(obj);
-                    },
-                    timeout: 30000
-                });
-            }else{
-                $('.ui.modal').modal('show');
-            }
+    function renderNotice(msg, isError) {
+        var html = '';
+        if (msg) {
+            html = '<div class="ws-notice' + (isError ? ' error' : '') + '"><i class="' + (isError ? 'exclamation triangle' : 'info circle') + ' icon"></i><div>' + msg + '</div></div>';
+        } else if (state.info && !state.info.CanConnect) {
+            html = '<div class="ws-notice"><i class="info circle icon"></i><div>Connecting to networks from ArozOS is not supported on this platform' +
+                (state.info.Platform ? ' (' + esc(state.info.Platform) + ')' : '') + '. Nearby networks are shown read-only; use the operating system to change the connection.</div></div>';
         }
+        $id('ws-notice').innerHTML = html;
+    }
 
-        function confirmConnection(){
-            var ESSID = $("#ssid").val();
-            var PWD = $("#pwd").val();
-            $("#wifilist .selectable").addClass("disabled");
-            $.ajax({
-                url: "../../system/network/connectWifi",
-                data: {"ESSID": ESSID, "pwd": PWD},
-                method: "POST",
-                success: function(data){
-                    console.log(data);
-                    initWifiInfo();
-                    resetInterface();
-                },
-                error: function(){
-                    $("#wifilist .selectable").removeClass("disabled");
-                    resetInterface();
-                },
-                timeout: 30000
-            });
+    /* ---------- filters ---------- */
+    var BANDS = ['2.4 GHz', '5 GHz', '6 GHz', '60 GHz'];
+    function presentBands() {
+        var seen = {};
+        state.networks.forEach(function (n) { var b = bandOf(n); if (b) seen[b] = (seen[b] || 0) + 1; });
+        return BANDS.filter(function (b) { return seen[b]; }).map(function (b) { return { band: b, count: seen[b] }; });
+    }
+
+    function renderBandFilters() {
+        var bands = presentBands();
+        if (state.band !== 'all' && !bands.some(function (b) { return b.band === state.band; })) state.band = 'all';
+        var html = '<button data-band="all" class="' + (state.band === 'all' ? 'active' : '') + '">All</button>';
+        bands.forEach(function (b) {
+            html += '<button data-band="' + b.band + '" class="' + (state.band === b.band ? 'active' : '') + '">' + b.band + '</button>';
+        });
+        $id('ws-bands').innerHTML = bands.length > 1 ? html : '';
+        $id('ws-bands').style.display = bands.length > 1 ? '' : 'none';
+
+        if (!state.chartBand || !bands.some(function (b) { return b.band === state.chartBand; })) {
+            state.chartBand = bands.length ? bands.slice().sort(function (a, b) { return b.count - a.count; })[0].band : null;
+        }
+        var cur = currentInterface();
+        if (cur && cur.Connected && cur.Band && !state._chartChosen && bands.some(function (b) { return b.band === cur.Band; })) {
+            state.chartBand = cur.Band;
         }
+        $id('ws-chart-bands').innerHTML = bands.map(function (b) {
+            return '<button data-chart-band="' + b.band + '" class="' + (state.chartBand === b.band ? 'active' : '') + '">' + b.band +
+                ' <span style="opacity:.7">(' + b.count + ')</span></button>';
+        }).join('');
+    }
 
-        function showPassword(){
-            $("#pwd").attr("type","text");
+    function visibleNetworks() {
+        var q = state.filter.trim().toLowerCase();
+        var list = state.networks.filter(function (n) {
+            if (state.band !== 'all' && bandOf(n) !== state.band) return false;
+            if (!q) return true;
+            return [n.ESSID, n.Address, securityOf(n), n.PHYType, n.Generation, n.Country].join(' ').toLowerCase().indexOf(q) >= 0;
+        });
+        var s = state.sort;
+        var key = {
+            name: function (n) { return (n.ESSID || String.fromCharCode(0xffff)).toLowerCase(); },
+            signal: function (n) { return rssiOf(n); },
+            channel: function (n) { return (n.FrequencyMHz || 0) * 1000 + (n.Channel || 0); },
+            std: function (n) { return n.PHYType || ''; },
+            sec: function (n) { return securityOf(n); },
+            bssid: function (n) { return n.Address || ''; }
+        }[s.key];
+        list.sort(function (a, b) {
+            // The connected network always comes first
+            var ca = isConnected(a), cb = isConnected(b);
+            if (ca !== cb) return ca ? -1 : 1;
+            var x = key(a), y = key(b);
+            if (x < y) return -s.dir;
+            if (x > y) return s.dir;
+            return rssiOf(b) - rssiOf(a);
+        });
+        return list;
+    }
+
+    /* ---------- network list ---------- */
+    function detailItem(label, value, wide) {
+        if (value === '' || value === null || value === undefined) return '';
+        return '<div' + (wide ? ' class="wide"' : '') + '><dt>' + esc(label) + '</dt><dd>' + value + '</dd></div>';
+    }
+    function tagList(list, basic) {
+        if (!list || !list.length) return '';
+        return '<div class="ws-tags">' + list.map(function (r) {
+            return '<span class="ws-tag' + (basic && basic.indexOf(r) >= 0 ? ' basic' : '') + '">' + esc(r) + '</span>';
+        }).join('') + '</div>';
+    }
+
+    function detailHTML(n) {
+        var e = esc;
+        var chan = n.Channel ? e(n.Channel) + (n.CenterChannel ? ' (center ' + e(n.CenterChannel) + ')' : '') : '';
+        return '<div class="ws-detail-grid">' +
+            detailItem('SSID', n.Hidden ? '<i>Hidden network</i>' : e(n.ESSID)) +
+            detailItem('BSSID', '<span class="ws-mono">' + e(n.Address) + '</span>') +
+            detailItem('Signal', n.SignalDBm ? e(n.SignalDBm) + ' dBm' : e(n.SignalLevel)) +
+            detailItem('Link quality', n.QualityPercent >= 0 ? e(n.QualityPercent) + ' %' : '') +
+            detailItem('Band', e(bandOf(n))) +
+            detailItem('Channel', chan) +
+            detailItem('Frequency', n.FrequencyMHz ? e(n.FrequencyMHz) + ' MHz' : e(n.Frequency)) +
+            detailItem('Channel width', n.ChannelWidth ? e(n.ChannelWidth) + ' MHz' : '') +
+            detailItem('Standard', e([n.Generation, n.PHYType].filter(Boolean).join(' · '))) +
+            detailItem('Spatial streams', n.SpatialStreams ? e(n.SpatialStreams) : '') +
+            detailItem('Max PHY rate', n.MaxRateMbps ? e(fmtRate(n.MaxRateMbps)) + ' <span class="ws-small">(est.)</span>' : '') +
+            detailItem('Security', e(securityOf(n))) +
+            detailItem('Cipher', e(n.Cipher)) +
+            detailItem('Protected mgmt. frames', e(n.PMF)) +
+            detailItem('WPS', n.WPS ? 'Enabled' : '') +
+            detailItem('Fast roaming', e((n.Roaming || []).join(', '))) +
+            detailItem('Country', e(n.Country)) +
+            detailItem('Beacon interval', n.BeaconInterval ? e(n.BeaconInterval) + ' TU (' + (n.BeaconInterval * 1.024).toFixed(1) + ' ms)' : '') +
+            detailItem('AP uptime', e(fmtUptime(n.UptimeSeconds))) +
+            detailItem('Clients', n.StationCount != null ? e(n.StationCount) : '') +
+            detailItem('Channel busy', n.ChannelUtilization != null ? e(n.ChannelUtilization) + ' %' : '') +
+            detailItem('Network type', e(n.BSSType)) +
+            detailItem('Saved profile', n.ConnectedBefore ? 'Yes' : '') +
+            detailItem('Supported rates (Mbps)', tagList(n.SupportedRates, n.BasicRates), true) +
+            '</div>';
+    }
+
+    function renderList() {
+        var listEl = $id('ws-list');
+        var nets = visibleNetworks();
+        var canConnect = state.info && state.info.CanConnect;
+        $id('ws-count').textContent = state.networks.length
+            ? (nets.length === state.networks.length ? nets.length + ' access points' : nets.length + ' of ' + state.networks.length + ' access points')
+            : '';
+
+        if (!state.networks.length) {
+            if (!state.scanning) listEl.innerHTML = '<div class="ws-state"><i class="big wifi icon" style="opacity:.35"></i>No access points found.</div>';
+            return;
+        }
+        if (!nets.length) {
+            listEl.innerHTML = '<div class="ws-state">No access points match the filter.</div>';
+            return;
         }
 
-        function hidePassword(){
-            $("#pwd").attr("type","password");
+        var s = state.sort;
+        function th(key, label, cls) {
+            var arrow = s.key === key ? '<i class="arrow caret ' + (s.dir > 0 ? 'up' : 'down') + ' icon"></i>' : '';
+            return '<th data-sort="' + key + '" class="' + (cls || '') + '">' + label + arrow + '</th>';
         }
+        var cols = canConnect ? 7 : 6;
+        var html = '<table class="ws-table"><thead><tr>' +
+            th('name', 'Network') + th('signal', 'Signal') + th('channel', 'Channel') +
+            th('std', 'Standard', 'ws-col-std') + th('sec', 'Security', 'ws-col-sec') + th('bssid', 'BSSID', 'ws-col-bssid') +
+            (canConnect ? '<th class="nosort"></th>' : '') + '</tr></thead><tbody>';
+
+        nets.forEach(function (n) {
+            var rssi = rssiOf(n), lv = level(rssi), q = qualityOf(n);
+            var connected = isConnected(n);
+            var open = !!state.open[n.Address];
+            var badges = '';
+            if (connected) badges += '<span class="ws-pill on">Connected</span>';
+            if (n.ConnectedBefore && !connected) badges += '<span class="ws-pill accent">Saved</span>';
+            var sub = [bandOf(n), n.Generation].filter(Boolean).join(' · ');
+            var sec = securityOf(n);
 
-        //Also init WiFi view in this interface
-        initWifiInfo();
-        function initWifiInfo(){
-            $.ajax({
-                url: "../../system/network/wifiinfo",
-                method:"GET",
-                success: function(data){
-                    if (data.error !== undefined){
-                        $("#SSID").text("Access Failed");
-                        $("#Interface").text("loopback");
-                    }else{
-                        $("#SSID").text(data.ESSID);
-                        $("#Interface").text(data.Interface);
-                    }
+            var actions = '';
+            if (canConnect) {
+                if (connected) actions = '<span class="ws-small"><i class="check icon"></i></span>';
+                else if (!n.Hidden) actions = '<button class="ws-link" data-act="connect">Connect</button>';
+                if (n.ConnectedBefore && !n.Hidden) actions += '<button class="ws-link danger" data-act="forget">Forget</button>';
+            }
+
+            html += '<tr class="row' + (open ? ' open' : '') + (state.hover === n.Address ? ' hover' : '') + '" data-bssid="' + esc(n.Address) + '">' +
+                '<td><div class="ws-net"><span class="ws-swatch" style="background:' + colorFor(n) + '"></span>' + barsSVG(lv.bars, lv.color) +
+                    '<div style="min-width:0"><div class="ws-net-name' + (n.Hidden ? ' hidden-ssid' : '') + '" title="' + esc(n.ESSID) + '">' +
+                    (n.Hidden ? 'Hidden network' : esc(n.ESSID)) + '</div>' +
+                    '<div class="ws-net-sub">' + esc(sub) + badges + '</div></div></div></td>' +
+                '<td><div class="ws-sig"><b>' + (n.SignalDBm ? esc(n.SignalDBm) + ' dBm' : esc(n.SignalLevel || '—')) + '</b>' +
+                    '<div class="ws-sig-bar" title="' + esc(lv.name) + ' · ' + esc(q) + '%"><span style="width:' + q + '%;background:' + lv.color + '"></span></div></div></td>' +
+                '<td><div>' + (n.Channel ? esc(n.Channel) : '—') + '</div><div class="ws-small">' + (n.ChannelWidth ? esc(n.ChannelWidth) + ' MHz' : esc(n.Frequency || '')) + '</div></td>' +
+                '<td class="ws-col-std"><div>' + esc(n.Generation || n.PHYType || '—') + '</div><div class="ws-small">' + (n.Generation ? esc(n.PHYType) : '') + '</div></td>' +
+                '<td class="ws-col-sec"><span class="ws-sec' + (sec === 'Open' ? ' open-net' : '') + '"><i class="' + (sec === 'Open' ? 'unlock' : 'lock') + ' icon"></i>' + esc(sec) + '</span>' +
+                    (n.Cipher ? '<div class="ws-small">' + esc(n.Cipher) + '</div>' : '') + '</td>' +
+                '<td class="ws-col-bssid ws-mono">' + esc(n.Address) + '</td>' +
+                (canConnect ? '<td class="ws-actions">' + actions + '</td>' : '') +
+                '</tr>';
+            if (open) html += '<tr class="ws-detail"><td colspan="' + cols + '">' + detailHTML(n) + '</td></tr>';
+        });
+        listEl.innerHTML = html + '</tbody></table>';
+    }
+
+    /* ---------- channel map ---------- */
+    var BAND_AXIS = {
+        '2.4 GHz': { min: 2400, max: 2496, ticks: [1,2,3,4,5,6,7,8,9,10,11,12,13,14], freq: function (c) { return c === 14 ? 2484 : 2407 + c * 5; } },
+        '5 GHz':   { min: 5150, max: 5895, ticks: [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], freq: function (c) { return 5000 + c * 5; } },
+        '6 GHz':   { min: 5945, max: 7125, ticks: (function () { var t = []; for (var c = 1; c <= 233; c += 4) t.push(c); return t; }()), freq: function (c) { return 5950 + c * 5; } },
+        '60 GHz':  { min: 57240, max: 70200, ticks: [1,2,3,4,5,6], freq: function (c) { return 56160 + c * 2160; } }
+    };
+
+    function renderChart() {
+        var chart = $id('ws-chart');
+        var band = state.chartBand;
+        var axis = BAND_AXIS[band];
+        var nets = state.networks.filter(function (n) { return bandOf(n) === band; });
+        if (!axis || !nets.length) {
+            chart.innerHTML = '<div class="ws-chart-empty">' + (state.scanning ? 'Scanning…' : 'No access points to chart.') + '</div>';
+            return;
+        }
+        var W = Math.max(320, chart.clientWidth - 24), H = 250;
+        var padL = 40, padR = 12, padT = 20, padB = 34;
+        var plotW = W - padL - padR, plotH = H - padT - padB;
+        var dbmMin = -100, dbmMax = -20;
+        function x(mhz) { return padL + (mhz - axis.min) / (axis.max - axis.min) * plotW; }
+        function y(dbm) { return padT + (dbmMax - Math.max(dbmMin, Math.min(dbmMax, dbm))) / (dbmMax - dbmMin) * plotH; }
+
+        var svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" height="' + H + '">';
+        for (var d = -90; d <= -30; d += 10) {
+            svg += '<line class="grid" x1="' + padL + '" x2="' + (W - padR) + '" y1="' + y(d) + '" y2="' + y(d) + '"/>' +
+                '<text class="axis-label" x="' + (padL - 6) + '" y="' + (y(d) + 3.5) + '" text-anchor="end">' + d + '</text>';
+        }
+        svg += '<text class="axis-title" x="' + (padL - 6) + '" y="' + (padT - 8) + '" text-anchor="end">dBm</text>';
+
+        // Channel ticks, thinned out to keep the labels apart
+        var minGap = 26, lastX = -1e9;
+        axis.ticks.forEach(function (c) {
+            var tx = x(axis.freq(c));
+            svg += '<line class="grid" x1="' + tx + '" x2="' + tx + '" y1="' + (padT + plotH) + '" y2="' + (padT + plotH + 4) + '"/>';
+            if (tx - lastX >= minGap) {
+                svg += '<text class="axis-label" x="' + tx + '" y="' + (padT + plotH + 16) + '" text-anchor="middle">' + c + '</text>';
+                lastX = tx;
+            }
+        });
+        svg += '<line class="grid" x1="' + padL + '" x2="' + (W - padR) + '" y1="' + (padT + plotH) + '" y2="' + (padT + plotH) + '"/>';
+        svg += '<text class="axis-title" x="' + (padL + plotW / 2) + '" y="' + (H - 2) + '" text-anchor="middle">Channel</text>';
+
+        // Labels are placed strongest first; a label that would overlap one
+        // already placed moves inside its shape, or is left out (the tooltip
+        // still names it)
+        var placed = [];
+        var labelPos = {};
+        nets.slice().sort(function (a, b) { return rssiOf(b) - rssiOf(a); }).forEach(function (n) {
+            var g = geometry(n);
+            var text = n.Hidden ? '(hidden)' : n.ESSID;
+            var maxChars = Math.max(4, Math.floor((g.x1 - g.x0) / 6.2) + 6);
+            if (text.length > maxChars) text = text.slice(0, maxChars - 1) + '…';
+            var w = text.length * 6.4 + 4;
+            var cx = Math.max(padL + w / 2, Math.min(W - padR - w / 2, (g.x0 + g.x1) / 2));
+            var options = [g.yt - 5, g.yt + 14];
+            for (var i = 0; i < options.length; i++) {
+                var box = { l: cx - w / 2, r: cx + w / 2, t: options[i] - 11, b: options[i] + 2 };
+                if (box.b > padT + plotH) continue;
+                var clash = placed.some(function (o) { return box.l < o.r && box.r > o.l && box.t < o.b && box.b > o.t; });
+                if (!clash) {
+                    placed.push(box);
+                    labelPos[n.Address] = { x: cx, y: options[i], text: text };
+                    break;
                 }
-            });
+            }
+        });
+
+        function geometry(n) {
+            var width = n.ChannelWidth || (band === '2.4 GHz' ? 22 : 20);
+            if (band === '60 GHz') width = 2160;
+            var center = n.FrequencyMHz || axis.freq(n.Channel || 0);
+            if (n.CenterChannel && width > 20) center = axis.freq(n.CenterChannel);
+            return { x0: x(center - width / 2), x1: x(center + width / 2), yt: y(rssiOf(n)) };
         }
-    </script>
-   
-</body>
 
-</html>
+        // Weakest first so the strong ones sit on top
+        nets.slice().sort(function (a, b) { return rssiOf(a) - rssiOf(b); }).forEach(function (n) {
+            var g = geometry(n);
+            var x0 = g.x0, x1 = g.x1;
+            var yb = padT + plotH, yt = g.yt;
+            var ramp = Math.min((x1 - x0) * 0.22, 14);
+            var color = colorFor(n);
+            var path = 'M' + x0.toFixed(1) + ' ' + yb + ' C' + (x0 + ramp * 0.6).toFixed(1) + ' ' + yt.toFixed(1) + ' ' + (x0 + ramp).toFixed(1) + ' ' + yt.toFixed(1) + ' ' +
+                (x0 + ramp * 1.6).toFixed(1) + ' ' + yt.toFixed(1) + ' L' + (x1 - ramp * 1.6).toFixed(1) + ' ' + yt.toFixed(1) +
+                ' C' + (x1 - ramp).toFixed(1) + ' ' + yt.toFixed(1) + ' ' + (x1 - ramp * 0.6).toFixed(1) + ' ' + yt.toFixed(1) + ' ' + x1.toFixed(1) + ' ' + yb;
+            var label = labelPos[n.Address];
+            svg += '<g class="ap' + (isConnected(n) ? ' connected' : '') + (state.hover === n.Address ? ' hover' : '') + '" data-bssid="' + esc(n.Address) + '">' +
+                '<path d="' + path + '" fill="' + color + '" fill-opacity="0.14" stroke="' + color + '"/>' +
+                (label ? '<text x="' + label.x.toFixed(1) + '" y="' + label.y.toFixed(1) + '" text-anchor="middle" fill="' + color + '">' + esc(label.text) + '</text>' : '') +
+                '</g>';
+        });
+        svg += '</svg><div class="ws-tip" id="ws-tip"></div>';
+        chart.innerHTML = svg;
+        chart.classList.toggle('has-hover', !!state.hover);
+    }
+
+    function setHover(bssid) {
+        if (state.hover === bssid) return;
+        state.hover = bssid;
+        var chart = $id('ws-chart');
+        chart.classList.toggle('has-hover', !!bssid);
+        Array.prototype.forEach.call(chart.querySelectorAll('.ap'), function (g) {
+            g.classList.toggle('hover', g.getAttribute('data-bssid') === bssid);
+        });
+        Array.prototype.forEach.call(root.querySelectorAll('tr.row'), function (tr) {
+            tr.classList.toggle('hover', tr.getAttribute('data-bssid') === bssid);
+        });
+    }
+
+    function findNetwork(bssid) {
+        for (var i = 0; i < state.networks.length; i++) if (state.networks[i].Address === bssid) return state.networks[i];
+        return null;
+    }
+
+    function renderAll() {
+        if (!alive()) return;
+        renderHeader();
+        renderBandFilters();
+        renderChart();
+        renderList();
+        var meta = [];
+        if (state.scanning) meta.push('Scanning…');
+        else if (state.scannedAt) meta.push('Scanned ' + state.scannedAt.toLocaleTimeString());
+        $id('ws-meta').textContent = meta.join(' ');
+    }
+
+    /* ---------- data ---------- */
+    function loadInfo() {
+        return api('../../system/network/wifiinfo').then(function (data) {
+            if (!alive()) return;
+            if (data.error !== undefined) {
+                renderNotice('Unable to read the interface state: ' + esc(data.error), true);
+                return;
+            }
+            state.info = data;
+            var list = data.Interfaces || [];
+            if (!state.iface || !list.some(function (i) { return i.Name === state.iface; })) {
+                var c = list.filter(function (i) { return i.Connected; })[0];
+                state.iface = c ? c.Name : (list[0] ? list[0].Name : (data.Interface || null));
+            }
+            if (!$id('ws-notice').querySelector('.error')) renderNotice();
+        });
+    }
+
+    function loadPower() {
+        return api('../../system/network/power').then(function (data) {
+            state.power = Array.isArray(data) ? data : null;
+        });
+    }
+
+    function scan() {
+        if (state.scanning) return;
+        state.scanning = true;
+        var btn = $id('ws-rescan');
+        btn.disabled = true;
+        btn.innerHTML = '<i class="circle notch loading icon"></i> Scanning';
+        if (!state.networks.length) $id('ws-list').innerHTML = '<div class="ws-state"><i class="big circle notch loading icon"></i>Scanning nearby access points&hellip;</div>';
+        renderAll();
+        var url = '../../system/network/scanWifi' + (state.iface ? '?interface=' + encodeURIComponent(state.iface) : '');
+        api(url).then(function (data) {
+            if (!alive()) return;
+            state.scanning = false;
+            if (data.error !== undefined) {
+                // Linux drivers often report "busy" right after another scan
+                if (state.retries < 3 && !/not found/i.test(data.error)) {
+                    state.retries++;
+                    setTimeout(scan, 3000);
+                    return;
+                }
+                state.retries = 0;
+                renderNotice('WiFi scan failed: ' + esc(data.error), true);
+                if (!state.networks.length) $id('ws-list').innerHTML = '<div class="ws-state"><i class="big exclamation triangle icon" style="opacity:.4"></i>WiFi scanning failed.</div>';
+            } else {
+                state.retries = 0;
+                state.networks = Array.isArray(data) ? data : [];
+                state.scannedAt = new Date();
+                if ($id('ws-notice').querySelector('.error')) renderNotice();
+            }
+        }).catch(function (e) {
+            state.scanning = false;
+            renderNotice('WiFi scan failed: ' + esc(e.message || e), true);
+        }).then(function () {
+            if (!alive()) return;
+            if (!state.scanning) {
+                btn.disabled = false;
+                btn.innerHTML = '<i class="sync alternate icon"></i> Rescan';
+            }
+            renderAll();
+        });
+    }
+
+    function refreshInfo() {
+        clearTimeout(infoTimer);
+        if (!alive()) return;
+        if (!document.hidden) loadInfo().then(function () { if (alive()) { renderHeader(); } });
+        infoTimer = setTimeout(refreshInfo, 10000);
+    }
+
+    /* ---------- connect / forget ---------- */
+    var dialogNet = null;
+    function openConnectDialog(n) {
+        dialogNet = n;
+        var lv = level(rssiOf(n));
+        $id('ws-dialog-icon').innerHTML = barsSVG(lv.bars, lv.color, '') +
+            '<div><h3 id="ws-dialog-title">' + esc(n.ESSID) + '</h3><div class="ws-small">' + esc([securityOf(n), bandOf(n), n.SignalDBm ? n.SignalDBm + ' dBm' : ''].filter(Boolean).join(' · ')) + '</div></div>';
+        var needsPwd = securityOf(n) !== 'Open';
+        $id('ws-pwd-field').style.display = needsPwd ? '' : 'none';
+        $id('ws-pwd').value = '';
+        $id('ws-pwd').type = 'password';
+        $id('ws-dialog-msg').textContent = '';
+        $id('ws-dialog-ok').disabled = false;
+        $id('ws-dialog-ok').textContent = 'Connect';
+        $id('ws-dialog').classList.add('show');
+        setTimeout(function () { (needsPwd ? $id('ws-pwd') : $id('ws-dialog-ok')).focus(); }, 30);
+    }
+    function closeDialog() { $id('ws-dialog').classList.remove('show'); dialogNet = null; }
+
+    function connect(n, params, onDone) {
+        api('../../system/network/connectWifi', params).then(function (data) {
+            if (data.error !== undefined) { onDone(data.error); return; }
+            onDone(null, data);
+            toast(data.ConnectedSSID ? 'Connected to ' + data.ConnectedSSID : 'Connection request sent');
+            loadInfo().then(scan);
+        }).catch(function (e) { onDone(e.message || String(e)); });
+    }
+
+    function confirmDialog() {
+        if (!dialogNet) return;
+        var n = dialogNet;
+        var btn = $id('ws-dialog-ok');
+        btn.disabled = true;
+        btn.innerHTML = '<i class="circle notch loading icon"></i> Connecting';
+        $id('ws-dialog-msg').textContent = '';
+        connect(n, { ESSID: n.ESSID, pwd: $id('ws-pwd').value }, function (err) {
+            if (err) {
+                btn.disabled = false;
+                btn.textContent = 'Connect';
+                $id('ws-dialog-msg').textContent = err;
+                return;
+            }
+            closeDialog();
+        });
+    }
+
+    function switchTo(n, button) {
+        button.disabled = true;
+        button.innerHTML = '<i class="circle notch loading icon"></i>';
+        connect(n, { ESSID: n.ESSID, ConnType: 'switch' }, function (err) {
+            if (err) { toast('Connect failed: ' + err); renderList(); }
+        });
+    }
+
+    function forget(n) {
+        if (!window.confirm('Forget the saved network "' + n.ESSID + '"?')) return;
+        api('../../system/network/removeWifi', { ESSID: n.ESSID }).then(function (data) {
+            if (data.error !== undefined) { toast('Unable to forget network: ' + data.error); return; }
+            toast('Forgot ' + n.ESSID);
+            scan();
+        });
+    }
+
+    function setPower(on) {
+        var cur = currentInterface();
+        if (!cur) return;
+        api('../../system/network/power', { status: on ? 'on' : 'off', interface: cur.Name }).then(function (data) {
+            if (data.error !== undefined) toast('Unable to change radio state: ' + data.error);
+            else toast('Wireless radio turned ' + (on ? 'on' : 'off'));
+            return Promise.all([loadPower(), loadInfo()]);
+        }).then(renderHeader);
+    }
+
+    /* ---------- events ---------- */
+    root.addEventListener('click', function (ev) {
+        var t = ev.target;
+        var el;
+        if ((el = t.closest('#ws-rescan'))) { loadInfo().then(function () { renderHeader(); scan(); }); return; }
+        if ((el = t.closest('[data-band]'))) { state.band = el.getAttribute('data-band'); renderBandFilters(); renderList(); return; }
+        if ((el = t.closest('[data-chart-band]'))) { state.chartBand = el.getAttribute('data-chart-band'); state._chartChosen = true; renderBandFilters(); renderChart(); return; }
+        if ((el = t.closest('th[data-sort]'))) {
+            var key = el.getAttribute('data-sort');
+            if (state.sort.key === key) state.sort.dir = -state.sort.dir;
+            else state.sort = { key: key, dir: key === 'signal' ? -1 : 1 };
+            renderList();
+            return;
+        }
+        if ((el = t.closest('[data-act]'))) {
+            ev.stopPropagation();
+            var n = findNetwork(el.closest('tr').getAttribute('data-bssid'));
+            if (!n) return;
+            var act = el.getAttribute('data-act');
+            if (act === 'forget') forget(n);
+            else if (n.ConnectedBefore) switchTo(n, el);
+            else openConnectDialog(n);
+            return;
+        }
+        if ((el = t.closest('.ap'))) {
+            var bssid = el.getAttribute('data-bssid');
+            state.open[bssid] = true;
+            if (state.band !== 'all' && state.band !== state.chartBand) state.band = 'all';
+            renderBandFilters();
+            renderList();
+            var row = root.querySelector('tr.row[data-bssid="' + CSS.escape(bssid) + '"]');
+            if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' });
+            return;
+        }
+        if ((el = t.closest('tr.row'))) {
+            var b = el.getAttribute('data-bssid');
+            state.open[b] = !state.open[b];
+            renderList();
+            return;
+        }
+        if (t.id === 'ws-dialog' || t.closest('#ws-dialog-cancel')) { closeDialog(); return; }
+        if (t.closest('#ws-dialog-ok')) { confirmDialog(); return; }
+        if ((el = t.closest('#ws-pwd-eye'))) {
+            var p = $id('ws-pwd');
+            p.type = p.type === 'password' ? 'text' : 'password';
+            el.innerHTML = '<i class="eye' + (p.type === 'text' ? ' slash' : '') + ' icon"></i>';
+        }
+    });
+    root.addEventListener('change', function (ev) {
+        if (ev.target.id === 'ws-iface') {
+            state.iface = ev.target.value;
+            state.networks = [];
+            state.open = {};
+            renderAll();
+            scan();
+        } else if (ev.target.id === 'ws-power') {
+            setPower(ev.target.checked);
+        }
+    });
+    root.addEventListener('input', function (ev) {
+        if (ev.target.id === 'ws-filter') { state.filter = ev.target.value; renderList(); }
+    });
+    root.addEventListener('keydown', function (ev) {
+        if (!$id('ws-dialog').classList.contains('show')) return;
+        if (ev.key === 'Escape') closeDialog();
+        if (ev.key === 'Enter') confirmDialog();
+    });
+    root.addEventListener('mouseover', function (ev) {
+        var el = ev.target.closest('.ap, tr.row');
+        setHover(el ? el.getAttribute('data-bssid') : null);
+    });
+    root.addEventListener('mouseleave', function () { setHover(null); });
+    $id('ws-chart').addEventListener('mousemove', function (ev) {
+        var tip = $id('ws-tip');
+        if (!tip) return;
+        var g = ev.target.closest('.ap');
+        var n = g && findNetwork(g.getAttribute('data-bssid'));
+        if (!n) { tip.style.display = 'none'; return; }
+        tip.innerHTML = '<b>' + esc(n.Hidden ? 'Hidden network' : n.ESSID) + '</b>' +
+            esc([n.SignalDBm ? n.SignalDBm + ' dBm' : '', 'ch ' + n.Channel + (n.ChannelWidth ? ' / ' + n.ChannelWidth + ' MHz' : ''), n.Generation || n.PHYType].filter(Boolean).join(' · ')) +
+            '<div class="ws-mono">' + esc(n.Address) + '</div>';
+        var box = this.getBoundingClientRect();
+        var px = ev.clientX - box.left + 14, py = ev.clientY - box.top + 12;
+        tip.style.display = 'block';
+        if (px + tip.offsetWidth > box.width) px = ev.clientX - box.left - tip.offsetWidth - 10;
+        tip.style.left = px + 'px';
+        tip.style.top = py + 'px';
+    });
+    $id('ws-chart').addEventListener('mouseleave', function () { var tip = $id('ws-tip'); if (tip) tip.style.display = 'none'; });
+
+    if (window.ResizeObserver) {
+        var lastW = 0;
+        var ro = new ResizeObserver(function () {
+            if (!alive()) { ro.disconnect(); return; }
+            var w = $id('ws-chart').clientWidth;
+            if (Math.abs(w - lastW) > 4) { lastW = w; renderChart(); }
+        });
+        ro.observe($id('ws-chart'));
+    }
+
+    /* ---------- start ---------- */
+    renderHeader();
+    Promise.all([loadInfo(), loadPower()]).then(function () {
+        renderAll();
+        if (state.info && !(state.info.Interfaces || []).length) {
+            renderNotice('No wireless interface was found on this host. Plug in or enable a WiFi adapter, then press Rescan.', true);
+            $id('ws-list').innerHTML = '<div class="ws-state"><i class="big wifi icon" style="opacity:.35"></i>No wireless interface.</div>';
+        } else {
+            scan();
+        }
+        infoTimer = setTimeout(refreshInfo, 10000);
+    });
+}());
+</script>
+</body>
+</html>

+ 479 - 108
src/web/SystemAO/network/wifiinfo.html

@@ -1,127 +1,498 @@
 <!DOCTYPE html>
 <html>
-
 <head>
     <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <title>WiFi Info</title>
     <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
-    <script type="text/javascript" src="../../script/jquery.min.js"></script>
-    <script type="text/javascript" src="../../script/semantic/semantic.min.js"></script>
-    <script type="text/javascript" src="../../script/ao.css"></script>
+</head>
+<body>
+<!-- NOTE: <style> and <script> live in <body> so they survive being injected
+     into System Settings with jQuery .load(). No jQuery is required. -->
+<style>
+#wi-root {
+    --wi-card:      #ffffff;
+    --wi-border:    rgba(0,0,0,0.07);
+    --wi-shadow:    0 2px 8px rgba(0,0,0,0.06), 0 0 1px rgba(0,0,0,0.05);
+    --wi-text:      #1a1a1a;
+    --wi-dim:       #5a5a5a;
+    --wi-muted:     #949494;
+    --wi-sep:       rgba(0,0,0,0.07);
+    --wi-chip:      #f1f3f5;
+    --wi-chip-fg:   #3d3d3d;
+    --wi-accent:    #0f6cbd;
+    --wi-track:     rgba(0,0,0,0.08);
+    --wi-good:      #13a10e;
+    --wi-ok:        #0f9d8a;
+    --wi-fair:      #c58a00;
+    --wi-weak:      #d13438;
+    --wi-off:       #a0a0a0;
+    --wi-hero-a:    #fdfefe;
+    --wi-hero-b:    #eef6fc;
+    --wi-tile:      #fafbfc;
 
-    <link href="../../script/aos/aos.css" rel="stylesheet">
-    <script src="../../script/aos/aos.js"></script>
+    font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+    font-size: 14px;
+    color: var(--wi-text);
+    -webkit-font-smoothing: antialiased;
+    max-width: 1100px;
+    padding: 4px 0 28px;
+}
+body.dark #wi-root {
+    --wi-card:      #2b2b2b;
+    --wi-border:    rgba(255,255,255,0.08);
+    --wi-shadow:    0 2px 10px rgba(0,0,0,0.3), 0 0 1px rgba(0,0,0,0.5);
+    --wi-text:      #f0f0f0;
+    --wi-dim:       #b4b4b4;
+    --wi-muted:     #828282;
+    --wi-sep:       rgba(255,255,255,0.07);
+    --wi-chip:      rgba(255,255,255,0.08);
+    --wi-chip-fg:   #d0d0d0;
+    --wi-accent:    #4cc2ff;
+    --wi-track:     rgba(255,255,255,0.1);
+    --wi-good:      #6ccb5f;
+    --wi-ok:        #4fd1c5;
+    --wi-fair:      #f2c94c;
+    --wi-weak:      #ff6b6b;
+    --wi-off:       #6e6e6e;
+    --wi-hero-a:    #2d2d2d;
+    --wi-hero-b:    #24303a;
+    --wi-tile:      #313131;
+}
+#wi-root * { box-sizing: border-box; }
+#wi-root .wi-wrap { container-type: inline-size; }
 
-    <title>WiFi Info</title>
-    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
-    <style>
-        #sys-setting-page { padding: 20px; }
+/* Interface tabs */
+#wi-root .wi-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
+#wi-root .wi-tab {
+    border: 1px solid var(--wi-border); background: var(--wi-card); color: var(--wi-dim);
+    border-radius: 18px; padding: 5px 14px; font: inherit; font-size: 13px; cursor: pointer;
+}
+#wi-root .wi-tab.active { background: var(--wi-accent); border-color: var(--wi-accent); color: #fff; }
+body.dark #wi-root .wi-tab.active { color: #102030; }
 
-        .gradientbg{
-            border-radius: 12px;
-            background: rgb(253,255,254);
-            background: linear-gradient(104deg, rgba(253,255,254,1) 29%, rgba(240,250,255,1) 100%);
-        }
+/* Cards */
+#wi-root .wi-card {
+    background: var(--wi-card); border: 1px solid var(--wi-border); border-radius: 12px;
+    box-shadow: var(--wi-shadow); margin-bottom: 14px; overflow: hidden;
+}
+#wi-root .wi-card-title {
+    display: flex; align-items: center; gap: 8px; padding: 12px 16px;
+    font-size: 13px; font-weight: 600; color: var(--wi-dim); border-bottom: 1px solid var(--wi-sep);
+}
+#wi-root .wi-card-title i.icon { margin: 0; opacity: .75; }
+#wi-root .wi-card-title .wi-sub { margin-left: auto; font-weight: 400; color: var(--wi-muted); font-size: 12px; }
 
-        .statusIcon{
-            color: #ffffff;
-            padding: 4em !important;
-            text-align: center;
-        }
+/* Hero */
+#wi-root .wi-hero {
+    display: grid; grid-template-columns: auto 1fr auto; gap: 24px; align-items: center;
+    padding: 24px 26px; background: linear-gradient(110deg, var(--wi-hero-a) 35%, var(--wi-hero-b) 100%);
+}
+#wi-root .wi-glyph { width: 108px; height: 108px; position: relative; }
+#wi-root .wi-glyph svg { width: 100%; height: 100%; display: block; }
+#wi-root .wi-glyph .arc { fill: none; stroke-width: 9; stroke-linecap: round; stroke: var(--wi-track); transition: stroke .4s; }
+#wi-root .wi-glyph .dot { fill: var(--wi-track); transition: fill .4s; }
+#wi-root .wi-glyph .on { stroke: var(--wi-level); }
+#wi-root .wi-glyph .dot.on { fill: var(--wi-level); }
+#wi-root .wi-glyph .slash { stroke: var(--wi-weak); stroke-width: 8; stroke-linecap: round; }
+#wi-root .wi-pulse {
+    position: absolute; left: 50%; bottom: 10px; width: 22px; height: 22px; margin-left: -11px;
+    border-radius: 50%; background: var(--wi-level); opacity: 0; animation: wi-pulse 2.6s ease-out infinite;
+}
+@keyframes wi-pulse { 0% { transform: scale(.6); opacity: .45; } 100% { transform: scale(4.2); opacity: 0; } }
+
+#wi-root .wi-hero-main { min-width: 0; }
+#wi-root .wi-badge {
+    display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 8px; border-radius: 20px;
+    font-size: 12px; font-weight: 600; background: var(--wi-chip); color: var(--wi-chip-fg);
+}
+#wi-root .wi-badge .wi-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
+#wi-root .wi-badge.connected { color: var(--wi-good); background: color-mix(in srgb, var(--wi-good) 14%, transparent); }
+#wi-root .wi-badge.warn { color: var(--wi-fair); background: color-mix(in srgb, var(--wi-fair) 15%, transparent); }
+#wi-root .wi-badge.error { color: var(--wi-weak); background: color-mix(in srgb, var(--wi-weak) 13%, transparent); }
+#wi-root .wi-ssid {
+    font-size: 26px; font-weight: 600; line-height: 1.2; margin: 8px 0 4px;
+    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+#wi-root .wi-ssid.none { color: var(--wi-muted); font-weight: 500; }
+#wi-root .wi-adapter { color: var(--wi-dim); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+#wi-root .wi-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
+#wi-root .wi-chip {
+    display: inline-flex; align-items: center; gap: 5px; background: var(--wi-chip); color: var(--wi-chip-fg);
+    border-radius: 6px; padding: 3px 9px; font-size: 12px; font-weight: 500;
+}
+#wi-root .wi-chip i.icon { margin: 0; font-size: 11px; height: auto; }
+
+/* Gauge */
+#wi-root .wi-gauge { display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 170px; }
+#wi-root .wi-ring { width: 120px; height: 120px; position: relative; }
+#wi-root .wi-ring svg { width: 100%; height: 100%; transform: rotate(135deg); }
+#wi-root .wi-ring .track { fill: none; stroke: var(--wi-track); stroke-width: 10; stroke-linecap: round; }
+#wi-root .wi-ring .value { fill: none; stroke: var(--wi-level); stroke-width: 10; stroke-linecap: round; transition: stroke-dasharray .6s, stroke .4s; }
+#wi-root .wi-ring-text { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
+#wi-root .wi-ring-text b { font-size: 26px; font-weight: 600; line-height: 1; }
+#wi-root .wi-ring-text span { font-size: 12px; color: var(--wi-dim); margin-top: 4px; }
+#wi-root .wi-quality-label { font-size: 12px; font-weight: 600; color: var(--wi-level); }
+#wi-root .wi-spark { width: 170px; height: 38px; display: block; }
+#wi-root .wi-spark path.line { fill: none; stroke: var(--wi-level); stroke-width: 1.6; }
+#wi-root .wi-spark path.area { fill: var(--wi-level); opacity: .12; stroke: none; }
+#wi-root .wi-spark-caption { font-size: 11px; color: var(--wi-muted); }
+
+/* Stat tiles */
+#wi-root .wi-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; padding: 14px 16px 16px; }
+#wi-root .wi-tile { background: var(--wi-tile); border: 1px solid var(--wi-sep); border-radius: 10px; padding: 11px 13px; min-width: 0; }
+#wi-root .wi-tile dt { font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--wi-muted); font-weight: 600; margin-bottom: 4px; }
+#wi-root .wi-tile dd { margin: 0; font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+#wi-root .wi-tile dd small { font-size: 12px; font-weight: 400; color: var(--wi-dim); margin-left: 3px; }
+#wi-root .wi-tile .wi-tile-sub { font-size: 12px; color: var(--wi-dim); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+/* Property list */
+#wi-root .wi-props { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; background: var(--wi-sep); margin: 0; }
+#wi-root .wi-prop { background: var(--wi-card); padding: 10px 16px; min-width: 0; }
+#wi-root .wi-prop.full { grid-column: 1 / -1; }
+#wi-root .wi-prop dt { font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--wi-muted); font-weight: 600; margin-bottom: 3px; }
+#wi-root .wi-prop dd { margin: 0; font-size: 13px; font-family: 'Cascadia Mono', Consolas, Menlo, monospace; word-break: break-all; }
+#wi-root .wi-prop dd.text { font-family: inherit; word-break: normal; }
+#wi-root .wi-prop dd.na { color: var(--wi-muted); font-family: inherit; font-style: italic; }
+#wi-root .wi-prop dd .wi-line { display: block; }
+
+#wi-root .wi-tags { display: flex; flex-wrap: wrap; gap: 4px; }
+#wi-root .wi-tag { background: var(--wi-chip); color: var(--wi-chip-fg); border-radius: 4px; padding: 1px 7px; font-size: 11.5px; font-family: inherit; }
+#wi-root .wi-tag.basic { outline: 1px solid var(--wi-accent); outline-offset: -1px; }
+
+/* Utilisation meter */
+#wi-root .wi-meter { height: 6px; border-radius: 3px; background: var(--wi-track); margin-top: 8px; overflow: hidden; }
+#wi-root .wi-meter > span { display: block; height: 100%; background: var(--wi-accent); border-radius: 3px; }
+
+/* Footer / state */
+#wi-root .wi-footer { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; color: var(--wi-muted); font-size: 12px; padding: 4px 2px; }
+#wi-root .wi-footer label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; margin-left: auto; }
+#wi-root .wi-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 70px 24px; color: var(--wi-dim); text-align: center; }
+#wi-root .wi-state svg { width: 64px; height: 64px; opacity: .45; }
+#wi-root .wi-hint { padding: 12px 16px; font-size: 13px; color: var(--wi-dim); }
+
+@container (max-width: 680px) {
+    #wi-root .wi-hero { grid-template-columns: 1fr; justify-items: center; text-align: center; padding: 22px 16px; }
+    #wi-root .wi-hero-main { max-width: 100%; }
+    #wi-root .wi-chips { justify-content: center; }
+    #wi-root .wi-ssid { font-size: 22px; }
+}
+@container (max-width: 520px) {
+    #wi-root .wi-props { grid-template-columns: 1fr; }
+}
+</style>
+
+<div id="wi-root">
+    <div id="wi-content" class="wi-wrap">
+        <div class="wi-state">
+            <i class="big circle notch loading icon"></i>
+            <div>Reading wireless interfaces&hellip;</div>
+        </div>
+    </div>
+    <div class="wi-footer">
+        <span id="wi-source">WiFi information is read from the native WLAN API (WlanSvc) on Windows and from iw / iwlist, wpa_supplicant or nmcli on Linux.</span>
+        <label><input type="checkbox" id="wi-live" checked> Live update</label>
+    </div>
+</div>
+
+<script>
+(function () {
+    var root = document.getElementById('wi-root');
+    var content = document.getElementById('wi-content');
+    var liveBox = document.getElementById('wi-live');
+
+    var REFRESH_MS = 3000;
+    var HISTORY = 40;
+    var selected = null;      // name of the selected interface
+    var history = {};         // interface name -> [rssi, ...]
+    var lastData = null;
+    var timer = null;
+
+    /* ---------- helpers ---------- */
+    function esc(s) {
+        return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
+            return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
+        });
+    }
+    function has(v) { return !(v === undefined || v === null || v === '' || v === 0 || (Array.isArray(v) && v.length === 0)); }
+    function fmtRate(mbps) {
+        if (!mbps) return null;
+        if (mbps >= 1000) return (mbps / 1000).toFixed(2).replace(/\.?0+$/, '') + ' Gbps';
+        return (Math.round(mbps * 10) / 10) + ' Mbps';
+    }
+    function fmtUptime(sec) {
+        if (!sec) return null;
+        var d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60);
+        if (d > 0) return d + 'd ' + h + 'h';
+        if (h > 0) return h + 'h ' + m + 'm';
+        return m + 'm';
+    }
+    function fmtFreq(mhz) { return mhz ? (mhz / 1000).toFixed(3) + ' GHz' : null; }
+
+    // Signal level from RSSI (dBm) or quality (%)
+    function level(rssi, quality) {
+        var q = quality;
+        if (rssi) q = Math.max(0, Math.min(100, 2 * (rssi + 100)));
+        if (q === undefined || q === null || q < 0) return { bars: 0, name: 'Unknown', color: 'var(--wi-off)', q: 0 };
+        if (rssi ? rssi >= -55 : q >= 90) return { bars: 4, name: 'Excellent', color: 'var(--wi-good)', q: q };
+        if (rssi ? rssi >= -67 : q >= 66) return { bars: 3, name: 'Good', color: 'var(--wi-ok)', q: q };
+        if (rssi ? rssi >= -75 : q >= 50) return { bars: 2, name: 'Fair', color: 'var(--wi-fair)', q: q };
+        return { bars: 1, name: 'Weak', color: 'var(--wi-weak)', q: q };
+    }
 
-        #status{
-            font-size: 1.2em;
-            color: #1f1f1f;
+    function glyphSVG(bars, crossed) {
+        // Three arcs and a dot, all centred on (60, 96)
+        var arcs = [
+            'M24 60 A51 51 0 0 1 96 60',
+            'M37 73 A33 33 0 0 1 83 73',
+            'M49 85 A16 16 0 0 1 71 85'
+        ];
+        var html = '<svg viewBox="0 0 120 112" aria-hidden="true">';
+        arcs.forEach(function (d, i) {
+            html += '<path class="arc' + (bars >= 4 - i ? ' on' : '') + '" d="' + d + '"/>';
+        });
+        html += '<circle class="dot' + (bars >= 1 ? ' on' : '') + '" cx="60" cy="97" r="7"/>';
+        if (crossed) html += '<line class="slash" x1="22" y1="16" x2="98" y2="104"/>';
+        return html + '</svg>';
+    }
+
+    function ringSVG(q) {
+        var r = 50, c = 2 * Math.PI * r, span = c * 0.75;
+        var v = Math.max(0, Math.min(100, q || 0)) / 100 * span;
+        return '<svg viewBox="0 0 120 120" aria-hidden="true">' +
+            '<circle class="track" cx="60" cy="60" r="' + r + '" stroke-dasharray="' + span + ' ' + c + '"/>' +
+            '<circle class="value" cx="60" cy="60" r="' + r + '" stroke-dasharray="' + v + ' ' + c + '"/>' +
+            '</svg>';
+    }
+
+    function sparkSVG(samples) {
+        var w = 170, h = 38;
+        if (!samples || samples.length < 2) return '<svg class="wi-spark" viewBox="0 0 170 38"></svg>';
+        var min = -95, max = -25;
+        var step = w / (HISTORY - 1);
+        var off = (HISTORY - samples.length) * step;
+        var pts = samples.map(function (v, i) {
+            var y = h - 2 - (Math.max(min, Math.min(max, v)) - min) / (max - min) * (h - 4);
+            return [off + i * step, y];
+        });
+        var line = pts.map(function (p, i) { return (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1); }).join(' ');
+        var area = line + ' L' + pts[pts.length - 1][0].toFixed(1) + ' ' + h + ' L' + pts[0][0].toFixed(1) + ' ' + h + ' Z';
+        return '<svg class="wi-spark" viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none">' +
+            '<path class="area" d="' + area + '"/><path class="line" d="' + line + '"/></svg>';
+    }
+
+    function tile(label, value, unit, sub) {
+        if (!has(value)) return '';
+        return '<div class="wi-tile"><dt>' + esc(label) + '</dt><dd>' + esc(value) +
+            (unit ? '<small>' + esc(unit) + '</small>' : '') + '</dd>' +
+            (sub ? '<div class="wi-tile-sub">' + esc(sub) + '</div>' : '') + '</div>';
+    }
+
+    function prop(label, value, opt) {
+        opt = opt || {};
+        var empty = !has(value);
+        var body;
+        if (empty) body = '&mdash;';
+        else if (Array.isArray(value)) body = value.map(function (v) { return '<span class="wi-line">' + esc(v) + '</span>'; }).join('');
+        else if (opt.html) body = value;
+        else body = esc(value);
+        var cls = empty ? 'na' : (opt.text ? 'text' : '');
+        return '<div class="wi-prop' + (opt.full ? ' full' : '') + '"><dt>' + esc(label) + '</dt><dd class="' + cls + '">' + body + '</dd></div>';
+    }
+
+    function tags(list, basic) {
+        if (!list || !list.length) return null;
+        return '<div class="wi-tags">' + list.map(function (r) {
+            return '<span class="wi-tag' + (basic && basic.indexOf(r) >= 0 ? ' basic' : '') + '">' + esc(r) + '</span>';
+        }).join('') + '</div>';
+    }
+
+    function radioText(s) {
+        if (!s || s === 'unknown') return null;
+        return s === 'on' ? 'On' : 'Off';
+    }
+
+    /* ---------- rendering ---------- */
+    function statusBadge(inf) {
+        if (inf.HardwareRadio === 'off') return '<span class="wi-badge error"><span class="wi-dot"></span>Radio switched off (hardware)</span>';
+        if (inf.SoftwareRadio === 'off') return '<span class="wi-badge error"><span class="wi-dot"></span>Radio off</span>';
+        if (inf.Connected) return '<span class="wi-badge connected"><span class="wi-dot"></span>Connected</span>';
+        var s = inf.State || 'disconnected';
+        if (s === 'disconnected') return '<span class="wi-badge"><span class="wi-dot"></span>Not connected</span>';
+        if (s === 'down' || s === 'not ready') return '<span class="wi-badge error"><span class="wi-dot"></span>' + esc(s === 'down' ? 'Interface down' : 'Not ready') + '</span>';
+        return '<span class="wi-badge warn"><span class="wi-dot"></span>' + esc(s.charAt(0).toUpperCase() + s.slice(1)) + '&hellip;</span>';
+    }
+
+    function renderInterface(inf) {
+        var lv = inf.Connected ? level(inf.RSSI, inf.SignalQuality) : { bars: 0, name: '', color: 'var(--wi-off)', q: 0 };
+        var radioOff = inf.SoftwareRadio === 'off' || inf.HardwareRadio === 'off';
+        var ap = inf.AP || {};
+        var samples = history[inf.Name] || [];
+
+        var chips = [];
+        if (inf.Connected) {
+            if (inf.Band) chips.push('<span class="wi-chip"><i class="podcast icon"></i>' + esc(inf.Band) + '</span>');
+            if (inf.Generation || inf.PHYType) chips.push('<span class="wi-chip">' + esc(inf.Generation ? inf.Generation + ' · ' + inf.PHYType : inf.PHYType) + '</span>');
+            if (inf.Security) chips.push('<span class="wi-chip"><i class="' + (inf.Security === 'Open' ? 'unlock' : 'lock') + ' icon"></i>' + esc(inf.Security) + '</span>');
+            if (inf.Channel) chips.push('<span class="wi-chip">Channel ' + esc(inf.Channel) + (inf.ChannelWidth ? ' · ' + inf.ChannelWidth + ' MHz' : '') + '</span>');
         }
 
-        #status.error{
-            color: rgb(245, 86, 86);
+        var hero =
+            '<div class="wi-card"><div class="wi-hero" style="--wi-level:' + lv.color + '">' +
+                '<div class="wi-glyph">' + (inf.Connected ? '<span class="wi-pulse"></span>' : '') + glyphSVG(lv.bars, radioOff) + '</div>' +
+                '<div class="wi-hero-main">' +
+                    statusBadge(inf) +
+                    (inf.Connected
+                        ? '<div class="wi-ssid" title="' + esc(inf.SSID) + '">' + esc(inf.SSID || 'Hidden network') + '</div>'
+                        : '<div class="wi-ssid none">' + (radioOff ? 'Wireless radio is off' : 'Not connected to a network') + '</div>') +
+                    '<div class="wi-adapter">' + esc(inf.Name) + (inf.Description ? ' · ' + esc(inf.Description) : '') + '</div>' +
+                    (chips.length ? '<div class="wi-chips">' + chips.join('') + '</div>' : '') +
+                '</div>' +
+                (inf.Connected
+                    ? '<div class="wi-gauge">' +
+                        '<div class="wi-ring">' + ringSVG(lv.q) +
+                            '<div class="wi-ring-text"><b>' + (inf.RSSI ? esc(inf.RSSI) : esc(lv.q)) + '</b><span>' + (inf.RSSI ? 'dBm' : '%') + '</span></div>' +
+                        '</div>' +
+                        '<div class="wi-quality-label">' + esc(lv.name) + (inf.SignalQuality >= 0 ? ' · ' + esc(inf.SignalQuality) + '% quality' : '') + '</div>' +
+                        sparkSVG(samples) +
+                        '<div class="wi-spark-caption">Signal over the last ' + Math.round(HISTORY * REFRESH_MS / 60000) + ' minutes</div>' +
+                      '</div>'
+                    : '<div></div>') +
+            '</div></div>';
+
+        var connection = '';
+        if (inf.Connected) {
+            var tiles =
+                tile('Receive rate', fmtRate(inf.RxRateMbps), '', 'Current link rate') +
+                tile('Transmit rate', fmtRate(inf.TxRateMbps), '', 'Current link rate') +
+                tile('Channel', inf.Channel, inf.ChannelWidth ? inf.ChannelWidth + ' MHz' : '', [fmtFreq(inf.FrequencyMHz), inf.Band].filter(Boolean).join(' · ')) +
+                tile('Standard', inf.Generation || inf.PHYType, '', inf.Generation ? inf.PHYType : '') +
+                tile('Security', inf.Security, '', inf.Cipher ? inf.Cipher + (inf.OneX ? ' · 802.1X' : '') : '') +
+                tile('Signal', inf.RSSI || null, inf.RSSI ? 'dBm' : '', inf.SignalQuality >= 0 ? inf.SignalQuality + '% link quality' : '');
+            connection =
+                '<div class="wi-card"><div class="wi-card-title"><i class="exchange icon"></i>Connection</div>' +
+                '<dl class="wi-tiles">' + tiles + '</dl>' +
+                '<dl class="wi-props">' +
+                    prop('BSSID', inf.BSSID) +
+                    prop('Profile', inf.ProfileName, { text: true }) +
+                    prop('Connection mode', inf.ConnectionMode, { text: true }) +
+                    prop('Network type', inf.BSSType, { text: true }) +
+                '</dl></div>';
         }
 
-        #status.connected{
-            color: rgb(68, 177, 132);
+        var apCard = '';
+        if (inf.Connected && inf.AP) {
+            var util = ap.ChannelUtilization;
+            apCard =
+                '<div class="wi-card"><div class="wi-card-title"><i class="sitemap icon"></i>Access point' +
+                    '<span class="wi-sub">From the AP\'s beacon</span></div>' +
+                '<dl class="wi-tiles">' +
+                    tile('Max PHY rate', fmtRate(ap.MaxRateMbps), '', 'Estimated from capabilities') +
+                    tile('Spatial streams', ap.SpatialStreams ? ap.SpatialStreams + ' × ' + ap.SpatialStreams : null, '', 'MIMO') +
+                    tile('AP uptime', fmtUptime(ap.UptimeSeconds), '', 'From the beacon timestamp') +
+                    (ap.StationCount != null ? tile('Clients', String(ap.StationCount), '', 'Associated stations') : '') +
+                    (util != null ? '<div class="wi-tile"><dt>Channel busy</dt><dd>' + esc(util) + '<small>%</small></dd><div class="wi-meter"><span style="width:' + Math.min(100, util) + '%"></span></div></div>' : '') +
+                '</dl>' +
+                '<dl class="wi-props">' +
+                    prop('Country', ap.Country, { text: true }) +
+                    prop('Beacon interval', ap.BeaconInterval ? ap.BeaconInterval + ' TU (' + (ap.BeaconInterval * 1.024).toFixed(1) + ' ms)' : null, { text: true }) +
+                    prop('Protected management frames', ap.PMF, { text: true }) +
+                    prop('WPS', inf.AP ? (ap.WPS ? 'Enabled' : 'Disabled') : null, { text: true }) +
+                    prop('Fast roaming', ap.Roaming && ap.Roaming.length ? ap.Roaming.join(', ') : null, { text: true }) +
+                    prop('Center channel', ap.CenterChannel || null, { text: true }) +
+                    prop('Basic rates (Mbps)', tags(ap.BasicRates), { html: true, full: true }) +
+                    prop('Supported rates (Mbps)', tags(ap.SupportedRates, ap.BasicRates), { html: true, full: true }) +
+                '</dl></div>';
         }
 
-        #wifiicon{
-            margin-left: auto;
-            margin-right: auto;
-            display: block;
+        var adapter =
+            '<div class="wi-card"><div class="wi-card-title"><i class="microchip icon"></i>Adapter</div>' +
+            '<dl class="wi-props">' +
+                prop('Interface', inf.Name, { text: true }) +
+                prop('Model', inf.Description, { text: true }) +
+                prop('MAC address', inf.MAC) +
+                prop('MTU', inf.MTU || null) +
+                prop('Radio (software)', radioText(inf.SoftwareRadio), { text: true }) +
+                prop('Radio (hardware switch)', radioText(inf.HardwareRadio), { text: true }) +
+                prop('IPv4 address', inf.IPv4) +
+                prop('Default gateway', inf.Gateways) +
+                prop('IPv6 address', inf.IPv6, { full: true }) +
+                prop('DNS servers', inf.DNS, { full: true }) +
+                (inf.GUID ? prop('Interface GUID', inf.GUID, { full: true }) : '') +
+            '</dl></div>';
+
+        return hero + connection + apCard + adapter;
+    }
+
+    function render(data) {
+        var list = data.Interfaces || [];
+        if (!list.length) {
+            content.innerHTML = '<div class="wi-card"><div class="wi-state">' + glyphSVG(0, true) +
+                '<div><b>No wireless interface found</b></div><div>This host has no WiFi adapter, or the wireless service is not running.</div></div></div>';
+            return;
         }
-    </style>
-</head>
-<body>
-    <br><br>
-    <div class="ui container gradientbg" id="sys-setting-page">
-        <div class="ui stackable grid">
-            <div class="six wide column statusIcon" data-aos="fade-right"  align="center">
-                <img id="wifiicon" class="ui small image"  src="../network/img/wifi-on.svg">
-                <div id="status">Loading...</div>
-            </div>
-            <div class="ten wide column">
-                <div class="ui container" style="padding: 4em;" data-aos="fade-left">
-                    <div class="ui segment">
-                        <h4 class="ui header">
-                            <i class="signal icon"></i>
-                            <div class="content">
-                                <span id="SSID"><i class="loading spinner icon"></i> Loading...</span>
-                                <div class="sub header" >WiFi SSID</div>
-                            </div>
-                        </h4>
-                        <h4 class="ui header">
-                            <i class="globe icon"></i>
-                            <div class="content">
-                                <span id="Interface">Loading...</span>
-                                <div class="sub header">Interface</div>
-                            </div>
-                        </h4>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-    <div class="ui container" style="margin-top: 2em;">
-        <div class="ui divider"></div>
-        <small>WiFi information is supplied by wpa-supplicant or nmcli on Linux and Wireless AutoConfig Service (wlansvc) on Windows</small>
-    </div>
-    <script>
-        AOS.init();
-
-        initWifiInfo();
-        function initWifiInfo(){
-            $.ajax({
-                url: "../../system/network/wifiinfo",
-                method:"GET",
-                success: function(data){
-                    if (data.error !== undefined){
-                        $("#SSID").text("N/A");
-                        $("#Interface").text("loopback");
-                        $("#status").text("Access Error");
-                        $("#wifiicon").attr('src', "../network/img/wifi-off.svg");
-                        console.log(data.error);
-                    }else if (data.ESSID == "" && data.Interface != ""){
-                        //Have WiFi interface but not connected to any hostspot
-                        $("#SSID").text("N/A");
-                        $("#Interface").text(data.Interface);
-                        $("#status").text("Standby");
-                        $("#wifiicon").attr('src', "../network/img/wifi-on.svg");
-                    }else if (data.Interface == ""){
-                        //No WiFi Interface
-                        $("#SSID").text("N/A");
-                        $("#Interface").text("No WiFi Interface");
-                        $("#status").html(`<i class="ui remove icon"></i> Missing WiFi Interface`);
-                        $("#wifiicon").attr('src', "../network/img/wifi-noiface.svg");
-                        $("#status").addClass("error");
-                    }else{
-                        //Connection Established
-                        $("#SSID").text(data.ESSID);
-                        $("#Interface").text(data.Interface);
-                        $("#status").html(`<i class="ui checkmark icon"></i> Connected`);
-                        $("#wifiicon").attr('src', "../network/img/wifi-on.svg");
-                        $("#status").addClass("connected");
-                    }
-                }
-            });
+        if (!selected || !list.some(function (i) { return i.Name === selected; })) {
+            var connected = list.filter(function (i) { return i.Connected; })[0];
+            selected = (connected || list[0]).Name;
         }
-    </script>
-</body>
+        var tabs = '';
+        if (list.length > 1) {
+            tabs = '<div class="wi-tabs">' + list.map(function (i) {
+                return '<button class="wi-tab' + (i.Name === selected ? ' active' : '') + '" data-name="' + esc(i.Name) + '">' +
+                    '<i class="wifi icon"></i>' + esc(i.Name) + '</button>';
+            }).join('') + '</div>';
+        }
+        var inf = list.filter(function (i) { return i.Name === selected; })[0];
+        content.innerHTML = tabs + renderInterface(inf);
+    }
 
-</html>
+    function showError(msg) {
+        content.innerHTML = '<div class="wi-card"><div class="wi-state">' + glyphSVG(0, true) +
+            '<div><b>Unable to read WiFi information</b></div><div>' + esc(msg) + '</div></div></div>';
+    }
+
+    /* ---------- data ---------- */
+    function alive() { return document.body.contains(root); }
+
+    function load() {
+        if (!alive()) { clearTimeout(timer); return; }
+        fetch('../../system/network/wifiinfo', { credentials: 'same-origin', cache: 'no-store' })
+            .then(function (r) { return r.json(); })
+            .then(function (data) {
+                if (!alive()) return;
+                if (data.error !== undefined) { showError(data.error); return; }
+                (data.Interfaces || []).forEach(function (i) {
+                    var h = history[i.Name] = history[i.Name] || [];
+                    if (i.Connected && i.RSSI) h.push(i.RSSI);
+                    else if (!i.Connected) h.length = 0;
+                    while (h.length > HISTORY) h.shift();
+                });
+                lastData = data;
+                render(data);
+            })
+            .catch(function (e) { if (alive()) showError(e && e.message ? e.message : 'Request failed'); })
+            .then(schedule);
+    }
+
+    function schedule() {
+        clearTimeout(timer);
+        if (alive() && liveBox.checked) timer = setTimeout(load, REFRESH_MS);
+    }
+
+    root.addEventListener('click', function (ev) {
+        var tab = ev.target.closest('.wi-tab');
+        if (!tab) return;
+        selected = tab.getAttribute('data-name');
+        if (lastData) render(lastData);
+    });
+    liveBox.addEventListener('change', function () { if (liveBox.checked) load(); else clearTimeout(timer); });
+    document.addEventListener('visibilitychange', function () {
+        if (!alive()) return;
+        if (document.hidden) clearTimeout(timer); else if (liveBox.checked) load();
+    });
+
+    load();
+}());
+</script>
+</body>
+</html>

+ 63 - 11
src/wifi.go

@@ -3,6 +3,7 @@ package main
 import (
 	"encoding/json"
 	"net/http"
+	"runtime"
 	"strings"
 
 	wifi "imuslab.com/arozos/mod/network/wifi"
@@ -96,16 +97,18 @@ func network_wifi_handleWiFiPower(w http.ResponseWriter, r *http.Request) {
 		}
 
 		type WlanInterfaceStatus struct {
-			Name    string
-			Running bool
+			Name         string
+			Running      bool
+			Controllable bool //SetInterfacePower is supported on this platform
 		}
 
 		results := []WlanInterfaceStatus{}
 		for _, inf := range infs {
 			status, _ := wifiManager.GetInterfacePowerStatuts(strings.TrimSpace(inf))
 			results = append(results, WlanInterfaceStatus{
-				Name:    inf,
-				Running: status,
+				Name:         inf,
+				Running:      status,
+				Controllable: wifiManager.CanControlInterfacePower(),
 			})
 		}
 
@@ -167,8 +170,24 @@ func network_wifi_handleScan(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	//Get the first ethernet interface and use it to scan nearby wifi
-	scannedWiFiInfo, err := wifiManager.ScanNearbyWiFi(wirelessInterfaces[0])
+	//Scan with the requested interface, or the first one if none is given
+	scanInterface := wirelessInterfaces[0]
+	requestedInterface, _ := utils.GetPara(r, "interface")
+	if requestedInterface != "" {
+		found := false
+		for _, inf := range wirelessInterfaces {
+			if strings.TrimSpace(inf) == requestedInterface {
+				scanInterface = inf
+				found = true
+				break
+			}
+		}
+		if !found {
+			utils.SendErrorResponse(w, "Wireless Interface Not Found")
+			return
+		}
+	}
+	scannedWiFiInfo, err := wifiManager.ScanNearbyWiFi(strings.TrimSpace(scanInterface))
 	if err != nil {
 		utils.SendErrorResponse(w, err.Error())
 		return
@@ -242,6 +261,7 @@ func network_wifi_handleWiFiRemove(w http.ResponseWriter, r *http.Request) {
 	err = wifiManager.RemoveWifi(ESSID)
 	if err != nil {
 		utils.SendErrorResponse(w, err.Error())
+		return
 	}
 	utils.SendOK(w)
 }
@@ -254,15 +274,47 @@ func network_wifi_handleWiFiInfo(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	ESSID, interfaceName, err := wifiManager.GetConnectedWiFi()
+	//Detailed state of every wireless interface
+	interfaces, err := wifiManager.GetInterfaceStatus()
 	if err != nil {
-		utils.SendErrorResponse(w, "Failed to retrieve WiFi Information")
+		utils.SendErrorResponse(w, "Failed to retrieve WiFi Information: "+err.Error())
 		return
 	}
 
-	jsonString, _ := json.Marshal(map[string]string{
-		"ESSID":     ESSID,
-		"Interface": interfaceName,
+	//ESSID and Interface are kept for older clients: the first connected
+	//interface, or the first interface when none is connected
+	ESSID, interfaceName := "", ""
+	for _, inf := range interfaces {
+		if inf.Connected {
+			ESSID, interfaceName = inf.SSID, inf.Name
+			break
+		}
+	}
+	if interfaceName == "" && len(interfaces) > 0 {
+		interfaceName = interfaces[0].Name
+	} else if interfaceName == "" {
+		//Platforms without a status implementation
+		ESSID, interfaceName, err = wifiManager.GetConnectedWiFi()
+		if err != nil {
+			utils.SendErrorResponse(w, "Failed to retrieve WiFi Information")
+			return
+		}
+	}
+
+	jsonString, _ := json.Marshal(struct {
+		ESSID           string
+		Interface       string
+		Interfaces      []wifi.WiFiInterfaceStatus
+		Platform        string
+		CanConnect      bool //Connecting to and forgetting networks is supported
+		CanControlPower bool //The radio power toggle is supported
+	}{
+		ESSID:           ESSID,
+		Interface:       interfaceName,
+		Interfaces:      interfaces,
+		Platform:        runtime.GOOS,
+		CanConnect:      wifiManager.CanConnect(),
+		CanControlPower: sudo_mode && wifiManager.CanControlInterfacePower(),
 	})
 	utils.SendJSONResponse(w, string(jsonString))
 }