diff --git a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg index e23da3d7f..255e094e0 100644 --- a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg @@ -17,7 +17,25 @@ "state": { "admin-state": "unlocked" } + }, + { + "name": "radio0", + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": { + "country-code": "DE", + "band": "2.4GHz", + "channel": "auto" + } + }, + { + "name": "radio1", + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": { + "country-code": "DE", + "band": "5GHz", + "channel": "auto" } + } ] }, "ietf-interfaces:interfaces": { @@ -150,12 +168,36 @@ } }, { - "name": "wifi0", - "type": "infix-if-type:wifi" + "name": "wifi0-ap", + "type": "infix-if-type:wifi", + "infix-interfaces:wifi": { + "radio": "radio0", + "access-point": { + "ssid": "Infix", + "security": { + "secret": "wifi" + } + } + }, + "infix-interfaces:bridge-port": { + "bridge": "br0" + } }, { - "name": "wifi1", - "type": "infix-if-type:wifi" + "name": "wifi1-ap", + "type": "infix-if-type:wifi", + "infix-interfaces:wifi": { + "radio": "radio1", + "access-point": { + "ssid": "Infix5Ghz", + "security": { + "secret": "wifi" + } + } + }, + "infix-interfaces:bridge-port": { + "bridge": "br0" + } } ] }, @@ -171,6 +213,15 @@ "certificates": {} } ] + }, + "symmetric-keys": { + "symmetric-key": [ + { + "name": "wifi", + "infix-keystore:symmetric-key": "infixinfix", + "infix-keystore:key-format": "infix-crypto-types:wifi-preshared-key-format" + } + ] } }, "ietf-netconf-acm:nacm": { diff --git a/board/aarch64/raspberrypi-rpi64/README.md b/board/aarch64/raspberrypi-rpi64/README.md index 14be9f130..5c9dff8cb 100644 --- a/board/aarch64/raspberrypi-rpi64/README.md +++ b/board/aarch64/raspberrypi-rpi64/README.md @@ -108,7 +108,7 @@ To configure WiFi as a client, first store your WiFi password in the keystore: admin@infix:/> configure admin@infix:/config/> edit keystore symmetric-key mywifi admin@infix:/config/keystore/…/mywifi/> set key-format wifi-preshared-key-format -admin@infix:/config/keystore/…/mywifi/> set cleartext-symmetric-key YourWiFiPassword +admin@infix:/config/keystore/…/mywifi/> set symmetric-key YourWiFiPassword admin@infix:/config/keystore/…/mywifi/> leave ``` diff --git a/board/common/rootfs/etc/finit.d/available/hostapd@.conf b/board/common/rootfs/etc/finit.d/available/hostapd@.conf new file mode 100644 index 000000000..4b3b655a3 --- /dev/null +++ b/board/common/rootfs/etc/finit.d/available/hostapd@.conf @@ -0,0 +1,4 @@ +service name:hostapd :%i \ + [2345] hostapd -P/var/run/hostapd-%i.pid /etc/hostapd-%i.conf \ + -- Wi-Fi Access Point @%i + diff --git a/board/common/rootfs/etc/finit.d/available/wifi@.conf b/board/common/rootfs/etc/finit.d/available/wifi@.conf index eb0321df6..e530b39c8 100644 --- a/board/common/rootfs/etc/finit.d/available/wifi@.conf +++ b/board/common/rootfs/etc/finit.d/available/wifi@.conf @@ -1,5 +1,5 @@ service name:wpa_supplicant :%i \ [2345] wpa_supplicant -s -i %i -c /etc/wpa_supplicant-%i.conf -P/var/run/wpa_supplicant-%i.pid \ - -- WPA supplicant @%i + -- Wi-Fi Station @%i -task name:wifi-scanner :%i [2345] /usr/libexec/infix/wifi-scanner %i -- Start scanning for SSID @ %i +task name:wifi-scanner :%i [2345] /usr/libexec/infix/wifi-scanner %i -- Start scanning for SSID @%i diff --git a/board/common/rootfs/etc/udev/rules.d/60-rename-wifi-phy.rules b/board/common/rootfs/etc/udev/rules.d/60-rename-wifi-phy.rules new file mode 100644 index 000000000..da561c0ee --- /dev/null +++ b/board/common/rootfs/etc/udev/rules.d/60-rename-wifi-phy.rules @@ -0,0 +1,4 @@ +# Rename WiFi PHY devices from phy0 to wifi-phy0 to avoid name clashes +# This must run before wlan interface cleanup (70-rename-wifi.rules) +SUBSYSTEM=="ieee80211", ACTION=="add", KERNEL=="phy*", \ + RUN+="/bin/sh -c '/usr/sbin/iw phy %k set name radio%n'" diff --git a/board/common/rootfs/etc/udev/rules.d/70-remove-virtual-wifi-interfaces.rules b/board/common/rootfs/etc/udev/rules.d/70-remove-virtual-wifi-interfaces.rules new file mode 100644 index 000000000..572fa5cc5 --- /dev/null +++ b/board/common/rootfs/etc/udev/rules.d/70-remove-virtual-wifi-interfaces.rules @@ -0,0 +1,7 @@ +# Remove kernel-created WiFi interfaces +# All WiFi interfaces are now virtual interfaces created by confd +SUBSYSTEM=="net", ACTION=="add", KERNEL=="wlan*", \ + TEST=="/sys/class/net/$name/phy80211/name", \ + PROGRAM="/bin/cat /sys/class/net/%k/phy80211/name", \ + TEST!="/run/wifi-cleaned-%c", \ + RUN+="/bin/sh -c '/usr/sbin/iw dev %k del && touch /run/wifi-cleaned-%c'" diff --git a/board/common/rootfs/etc/udev/rules.d/70-rename-wifi.rules b/board/common/rootfs/etc/udev/rules.d/70-rename-wifi.rules deleted file mode 100644 index 4251fadf8..000000000 --- a/board/common/rootfs/etc/udev/rules.d/70-rename-wifi.rules +++ /dev/null @@ -1 +0,0 @@ -SUBSYSTEM=="net", ACTION=="add", TEST=="/sys/class/net/$name/wireless", NAME="wifi%n" diff --git a/board/common/rootfs/usr/libexec/infix/iw.py b/board/common/rootfs/usr/libexec/infix/iw.py new file mode 100755 index 000000000..47e7db128 --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/iw.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +""" +iw command wrapper that returns structured JSON data + +Usage: + iw.py list - List all PHY devices + iw.py dev - List all interfaces grouped by PHY + iw.py info - Get PHY or interface information + iw.py survey - Get channel survey data +""" + +import sys +import json +import subprocess +import re + + +def run_iw(*args): + """Run iw command and return output""" + try: + result = subprocess.run( + ['iw'] + list(args), + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + return result.stdout + return None + except Exception: + return None + + +def normalize_phy_name(name): + """ + Convert radioN to phyN or vice versa based on what exists in sysfs. + Returns the actual phy name that exists. + """ + import os + + # Try the name as-is first + if os.path.exists(f'/sys/class/ieee80211/{name}'): + return name + + # Try converting radioN <-> phyN + if name.startswith('radio'): + phy_name = 'phy' + name[5:] + if os.path.exists(f'/sys/class/ieee80211/{phy_name}'): + return phy_name + elif name.startswith('phy'): + radio_name = 'radio' + name[3:] + if os.path.exists(f'/sys/class/ieee80211/{radio_name}'): + return radio_name + + # Return original if nothing found + return name + + +def parse_phy_info(phy_name): + """ + Parse 'iw phy info' output or 'iw info' output + Returns: {bands, driver, manufacturer, max_txpower, num_virtual_interfaces, interface_combinations} + """ + # Normalize the phy name + actual_phy = normalize_phy_name(phy_name) + + # Try 'iw phy info' first + output = run_iw('phy', actual_phy, 'info') + + # If that fails, try 'iw info' (some systems support this) + if not output: + output = run_iw(actual_phy, 'info') + + if not output: + return {} + + result = { + 'name': phy_name, + 'bands': [], + 'driver': None, + 'manufacturer': None, + 'max_txpower': None, + 'num_virtual_interfaces': 0, + 'interface_combinations': [] + } + + current_band = None + band_num = 0 + in_combinations = False + max_power = None + + for line in output.splitlines(): + stripped = line.strip() + + # Detect band sections + if stripped.startswith('Band '): + if current_band and current_band.get('frequencies'): + result['bands'].append(current_band) + band_num += 1 + current_band = { + 'band': band_num, + 'frequencies': [], + 'name': None, + 'ht_capable': False, + 'vht_capable': False, + 'he_capable': False + } + in_combinations = False + + # Parse frequencies (handle both "2412 MHz" and "2412.0 MHz" formats) + elif current_band and not in_combinations: + freq_match = re.match(r'\* ([0-9.]+) MHz.*?\(([0-9.]+) dBm\)', stripped) + if freq_match: + freq = int(float(freq_match.group(1))) # Convert "2412.0" to 2412 + power = float(freq_match.group(2)) + + current_band['frequencies'].append(freq) + + # Track max power + if max_power is None or power > max_power: + max_power = power + + # Check capabilities + if 'HT ' in stripped or 'High Throughput' in stripped: + current_band['ht_capable'] = True + if 'VHT' in stripped or 'Very High Throughput' in stripped: + current_band['vht_capable'] = True + if 'HE ' in stripped or 'High Efficiency' in stripped: + current_band['he_capable'] = True + + # Detect interface combinations section + if 'valid interface combinations:' in stripped.lower(): + in_combinations = True + continue + + # Parse interface combinations + if in_combinations: + if stripped.startswith('*'): + # Parse combination line + comb_info = {'limits': []} + + # Parse limits: #{ type } <= max + limit_matches = re.findall(r'#\{\s*([^}]+)\s*\}\s*<=\s*(\d+)', stripped) + for types_str, max_val in limit_matches: + types = [t.strip() for t in types_str.split(',')] + comb_info['limits'].append({ + 'max': int(max_val), + 'types': types + }) + + # Parse total + total_match = re.search(r'total\s*<=\s*(\d+)', stripped) + if total_match: + comb_info['max_total'] = int(total_match.group(1)) + + # Parse channels + channels_match = re.search(r'#channels\s*<=\s*(\d+)', stripped) + if channels_match: + comb_info['num_channels'] = int(channels_match.group(1)) + + if comb_info.get('limits') or comb_info.get('max_total'): + result['interface_combinations'].append(comb_info) + + elif not stripped.startswith('#') and ':' in stripped and not stripped.startswith('*'): + # End of combinations section + in_combinations = False + + # Add last band + if current_band and current_band.get('frequencies'): + result['bands'].append(current_band) + + # Determine band names and assign band numbers + for band in result['bands']: + if band['frequencies']: + freq = band['frequencies'][0] + if 2400 <= freq <= 2500: + band['name'] = '2.4GHz' + band['band'] = 1 + elif 5150 <= freq <= 5900: + band['name'] = '5GHz' + band['band'] = 2 + elif 5955 <= freq <= 7115: + band['name'] = '6GHz' + band['band'] = 3 + + # Set max TX power + if max_power is not None: + result['max_txpower'] = int(max_power) + + # Get driver and manufacturer from sysfs + try: + driver_link = subprocess.run( + ['readlink', '-f', f'/sys/class/ieee80211/{actual_phy}/device/driver'], + capture_output=True, text=True, timeout=1 + ).stdout.strip() + + if driver_link: + driver_name = driver_link.split('/')[-1] + result['driver'] = driver_name + + # Map driver to manufacturer + driver_lower = driver_name.lower() + if 'mt' in driver_lower or 'mediatek' in driver_lower: + result['manufacturer'] = 'MediaTek Inc.' + elif 'rtw' in driver_lower or 'realtek' in driver_lower: + result['manufacturer'] = 'Realtek Semiconductor Corp.' + elif 'ath' in driver_lower or 'qca' in driver_lower: + result['manufacturer'] = 'Qualcomm Atheros' + elif 'iwl' in driver_lower or 'intel' in driver_lower: + result['manufacturer'] = 'Intel Corporation' + elif 'brcm' in driver_lower or 'broadcom' in driver_lower: + result['manufacturer'] = 'Broadcom Inc.' + except Exception: + pass + + # Count virtual interfaces + dev_output = run_iw('dev') + if dev_output: + # Extract phy number from actual phy name + phy_num = None + if actual_phy.startswith('radio'): + phy_num = actual_phy[5:] + elif actual_phy.startswith('phy'): + phy_num = actual_phy[3:] + + if phy_num: + count = 0 + current_phy = None + for line in dev_output.splitlines(): + if line.startswith('phy#'): + current_phy = line.replace('phy#', '').strip() + elif current_phy == phy_num and 'Interface' in line: + count += 1 + result['num_virtual_interfaces'] = count + + return result + + +def parse_interface_info(ifname): + """ + Parse 'iw dev info' output + Returns: {ifname, iftype, mac, ssid, frequency, channel, txpower, channel_width} + """ + output = run_iw('dev', ifname, 'info') + if not output: + return {} + + result = {'ifname': ifname} + + for line in output.splitlines(): + stripped = line.strip() + + # Interface type + if stripped.startswith('type '): + result['iftype'] = stripped.split()[1] + + # MAC address + elif stripped.startswith('addr '): + result['mac'] = stripped.split()[1] + + # SSID + elif stripped.startswith('ssid '): + result['ssid'] = ' '.join(stripped.split()[1:]) + + # Channel/frequency + elif stripped.startswith('channel '): + parts = stripped.split() + if len(parts) >= 2: + result['channel'] = int(parts[1]) + if 'MHz' in stripped: + freq_match = re.search(r'\((\d+) MHz', stripped) + if freq_match: + result['frequency'] = int(freq_match.group(1)) + # Channel width + if 'width:' in stripped: + width_match = re.search(r'width:\s*(\d+)\s*MHz', stripped) + if width_match: + result['channel_width'] = f"{width_match.group(1)} MHz" + + # TX power + elif stripped.startswith('txpower '): + power_match = re.search(r'([0-9.]+) dBm', stripped) + if power_match: + result['txpower'] = float(power_match.group(1)) + + return result + + +def parse_survey(ifname): + """ + Parse 'iw dev survey dump' output + Returns: list of {frequency, in_use, noise, active_time, busy_time, receive_time, transmit_time} + """ + output = run_iw('dev', ifname, 'survey', 'dump') + if not output: + return [] + + channels = [] + current_channel = None + + for line in output.splitlines(): + stripped = line.strip() + + # New survey entry + if stripped.startswith('Survey data from'): + if current_channel: + channels.append(current_channel) + current_channel = None + + # Frequency + elif stripped.startswith('frequency:'): + parts = stripped.split() + if len(parts) >= 2: + freq = int(parts[1]) + in_use = '[in use]' in stripped + current_channel = { + 'frequency': freq, + 'in_use': in_use + } + + # Channel metrics + elif current_channel: + if stripped.startswith('noise:'): + noise_match = re.search(r'(-?\d+) dBm', stripped) + if noise_match: + current_channel['noise'] = int(noise_match.group(1)) + + elif stripped.startswith('channel active time:'): + time_match = re.search(r'(\d+) ms', stripped) + if time_match: + current_channel['active_time'] = int(time_match.group(1)) + + elif stripped.startswith('channel busy time:'): + time_match = re.search(r'(\d+) ms', stripped) + if time_match: + current_channel['busy_time'] = int(time_match.group(1)) + + elif stripped.startswith('channel receive time:'): + time_match = re.search(r'(\d+) ms', stripped) + if time_match: + current_channel['receive_time'] = int(time_match.group(1)) + + elif stripped.startswith('channel transmit time:'): + time_match = re.search(r'(\d+) ms', stripped) + if time_match: + current_channel['transmit_time'] = int(time_match.group(1)) + + # Add last channel + if current_channel: + channels.append(current_channel) + + return channels + + +def parse_list(): + """ + Parse 'iw list' output + Returns: list of PHY names + """ + output = run_iw('list') + if not output: + return [] + + phys = [] + for line in output.splitlines(): + match = re.match(r'Wiphy (phy\d+|radio\d+)', line) + if match: + phys.append(match.group(1)) + + return phys + + +def parse_dev(): + """ + Parse 'iw dev' output + Returns: dict mapping PHY numbers to list of interfaces + """ + output = run_iw('dev') + if not output: + return {} + + result = {} + current_phy = None + + for line in output.splitlines(): + # PHY line: "phy#0" or "phy#1" + if line.startswith('phy#'): + current_phy = line.replace('phy#', '').strip() + if current_phy not in result: + result[current_phy] = [] + # Interface line: " Interface wlan0" + elif current_phy and 'Interface' in line: + ifname = line.split('Interface')[1].strip() + result[current_phy].append(ifname) + + return result + + +def main(): + if len(sys.argv) < 2: + print(json.dumps({ + 'error': 'Usage: iw.py [device]', + 'commands': { + 'list': 'List all PHY devices', + 'dev': 'List all interfaces grouped by PHY', + 'info': 'Get PHY or interface information (requires device)', + 'survey': 'Get channel survey data (requires interface name)' + }, + 'examples': [ + 'iw.py list', + 'iw.py dev', + 'iw.py info radio0', + 'iw.py info phy4', + 'iw.py info wlan0', + 'iw.py survey wlan0' + ] + }, indent=2)) + sys.exit(1) + + command = sys.argv[1] + + try: + if command == 'list': + data = parse_list() + elif command == 'dev': + data = parse_dev() + elif command == 'info': + if len(sys.argv) < 3: + data = {'error': 'info command requires device argument'} + else: + device = sys.argv[2] + # Auto-detect if device is a PHY (phy*/radio*) or interface + if device.startswith('phy') or device.startswith('radio'): + data = parse_phy_info(device) + else: + data = parse_interface_info(device) + elif command == 'survey': + if len(sys.argv) < 3: + data = {'error': 'survey command requires device argument'} + else: + device = sys.argv[2] + data = parse_survey(device) + else: + data = {'error': f'Unknown command: {command}'} + + print(json.dumps(data, indent=2)) + + except Exception as e: + print(json.dumps({'error': str(e)})) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/board/common/rootfs/usr/libexec/infix/wifi-channel-map.py b/board/common/rootfs/usr/libexec/infix/wifi-channel-map.py new file mode 100755 index 000000000..706058517 --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/wifi-channel-map.py @@ -0,0 +1,724 @@ +#!/usr/bin/env python3 +""" +WiFi Channel Visualization Tool +Shows graphical representation of WiFi channel overlap and utilization +""" + +import sys +import json +import argparse + + +class Colors: + """ANSI color codes for terminal output""" + RESET = '\033[0m' + BOLD = '\033[1m' + RED = '\033[91m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + MAGENTA = '\033[95m' + CYAN = '\033[96m' + GRAY = '\033[90m' + BG_RED = '\033[101m' + BG_GREEN = '\033[102m' + BG_YELLOW = '\033[103m' + BG_BLUE = '\033[104m' + BG_GRAY = '\033[100m' + + +def freq_to_channel(freq): + """Convert frequency (MHz) to WiFi channel number""" + # 2.4 GHz band + if 2412 <= freq <= 2484: + if freq == 2484: + return 14 + return (freq - 2412) // 5 + 1 + # 5 GHz band + elif 5170 <= freq <= 5825: + return (freq - 5000) // 5 + # 6 GHz band + elif 5955 <= freq <= 7115: + return (freq - 5950) // 5 + return None + + +def get_channel_frequency(channel, band='2.4'): + """Get center frequency for a channel""" + if band == '2.4': + if channel == 14: + return 2484 + return 2412 + (channel - 1) * 5 + elif band == '5': + return 5000 + channel * 5 + return None + + +def get_busy_percentage(channel_data): + """Calculate channel busy percentage""" + active = channel_data.get('active-time', 0) + busy = channel_data.get('busy-time', 0) + if active > 0: + return (busy / active) * 100 + return 0 + + +def get_utilization_color(busy_pct): + """Get color based on channel utilization""" + if busy_pct >= 50: + return Colors.RED + elif busy_pct >= 25: + return Colors.YELLOW + elif busy_pct >= 10: + return Colors.CYAN + else: + return Colors.GREEN + + +def draw_channel_graph_2_4ghz(survey_data): + """Draw channel overlap graph for 2.4 GHz band""" + # Parse survey data + channels = {} + in_use_channel = None + + for ch_data in survey_data: + freq = ch_data.get('frequency') + ch_num = freq_to_channel(freq) + if ch_num and 1 <= ch_num <= 14: + busy_pct = get_busy_percentage(ch_data) + channels[ch_num] = { + 'freq': freq, + 'noise': ch_data.get('noise', -100), + 'busy': busy_pct, + 'in_use': ch_data.get('in-use', False), + 'active_time': ch_data.get('active-time', 0), + 'busy_time': ch_data.get('busy-time', 0) + } + if ch_data.get('in-use'): + in_use_channel = ch_num + + if not channels: + print("No 2.4 GHz channel data available") + return + + print(f"\n{Colors.BOLD}2.4 GHz WiFi Channel Overlap Visualization{Colors.RESET}") + print("=" * 80) + print(f"Channel width: 20 MHz | Channel spacing: 5 MHz") + print(f"Non-overlapping channels: 1, 6, 11 (shown in {Colors.GREEN}green{Colors.RESET})") + print() + + # Draw frequency scale + print("Frequency (MHz):") + print("2400 2420 2440 2460 2480") + print("|-----------|-----------|-----------|-----------|") + + # Draw each channel as a bar showing its 20 MHz width + # Each channel occupies ~4 adjacent channels worth of space + for ch in range(1, 14): + if ch not in channels: + continue + + data = channels[ch] + busy_pct = data['busy'] + is_in_use = data['in_use'] + noise = data['noise'] + + # Determine color based on status + if is_in_use: + color = Colors.BG_BLUE + marker = '█' + elif busy_pct >= 50: + color = Colors.RED + marker = '▓' + elif busy_pct >= 25: + color = Colors.YELLOW + marker = '▒' + elif busy_pct > 0: + color = Colors.CYAN + marker = '░' + else: + color = Colors.GRAY + marker = '·' + + # Non-overlapping channels get green color + if ch in [1, 6, 11] and not is_in_use and busy_pct < 10: + color = Colors.GREEN + + # Calculate position (each channel is offset by 5 MHz = 1 position) + # Channel 1 is at 2412 MHz, base is 2400 + offset = ((data['freq'] - 2400) // 5) + + # Draw channel bar (20 MHz = 4 positions wide) + line = ' ' * 80 + line_arr = list(line) + + # Mark the channel span (20 MHz width) + for i in range(4): + pos = offset + i - 2 # Center the 20 MHz around channel + if 0 <= pos < len(line_arr): + line_arr[pos] = marker + + # Add channel label + label_pos = offset + if 0 <= label_pos < len(line_arr) - 5: + # Clear space for label + for i in range(5): + if label_pos + i < len(line_arr): + line_arr[label_pos + i] = ' ' + + line = ''.join(line_arr) + + # Status indicators + status = "" + if is_in_use: + status = f" {Colors.BOLD}[IN USE]{Colors.RESET}" + + busy_color = get_utilization_color(busy_pct) + + print(f"{color}Ch{ch:2d}{Colors.RESET} {color}{line}{Colors.RESET} " + f"{busy_color}{busy_pct:5.1f}%{Colors.RESET} " + f"{noise:4d}dBm{status}") + + print("\n" + "=" * 80) + print(f"\n{Colors.BOLD}Legend:{Colors.RESET}") + print(f" {Colors.BG_BLUE}██{Colors.RESET} In use (your network)") + print(f" {Colors.RED}▓▓{Colors.RESET} High usage (>50%)") + print(f" {Colors.YELLOW}▒▒{Colors.RESET} Medium usage (25-50%)") + print(f" {Colors.CYAN}░░{Colors.RESET} Low usage (1-25%)") + print(f" {Colors.GRAY}··{Colors.RESET} Idle (<1%)") + print() + + +def draw_channel_list(survey_data): + """Draw a simple channel list with utilization bars""" + print(f"\n{Colors.BOLD}Channel Utilization{Colors.RESET}") + print("=" * 80) + print(f"{'Ch':<4} {'Freq':<6} {'Noise':<8} {'Busy%':<8} {'Utilization Bar':<40}") + print("-" * 80) + + for ch_data in sorted(survey_data, key=lambda x: x.get('frequency', 0)): + freq = ch_data.get('frequency') + ch_num = freq_to_channel(freq) + if not ch_num: + continue + + noise = ch_data.get('noise', -100) + busy_pct = get_busy_percentage(ch_data) + is_in_use = ch_data.get('in-use', False) + + # Create utilization bar (40 chars wide = 100%) + bar_length = int(busy_pct * 40 / 100) + bar_color = get_utilization_color(busy_pct) + + if is_in_use: + bar = f"{Colors.BG_BLUE}{'█' * bar_length}{Colors.RESET}" + marker = f" {Colors.BOLD}◀ IN USE{Colors.RESET}" + else: + bar = f"{bar_color}{'█' * bar_length}{Colors.RESET}" + marker = "" + + empty = '░' * (40 - bar_length) + + print(f"{ch_num:<4} {freq:<6} {noise:<8} {busy_pct:5.1f}% {bar}{Colors.GRAY}{empty}{Colors.RESET}{marker}") + + print() + + +def draw_overlap_pie(survey_data): + """Draw a pie-style visualization of channel group utilization""" + # Parse channel data into 3 non-overlapping groups + # Group 1: channels 1-5 (centered on ch 1) + # Group 2: channels 4-8 (centered on ch 6) + # Group 3: channels 9-13 (centered on ch 11) + + channels = {} + in_use_channel = None + + for ch_data in survey_data: + freq = ch_data.get('frequency') + ch_num = freq_to_channel(freq) + if ch_num and 1 <= ch_num <= 13: + busy_pct = get_busy_percentage(ch_data) + channels[ch_num] = { + 'busy': busy_pct, + 'noise': ch_data.get('noise', -100), + 'in_use': ch_data.get('in-use', False) + } + if ch_data.get('in-use'): + in_use_channel = ch_num + + if not channels: + return + + # Calculate group utilization (average of channels in each group) + groups = [ + {'name': 'Ch 1', 'channels': [1, 2, 3, 4, 5], 'center': 1}, + {'name': 'Ch 6', 'channels': [4, 5, 6, 7, 8], 'center': 6}, + {'name': 'Ch 11', 'channels': [9, 10, 11, 12, 13], 'center': 11}, + ] + + for group in groups: + busy_values = [channels.get(ch, {}).get('busy', 0) for ch in group['channels'] if ch in channels] + group['avg_busy'] = sum(busy_values) / len(busy_values) if busy_values else 0 + group['center_busy'] = channels.get(group['center'], {}).get('busy', 0) + group['in_use'] = in_use_channel in group['channels'] if in_use_channel else False + + total_busy = sum(g['avg_busy'] for g in groups) + + print(f"\n{Colors.BOLD}Channel Group Utilization (2.4 GHz){Colors.RESET}") + print("=" * 60) + print("Non-overlapping channel groups with their overlap zones:\n") + + # Draw ASCII donut/pie + pie_width = 50 + + # Calculate proportions + if total_busy > 0: + for group in groups: + group['proportion'] = group['avg_busy'] / total_busy + group['width'] = max(1, int(group['proportion'] * pie_width)) + else: + for group in groups: + group['proportion'] = 1/3 + group['width'] = pie_width // 3 + + # Adjust to exactly fill pie_width + total_width = sum(g['width'] for g in groups) + if total_width < pie_width: + groups[0]['width'] += pie_width - total_width + + # Draw the pie bar + pie_chars = ['█', '▓', '░'] + colors = [Colors.GREEN, Colors.YELLOW, Colors.CYAN] + + pie_line = "" + for i, group in enumerate(groups): + if group['in_use']: + color = Colors.BG_BLUE + elif group['avg_busy'] >= 50: + color = Colors.RED + elif group['avg_busy'] >= 25: + color = Colors.YELLOW + else: + color = Colors.GREEN + + char = pie_chars[i % len(pie_chars)] + pie_line += f"{color}{char * group['width']}{Colors.RESET}" + + # Draw centered pie + print(f" ┌{'─' * pie_width}┐") + print(f" │{pie_line}│") + print(f" └{'─' * pie_width}┘") + + # Legend with percentages + print() + for i, group in enumerate(groups): + char = pie_chars[i % len(pie_chars)] + + if group['in_use']: + color = Colors.BG_BLUE + marker = " ◀ IN USE" + elif group['avg_busy'] >= 50: + color = Colors.RED + marker = "" + elif group['avg_busy'] >= 25: + color = Colors.YELLOW + marker = "" + else: + color = Colors.GREEN + marker = "" + + pct_of_total = group['proportion'] * 100 + print(f" {color}{char * 3}{Colors.RESET} {group['name']:>5}: " + f"{group['center_busy']:5.1f}% busy (center), " + f"{group['avg_busy']:5.1f}% avg in overlap zone, " + f"{pct_of_total:4.1f}% of total{marker}") + + # Draw overlap diagram + print(f"\n{Colors.BOLD}Channel Overlap Diagram:{Colors.RESET}") + print(" Ch: 1 2 3 4 5 6 7 8 9 10 11 12 13") + print(" ╔═══════════════════╗") + print(" G1: ║ 1 ─ 2 ─ 3 ─ 4 ─ 5 ║ (centered on ch 1)") + print(" ╚═══════╦═══════════╝") + print(" ╔═══════════════════╗") + print(" G2: ║ 4 ─ 5 ─ 6 ─ 7 ─ 8 ║ (centered on ch 6)") + print(" ╚═══════════╦═══════╝") + print(" ╔═══════════════════════╗") + print(" G3: ║ 9 ─10 ─11 ─12 ─13 ║ (centered on ch 11)") + print(" ╚═══════════════════════╝") + print() + + +def generate_svg(survey_data, output_file=None): + """Generate SVG image(s) showing channel overlap and utilization for both bands""" + # Separate channels by band + channels_2_4 = {} + channels_5 = {} + in_use_2_4 = None + in_use_5 = None + + for ch_data in survey_data: + freq = ch_data.get('frequency') + ch_num = freq_to_channel(freq) + if not ch_num: + continue + + busy_pct = get_busy_percentage(ch_data) + ch_info = { + 'freq': freq, + 'busy': busy_pct, + 'noise': ch_data.get('noise', -100), + 'in_use': ch_data.get('in-use', False) + } + + if 2400 <= freq <= 2500: + channels_2_4[ch_num] = ch_info + if ch_info['in_use']: + in_use_2_4 = ch_num + elif 5100 <= freq <= 5900: + channels_5[ch_num] = ch_info + if ch_info['in_use']: + in_use_5 = ch_num + + def busy_to_color(busy_pct, is_in_use=False): + if is_in_use: + return "#3b82f6" # Blue + elif busy_pct >= 50: + return "#ef4444" # Red + elif busy_pct >= 25: + return "#f59e0b" # Yellow/Orange + elif busy_pct >= 10: + return "#06b6d4" # Cyan + else: + return "#22c55e" # Green + + def generate_band_svg(channels, band, freq_min, freq_max, title, non_overlap_channels=None): + if not channels: + return None + + width = 900 + height = 400 + margin_left = 60 + margin_right = 40 + margin_top = 60 + margin_bottom = 80 + chart_width = width - margin_left - margin_right + chart_height = height - margin_top - margin_bottom + + def freq_to_x(freq): + return margin_left + (freq - freq_min) / (freq_max - freq_min) * chart_width + + def busy_to_height(busy_pct): + return (busy_pct / 100) * chart_height + + svg_parts = [] + + # SVG header + svg_parts.append(f''' + + + + + + + + + {title} + + + +''') + + # Draw grid lines + for pct in [25, 50, 75, 100]: + y = margin_top + chart_height - busy_to_height(pct) + svg_parts.append(f' ') + svg_parts.append(f' {pct}%') + + # Y-axis label + svg_parts.append(f' Channel Busy %') + + # Draw channels as bars + for ch_num, data in sorted(channels.items()): + center_freq = data['freq'] + busy_pct = data['busy'] + is_in_use = data['in_use'] + + # 20 MHz width: ±10 MHz from center + x1 = freq_to_x(center_freq - 10) + x2 = freq_to_x(center_freq + 10) + bar_width = x2 - x1 + bar_height = busy_to_height(busy_pct) + bar_y = margin_top + chart_height - bar_height + + color = busy_to_color(busy_pct, is_in_use) + opacity = 0.6 if not is_in_use else 0.8 + + # Draw the channel bar + svg_parts.append(f' ') + + # Channel label at bottom + label_x = freq_to_x(center_freq) + svg_parts.append(f' {ch_num}') + + # Frequency label (only for some channels to avoid clutter) + if band == '2.4' or ch_num in [36, 52, 100, 149, 165]: + svg_parts.append(f' {center_freq}') + + # Busy percentage on top of bar (if tall enough) + if bar_height > 20: + svg_parts.append(f' {busy_pct:.0f}%') + + # X-axis labels + svg_parts.append(f' Channel (Center Frequency MHz)') + + # Legend + legend_y = margin_top + 10 + legend_x = margin_left + chart_width - 150 + legend_items = [ + ("#3b82f6", "In Use"), + ("#ef4444", "High (>50%)"), + ("#f59e0b", "Medium (25-50%)"), + ("#06b6d4", "Low (10-25%)"), + ("#22c55e", "Idle (<10%)"), + ] + + svg_parts.append(f' ') + + for i, (color, label) in enumerate(legend_items): + y = legend_y + 10 + i * 18 + svg_parts.append(f' ') + svg_parts.append(f' {label}') + + # Non-overlapping channels note (for 2.4 GHz) + if non_overlap_channels: + svg_parts.append(f' Non-overlapping: Ch {", ".join(map(str, non_overlap_channels))}') + for ch in non_overlap_channels: + if ch in channels: + center_freq = channels[ch]['freq'] + x = freq_to_x(center_freq) + svg_parts.append(f' ') + svg_parts.append(f' {ch}') + else: + svg_parts.append(f' All channels non-overlapping (20 MHz spacing)') + + svg_parts.append('') + return '\n'.join(svg_parts) + + # Generate SVGs for each band + svg_2_4 = generate_band_svg(channels_2_4, '2.4', 2400, 2485, + "2.4 GHz WiFi Channel Overlap & Utilization", + [1, 6, 11]) + svg_5 = generate_band_svg(channels_5, '5', 5150, 5850, + "5 GHz WiFi Channel Utilization", + None) + + # Combine or output separately + if svg_2_4 and svg_5: + # Combine into one SVG with both bands stacked + combined = f''' + +{svg_2_4.replace('', '').replace('', '')} + + +{svg_5.replace('', '').replace('', '')} + +''' + svg_content = combined + elif svg_2_4: + svg_content = svg_2_4 + elif svg_5: + svg_content = svg_5 + else: + return None + + if output_file: + with open(output_file, 'w') as f: + f.write(svg_content) + print(f"SVG written to: {output_file}") + else: + print(svg_content) + + return svg_content + + +def draw_recommendations(survey_data, json_output=False): + """Analyze channels and provide recommendations""" + # Parse channel data + channels = {} + in_use_channel = None + + for ch_data in survey_data: + freq = ch_data.get('frequency') + ch_num = freq_to_channel(freq) + if ch_num: + busy_pct = get_busy_percentage(ch_data) + channels[ch_num] = { + 'busy': busy_pct, + 'noise': ch_data.get('noise', -100), + 'in_use': ch_data.get('in-use', False) + } + if ch_data.get('in-use'): + in_use_channel = ch_num + + # Find least congested non-overlapping channels + best_channels = [] + for ch in [1, 6, 11]: + if ch in channels: + best_channels.append((ch, channels[ch]['busy'])) + + best_channels.sort(key=lambda x: x[1]) + + # JSON output + if json_output: + output = { + "recommended_channels": [ + {"channel": ch, "busy_percent": round(busy, 1)} + for ch, busy in best_channels + ] + } + if in_use_channel: + output["current_channel"] = in_use_channel + output["current_busy_percent"] = round(channels.get(in_use_channel, {}).get('busy', 0), 1) + + print(json.dumps(output, indent=2)) + return + + # Text output + print(f"\n{Colors.BOLD}Channel Recommendations{Colors.RESET}") + print("=" * 80) + + if in_use_channel: + print(f"Current channel: {Colors.BOLD}{in_use_channel}{Colors.RESET}") + current_busy = channels.get(in_use_channel, {}).get('busy', 0) + if current_busy > 50: + print(f" {Colors.RED}⚠{Colors.RESET} High congestion detected ({current_busy:.1f}% busy)") + elif current_busy > 25: + print(f" {Colors.YELLOW}⚠{Colors.RESET} Moderate congestion ({current_busy:.1f}% busy)") + else: + print(f" {Colors.GREEN}✓{Colors.RESET} Good channel utilization ({current_busy:.1f}% busy)") + + print(f"\nRecommended non-overlapping channels (2.4 GHz):") + for i, (ch, busy) in enumerate(best_channels[:3], 1): + color = get_utilization_color(busy) + marker = "★" if i == 1 else " " + print(f" {marker} Channel {ch:2d}: {color}{busy:5.1f}% busy{Colors.RESET}") + + print() + + +def main(): + parser = argparse.ArgumentParser( + description='Visualize WiFi channel overlap and utilization', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + # Read from yanger output (show all sections) + yanger -x "ixll -A ssh host sudo" ietf-hardware | %(prog)s + + # Read from file + %(prog)s survey_data.json + + # Show only list view + %(prog)s --list survey_data.json + + # Show only specific sections + %(prog)s --overlap survey_data.json + %(prog)s --pie survey_data.json + %(prog)s --utilization survey_data.json + %(prog)s --recommendations survey_data.json + %(prog)s --overlap --pie survey_data.json + + # Output recommendations as JSON + %(prog)s --recommendations --json survey_data.json + + # Generate SVG image + %(prog)s --svg /tmp/wifi-channels.svg survey_data.json + %(prog)s --svg - survey_data.json > output.svg + ''' + ) + parser.add_argument('file', nargs='?', help='JSON file with hardware data (default: stdin)') + parser.add_argument('--list', action='store_true', help='Show simple list view instead of overlap graph') + parser.add_argument('--no-color', action='store_true', help='Disable colors') + parser.add_argument('--json', action='store_true', help='Output recommendations in JSON format') + parser.add_argument('--svg', metavar='FILE', help='Generate SVG image to FILE (use - for stdout)') + + # Section filters + parser.add_argument('--overlap', action='store_true', help='Show only channel overlap visualization (2.4 GHz)') + parser.add_argument('--pie', action='store_true', help='Show only channel group pie chart (2.4 GHz)') + parser.add_argument('--utilization', action='store_true', help='Show only channel utilization list') + parser.add_argument('--recommendations', action='store_true', help='Show only channel recommendations') + + args = parser.parse_args() + + # Disable colors if requested + if args.no_color: + for attr in dir(Colors): + if not attr.startswith('_'): + setattr(Colors, attr, '') + + # Read input + if args.file: + with open(args.file, 'r') as f: + data = json.load(f) + else: + data = json.load(sys.stdin) + + # Extract survey data from hardware components + survey_data = [] + hardware = data.get('ietf-hardware:hardware', {}) + components = hardware.get('component', []) + + for component in components: + if component.get('class') == 'infix-hardware:wifi': + wifi_radio = component.get('infix-hardware:wifi-radio', {}) + survey = wifi_radio.get('survey', {}) + channels = survey.get('channel', []) + if channels: + survey_data.extend(channels) + + if not survey_data: + print("No WiFi survey data found in input", file=sys.stderr) + print("Expected format: yanger ietf-hardware output with wifi-radio survey data", file=sys.stderr) + sys.exit(1) + + # Generate SVG if requested (exclusive mode) + if args.svg: + output_file = None if args.svg == '-' else args.svg + generate_svg(survey_data, output_file) + return + + # Determine which sections to show + # If no section flags are set, show all sections + show_all = not (args.overlap or args.pie or args.utilization or args.recommendations) + + show_overlap = show_all or args.overlap + show_pie = show_all or args.pie + show_utilization = show_all or args.utilization + show_recommendations_section = show_all or args.recommendations + + # Draw visualization + freqs = [ch.get('frequency', 0) for ch in survey_data] + has_2_4ghz = any(2400 <= f <= 2500 for f in freqs) + + if show_overlap and has_2_4ghz and not args.list: + draw_channel_graph_2_4ghz(survey_data) + + if show_pie and has_2_4ghz: + draw_overlap_pie(survey_data) + + if show_utilization: + draw_channel_list(survey_data) + + if show_recommendations_section: + draw_recommendations(survey_data, json_output=args.json) + + +if __name__ == '__main__': + main() diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index bc0e97e28..17ceeae4b 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -6,6 +6,20 @@ All notable changes to the project are documented in this file. [v26.01.0][UNRELEASED] ------------------------- +> [!WARNING] +> **BREAKING CHANGES:** This release includes breaking changes to WiFi configuration: +> +> - WiFi station/client configuration has been restructured. The `wifi` container +> now requires a `radio` reference, and station configuration has moved under a +> `wifi/station` container. Existing WiFi configurations must be manually updated. +> - WiFi radios are now configured via `ietf-hardware` instead of the interfaces module. + +> [!NOTE] +> Noteworthy changes and additions in this release: +> +> - WiFi Access Point (AP) mode support with multi-SSID capability +> - RIPv2 routing support + ### Changes - Upgrade Linux kernel to 6.12.63 (LTS) @@ -21,11 +35,19 @@ All notable changes to the project are documented in this file. forwarding. Inspect from CLI using `show interface`, look for `⇅` flag - Add operational data journal to statd with hierarchical time-based retention policy, keeping snapshots from every 5 minutes (recent) to yearly (historical) +- Add WiFi Access Point (AP) mode with multi-SSID support and WPA2/WPA3 security. + **BREAKING:** WiFi architecture refactored with radios configured via + `ietf-hardware` and interfaces requiring `radio` reference. Station config + moved to `wifi/station` container. Manual migration required. See + [wifi.md](wifi.md) for details ### Fixes - Fix #1314: Raspberry Pi 4B with 1 or 8 GiB RAM does not boot. This was due newer EEPROM firmware in newer boards require a newer rpi-firmware package +- Fix #1082: Wi-Fi interfaces always scanned, introduce a `scan-mode` + to the Wi-Fi concept in Infix. + [v25.11.0][] - 2025-12-02 ------------------------- diff --git a/doc/wifi.md b/doc/wifi.md index 1ab89625e..155b484eb 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -1,14 +1,29 @@ # Wi-Fi (Wireless LAN) -Infix includes built-in Wi-Fi client support for connecting to -wireless networks. When a compatible Wi-Fi adapter is detected, the -system automatically begins scanning for available networks. +Infix includes comprehensive Wi-Fi support for both client (Station) and +Access Point modes. When a compatible Wi-Fi adapter is detected, the system +automatically creates a WiFi radio (PHY) in factory-config, that can +host virtual interfaces. + +## Architecture Overview + +Infix uses a two-layer WiFi architecture: + +1. **WiFi Radio (PHY layer)**: Represents the physical wireless hardware + - Configured via `ietf-hardware` module + - Controls channel, transmit power, regulatory domain + - One radio can host multiple virtual interfaces + +2. **WiFi Interface (Network layer)**: Virtual interface on a radio + - Configured via `infix-interfaces` module + - Can operate in Station (client) or Access Point mode + - Each interface references a parent radio ## Current Limitations -- Only client mode is supported (no access point functionality) - USB hotplug is not supported - adapters must be present at boot - Interface naming may be inconsistent with multiple USB Wi-Fi adapters +- AP and Station modes cannot be mixed on the same radio ## Supported Wi-Fi Adapters @@ -16,6 +31,8 @@ Wi-Fi support is primarily tested with Realtek chipset-based adapters. ### Known Working Chipsets +- Built-in Wi-Fi on Banana Pi r3 +- Built-in Wi-Fi on Raspberry Pi 4/CM4 - RTL8821CU - Other Realtek chipsets may work but are not guaranteed @@ -24,35 +41,128 @@ Wi-Fi support is primarily tested with Realtek chipset-based adapters. > Firmware requirements vary by chipset > Check kernel logs if your adapter is not detected -## Configuration +## Radio Configuration + +Before configuring WiFi interfaces, you must first configure the WiFi radio. +Radios are automatically discovered and named `radio0`, `radio1`, etc. + +### Country Code and Regulatory Compliance + +> [!IMPORTANT] +> The `country-code` setting is **legally required** and determines which WiFi channels and power levels are permitted in your location. Using an incorrect country code may violate local wireless regulations. + +**Factory default**: Systems may ship with a default country code (typically "DE" for Germany in European builds or "00" for World domain). **You must configure the correct country code for your deployment location.** + +**Common country codes**: +- Europe: DE (Germany), SE (Sweden), GB (UK), FR (France), ES (Spain) +- Americas: US (United States), CA (Canada), BR (Brazil) +- Asia-Pacific: JP (Japan), AU (Australia), CN (China) + +See [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) for the complete list. + +### Basic Radio Setup + +Configure the radio with channel, power, and regulatory domain. + +**For Station (client) mode:** +``` +admin@example:/> configure +admin@example:/config/> edit hardware component radio0 wifi-radio +admin@example:/config/hardware/component/radio0/wifi-radio/> set country-code DE +admin@example:/config/hardware/component/radio0/wifi-radio/> leave +``` + +**For Access Point mode:** +``` +admin@example:/> configure +admin@example:/config/> edit hardware component radio0 wifi-radio +admin@example:/config/hardware/component/radio0/wifi-radio/> set country-code DE +admin@example:/config/hardware/component/radio0/wifi-radio/> set band 5GHz +admin@example:/config/hardware/component/radio0/wifi-radio/> set channel 36 +admin@example:/config/hardware/component/radio0/wifi-radio/> leave +``` -Add a supported Wi-Fi network device. To verify that it has been -detected, look for `wifi0` in `show interface` +**Key radio parameters:** +- `country-code`: Two-letter ISO 3166-1 code - determines allowed channels and maximum power. Examples: US, DE, GB, SE, FR, JP. **Must match your physical location for legal compliance.** +- `band`: 2.4GHz, 5GHz, or 6GHz (required for AP mode). Band selection automatically enables appropriate WiFi standards (2.4GHz: 802.11n, 5GHz: 802.11n/ac, 6GHz: 802.11n/ac/ax) +- `channel`: Channel number (1-196) or "auto" (required for AP mode). When set to "auto", defaults to channel 6 for 2.4GHz, channel 36 for 5GHz, or channel 109 for 6GHz +- `enable-wifi6`: Boolean (default: false). Opt-in to enable WiFi 6 (802.11ax) on 2.4GHz and 5GHz bands. The 6GHz band always uses WiFi 6 regardless of this setting +> [!NOTE] +> TX power and channel width are automatically determined by the driver based on regulatory constraints, PHY mode, and hardware capabilities. + +### WiFi 6 (802.11ax) Support + +WiFi 6 (802.11ax) provides improved performance in congested environments through +features like OFDMA, Target Wake Time, and BSS Coloring. By default, WiFi 6 is +only enabled on the 6GHz band (WiFi 6E requirement). + +To enable WiFi 6 on 2.4GHz or 5GHz bands: + +``` +admin@example:/> configure +admin@example:/config/> edit hardware component radio0 wifi-radio +admin@example:/config/hardware/component/radio0/wifi-radio/> set country-code DE +admin@example:/config/hardware/component/radio0/wifi-radio/> set band 5GHz +admin@example:/config/hardware/component/radio0/wifi-radio/> set channel 36 +admin@example:/config/hardware/component/radio0/wifi-radio/> set enable-wifi6 true +admin@example:/config/hardware/component/radio0/wifi-radio/> leave ``` -admin@example:/> show interface -INTERFACE PROTOCOL STATE DATA -lo loopback UP - ipv4 127.0.0.1/8 (static) - ipv6 ::1/128 (static) -e1 ethernet UP 02:00:00:00:00:01 - ipv6 fe80::ff:fe00:1/64 (link-layer) - ipv6 fec0::ff:fe00:1/64 (link-layer) -wifi0 ethernet DOWN f0:09:0d:36:5f:86 - wifi ssid: ------, signal: ------ +**WiFi 6 Benefits:** +- **OFDMA**: Better multi-user efficiency in dense environments +- **Target Wake Time**: Improved battery life for client devices +- **1024-QAM**: Higher throughput with strong signal conditions +- **BSS Coloring**: Reduced interference from neighboring networks + +**Requirements:** +- Hardware must support 802.11ax +- Client devices must support WiFi 6 for full benefits +- Older WiFi 5/4 clients can still connect but won't use WiFi 6 features + +> [!NOTE] +> The 6GHz band always uses WiFi 6 (802.11ax) regardless of the `enable-wifi6` +> setting, as WiFi 6E requires 802.11ax support. + +## Discovering Available Networks (Scanning) + +Before connecting to a WiFi network, you need to discover which networks +are available. Infix automatically scans for networks when a WiFi interface +is created with a radio reference. + +### Enable Background Scanning + +To enable scanning without connecting, configure the radio and create a WiFi +interface referencing it: + +**Step 1: Configure the radio** + +``` +admin@example:/> configure +admin@example:/config/> edit hardware component radio0 wifi-radio +admin@example:/config/hardware/component/radio0/wifi-radio/> set country-code DE +admin@example:/config/hardware/component/radio0/wifi-radio/> leave ``` -Add the new Wi-Fi interface to the configuration to start scanning. + +**Step 2: Create WiFi interface with radio reference only** + ``` -admin@example:/config/> set interface wifi0 -admin@example:/config/> leave +admin@example:/> configure +admin@example:/config/> edit interface wifi0 +admin@example:/config/interface/wifi0/> set wifi radio radio0 +admin@example:/config/interface/wifi0/> leave ``` -Now the system will now start scanning in the background. To -see the result read the operational datastore for interface `wifi0` or -use the CLI + +The system will now start scanning in the background. The interface will +operate in scan-only mode until you configure a specific mode (station or +access-point). + +### View Available Networks + +Use `show interface` to see discovered networks and their signal strength: ``` -admin@infix-00-00-00:/> show interface wifi0 +admin@example:/> show interface wifi0 name : wifi0 type : wifi index : 3 @@ -64,89 +174,262 @@ ipv6 addresses : SSID : ---- Signal : ---- -SSID ENCRYPTION SIGNAL -ssid1 WPA2-Personal excellent -ssid2 WPA2-Personal excellent -ssid3 WPA2-Personal excellent -ssid4 WPA2-Personal good -ssid5 WPA2-Personal good -ssid6 WPA2-Personal good +SSID SECURITY SIGNAL +MyNetwork WPA2-Personal excellent +GuestWiFi WPA2-WPA3-Personal good +CoffeeShop Open fair +IoT-Devices WPA2-Personal good ``` -In the CLI, signal strength is reported as: excellent, good, poor or -bad. For precise values, use NETCONF or RESTCONF, where the RSSI (in -dBm) is available in the operational datastore. +In the CLI, signal strength is reported as: excellent, good, fair or bad. +For precise RSSI values in dBm, use NETCONF or RESTCONF to access the +operational datastore directly. + +### Connect to a Network -Configure your Wi-Fi secret in the keystore, it should be between 8 -and 63 characters +Once you've identified the desired network from the scan results, configure +station mode with the SSID and credentials. First, store your WiFi password +in the keystore: ``` admin@example:/> configure -admin@example:/config/> edit keystore symmetric-key example -admin@example:/config/keystore/…/example/> set key-format wifi-preshared-key-format -admin@example:/config/keystore/…/example/> set cleartext-symmetric-key mysecret -admin@example:/config/keystore/…/example/> leave -admin@example:/> +admin@example:/config/> edit keystore symmetric-key my-wifi-key +admin@example:/config/keystore/…/my-wifi-key/> set key-format wifi-preshared-key-format +admin@example:/config/keystore/…/my-wifi-key/> set symmetric-key YourWiFiPassword +admin@example:/config/keystore/…/my-wifi-key/> leave +``` + +Then configure the WiFi interface for station mode: + +``` +admin@example:/> configure +admin@example:/config/> edit interface wifi0 +admin@example:/config/interface/wifi0/> set wifi station ssid MyNetwork +admin@example:/config/interface/wifi0/> set wifi station security secret my-wifi-key +admin@example:/config/interface/wifi0/> leave ``` -Configure the Wi-Fi settings, set secret to the name selected above -for the symmetric key, in this case `example`. +The interface will transition from scan-only mode to station mode and +attempt to connect to the specified network. -WPA2 or WPA3 encryption will be automatically selected based on what -the access point supports. No manual selection is required unless -connecting to an open network. No support for certificate based -authentication yet. +## Station Mode (Client) + +Station mode connects to an existing Wi-Fi network. Before configuring station +mode, follow the "Discovering Available Networks (Scanning)" section above to +scan for available networks and identify the SSID you want to connect to. + +### Step 1: Configure WiFi Password + +Create a keystore entry for your WiFi password (8-63 characters): -Unencrypted network is also supported, to connect to an unencrypted -network (generally not recommended): ``` -admin@example:/config/interface/wifi0/> set wifi encryption disabled +admin@example:/> configure +admin@example:/config/> edit keystore symmetric-key my-wifi-key +admin@example:/config/keystore/…/my-wifi-key/> set key-format wifi-preshared-key-format +admin@example:/config/keystore/…/my-wifi-key/> set symmetric-key MyPassword123 +admin@example:/config/keystore/…/my-wifi-key/> leave ``` -A valid `country-code` is also required for regulatory compliance, the -valid codes are documented in the YANG model `infix-wifi-country-codes` +### Step 2: Connect to Network +Configure station mode with the SSID and password to connect: ``` admin@example:/> configure admin@example:/config/> edit interface wifi0 -admin@example:/config/interface/wifi0/> -admin@example:/config/interface/wifi0/> set wifi ssid ssid1 -admin@example:/config/interface/wifi0/> set wifi secret example -admin@example:/config/interface/wifi0/> set wifi country-code SE +admin@example:/config/interface/wifi0/> set wifi station ssid MyHomeNetwork +admin@example:/config/interface/wifi0/> set wifi station security secret my-wifi-key admin@example:/config/interface/wifi0/> leave ``` -The Wi-Fi negotiation should now start immediately, provided that the -SSID and pre-shared key are correct. You can verify the connection by -running `show interface` again. +The connection attempt will start immediately. You can verify the connection status: + +``` +admin@example:/> show interface wifi0 +name : wifi0 +type : wifi +operational status : up +physical address : f0:09:0d:36:5f:86 +SSID : MyHomeNetwork +Signal : excellent +``` + +**Station configuration parameters:** +- `radio`: Reference to the WiFi radio (mandatory) - already set during scanning +- `station ssid`: Network name to connect to (mandatory) +- `station security mode`: `auto` (default, WPA2/WPA3 auto-negotiation) or `disabled` (open network) +- `station security secret`: Reference to keystore entry (required unless mode is `disabled`) + +> [!NOTE] +> The `auto` security mode automatically selects WPA3-SAE or WPA2-PSK based on +> what the access point supports, prioritizing WPA3 for better security. +> Certificate-based authentication (802.1X/EAP) is not yet supported. + +## Access Point Mode +Access Point (AP) mode allows your device to create a WiFi network that +other devices can connect to. APs are configured as virtual interfaces on +a WiFi radio. + +### Basic AP Configuration + +First, ensure the radio is configured (see Radio Configuration above). Then +create an AP interface: ``` -admin@example:/> show interface -INTERFACE PROTOCOL STATE DATA -lo loopback UP - ipv4 127.0.0.1/8 (static) - ipv6 ::1/128 (static) -e1 ethernet UP 02:00:00:00:00:01 - ipv6 fe80::ff:fe00:1/64 (link-layer) - ipv6 fec0::ff:fe00:1/64 (link-layer) -wifi0 ethernet UP f0:09:0d:36:5f:86 - wifi ssid: ssid1, signal: excellent +admin@example:/> configure +admin@example:/config/> edit interface wifi0 +admin@example:/config/interface/wifi0/> set wifi radio radio0 +admin@example:/config/interface/wifi0/> set wifi access-point ssid MyNetwork +admin@example:/config/interface/wifi0/> set wifi access-point security mode wpa2-personal +admin@example:/config/interface/wifi0/> set wifi access-point security secret example +admin@example:/config/interface/wifi0/> leave +``` + +> [!NOTE] +> Using `wifiN` as the interface name automatically sets the type to WiFi. +> Alternatively, you can use any name and explicitly set `type wifi`. -admin@example:/> +**Access Point configuration parameters:** +- `radio`: Reference to the WiFi radio (mandatory) +- `access-point ssid`: Network name (SSID) to broadcast +- `access-point hidden`: Set to `true` to hide SSID (optional, default: false) +- `access-point security mode`: Security mode (see below) +- `access-point security secret`: Reference to keystore entry (for secured networks) + +**Security modes:** +- `open`: No encryption (not recommended) +- `wpa2-personal`: WPA2-PSK (most compatible) +- `wpa3-personal`: WPA3-SAE (more secure, requires WPA3-capable clients) +- `wpa2-wpa3-personal`: Mixed mode (maximum compatibility) + +### Hidden Network (SSID Hiding) + +To create a hidden network that doesn't broadcast its SSID: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point hidden true +``` + +### Multi-SSID Configuration + +Multiple AP interfaces on the same radio allow broadcasting multiple SSIDs, +each with independent security settings. This is useful for guest networks, +IoT devices, or segregating traffic into different VLANs. + +**Step 1: Configure the radio** (shared by all APs) + +``` +admin@example:/> configure +admin@example:/config/> edit hardware component radio0 wifi-radio +admin@example:/config/hardware/component/radio0/wifi-radio/> set country-code DE +admin@example:/config/hardware/component/radio0/wifi-radio/> set band 5GHz +admin@example:/config/hardware/component/radio0/wifi-radio/> set channel 36 +admin@example:/config/hardware/component/radio0/wifi-radio/> leave +``` + +**Step 2: Configure keystore secrets** + +``` +admin@example:/> configure +admin@example:/config/> edit keystore symmetric-key main-secret +admin@example:/config/keystore/…/main-secret/> set key-format wifi-preshared-key-format +admin@example:/config/keystore/…/main-secret/> set symmetric-key MyMainPassword +admin@example:/config/> edit keystore symmetric-key guest-secret +admin@example:/config/keystore/…/guest-secret/> set key-format wifi-preshared-key-format +admin@example:/config/keystore/…/guest-secret/> set symmetric-key GuestPassword123 +admin@example:/config/> edit keystore symmetric-key iot-secret +admin@example:/config/keystore/…/iot-secret/> set key-format wifi-preshared-key-format +admin@example:/config/keystore/…/iot-secret/> set symmetric-key IoTDevices2025 +admin@example:/config/keystore/…/iot-secret/> leave +``` + +**Step 3: Create multiple AP interfaces** (all on radio0) + +``` +admin@example:/> configure +# Primary AP - Main network (WPA3 for maximum security) +admin@example:/config/> edit interface wifi0 +admin@example:/config/interface/wifi0/> set wifi radio radio0 +admin@example:/config/interface/wifi0/> set wifi access-point ssid MainNetwork +admin@example:/config/interface/wifi0/> set wifi access-point security mode wpa3-personal +admin@example:/config/interface/wifi0/> set wifi access-point security secret main-secret + +# Guest AP - Guest network (WPA2/WPA3 mixed for compatibility) +admin@example:/config/> edit interface wifi1 +admin@example:/config/interface/wifi1/> set wifi radio radio0 +admin@example:/config/interface/wifi1/> set wifi access-point ssid GuestNetwork +admin@example:/config/interface/wifi1/> set wifi access-point security mode wpa2-wpa3-personal +admin@example:/config/interface/wifi1/> set wifi access-point security secret guest-secret +admin@example:/config/interface/wifi1/> set custom-phys-address static 00:0c:43:26:60:01 + +# IoT AP - IoT devices (WPA2 for older device compatibility) +admin@example:/config/> edit interface wifi2 +admin@example:/config/interface/wifi2/> set wifi radio radio0 +admin@example:/config/interface/wifi2/> set wifi access-point ssid IoT-Devices +admin@example:/config/interface/wifi2/> set wifi access-point security mode wpa2-personal +admin@example:/config/interface/wifi2/> set wifi access-point security secret iot-secret +admin@example:/config/interface/wifi2/> set custom-phys-address static 00:0c:43:26:60:02 +admin@example:/config/interface/wifi2/> leave +``` + +> [!IMPORTANT] +> **MAC Address Requirement for Multi-SSID:** +> When creating multiple AP interfaces on the same radio, you **must** configure +> a unique MAC address for each secondary interface (wifi1, wifi2, etc.) using +> `set custom-phys-address static `. All interfaces on the same radio inherit +> the radio's hardware MAC address by default, which causes network conflicts. Only +> the primary interface (alphabetically first, e.g., wifi0) should use the default +> hardware MAC address. +> +> Choose MAC addresses from the same locally-administered range: +> - Primary (wifi0): Uses hardware MAC (e.g., `00:0c:43:26:60:00`) +> - Secondary (wifi1): `00:0c:43:26:60:01` (increment last octet) +> - Tertiary (wifi2): `00:0c:43:26:60:02` (increment last octet) + +**Result:** Three SSIDs broadcasting simultaneously on radio0: +- `MainNetwork` (WPA3, most secure) +- `GuestNetwork` (WPA2/WPA3 mixed mode) +- `IoT-Devices` (WPA2 for compatibility) + +All APs on the same radio share the same channel and physical layer settings +(configured at the radio level). Each AP can have its own: +- SSID (network name) +- Security mode and passphrase +- Hidden/visible SSID setting +- Bridge membership + +You can verify the configuration with `show hardware component radio0` to see +radio settings, and `show interface` to see all active AP interfaces. + +> [!IMPORTANT] +> AP and Station modes cannot be mixed on the same radio. All virtual interfaces +> on a radio must be the same mode (all APs or all Stations). + +### AP as Bridge Port + +WiFi AP interfaces can be added to bridges to integrate wireless devices +into your LAN: + +``` +admin@example:/> configure +admin@example:/config/> edit interface br0 +admin@example:/config/interface/br0/> set type bridge + +admin@example:/config/> edit interface wifi0 +admin@example:/config/interface/wifi0/> set bridge-port bridge br0 +admin@example:/config/interface/wifi0/> leave ``` ## Troubleshooting Connection Issues -Use `show wifi scan wifi0` and `show interface` to verify signal strength -and connection status. If issues arise, try the following -troubleshooting steps: +Use `show interface wifi0` to verify signal strength and connection status. +If issues arise, try the following troubleshooting steps: -1. **Verify signal strength**: Check that the target network shows "good" or "excellent" signal -2. **Check credentials**: Verify the preshared key in `ietf-keystore` +1. **Verify signal strength**: Check that the target network shows "good" or "excellent" signal in scan results +2. **Check credentials**: Verify the preshared key in the keystore matches the network password 3. **Review logs**: Check system logs with `show log` for Wi-Fi related errors -4. **Regulatory compliance**: Ensure the country-code matches your location -5. **Hardware detection**: Confirm the adapter appears in `show interface` +4. **Regulatory compliance**: Ensure the country-code on the radio matches your location +5. **Hardware detection**: Confirm the WiFi radio appears in `show hardware` If issues persist, check the system log for specific error messages that can help identify the root cause. diff --git a/package/feature-wifi/Config.in b/package/feature-wifi/Config.in index 87ed3852a..946e22388 100644 --- a/package/feature-wifi/Config.in +++ b/package/feature-wifi/Config.in @@ -6,6 +6,10 @@ config BR2_PACKAGE_FEATURE_WIFI select BR2_PACKAGE_WPA_SUPPLICANT_AUTOSCAN select BR2_PACKAGE_WPA_SUPPLICANT_CLI select BR2_PACKAGE_WIRELESS_REGDB + select BR2_PACKAGE_HOSTAPD + select BR2_PACKAGE_HOSTAPD_DRIVER_NL80211 + select BR2_PACKAGE_HOSTAPD_WPA3 + select BR2_PACKAGE_HOSTAPD_WPS select BR2_PACKAGE_IW help Enables WiFi in Infix. Enables all requried applications. diff --git a/src/confd/bin/bootstrap b/src/confd/bin/bootstrap index e2473f07f..371d80286 100755 --- a/src/confd/bin/bootstrap +++ b/src/confd/bin/bootstrap @@ -3,8 +3,8 @@ # ######################################################################## # The system factory-config, failure-config and test-config are derived -# from default settings snippets, from /usr/share/confd/factory.d, and -# some generated snippets, e.g., hostname (based on base MAC address) +# from default settings snippets, from /usr/share/confd/factory.d, and +# some generated snippets, e.g., hostname (based on base MAC address) # and number of interfaces. # # The resulting factory-config is used to create the syrepo db (below) @@ -147,7 +147,7 @@ gen_test_cfg() # Both factory-config and failure-config are generated every boot # regardless if there is a static /etc/factory-config.cfg or not. -gen_factory_cfg +gen_factory_cfg gen_failure_cfg if [ -f "/mnt/aux/test-mode" ]; then diff --git a/src/confd/bin/gen-hardware b/src/confd/bin/gen-hardware index eec613d3e..33000a715 100755 --- a/src/confd/bin/gen-hardware +++ b/src/confd/bin/gen-hardware @@ -6,6 +6,8 @@ if jq -e '.["usb-ports"]' /run/system.json > /dev/null; then else usb_ports="" fi +wifi_radios=$(/usr/libexec/infix/iw.py list 2>/dev/null | jq -r '.[]' || echo "") + gen_port() { @@ -20,6 +22,43 @@ gen_port() } EOF } + +gen_radio() +{ + radio="$1" + + # Detect supported bands from iw.py info JSON output + phy_info=$(/usr/libexec/infix/iw.py info "$radio" 2>/dev/null || echo '{"bands":[]}') + # Check if 2.4GHz band exists (band name "2.4GHz") + has_2ghz=$(echo "$phy_info" | jq '[.bands[] | select(.name == "2.4GHz")] | length') + # Check if 5GHz band exists (band name "5GHz") + has_5ghz=$(echo "$phy_info" | jq '[.bands[] | select(.name == "5GHz")] | length') + + # Determine band setting + # If both bands supported, prefer 5GHz for better performance + if [ "$has_2ghz" -gt 0 ] && [ "$has_5ghz" -gt 0 ]; then + band="5GHz" + elif [ "$has_5ghz" -gt 0 ]; then + band="5GHz" + elif [ "$has_2ghz" -gt 0 ]; then + band="2.4GHz" + else + band="2.4GHz" # Fallback to 2.4GHz for maximum compatibility + fi + + cat <count > 0) { + for (uint32_t i = 0; i < radios->count; i++) { + struct lyd_node *hradio = radios->dnodes[i]; + const char *radio_name = lydx_get_cattr(hradio, "name"); + struct ly_set *ifaces; + uint32_t j; + char xpath[256]; + + if (!radio_name) + continue; + + /* Find all interfaces that reference this radio */ + ifaces = lydx_find_xpathf(config, + "/ietf-interfaces:interfaces/interface[infix-interfaces:wifi/radio='%s']", + radio_name); + if (ifaces && ifaces->count > 0) { + for (j = 0; j < ifaces->count; j++) { + const char *ifname = lydx_get_cattr(ifaces->dnodes[j], "name"); + + /* Add the wifi container */ + snprintf(xpath, sizeof(xpath), + "/ietf-interfaces:interfaces/interface[name='%s']/infix-interfaces:wifi", + ifname); + result = add_dependencies(diff, xpath, ifname); + if (result == CONFD_DEP_ERROR) { + ERROR("Failed to add interface wifi for %s (radio %s)", ifname, radio_name); + ly_set_free(ifaces, NULL); + ly_set_free(radios, NULL); + return result; + } + + /* Add the radio leaf */ + snprintf(xpath, sizeof(xpath), + "/ietf-interfaces:interfaces/interface[name='%s']/infix-interfaces:wifi/radio", + ifname); + result = add_dependencies(diff, xpath, radio_name); + if (result == CONFD_DEP_ERROR) { + ERROR("Failed to add radio leaf for interface %s (radio %s)", ifname, radio_name); + ly_set_free(ifaces, NULL); + ly_set_free(radios, NULL); + return result; + } + + /* Add station or access-point container depending on mode */ + if (lydx_get_descendant(ifaces->dnodes[j], "interface", "wifi", "station", NULL)) { + snprintf(xpath, sizeof(xpath), + "/ietf-interfaces:interfaces/interface[name='%s']/infix-interfaces:wifi/station", + ifname); + result = add_dependencies(diff, xpath, ifname); + if (result == CONFD_DEP_ERROR) { + ERROR("Failed to add station for interface %s (radio %s)", ifname, radio_name); + ly_set_free(ifaces, NULL); + ly_set_free(radios, NULL); + return result; + } + } else if (lydx_get_descendant(ifaces->dnodes[j], "interface", "wifi", "access-point", NULL)) { + snprintf(xpath, sizeof(xpath), + "/ietf-interfaces:interfaces/interface[name='%s']/infix-interfaces:wifi/access-point", + ifname); + result = add_dependencies(diff, xpath, ifname); + if (result == CONFD_DEP_ERROR) { + ERROR("Failed to add access-point for interface %s (radio %s)", ifname, radio_name); + ly_set_free(ifaces, NULL); + ly_set_free(radios, NULL); + return result; + } + } + + DEBUG("Added interface %s to diff for radio %s", ifname, radio_name); + } + ly_set_free(ifaces, NULL); + } + } + ly_set_free(radios, NULL); + } + return result; } @@ -244,6 +366,8 @@ static int change_cb(sr_session_ctx_t *session, uint32_t sub_id, const char *mod if ((rc = meta_change_cb(session, config, diff, event, confd))) goto free_diff; + /* Note: WiFi radio handling is now integrated into hardware_change() */ + if (cfg) sr_release_data(cfg); diff --git a/src/confd/src/core.h b/src/confd/src/core.h index d376aa49c..9ffd97c14 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -242,6 +242,8 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l #define SSH_HOSTKEYS_NEXT SSH_HOSTKEYS"+" int keystore_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); +/* Note: WiFi radio handling is now integrated into hardware.c/hardware_change() */ + /* firewall.c */ int firewall_rpc_init(struct confd *confd); int firewall_candidate_init(struct confd *confd); diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index 83e52bfc2..a5d622127 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -10,8 +10,14 @@ #include #include "core.h" +#include "interfaces.h" +#include "dagger.h" -#define XPATH_BASE_ "/ietf-hardware:hardware" +#define XPATH_BASE_ "/ietf-hardware:hardware" +#define HOSTAPD_CONF "/etc/hostapd-%s.conf" +#define HOSTAPD_CONF_NEXT HOSTAPD_CONF"+" +#define WPA_SUPPLICANT_CONF "/etc/wpa_supplicant-%s.conf" +#define WPA_SUPPLICANT_CONF_NEXT WPA_SUPPLICANT_CONF"+" static int dir_cb(const char *fpath, const struct stat *sb, int typeflag, struct FTW *ftwbuf) @@ -150,6 +156,452 @@ static int hardware_cand_infer_class(json_t *root, sr_session_ctx_t *session, co free(xpath); return err; } + + +static int wifi_find_interfaces_on_radio(struct lyd_node *ifs, const char *radio_name, + struct lyd_node ***iface_list, int *count) +{ + struct lyd_node *iface, *wifi; + const char *radio; + struct lyd_node **list = NULL; + int n = 0; + + if (!ifs) + return 0; + + LYX_LIST_FOR_EACH(ifs, iface, "interface") { + wifi = lydx_get_child(iface, "wifi"); + if (!wifi) + continue; + + radio = lydx_get_cattr(wifi, "radio"); + if (!radio || strcmp(radio, radio_name)) + continue; + + if (lydx_get_op(iface) == LYDX_OP_DELETE) + continue; + list = realloc(list, sizeof(struct lyd_node *) * n + 1); + list[n++] = iface; + } + + *iface_list = list; + *count = n; + return 0; +} + +static int wifi_gen_station(const char *ifname, struct lyd_node *station, + const char *radio, struct lyd_node *config) +{ + const char *ssid, *secret_name, *secret, *security_mode; + struct lyd_node *security, *secret_node, *radio_node; + FILE *wpa_supplicant = NULL; + char *security_str = NULL; + const char *country; + int rc = SR_ERR_OK; + + /* If station is NULL, we're in scan-only mode (no station container) */ + if (station) { + ssid = lydx_get_cattr(station, "ssid"); + security = lydx_get_child(station, "security"); + security_mode = lydx_get_cattr(security, "mode"); + secret_name = lydx_get_cattr(security, "secret"); + } else { + ssid = NULL; + security = NULL; + security_mode = "disabled"; + secret_name = NULL; + } + + radio_node = lydx_get_xpathf(config, + "/hardware/component[name='%s']/wifi-radio", radio); + country = radio_node ? lydx_get_cattr(radio_node, "country-code") : NULL; + + if (secret_name && strcmp(security_mode, "disabled") != 0) { + secret_node = lydx_get_xpathf(config, + "/keystore/symmetric-keys/symmetric-key[name='%s']/symmetric-key", + secret_name); + secret = secret_node ? lyd_get_value(secret_node) : NULL; + } else { + secret = NULL; + } + + wpa_supplicant = fopenf("w", WPA_SUPPLICANT_CONF_NEXT, ifname); + if (!wpa_supplicant) { + rc = SR_ERR_INTERNAL; + goto out; + } + + fprintf(wpa_supplicant, + "ctrl_interface=/run/wpa_supplicant\n" + "autoscan=periodic:10\n" + "ap_scan=1\n"); + + if (country) + fprintf(wpa_supplicant, "country=%s\n", country); + + /* If SSID is present, create network block. Otherwise, scan-only mode */ + if (ssid) { + /* Station mode with network configured */ + if (!strcmp(security_mode, "disabled")) { + asprintf(&security_str, "key_mgmt=NONE"); + } else if (secret) { + asprintf(&security_str, "key_mgmt=SAE WPA-PSK\npsk=\"%s\"", secret); + } + fprintf(wpa_supplicant, + "network={\n" + "bgscan=\"simple: 30:-45:300\"\n" + "ssid=\"%s\"\n" + "%s\n" + "}\n", ssid, security_str); + free(security_str); + } else { + /* Scan-only mode - no station container configured */ + fprintf(wpa_supplicant, "# Scan-only mode - no network configured\n"); + } + +out: + if (wpa_supplicant) + fclose(wpa_supplicant); + return rc; +} + +/* Helper: Find all AP interfaces on a specific radio */ +static int wifi_find_radio_aps(struct lyd_node *cifs, const char *radio_name, + char ***ap_list, int *count) +{ + struct lyd_node *cif, *wifi, *ap; + const char *ifname, *radio; + char **list = NULL; + int n = 0; + + LYX_LIST_FOR_EACH(cifs, cif, "interface") { + wifi = lydx_get_child(cif, "wifi"); + if (!wifi) + continue; + + radio = lydx_get_cattr(wifi, "radio"); + if (!radio || strcmp(radio, radio_name)) + continue; + + ap = lydx_get_child(wifi, "access-point"); + if (!ap) + continue; + list = realloc(list, sizeof(char *) *n+1); + + + ifname = lydx_get_cattr(cif, "name"); + list[n++] = strdup(ifname); + } + + /* Sort alphabetically for consistent primary selection */ + for (int i = 0; i < n - 1; i++) { + for (int j = i + 1; j < n; j++) { + if (strcmp(list[i], list[j]) > 0) { + char *tmp = list[i]; + list[i] = list[j]; + list[j] = tmp; + } + } + } + + *ap_list = list; + *count = n; + return 0; +} + +/* Generate BSS section for secondary AP (multi-SSID) */ +static int wifi_gen_bss_section(FILE *hostapd, struct lyd_node *cifs, const char *ifname, struct lyd_node *config) +{ + const char *ssid, *hidden, *security_mode, *secret_name, *secret; + struct lyd_node *cif, *wifi, *ap, *security, *secret_node; + + /* Find the interface node for this BSS */ + LYX_LIST_FOR_EACH(cifs, cif, "interface") { + const char *name = lydx_get_cattr(cif, "name"); + if (strcmp(name, ifname) == 0) + break; + } + + if (!cif) { + ERROR("Failed to find interface %s for BSS section", ifname); + return SR_ERR_INVAL_ARG; + } + + wifi = lydx_get_child(cif, "wifi"); + ap = lydx_get_child(wifi, "access-point"); + + fprintf(hostapd, "\n# BSS %s\n", ifname); + fprintf(hostapd, "bss=%s\n", ifname); + + /* SSID configuration */ + ssid = lydx_get_cattr(ap, "ssid"); + hidden = lydx_get_cattr(ap, "hidden"); + + if (ssid) + fprintf(hostapd, "ssid=%s\n", ssid); + if (hidden && !strcmp(hidden, "true")) + fprintf(hostapd, "ignore_broadcast_ssid=1\n"); + + /* Security configuration */ + security = lydx_get_child(ap, "security"); + security_mode = lydx_get_cattr(security, "mode"); + + if (!security_mode) + security_mode = "open"; + + /* Get secret from keystore if needed */ + secret = NULL; + if (strcmp(security_mode, "open") != 0) { + secret_name = lydx_get_cattr(security, "secret"); + if (secret_name) { + secret_node = lydx_get_xpathf(config, + "/keystore/symmetric-keys/symmetric-key[name='%s']/symmetric-key", + secret_name); + if (secret_node) + secret = lyd_get_value(secret_node); + } + } + + if (!strcmp(security_mode, "open")) { + fprintf(hostapd, "# Open network\n"); + fprintf(hostapd, "auth_algs=1\n"); + } else if (!strcmp(security_mode, "wpa2-personal")) { + fprintf(hostapd, "# WPA2-Personal\n"); + fprintf(hostapd, "wpa=2\n"); + fprintf(hostapd, "wpa_key_mgmt=WPA-PSK\n"); + fprintf(hostapd, "wpa_pairwise=CCMP\n"); + if (secret) + fprintf(hostapd, "wpa_passphrase=%s\n", secret); + } else if (!strcmp(security_mode, "wpa3-personal")) { + fprintf(hostapd, "# WPA3-Personal\n"); + fprintf(hostapd, "wpa=2\n"); + fprintf(hostapd, "wpa_key_mgmt=SAE\n"); + fprintf(hostapd, "rsn_pairwise=CCMP\n"); + if (secret) + fprintf(hostapd, "sae_password=%s\n", secret); + fprintf(hostapd, "ieee80211w=2\n"); + } else if (!strcmp(security_mode, "wpa2-wpa3-personal")) { + fprintf(hostapd, "# WPA2/WPA3 Mixed\n"); + fprintf(hostapd, "wpa=2\n"); + fprintf(hostapd, "wpa_key_mgmt=WPA-PSK SAE\n"); + fprintf(hostapd, "rsn_pairwise=CCMP\n"); + if (secret) { + fprintf(hostapd, "wpa_passphrase=%s\n", secret); + fprintf(hostapd, "sae_password=%s\n", secret); + } + fprintf(hostapd, "ieee80211w=1\n"); + } + + return 0; +} + +/* Generate hostapd config for all APs on a radio (multi-SSID support) */ +static int wifi_gen_aps_on_radio(const char *radio_name, struct lyd_node *cifs, + struct lyd_node *radio_node, struct lyd_node *config) +{ + const char *ssid, *hidden, *security_mode, *secret_name, *secret; + struct lyd_node *primary_cif, *cif; + struct lyd_node *primary_wifi, *primary_ap; + struct lyd_node *security, *secret_node; + const char *country, *channel, *band; + const char *primary_ifname; + char hostapd_conf[256]; + char **ap_list = NULL; + FILE *hostapd = NULL; + bool wifi6_enabled; + int ap_count = 0; + int i; + + int rc = SR_ERR_OK; + + wifi_find_radio_aps(cifs, radio_name, &ap_list, &ap_count); + + if (ap_count == 0) { + DEBUG("No APs found on radio %s", radio_name); + return SR_ERR_OK; + } + + DEBUG("Generating hostapd config for radio %s (%d APs)", radio_name, ap_count); + + primary_ifname = ap_list[0]; + primary_cif = NULL; + LYX_LIST_FOR_EACH(cifs, cif, "interface") { + if (!strcmp(lydx_get_cattr(cif, "name"), primary_ifname)) { + primary_cif = cif; + break; + } + } + + if (!primary_cif) { + ERROR("Failed to find primary AP interface %s", primary_ifname); + rc = SR_ERR_INVAL_ARG; + goto cleanup; + } + + primary_wifi = lydx_get_child(primary_cif, "wifi"); + primary_ap = lydx_get_child(primary_wifi, "access-point"); + + /* Get AP configuration */ + ssid = lydx_get_cattr(primary_ap, "ssid"); + hidden = lydx_get_cattr(primary_ap, "hidden"); + security = lydx_get_child(primary_ap, "security"); + security_mode = lydx_get_cattr(security, "mode"); + secret_name = lydx_get_cattr(security, "secret"); + secret = NULL; + + /* Get radio configuration */ + country = lydx_get_cattr(radio_node, "country-code"); + band = lydx_get_cattr(radio_node, "band"); + channel = lydx_get_cattr(radio_node, "channel"); + wifi6_enabled = lydx_get_bool(radio_node, "enable_wifi6"); + + /* Get secret from keystore if not open network */ + if (secret_name && strcmp(security_mode, "open") != 0) { + secret_node = lydx_get_xpathf(config, + "/keystore/symmetric-keys/symmetric-key[name='%s']/symmetric-key", + secret_name); + if (secret_node) { + secret = lyd_get_value(secret_node); + + } + } + + snprintf(hostapd_conf, sizeof(hostapd_conf), HOSTAPD_CONF_NEXT, radio_name); + + hostapd = fopen(hostapd_conf, "w"); + if (!hostapd) { + ERROR("Failed to create hostapd config: %s", hostapd_conf); + rc = SR_ERR_INTERNAL; + goto cleanup; + } + + fprintf(hostapd, "# Generated by Infix confd - WiFi Radio %s\n", radio_name); + fprintf(hostapd, "# Primary BSS: %s", primary_ifname); + if (ap_count > 1) + fprintf(hostapd, " (%d total APs)\n\n", ap_count); + else + fprintf(hostapd, "\n\n"); + + fprintf(hostapd, "interface=%s\n", primary_ifname); + fprintf(hostapd, "driver=nl80211\n"); + fprintf(hostapd, "ctrl_interface=/run/hostapd\n\n"); + + fprintf(hostapd, "ssid=%s\n", ssid); + if (hidden && !strcmp(hidden, "true")) + fprintf(hostapd, "ignore_broadcast_ssid=1\n"); + fprintf(hostapd, "\n"); + + if (country) + fprintf(hostapd, "country_code=%s\n", country); + + /* Enable 802.11d (regulatory domain) and 802.11h (spectrum management/DFS) */ + fprintf(hostapd, "ieee80211d=1\n"); + fprintf(hostapd, "ieee80211h=1\n"); + + /* Band and channel configuration */ + if (band) { + /* Set hardware mode based on band */ + if (!strcmp(band, "2.4GHz")) { + fprintf(hostapd, "hw_mode=g\n"); + } else if (!strcmp(band, "5GHz") || !strcmp(band, "6GHz")) { + fprintf(hostapd, "hw_mode=a\n"); + } + + /* Set channel */ + if (channel) { + if (strcmp(channel, "auto") == 0) { + /* + Use default channels: 6 for 2.4GHz, 36 for 5GHz, 109 for 6GHz, this + is a temporary hack, replace with logic for finding best free channel. + */ + if (!strcmp(band, "2.4GHz")) { + fprintf(hostapd, "channel=6\n"); + } else if (!strcmp(band, "5GHz")) { + fprintf(hostapd, "channel=36\n"); + } else if (!strcmp(band, "6GHz")) { + fprintf(hostapd, "channel=109\n"); + } else { + /* Unknown band - use ACS */ + fprintf(hostapd, "channel=0\n"); + } + } else { + fprintf(hostapd, "channel=%s\n", channel); + } + } + } + + + if (band) { + if (!strcmp(band, "2.4GHz")) { + /* 2.4GHz: Enable 802.11n (HT), optionally WiFi 6 */ + fprintf(hostapd, "ieee80211n=1\n"); + if (wifi6_enabled) { + fprintf(hostapd, "ieee80211ax=1\n"); + } + } else if (!strcmp(band, "5GHz")) { + /* 5GHz: Enable 802.11n and 802.11ac, optionally WiFi 6 */ + fprintf(hostapd, "ieee80211n=1\n"); + fprintf(hostapd, "ieee80211ac=1\n"); + if (wifi6_enabled) { + fprintf(hostapd, "ieee80211ax=1\n"); + } + } else if (!strcmp(band, "6GHz")) { + /* 6GHz: Enable 802.11ax (WiFi 6E required) */ + fprintf(hostapd, "ieee80211n=1\n"); + fprintf(hostapd, "ieee80211ac=1\n"); + fprintf(hostapd, "ieee80211ax=1\n"); + } + } + fprintf(hostapd, "\n"); + + /* Security configuration */ + if (!strcmp(security_mode, "open")) { + fprintf(hostapd, "# Open network (no encryption)\n"); + fprintf(hostapd, "auth_algs=1\n"); + } else if (!strcmp(security_mode, "wpa2-personal")) { + fprintf(hostapd, "# WPA2-Personal\n"); + fprintf(hostapd, "wpa=2\n"); + fprintf(hostapd, "wpa_key_mgmt=WPA-PSK\n"); + fprintf(hostapd, "wpa_pairwise=CCMP\n"); + fprintf(hostapd, "wpa_passphrase=%s\n", secret); + } else if (!strcmp(security_mode, "wpa3-personal")) { + fprintf(hostapd, "# WPA3-Personal\n"); + fprintf(hostapd, "wpa=2\n"); + fprintf(hostapd, "wpa_key_mgmt=SAE\n"); + fprintf(hostapd, "rsn_pairwise=CCMP\n"); + fprintf(hostapd, "sae_password=%s\n", secret); + fprintf(hostapd, "ieee80211w=2\n"); + } else if (!strcmp(security_mode, "wpa2-wpa3-personal")) { + fprintf(hostapd, "# WPA2/WPA3 Mixed Mode\n"); + fprintf(hostapd, "wpa=2\n"); + fprintf(hostapd, "wpa_key_mgmt=WPA-PSK SAE\n"); + fprintf(hostapd, "rsn_pairwise=CCMP\n"); + fprintf(hostapd, "wpa_passphrase=%s\n", secret); + fprintf(hostapd, "sae_password=%s\n", secret); + fprintf(hostapd, "ieee80211w=1\n"); + } + + /* Add BSS sections for secondary APs (multi-SSID) */ + for (i = 1; i < ap_count; i++) { + DEBUG("Adding BSS section for secondary AP %s", ap_list[i]); + rc = wifi_gen_bss_section(hostapd, cifs, ap_list[i], config); + if (rc != SR_ERR_OK) { + ERROR("Failed to generate BSS section for %s", ap_list[i]); + fclose(hostapd); + goto cleanup; + } + } + + fclose(hostapd); + +cleanup: + for (i = 0; i < ap_count; i++) + free(ap_list[i]); + free(ap_list); + + return rc; +} + static int hardware_cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, const char *xpath, sr_event_t event, unsigned request_id, void *priv) { @@ -187,49 +639,185 @@ static int hardware_cand(sr_session_ctx_t *session, uint32_t sub_id, const char int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd) { - struct lyd_node *difs = NULL, *dif = NULL, *cifs = NULL, *cif = NULL; + struct lyd_node *difs = NULL, *dif = NULL; int rc = SR_ERR_OK; - if (event != SR_EV_DONE || !lydx_find_xpathf(diff, XPATH_BASE_)) + if (!lydx_find_xpathf(diff, XPATH_BASE_)) return SR_ERR_OK; - cifs = lydx_get_descendant(config, "hardware", "component", NULL); difs = lydx_get_descendant(diff, "hardware", "component", NULL); LYX_LIST_FOR_EACH(difs, dif, "component") { enum lydx_op op; - struct lyd_node *state; + struct lyd_node *state, *cif; const char *admin_state; const char *class, *name; op = lydx_get_op(dif); name = lydx_get_cattr(dif, "name"); - if (op == LYDX_OP_DELETE) { - if (usb_authorize(confd->root, name, 0)) { - rc = SR_ERR_INTERNAL; - goto err;; - } + + /* Get the current config node for this component */ + cif = lydx_get_xpathf(config, "/hardware/component[name='%s']", name); + if (!cif) continue; - } - LYX_LIST_FOR_EACH(cifs, cif, "component") { - if (strcmp(name, lydx_get_cattr(cif, "name"))) + class = lydx_get_cattr(cif, "class"); + + /* Handle USB components */ + if (!strcmp(class, "infix-hardware:usb")) { + if (event != SR_EV_DONE) continue; - class = lydx_get_cattr(cif, "class"); - if (strcmp(class, "infix-hardware:usb")) { + if (op == LYDX_OP_DELETE) { + /* Handle USB deletion */ + if (usb_authorize(confd->root, name, 0)) { + rc = SR_ERR_INTERNAL; + goto err; + } continue; } state = lydx_get_child(dif, "state"); admin_state = lydx_get_cattr(state, "admin-state"); if (usb_authorize(confd->root, name, !strcmp(admin_state, "unlocked"))) { rc = SR_ERR_INTERNAL; - goto err;; + goto err; + } + } else if (!strcmp(class, "infix-hardware:wifi")) { + struct lyd_node *interfaces_config, *interfaces_diff; + struct lyd_node **wifi_iface_list = NULL; + struct lyd_node *station, *ap; + struct lyd_node *cwifi_radio; + int wifi_iface_count = 0; + char src[40], dst[40]; + int ap_interfaces = 0; + + switch (event) { + case SR_EV_ABORT: + continue; + case SR_EV_CHANGE: + break; + case SR_EV_DONE: + interfaces_diff = lydx_get_descendant(diff, "interfaces", "interface", NULL); + + wifi_find_interfaces_on_radio(interfaces_diff, name, + &wifi_iface_list, &wifi_iface_count); + if (wifi_iface_count > 0) { + bool running, enabled; + station = lydx_get_descendant(wifi_iface_list[0], "interface", "wifi", "station", NULL); + ap = lydx_get_descendant(wifi_iface_list[0], "interface", "wifi", "access-point", NULL); + + if (station || !ap || lydx_get_op(ap) == LYDX_OP_DELETE) { + const char *ifname = lydx_get_cattr(wifi_iface_list[0], "name"); + running = !systemf("initctl -bfq status wpa_supplicant:%s", ifname); + if (lydx_get_op(station) == LYDX_OP_DELETE) { + erasef(WPA_SUPPLICANT_CONF, ifname); + erasef(WPA_SUPPLICANT_CONF_NEXT, ifname); + systemf("initctl -bfq disable wifi@%s", ifname); + } else { + snprintf(src, sizeof(src), WPA_SUPPLICANT_CONF_NEXT, ifname); + snprintf(dst, sizeof(dst), WPA_SUPPLICANT_CONF, ifname); + running = !systemf("initctl -bfq status wpa_supplicant:%s", ifname); + enabled = fexistf(WPA_SUPPLICANT_CONF_NEXT, ifname); + + if (enabled) { + rename(src, dst); + if (running) + systemf("initctl -bfq touch wifi@%s", ifname); + else + systemf("initctl -bfq enable wifi@%s", ifname); + } + } + } else if (wifi_iface_count > 0) { + /* AP mode - activate hostapd for radio */ + snprintf(src, sizeof(src), HOSTAPD_CONF_NEXT, name); + snprintf(dst, sizeof(dst), HOSTAPD_CONF, name); + + running = !systemf("initctl -bfq status hostapd:%s", name); + enabled = fexistf(HOSTAPD_CONF_NEXT, name); + + if (enabled) { + rename(src, dst); + ap_interfaces++; + + if (running) + systemf("initctl -bfq touch hostapd@%s", name); + else + systemf("initctl -bfq enable hostapd@%s", name); + } + } + } + if (!ap_interfaces) { + systemf("initctl -bfq disable hostapd@%s", name); + erasef(HOSTAPD_CONF, name); + erasef(HOSTAPD_CONF_NEXT, name); + } + continue; + default: + continue; + } + + cwifi_radio = lydx_get_child(cif, "wifi-radio"); + + interfaces_config = lydx_get_descendant(config, "interfaces", "interface", NULL); + + wifi_find_interfaces_on_radio(interfaces_config, name, + &wifi_iface_list, &wifi_iface_count); + + + if (!wifi_iface_count) + continue; + /* + * A radio operates in one of three modes: + * 1. Station mode: One station interface (client mode) + * 2. AP mode: One or more AP interfaces (hostapd multi-SSID) + * 3. Scan-only mode: WiFi interface with radio but no mode configured + * + * Check for station first - there can be at most one per radio. + * If no station or AP is configured, default to scan-only mode. + */ + station = lydx_get_descendant(wifi_iface_list[0], "interface", "wifi", "station", NULL); + ap = lydx_get_descendant(wifi_iface_list[0], "interface", "wifi", "access-point", NULL); + if (wifi_iface_count == 1 && station) { + /* Station mode (with or without SSID for scan-only) */ + struct lyd_node *iface = wifi_iface_list[0]; + if (lydx_is_enabled(iface, "enabled")) { + const char *ifname = lydx_get_cattr(iface, "name"); + rc = wifi_gen_station(ifname, station, name, config); + if (rc != SR_ERR_OK) { + ERROR("Failed to generate station config for %s", ifname); + goto next; + } + } + } else if (!station && !ap) { + /* No station/AP configured - default to scan-only mode */ + struct lyd_node *iface = wifi_iface_list[0]; + if (lydx_is_enabled(iface, "enabled")) { + const char *ifname = lydx_get_cattr(iface, "name"); + rc = wifi_gen_station(ifname, NULL, name, config); + if (rc != SR_ERR_OK) { + ERROR("Failed to generate scan-only config for %s", ifname); + goto next; + } + } + } else { + /* Multiple interfaces or APs */ + rc = wifi_gen_aps_on_radio(name, interfaces_config, cwifi_radio, config); + if (rc != SR_ERR_OK) { + ERROR("Failed to generate AP config for radio %s", name); + goto next; + } + } + next: + /* Free the interface list */ + free(wifi_iface_list); + wifi_iface_list = NULL; + wifi_iface_count = 0; } } err: + return rc; } int hardware_candidate_init(struct confd *confd) diff --git a/src/confd/src/if-wifi.c b/src/confd/src/if-wifi.c index edd405359..9aebc7178 100644 --- a/src/confd/src/if-wifi.c +++ b/src/confd/src/if-wifi.c @@ -3,108 +3,121 @@ #include "interfaces.h" -#define WPA_SUPPLICANT_FINIT_CONF "/etc/finit.d/available/wpa_supplicant-%s.conf" -#define WPA_SUPPLICANT_CONF "/etc/wpa_supplicant-%s.conf" +/* + * WiFi Interface Management + * + * This file handles only virtual WiFi interface creation/deletion. + * WiFi daemon configuration (hostapd/wpa_supplicant) is handled by + * hardware.c when the WiFi radio (phy) is configured. + */ + +/* + * Determine WiFi mode from YANG configuration + */ +typedef enum wifi_mode_t { + wifi_station, + wifi_ap, + wifi_unknown +} wifi_mode_t; +static wifi_mode_t wifi_get_mode(struct lyd_node *wifi) +{ + if (lydx_get_child(wifi, "access-point")) + return wifi_ap; + else + return wifi_station; /* Need to return station even if "station" also is false, since that is the default scanning mode */ +} -static int wifi_gen_config(const char *ifname, const char *ssid, const char *country, const char *secret, const char* encryption, struct dagger *net) +int wifi_mode_changed(struct lyd_node *wifi) { - FILE *wpa_supplicant = NULL, *wpa = NULL; - char *encryption_str; - int rc = SR_ERR_OK; + struct lyd_node *station, *ap; + enum lydx_op station_op, ap_op; - if (!secret && (ssid && country && encryption)) { - /* Not an error, updated from two ways, interface cb and keystore cb. */ + if (!wifi) return 0; - } - - wpa = dagger_fopen_net_init(net, ifname, NETDAG_INIT_POST, "wpa_supplicant.sh"); - if (!wpa) { - rc = SR_ERR_INTERNAL; - goto out; - } + station = lydx_get_child(wifi, "station"); + ap = lydx_get_child(wifi, "access-point"); + if (station) + station_op = lydx_get_op(station); + if (ap) + ap_op = lydx_get_op(ap); + + return ((station && station_op == LYDX_OP_DELETE) || (ap && ap_op == LYDX_OP_DELETE)); +} +/* + * Add WiFi virtual interface using iw + */ +int wifi_add_iface(struct lyd_node *cif, struct dagger *net) +{ + const char *ifname, *radio; + struct lyd_node *wifi; + wifi_mode_t mode; + FILE *iw; + int rc = SR_ERR_OK; - fprintf(wpa, "# Generated by Infix confd\n"); + ifname = lydx_get_cattr(cif, "name"); + wifi = lydx_get_child(cif, "wifi"); - fprintf(wpa, "if [ -f '/etc/finit.d/enabled/wifi@%s.conf' ];then\n", ifname); - fprintf(wpa, "initctl -bfqn touch wifi@%s\n", ifname); - fprintf(wpa, "else\n"); - fprintf(wpa, "initctl -bfqn enable wifi@%s\n", ifname); - fprintf(wpa, "fi\n"); - fclose(wpa); + if (!wifi) { + ERROR("WiFi interface %s: no wifi container", ifname); + return SR_ERR_INVAL_ARG; + } - wpa_supplicant = fopenf("w", WPA_SUPPLICANT_CONF, ifname); - if (!wpa_supplicant) { - rc = SR_ERR_INTERNAL; - goto out; + radio = lydx_get_cattr(wifi, "radio"); + if (!radio) { + ERROR("WiFi interface %s: missing radio reference", ifname); + return SR_ERR_INVAL_ARG; } - if (!secret || !ssid || !country || !encryption) { - fprintf(wpa_supplicant, - "ctrl_interface=/run/wpa_supplicant\n" - "autoscan=periodic:10\n" - "ap_scan=1\n"); - } else { - if (!strcmp(encryption, "disabled")) { - asprintf(&encryption_str, "key_mgmt=NONE"); - } else { - asprintf(&encryption_str, "key_mgmt=SAE WPA-PSK\npsk=\"%s\"", secret); - } - fprintf(wpa_supplicant, - "country=%s\n" - "ctrl_interface=/run/wpa_supplicant\n" - "autoscan=periodic:10\n" - "ap_scan=1\n" - "network={\n" - "bgscan=\"simple: 30:-45:300\"\n" - "ssid=\"%s\"\n" - "%s\n" - "}\n", country, ssid, encryption_str); - free(encryption_str); + iw = dagger_fopen_net_init(net, ifname, NETDAG_INIT_PRE, "wifi-iface.sh"); + if (!iw) { + ERROR("Failed to open dagger file for WiFi interface creation"); + return SR_ERR_INTERNAL; } - fclose(wpa_supplicant); + mode = wifi_get_mode(wifi); + + fprintf(iw, "# Generated by Infix confd - WiFi Interface Creation\n"); + fprintf(iw, "# Create %s interface %s on radio %s\n", + mode == wifi_station ? "station" : "access point", ifname, radio); + + switch(mode) { + case wifi_station: + fprintf(iw, "iw phy %s interface add %s type managed\n", radio, ifname); + break; + case wifi_ap: + fprintf(iw, "iw phy %s interface add %s type __ap\n", radio, ifname); + break; + default: + ERROR("WiFi mode %d unknown", mode); + rc = SR_ERR_INVAL_ARG; + goto out; + } out: + fclose(iw); return rc; - } -int wifi_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net) + +/* + * Delete WiFi virtual interface using iw + */ +int wifi_del_iface(struct lyd_node *dif, struct dagger *net) { - const char *ssid, *secret_name, *secret, *ifname, *country, *encryption; - struct lyd_node *wifi, *secret_node; + const char *ifname; + FILE *iw; - bool enabled; - ifname = lydx_get_cattr(cif, "name"); + ifname = lydx_get_cattr(dif, "name"); - if (cif && !lydx_get_child(cif, "wifi")) { - return wifi_gen_config(ifname, NULL, NULL, NULL, NULL, net); + iw = dagger_fopen_net_exit(net, ifname, NETDAG_EXIT_POST, "wifi-iface.sh"); + if (!iw) { + ERROR("Failed to open dagger file for WiFi interface deletion"); + return SR_ERR_INTERNAL; } - enabled = lydx_get_bool(cif, "enabled"); - wifi = lydx_get_child(cif, "wifi"); - - ssid = lydx_get_cattr(wifi, "ssid"); - secret_name = lydx_get_cattr(wifi, "secret"); - country = lydx_get_cattr(wifi, "country-code"); - encryption = lydx_get_cattr(wifi, "encryption"); - secret_node = lydx_get_xpathf(cif, "../../keystore/symmetric-keys/symmetric-key[name='%s']", secret_name); - secret = lydx_get_cattr(secret_node, "cleartext-symmetric-key"); - - if (!enabled) - return wifi_gen_del(cif, net); - - return wifi_gen_config(ifname, ssid, country, secret, encryption, net); -} - -int wifi_gen_del(struct lyd_node *dif, struct dagger *net) -{ - const char *ifname = lydx_get_cattr(dif, "name"); - FILE *iw = dagger_fopen_net_exit(net, ifname, NETDAG_EXIT_PRE, "iw.sh"); - - fprintf(iw, "# Generated by Infix confd\n"); + fprintf(iw, "# Generated by Infix confd - WiFi Interface Deletion\n"); + fprintf(iw, "ip link set %s down\n", ifname); /* Required to change modes. */ fprintf(iw, "iw dev %s disconnect\n", ifname); - fprintf(iw, "initctl -bfqn disable wifi@%s\n", ifname); + fprintf(iw, "iw dev %s del\n", ifname); fclose(iw); - erasef(WPA_SUPPLICANT_CONF, ifname); return SR_ERR_OK; } diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c index 0a0cc0326..2aaf05384 100644 --- a/src/confd/src/interfaces.c +++ b/src/confd/src/interfaces.c @@ -418,7 +418,7 @@ static int netdag_gen_afspec_add(sr_session_ctx_t *session, struct dagger *net, case IFT_VXLAN: return vxlan_gen(NULL, cif, ip); case IFT_WIFI: - return wifi_gen(NULL, cif, net); + return wifi_add_iface(cif, net); case IFT_ETH: return netdag_gen_ethtool(net, cif, dif); case IFT_LO: @@ -448,7 +448,10 @@ static int netdag_gen_afspec_set(sr_session_ctx_t *session, struct dagger *net, case IFT_ETH: return netdag_gen_ethtool(net, cif, dif); case IFT_WIFI: - return wifi_gen(dif, cif, net); + /* WiFi daemon config (hostapd/wpa_supplicant) is handled by + * hardware.c when the radio (phy) is configured. Interface + * creation/deletion is handled in netdag_gen_afspec_add(). */ + return 0; case IFT_DUMMY: case IFT_GRE: case IFT_GRETAP: @@ -472,9 +475,10 @@ static bool netdag_must_del(struct lyd_node *dif, struct lyd_node *cif) case IFT_BRIDGE: case IFT_DUMMY: case IFT_LO: + case IFT_WIFI: + return lydx_get_child(dif, "custom-phys-address") || lydx_get_descendant(dif, "wifi", "radio", NULL) || wifi_mode_changed(lydx_get_child(dif, "wifi")); break; - case IFT_WIFI: case IFT_ETH: return lydx_get_child(dif, "custom-phys-address"); @@ -562,13 +566,12 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif, case IFT_LO: eth_gen_del(dif, ip); break; - case IFT_WIFI: - eth_gen_del(dif, ip); - wifi_gen_del(dif, net); - break; case IFT_VETH: veth_gen_del(dif, ip); break; + case IFT_WIFI: + wifi_del_iface(dif, net); + break; case IFT_BRIDGE: case IFT_DUMMY: case IFT_GRE: @@ -587,7 +590,7 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif, static sr_error_t netdag_gen_iface_timeout(struct dagger *net, const char *ifname, const char *iftype) { - if (!strcmp(iftype, "infix-if-type:ethernet") || !strcmp(iftype, "infix-if-type:wifi")) { + if (!strcmp(iftype, "infix-if-type:ethernet")) { FILE *wait = dagger_fopen_net_init(net, ifname, NETDAG_INIT_TIMEOUT, "wait-interface.sh"); if (!wait) { return -EIO; @@ -734,10 +737,9 @@ static int netdag_init_iface(struct lyd_node *cif) return vlan_add_deps(cif); case IFT_VETH: return veth_add_deps(cif); - + case IFT_WIFI: case IFT_DUMMY: case IFT_ETH: - case IFT_WIFI: case IFT_GRE: case IFT_GRETAP: case IFT_LO: diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h index aaa583688..71d2ef1b5 100644 --- a/src/confd/src/interfaces.h +++ b/src/confd/src/interfaces.h @@ -25,15 +25,15 @@ _map(IFT_BRIDGE, "infix-if-type:bridge") \ _map(IFT_DUMMY, "infix-if-type:dummy") \ _map(IFT_ETH, "infix-if-type:ethernet") \ - _map(IFT_WIFI, "infix-if-type:wifi") \ _map(IFT_GRE, "infix-if-type:gre") \ _map(IFT_GRETAP, "infix-if-type:gretap") \ - _map(IFT_LAG, "infix-if-type:lag") \ + _map(IFT_LAG, "infix-if-type:lag") \ _map(IFT_LO, "infix-if-type:loopback") \ _map(IFT_VETH, "infix-if-type:veth") \ _map(IFT_VLAN, "infix-if-type:vlan") \ _map(IFT_VXLAN, "infix-if-type:vxlan") \ - /* */ + _map(IFT_WIFI, "infix-if-type:wifi") \ +/* */ enum iftype { #define ift_enum(_enum, _str) _enum, @@ -122,8 +122,9 @@ int bridge_mcd_gen(struct lyd_node *cifs); int bridge_port_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip); /* if-wifi.c */ -int wifi_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net); -int wifi_gen_del(struct lyd_node *dif, struct dagger *net); +int wifi_add_iface(struct lyd_node *cif, struct dagger *net); +int wifi_del_iface(struct lyd_node *dif, struct dagger *net); +int wifi_mode_changed(struct lyd_node *wifi); /* if-gre.c */ int gre_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip); diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 8845f5b83..025665051 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -23,7 +23,7 @@ MODULES=( "infix-syslog@2025-11-17.yang" "iana-hardware@2018-03-13.yang" "ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor" - "infix-hardware@2025-10-30.yang" + "infix-hardware@2025-12-04.yang" "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2025-11-02.yang" "infix-if-type@2025-02-12.yang" diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index d702994fc..c4bce1ead 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -13,10 +13,18 @@ module infix-hardware { prefix yang; } + import infix-wifi-country-codes { + prefix iwcc; + } + organization "KernelKit"; contact "kernelkit@googlegroups.com"; description "Vital Product Data augmentation of ieee-hardware and deviations."; + revision 2025-12-04 { + description "Add WiFi radio survey container for channel utilization data."; + reference "internal"; + } revision 2025-10-30 { description "Add phys-address leaf for hardware components and enable sensor support."; reference "internal"; @@ -38,6 +46,38 @@ module infix-hardware { description "A two-letter country code."; } + /* + * WiFi-specific typedefs + */ + + typedef wifi-radio-ref { + type leafref { + path "/iehw:hardware/iehw:component/iehw:name"; + } + description + "Reference to a WiFi radio hardware component. + WiFi radios are hardware components with class 'ih:wifi'."; + } + + typedef wifi-band { + type enumeration { + enum "2.4GHz" { + description "2.4 GHz band (channels 1-14, maximum compatibility)"; + } + enum "5GHz" { + description "5 GHz band (less congestion, higher throughput, recommended)"; + } + enum "6GHz" { + description "6 GHz band (WiFi 6E, requires compatible hardware)"; + } + } + description "WiFi frequency band selection."; + } + + /* + * Hardware class identities + */ + identity usb { base iahw:hardware-class; description "This identity is used to describe a USB port"; @@ -143,5 +183,335 @@ module infix-hardware { } } } + + /* + * WiFi Radio configuration (when class = 'ih:wifi') + */ + + container wifi-radio { + when "derived-from-or-self(../iehw:class, 'ih:wifi')"; + presence "WiFi radio configuration"; + description + "WiFi radio/PHY configuration and operational data. + + This container is present when the hardware component represents + a WiFi radio (class 'ih:wifi'). WiFi radios are physical devices + that can host multiple virtual WiFi interfaces (APs or Stations)."; + + leaf country-code { + type iwcc:country-code; + mandatory true; + description + "Two-letter ISO 3166-1 country code for regulatory compliance. + + Sets the regulatory domain for this radio, determining: + - Allowed channels and frequencies + - Maximum transmit power + - DFS (Dynamic Frequency Selection) requirements + + Examples: 'US', 'DE', 'JP'. + + WARNING: Incorrect values may violate local laws and regulations."; + } + + leaf channel { + type union { + type uint16 { + range "1..196"; + } + type enumeration { + enum "auto" { + description "Automatic channel selection (ACS)"; + } + } + } + default "auto"; + description + "Operating channel number. + + Required for Access Point mode. + Not used in Station mode (station uses AP's channel). + + Channel availability depends on: + - Configured band (2.4/5/6 GHz) + - Regulatory domain (country-code) + - Hardware capabilities + + Common channels: + - 2.4 GHz: 1-14 (channels 12-14 restricted in some countries) + - 5 GHz: 36, 40, 44, 48, 149, 153, 157, 161, 165 (varies by region) + - 6 GHz: 1-233 (WiFi 6E, where permitted) + + Set to 'auto' for automatic channel selection."; + } + + leaf band { + type wifi-band; + description + "Frequency band selection. + + Required for Access Point mode. + Not used in Station mode (station uses AP's band). + + Constraints: + - Hardware must support the selected band + - Regulatory domain affects channel availability + - PHY mode must be compatible with selected band + + Recommendation: Use 5GHz for better performance and less + congestion in most environments."; + } + + leaf enable-wifi6 { + type boolean; + default false; + description + "Enable WiFi 6 (802.11ax) on 2.4GHz and 5GHz bands. + + By default, WiFi 6 is enabled only on 6GHz (WiFi 6E). + Set to 'true' to enable WiFi 6 on 2.4GHz and 5GHz bands. + + WiFi 6 provides: + - OFDMA (better multi-user efficiency) + - Target Wake Time (better battery life) + - 1024-QAM (higher throughput) + - BSS Coloring (reduced interference) + + Requires: + - Hardware support for 802.11ax + - Compatible clients for full benefits + + Note: 6GHz band always uses WiFi 6 regardless of this setting."; + } + + /* + * Operational state + */ + + leaf frequency { + config false; + type uint32; + units "MHz"; + description + "Current operating frequency in MHz. + + Derived from the configured channel and band. + + Example values: + - 2412 MHz (channel 1, 2.4 GHz) + - 5180 MHz (channel 36, 5 GHz) + - 5955 MHz (channel 1, 6 GHz)"; + } + + leaf noise { + config false; + type int16; + units "dBm"; + description + "Background noise level on current channel in dBm. + + Lower (more negative) values indicate a cleaner RF environment. + + Typical values: + - -95 to -100 dBm: Very low noise (excellent) + - -85 to -95 dBm: Low noise (good) + - -75 to -85 dBm: Moderate noise + - -65 to -75 dBm: High noise (congested)"; + } + + leaf-list supported-channels { + config false; + type uint16; + description + "List of channels supported by this radio in the current + regulatory domain. + + Channels depend on: + - Hardware capabilities + - Configured country-code + - Band selection + + This list reflects actual usable channels after applying + regulatory constraints."; + } + + leaf max-txpower { + config false; + type uint8; + units "dBm"; + description + "Maximum transmit power allowed by the regulatory domain + for the current channel. + + This is the regulatory limit for the current band and channel."; + } + + leaf num-virtual-interfaces { + config false; + type uint8; + description + "Number of virtual interfaces (AP/Station) currently + configured on this radio."; + } + + leaf driver { + config false; + type string; + description + "WiFi driver name (e.g., mt798x-wmac, ath10k)."; + } + + container max-interfaces { + config false; + description + "Maximum number of virtual interfaces supported by this radio."; + + leaf ap { + type uint8; + description + "Maximum number of AP interfaces."; + } + + leaf station { + type uint8; + description + "Maximum number of station interfaces."; + } + } + + list bands { + config false; + key "band"; + description + "Supported frequency bands and their capabilities."; + + leaf band { + type string; + description + "Band identifier from iw (e.g., '1' for 2.4GHz, '2' for 5GHz)."; + } + + leaf name { + type string; + description + "Human-readable band name (e.g., '2.4GHz', '5GHz', '6GHz')."; + } + + leaf ht-capable { + type boolean; + description + "High Throughput (802.11n) support."; + } + + leaf vht-capable { + type boolean; + description + "Very High Throughput (802.11ac) support."; + } + + leaf he-capable { + type boolean; + description + "High Efficiency (802.11ax/WiFi 6) support."; + } + } + + /* + * Channel survey data (operational state) + */ + + container survey { + config false; + description + "WiFi channel survey data providing channel utilization + and interference information. + + This data is collected from the WiFi driver and provides + insights into channel occupancy, noise levels, and RF activity."; + + list channel { + key "frequency"; + description + "Per-channel survey information. + + Includes utilization metrics for all channels scanned by + the radio, not just the currently active channel."; + + leaf frequency { + type uint32; + units "MHz"; + description + "Channel center frequency in MHz. + + Examples: + - 2412 MHz (2.4 GHz channel 1) + - 5180 MHz (5 GHz channel 36) + - 5955 MHz (6 GHz channel 1)"; + } + + leaf in-use { + type boolean; + description + "Whether this channel is currently in use by the radio. + + Only one channel will have this set to true at a time."; + } + + leaf noise { + type int16; + units "dBm"; + description + "Background noise level on this channel in dBm. + + Lower (more negative) values indicate cleaner RF environment. + + Typical values: + - -95 to -100 dBm: Very low noise (excellent) + - -85 to -95 dBm: Low noise (good) + - -75 to -85 dBm: Moderate noise + - -65 to -75 dBm: High noise (congested)"; + } + + leaf active-time { + type uint32; + units "milliseconds"; + description + "Total time the radio was active on this channel. + + This is the survey measurement period for this channel."; + } + + leaf busy-time { + type uint32; + units "milliseconds"; + description + "Time the channel was detected as busy. + + Includes time spent receiving frames, transmitting frames, + and time the channel was busy due to other sources. + + Channel utilization = (busy-time / active-time) * 100%"; + } + + leaf receive-time { + type uint32; + units "milliseconds"; + description + "Time spent receiving frames on this channel. + + Subset of busy-time spent on frame reception."; + } + + leaf transmit-time { + type uint32; + units "milliseconds"; + description + "Time spent transmitting frames on this channel. + + Subset of busy-time spent on frame transmission."; + } + } + } + } } } diff --git a/src/confd/yang/confd/infix-hardware@2025-10-30.yang b/src/confd/yang/confd/infix-hardware@2025-12-04.yang similarity index 100% rename from src/confd/yang/confd/infix-hardware@2025-10-30.yang rename to src/confd/yang/confd/infix-hardware@2025-12-04.yang diff --git a/src/confd/yang/confd/infix-if-bridge.yang b/src/confd/yang/confd/infix-if-bridge.yang index 72247ba61..c3af782c8 100644 --- a/src/confd/yang/confd/infix-if-bridge.yang +++ b/src/confd/yang/confd/infix-if-bridge.yang @@ -928,6 +928,9 @@ submodule infix-if-bridge { must "not(../ip:ipv4/ip:address or ../ip:ipv6/ip:address)" { error-message "Bridge ports cannot have IP addresses configured."; } + must "not(derived-from-or-self(../if:type, 'infix-ift:wifi')) or ../infix-if:wifi/infix-if:access-point" { + error-message "WiFi interfaces can only be bridge ports when configured as Access Points."; + } description "Bridge association and port specific settings."; uses bridge-port-common; uses bridge-port-lower { diff --git a/src/confd/yang/confd/infix-if-type.yang b/src/confd/yang/confd/infix-if-type.yang index 8e2ea0d6f..dd937609b 100644 --- a/src/confd/yang/confd/infix-if-type.yang +++ b/src/confd/yang/confd/infix-if-type.yang @@ -110,6 +110,6 @@ module infix-if-type { if-feature wifi; base infix-interface-type; base ianaift:ieee80211; - description "WiFi interface"; + description "WiFi (802.11) interface"; } } diff --git a/src/confd/yang/confd/infix-if-wifi.yang b/src/confd/yang/confd/infix-if-wifi.yang index e3f5e39b9..a68d1f604 100644 --- a/src/confd/yang/confd/infix-if-wifi.yang +++ b/src/confd/yang/confd/infix-if-wifi.yang @@ -21,8 +21,11 @@ submodule infix-if-wifi { import infix-if-type { prefix infixift; } - import infix-wifi-country-codes { - prefix iwcc; + import ietf-hardware { + prefix iehw; + } + import infix-hardware { + prefix ih; } organization "KernelKit"; @@ -30,12 +33,30 @@ submodule infix-if-wifi { description "WiFi-specific extensions to the standard IETF interfaces model. - This submodule defines configuration and operational data relevant to - WiFi interfaces, including security settings, network - discovery, and regulatory compliance. + This submodule defines configuration and operational data for WiFi + virtual interfaces, supporting both Access Point (AP) and Station + (client) modes. + + WiFi virtual interfaces are created on top of WiFi radios (PHYs) + defined in the infix-wifi-radio module. The radio provides physical + layer configuration (channel, power, PHY mode), while virtual + interfaces provide network-layer configuration (SSID, security). + + Key features: + - Dual mode support: AP and Station + - Multi-SSID: Multiple APs on same radio + - Security: WPA2/WPA3 with keystore integration + - Operational state: Connection status, RSSI, client lists"; - It supports WiFi client mode and enables comprehensive management of - wireless connections, including encryption, country codes, and scanning."; + revision 2025-12-17 { + description + "Major refactoring for AP mode support (BREAKING CHANGE): + - Added radio reference (parent PHY) + - Added wifi-mode choice (AP vs Station) + - Reorganized configuration hierarchy + - Old configurations must be migrated manually"; + reference "internal"; + } revision 2025-12-12 { description "Adapt to new revision of model ietf-keystore."; @@ -43,7 +64,7 @@ submodule infix-if-wifi { } revision 2025-05-27 { - description "Initial revision."; + description "Initial revision (Station mode only)."; reference "internal"; } @@ -51,29 +72,6 @@ submodule infix-if-wifi { description "WiFi support is an optional build-time feature in Infix."; } - typedef encryption { - type enumeration { - enum auto { - description - "Enables WPA/WPA2/WPA3 encryption with automatic protocol - negotiation. The system uses the strongest supported variant supported by Access Point."; - } - enum disabled { - description - "Disables encryption for an open network. - - WARNING: Open networks transmit data unencrypted and should only - be used in trusted environments."; - } - } - description - "Encryption modes available for WiFi connections. - - - auto: Secure connection using WPA3/WPA2/WPA (auto-selected) - - disabled: Open network (unencrypted)"; - } - - augment "/if:interfaces/if:interface" { when "derived-from-or-self(if:type, 'infixift:wifi')" { description @@ -82,107 +80,370 @@ submodule infix-if-wifi { container wifi { if-feature wifi; - presence "Configure Wi-Fi settings"; + presence "Configure Wi-Fi virtual interface"; description - "WiFi-specific configuration and operational data."; + "WiFi virtual interface configuration. - leaf country-code { - type iwcc:country-code; - mandatory true; - description - "Two-letter ISO 3166-1 country code for regulatory compliance. - - Examples: 'US', 'DE', 'JP'. - - WARNING: Incorrect values may violate local laws."; + Each WiFi interface represents a virtual interface (VAP - Virtual + Access Point, or Station) created on a physical radio. + The interface must reference a radio defined in infix-wifi-radio + module, which provides the physical layer configuration."; - } - - leaf encryption { - default auto; - type encryption; - - description - "WiFi encryption method. - - - auto (default): Enables WPA2/WPA3 auto-negotiation - - disabled: Disables encryption (open network)"; - } - - leaf ssid { - type string { - length "1..32"; + leaf radio { + type leafref { + path "/iehw:hardware/iehw:component/iehw:name"; } mandatory true; - - description - "WiFi network name (SSID). - - Case-sensitive, must match the target network. - - Length: 1–32 characters."; - } - - leaf secret { - type ks:central-symmetric-key-ref; - mandatory true; - must "../encryption != 'disabled'" { - error-message - "Pre-shared key required unless encryption is disabled."; + must "derived-from-or-self(/iehw:hardware/iehw:component[iehw:name=current()]/iehw:class, 'ih:wifi')" { + error-message "Referenced hardware component must be a WiFi radio (class 'ih:wifi')"; } description - "Pre-shared key (PSK) for WPA-secured networks."; - } + "Reference to parent WiFi radio (PHY). - leaf rssi { - config false; - type int16; - units "dBm"; - description - "Current received signal strength (RSSI) in dBm. + References a hardware component with class 'ih:wifi'. + The radio must exist and be configured before creating + virtual interfaces. - Lower (more negative) values indicate stronger signals."; + Example: 'phy0' for the first WiFi radio. + + All physical layer settings (channel, power, regulatory) + are inherited from the radio configuration."; } - list scan-results { - config false; - key ssid; + choice wifi-mode { description - "List of discovered networks."; - - leaf ssid { - type string; - description - "SSID of the discovered network."; - } - - leaf bssid { - type string; - description - "BSSID of the discovered network."; - } - - leaf rssi { - type int16; - units "dBm"; - description - "Signal strength of the network."; - } - - leaf channel { - type int16; - description - "Channel on which the network was detected."; + "WiFi interface operating mode. + + When no mode is configured, the interface operates in scan-only + mode, allowing discovery of available WiFi networks. + + Once you've identified a network, configure either: + - Station mode: Connect to an existing WiFi network + - Access Point mode: Create a WiFi network for clients + + Note: A radio can host either: + - Multiple AP interfaces (multi-SSID), OR + - A single Station interface + + Mixing AP and Station on the same radio is not supported."; + + case station { + container station { + presence "Configure WiFi station (client) mode"; + + description + "WiFi Station mode configuration. + + In station mode, the interface acts as a WiFi client, + connecting to an existing Access Point. + + Only one station interface is allowed per radio. + + Example use case: Connect to upstream WiFi network."; + + leaf ssid { + type string { + length "1..32"; + } + mandatory true; + description + "WiFi network name (SSID) to connect to. + + Case-sensitive, must match the target network exactly. + + Length: 1–32 characters."; + } + + container security { + description + "WiFi security configuration."; + + leaf mode { + type enumeration { + enum auto { + description + "Automatic security negotiation. + Tries WPA3-SAE, then WPA2-PSK, in that order. + Recommended for maximum compatibility and security."; + } + enum disabled { + description + "Open network (no security). + + WARNING: All traffic is transmitted unencrypted! + Only use in trusted environments."; + } + } + default auto; + description + "Security mode for WiFi connection. + + - auto (default): WPA3/WPA2 auto-negotiation (secure) + - disabled: Open network (insecure)"; + } + + leaf secret { + when "../mode != 'disabled'"; + type ks:central-symmetric-key-ref; + mandatory true; + description + "Pre-shared key (PSK) reference. + + References a symmetric key in the keystore. + + For WPA2/WPA3 networks, this is the WiFi password."; + } + } + + /* Operational state */ + + leaf rssi { + config false; + type int16; + units "dBm"; + description + "Current received signal strength indication (RSSI) in dBm. + + More negative values indicate weaker signal. + + Typical values: + - -30 to -50 dBm: Excellent + - -50 to -60 dBm: Good + - -60 to -70 dBm: Fair + - -70 to -80 dBm: Weak + - Below -80 dBm: Very weak"; + } + + list scan-results { + config false; + key ssid; + description + "List of discovered WiFi networks. + + Updated periodically by background scanning."; + + leaf ssid { + type string; + description "SSID of the discovered network."; + } + + leaf bssid { + type yang:mac-address; + description "BSSID (MAC address) of the AP."; + } + + leaf rssi { + type int16; + units "dBm"; + description "Signal strength of the network."; + } + + leaf channel { + type uint16; + description "Channel on which the network was detected."; + } + + leaf-list encryption { + ordered-by user; + type string; + description + "Human-readable security information. + + Examples: 'WPA2-Personal', 'WPA3-SAE', 'Open'"; + } + } + } } - leaf-list encryption { - ordered-by user; - type string; - description - "Human-readable description of the detected security."; + case access-point { + container access-point { + presence "Configure WiFi Access Point mode"; + + description + "WiFi Access Point mode configuration. + + In AP mode, the interface provides a WiFi network that + clients can connect to. + + Multiple AP interfaces can be created on the same radio + for multi-SSID support (Guest network, IoT network, etc.). + + Example use case: Create WiFi hotspot."; + + must "/iehw:hardware/iehw:component[iehw:name = current()/../radio]/ih:wifi-radio/ih:band" { + error-message "Parent radio must have 'band' configured for Access Point mode"; + } + + must "/iehw:hardware/iehw:component[iehw:name = current()/../radio]/ih:wifi-radio/ih:channel" { + error-message "Parent radio must have 'channel' configured for Access Point mode"; + } + + must "/iehw:hardware/iehw:component[iehw:name = current()/../radio]/ih:wifi-radio/ih:country-code != '00'" { + error-message "Country code '00' (world regulatory domain) is not allowed for Access Point mode. Please configure a specific country code on the radio."; + } + + leaf ssid { + type string { + length "1..32"; + } + mandatory true; + description + "WiFi network name (SSID) to broadcast. + + This is the network name that clients will see when + scanning for WiFi networks. + + Length: 1–32 characters."; + } + + leaf hidden { + type boolean; + default false; + description + "Hide the SSID from broadcast beacons. + + When true, the network will not appear in WiFi scans. + Clients must know the exact SSID to connect. + + Note: This provides minimal security benefit and may + cause compatibility issues with some clients."; + } + + container security { + description + "WiFi security configuration."; + + leaf mode { + type enumeration { + enum open { + description + "Open network (no encryption). + + WARNING: All client traffic is unencrypted! + Only use in controlled environments (captive portal, etc.)."; + } + enum wpa2-personal { + description + "WPA2-Personal (WPA2-PSK). + Widely compatible, secure for most use cases."; + } + enum wpa3-personal { + description + "WPA3-Personal (WPA3-SAE). + Enhanced security with forward secrecy. + Requires WPA3-capable clients."; + } + enum wpa2-wpa3-personal { + description + "WPA2/WPA3 transitional mode. + Accepts both WPA2 and WPA3 clients. + Recommended for maximum compatibility + security."; + } + } + default wpa2-wpa3-personal; + description + "WiFi security mode. + + Determines authentication and encryption methods. + + Recommended: wpa2-wpa3-personal for best security + and compatibility."; + } + + leaf secret { + when "../mode != 'open'"; + type ks:central-symmetric-key-ref; + mandatory true; + description + "Pre-shared key (PSK) reference. + + References a symmetric key in the keystore. + + This is the WiFi password that clients must provide + to connect to the network. + + Requirements: + - WPA2/WPA3: 8-63 characters (configured in keystore)"; + } + } + + /* Operational state */ + + container stations { + list station { + config false; + key mac-address; + description + "List of currently connected clients (stations)."; + + leaf mac-address { + type yang:mac-address; + description "Client MAC address."; + } + + leaf rssi { + type int16; + units "dBm"; + description "Client signal strength in dBm."; + } + + leaf connected-time { + type uint32; + units "seconds"; + description "Time since client connected, in seconds."; + } + + leaf rx-packets { + type uint32; + description "Packets received from this client."; + } + + leaf tx-packets { + type uint32; + description "Packets transmitted to this client."; + } + + leaf rx-bytes { + type uint32; + units "octets"; + description "Bytes received from this client."; + } + + leaf tx-bytes { + type uint32; + units "octets"; + description "Bytes transmitted to this client."; + } + + leaf rx-speed { + type uint32; + units "100 kbit/s"; + description + "Last received data rate from this client in 100 kbit/s. + + Examples: + - 10 = 1 Mbit/s + - 65 = 6.5 Mbit/s + - 866 = 86.6 Mbit/s"; + } + + leaf tx-speed { + type uint32; + units "100 kbit/s"; + description + "Last transmitted data rate to this client in 100 kbit/s. + + Examples: + - 10 = 1 Mbit/s + - 65 = 6.5 Mbit/s + - 866 = 86.6 Mbit/s"; + } + } + } + } } } } diff --git a/src/confd/yang/confd/infix-if-wifi@2025-12-10.yang b/src/confd/yang/confd/infix-if-wifi@2025-12-17.yang similarity index 100% rename from src/confd/yang/confd/infix-if-wifi@2025-12-10.yang rename to src/confd/yang/confd/infix-if-wifi@2025-12-17.yang diff --git a/src/confd/yang/confd/infix-interfaces.yang b/src/confd/yang/confd/infix-interfaces.yang index 9fea0434e..405cf8326 100644 --- a/src/confd/yang/confd/infix-interfaces.yang +++ b/src/confd/yang/confd/infix-interfaces.yang @@ -18,6 +18,12 @@ module infix-interfaces { import infix-if-type { prefix infix-ift; } + import ietf-hardware { + prefix iehw; + } + import infix-hardware { + prefix ih; + } include infix-if-base; include infix-if-bridge; diff --git a/src/confd/yang/confd/infix-keystore.yang b/src/confd/yang/confd/infix-keystore.yang index 705ccb0a7..f94a362c3 100644 --- a/src/confd/yang/confd/infix-keystore.yang +++ b/src/confd/yang/confd/infix-keystore.yang @@ -55,9 +55,9 @@ module infix-keystore { } augment "/ks:keystore/ks:symmetric-keys/ks:symmetric-key/ks:key-type" { case cleartext-symmetric-key { - leaf cleartext-symmetric-key { + leaf symmetric-key { type string; - must "../infix-ks:key-format != 'infix-ct:wifi-preshared-key-format' or " + + must "../../key-format != 'infix-ct:wifi-preshared-key-format' or " + "(string-length(.) >= 8 and string-length(.) <= 63)" { error-message "WiFi pre-shared key must be 8-63 characters long"; } diff --git a/src/confd/yang/confd/infix-wifi-country-codes.yang b/src/confd/yang/confd/infix-wifi-country-codes.yang index 58bee7701..5b189efa8 100644 --- a/src/confd/yang/confd/infix-wifi-country-codes.yang +++ b/src/confd/yang/confd/infix-wifi-country-codes.yang @@ -16,7 +16,12 @@ module infix-wifi-country-codes { The regulatory domain configuration follows the principles established in IETF RFCs for wireless access point management."; - + revision 2025-11-28 { + description + "Add support for 00 - World regularity domain."; + reference + "Internal"; + } revision 2025-06-02 { description "Initial revision for WiFi country code support."; @@ -27,6 +32,7 @@ module infix-wifi-country-codes { typedef country-code { type enumeration { + enum "00" { description "World regulatory domain (no country restrictions)"; } enum "AD" { description "Andorra"; } enum "AE" { description "United Arab Emirates"; } enum "AF" { description "Afghanistan"; } diff --git a/src/confd/yang/confd/infix-wifi-country-codes@2025-06-02.yang b/src/confd/yang/confd/infix-wifi-country-codes@2025-11-28.yang similarity index 100% rename from src/confd/yang/confd/infix-wifi-country-codes@2025-06-02.yang rename to src/confd/yang/confd/infix-wifi-country-codes@2025-11-28.yang diff --git a/src/show/bash_completion.d/show b/src/show/bash_completion.d/show index c7963444b..3c6489e68 100644 --- a/src/show/bash_completion.d/show +++ b/src/show/bash_completion.d/show @@ -4,7 +4,7 @@ _show_completions() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - commands="dhcp interface ntp routes software stp" + commands="dhcp interface ntp routes software stp wifi-radio" if [[ $COMP_CWORD -eq 1 ]]; then COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) diff --git a/src/show/show.py b/src/show/show.py index 849901680..c6003146d 100755 --- a/src/show/show.py +++ b/src/show/show.py @@ -378,6 +378,17 @@ def wifi(args: List[str]): else: print(f"Invalid interface name: {iface}") +def wifi_radio(args: List[str]) -> None: + data = run_sysrepocfg("/infix-wifi-radio:wifi-radios") + if not data: + print("No WiFi radio data retrieved.") + return + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + cli_pretty(data, "show-wifi-radio") + def system(args: List[str]) -> None: # Get system state from sysrepo data = run_sysrepocfg("/ietf-system:system-state") @@ -498,7 +509,8 @@ def execute_command(command: str, args: List[str]): 'software': software, 'stp': stp, 'system': system, - 'wifi': wifi + 'wifi': wifi, + 'wifi-radio': wifi_radio } if command in command_mapping: diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index c67a4e0f3..65827b51c 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -159,18 +159,6 @@ class PadDhcpServer: exp = 10 -class PadUsbPort: - title = 30 - name = 20 - state = 10 - oper = 10 - - @classmethod - def table_width(cls): - """Total width of USB port table""" - return cls.name + cls.state + cls.oper - - class PadSensor: name = 30 value = 20 @@ -190,12 +178,6 @@ class PadNtpSource: poll = 14 -class PadWifiScan: - ssid = 40 - encryption = 30 - signal = 9 - - class PadLldp: interface = 16 rem_idx = 10 @@ -388,7 +370,7 @@ def green(txt): @staticmethod def bright_green(txt): - return Decore.decorate("1;32", txt, "39") + return Decore.decorate("1;32", txt, "0") @staticmethod def yellow(txt): @@ -691,12 +673,6 @@ def __init__(self, data): self.state = get_json_data('', self.data, 'state', 'admin-state') self.oper = get_json_data('', self.data, 'state', 'oper-state') - def print(self): - row = f"{self.name:<{PadUsbPort.name}}" - row += f"{self.state:<{PadUsbPort.state}}" - row += f"{self.oper:<{PadUsbPort.oper}}" - print(row) - class Sensor: def __init__(self, data): @@ -1092,20 +1068,73 @@ def pr_proto_loopack(self, pipe=''): print(row) def pr_wifi_ssids(self): - hdr = (f"{'SSID':<{PadWifiScan.ssid}}" - f"{'ENCRYPTION':<{PadWifiScan.encryption}}" - f"{'SIGNAL':<{PadWifiScan.signal}}") - - print(Decore.invert(hdr)) - results = self.wifi.get("scan-results", {}) + print("\nAVAILABLE NETWORKS:") + ssid_table = SimpleTable([ + Column('SSID'), + Column('SECURITY'), + Column('SIGNAL'), + Column('CHANNEL') + ]) + + station = self.wifi.get("station", {}) + results = station.get("scan-results", {}) for result in results: - encstr = ", ".join(result["encryption"]) - status = rssi_to_status(result["rssi"]) - row = f"{result['ssid']:<{PadWifiScan.ssid}}" - row += f"{encstr:<{PadWifiScan.encryption}}" - row += f"{status:<{PadWifiScan.signal}}" + encstr = ", ".join(result.get("encryption", ["Unknown"])) + status = rssi_to_status(result.get("rssi", -100)) + channel = result.get("channel", "?") - print(row) + ssid_table.row(result.get('ssid', 'Hidden'), encstr, status, channel) + ssid_table.print() + + def pr_wifi_stations(self): + """Display connected stations for AP mode""" + if not self.wifi: + return + + # Get stations from access-point container + ap = self.wifi.get("access-point", {}) + stations_data = ap.get("stations", {}) + stations = stations_data.get("station", []) + + if not stations: + return + + print("\nCONNECTED STATIONS:") + stations_table = SimpleTable([ + Column('MAC'), + Column('SIGNAL'), + Column('TIME'), + Column('RX PKT'), + Column('TX PKT'), + Column('RX BYTES'), + Column('TX BYTES'), + Column('RX SPEED'), + Column('TX SPEED') + ]) + + for station in stations: + mac = station.get("mac-address", "unknown") + rssi = station.get("rssi") + signal_str = rssi_to_status(rssi) if rssi is not None else "------" + + conn_time = station.get("connected-time", 0) + time_str = f"{conn_time}s" + + rx_pkt = station.get("rx-packets", 0) + tx_pkt = station.get("tx-packets", 0) + rx_bytes = station.get("rx-bytes", 0) + tx_bytes = station.get("tx-bytes", 0) + + # Speed in 100 kbit/s units, convert to Mbps for display + rx_speed = station.get("rx-speed", 0) + tx_speed = station.get("tx-speed", 0) + rx_speed_str = f"{rx_speed / 10:.1f}" if rx_speed else "-" + tx_speed_str = f"{tx_speed / 10:.1f}" if tx_speed else "-" + + stations_table.row(mac, signal_str, time_str, rx_pkt, tx_pkt, + rx_bytes, tx_bytes, rx_speed_str, tx_speed_str) + + stations_table.print() def pr_proto_wifi(self, pipe=''): @@ -1113,20 +1142,35 @@ def pr_proto_wifi(self, pipe=''): print(row) ssid = None rssi = None + mode = None if self.wifi: - rssi=self.wifi.get("rssi") - ssid=self.wifi.get("ssid") - if ssid is None: - ssid="------" - - if rssi is None: - signal="------" + # Detect mode: AP has "stations", Station has "rssi" or "scan-results" + ap=self.wifi.get("access-point", {}) + if ap: + ssid = ap.get("ssid", "------") + mode = "AP" + stations_data = ap.get("stations", {}) + stations = stations_data.get("station", []) + station_count = len(stations) + data_str = f"{mode}, ssid: {ssid}, stations: {station_count}" + else: + station=self.wifi.get("station", {}) + ssid = station.get("ssid", "------") + rssi = station.get("rssi") + mode = "Station" + if rssi is not None: + signal = rssi_to_status(rssi) + data_str = f"{mode}, ssid: {ssid}, signal: {signal}" + else: + data_str = f"{mode}, ssid: {ssid}" else: - signal=rssi_to_status(rssi) - data_str = f"ssid: {ssid}, signal: {signal}" + data_str = "ssid: ------" - row = f"{'':<{Pad.iface}}" + row = f"{'':<{Pad.flags}}" + row += f"{pipe:<{Pad.iface}}" + row = f"{'':<{Pad.flags}}" + row += f"{pipe:<{Pad.iface}}" row += f"{'wifi':<{Pad.proto}}" row += f"{'':<{Pad.state}}{data_str}" print(row) @@ -1394,13 +1438,37 @@ def pr_iface(self): else: print(f"{'ipv6 addresses':<{20}}:") + if self.in_octets and self.out_octets: + print(f"{'in-octets':<{20}}: {self.in_octets}") + print(f"{'out-octets':<{20}}: {self.out_octets}") + + frame = get_json_data([], self.data,'ieee802-ethernet-interface:ethernet', + 'statistics', 'frame') + if self.wifi: - ssid=self.wifi.get('ssid', "----") - rssi=self.wifi.get('rssi', "----") - print(f"{'SSID':<{20}}: {ssid}") - print(f"{'Signal':<{20}}: {rssi}") - print("") - self.pr_wifi_ssids() + # Detect mode: AP has "stations", Station has "rssi" or "scan-results" + ap = self.wifi.get('access-point') + if ap: + mode = "access-point" + ssid = ap.get('ssid', "----") + stations_data = ap.get("stations", {}) + stations = stations_data.get("station", []) + print(f"{'mode':<{20}}: {mode}") + print(f"{'ssid':<{20}}: {ssid}") + print(f"{'connected stations':<{20}}: {len(stations)}") + self.pr_wifi_stations() + else: + mode = "station" + station = self.wifi.get('station', {}) + rssi = station.get('rssi') + ssid = station.get('ssid', "----") + print(f"{'mode':<{20}}: {mode}") + print(f"{'ssid':<{20}}: {ssid}") + if rssi is not None: + signal_status = rssi_to_status(rssi) + print(f"{'signal':<{20}}: {rssi} dBm ({signal_status})") + if "scan-results" in station: + self.pr_wifi_ssids() if self.gre: print(f"{'local address':<{20}}: {self.gre['local']}") @@ -1411,12 +1479,6 @@ def pr_iface(self): print(f"{'remote address':<{20}}: {self.vxlan['remote']}") print(f"{'VxLAN id':<{20}}: {self.vxlan['vni']}") - if self.in_octets and self.out_octets: - print(f"{'in-octets':<{20}}: {self.in_octets}") - print(f"{'out-octets':<{20}}: {self.out_octets}") - - frame = get_json_data([], self.data,'ieee802-ethernet-interface:ethernet', - 'statistics', 'frame') if frame: print("") for key, val in frame.items(): @@ -1797,9 +1859,6 @@ def show_services(json): services_data = get_json_data({}, json, 'ietf-system:system-state', 'infix-system:services') services = services_data.get("service", []) - # This is the first usage of simple table. I assume this will be - # copied so I left a lot of comments. If you copy it feel free - # to be less verbose.. service_table = SimpleTable([ Column('NAME'), Column('STATUS'), @@ -1849,9 +1908,9 @@ def show_hardware(json): motherboard = [c for c in components if c.get("class") == "iana-hardware:chassis"] usb_ports = [c for c in components if c.get("class") == "infix-hardware:usb"] sensors = [c for c in components if c.get("class") == "iana-hardware:sensor"] + wifi_radios = [c for c in components if c.get("class") == "infix-hardware:wifi"] - # Determine overall width (use the wider of the two sections) - width = max(PadUsbPort.table_width(), PadSensor.table_width()) + width = max(PadSensor.table_width(), 100) # Display full-width inverted heading print(Decore.invert(f"{'HARDWARE COMPONENTS':<{width}}")) @@ -1871,18 +1930,70 @@ def show_hardware(json): if board.get("hardware-rev"): print(f"Hardware Revision : {board['hardware-rev']}") + if wifi_radios: + Decore.title("WiFi radios", width) + + radios_table = SimpleTable([ + Column('NAME'), + Column('MANUFACTURER'), + Column('BANDS', 'right'), + Column('STANDARDS', 'right'), + Column('MAX AP', 'right') + ]) + + for component in wifi_radios: + phy = component.get("name", "") + manufacturer = component.get("mfg-name", "Unknown") + + radio_data = component.get("infix-hardware:wifi-radio", {}) + + bands = radio_data.get("bands", []) + band_names = [] + has_ht = False + has_vht = False + has_he = False + + for band in bands: + if band.get("name"): + band_names.append(band["name"]) + if band.get("ht-capable"): + has_ht = True + if band.get("vht-capable"): + has_vht = True + if band.get("he-capable"): + has_he = True + + bands_str = "/".join(band_names) if band_names else "Unknown" + + standards = [] + if has_ht: + standards.append("11n") + if has_vht: + standards.append("11ac") + if has_he: + standards.append("11ax") + standard_str = "/".join(standards) if standards else "Unknown" + + max_if = radio_data.get("max-interfaces", {}) + max_ap = max_if.get('ap', 'N/A') if max_if else 'N/A' + + radios_table.row(phy, manufacturer, bands_str, standard_str, max_ap) + radios_table.print() + if usb_ports: Decore.title("USB Ports", width) - hdr = (f"{'NAME':<{PadUsbPort.name}}" - f"{'STATE':<{PadUsbPort.state}}" - f"{'OPER':<{PadUsbPort.oper}}") - # Pad header to full width - hdr = f"{hdr:<{width}}" - print(Decore.invert(hdr)) + + usb_table = SimpleTable([ + Column('NAME'), + Column('STATE'), + Column('OPER') + ]) for component in usb_ports: port = USBport(component) - port.print() + usb_table.row(port.name, port.state, port.oper) + + usb_table.print() if sensors: Decore.title("Sensors", width) @@ -1949,6 +2060,65 @@ def show_ntp(json): print(row) +def show_wifi_radio(json): + """Display WiFi radio operational status""" + radios_data = json.get("infix-wifi-radio:wifi-radios", {}) + radios = radios_data.get("wifi-radio", []) + + if not radios: + print("No WiFi radios found.") + return + + radio_table = SimpleTable([ + Column('NAME'), + Column('PHY MODE'), + Column('CHANNEL'), + Column('FREQUENCY'), + Column('MAX POWER'), + Column('NOISE'), + Column('INTERFACES') + ]) + + for radio in radios: + name = radio.get('name', 'N/A') + + # PHY mode - extract just the mode name after colon + phy_mode = radio.get('current-phy-mode', 'N/A') + if ':' in phy_mode: + phy_mode = phy_mode.split(':')[-1] # e.g., "ieee80211ac" + + # Channel calculation from frequency (if frequency available) + frequency = radio.get('frequency', None) + if frequency: + freq_str = f"{frequency} MHz" + # Calculate channel from frequency + if 2412 <= frequency <= 2484: + channel = (frequency - 2407) // 5 + elif 5170 <= frequency <= 5825: + channel = (frequency - 5000) // 5 + else: + channel = "?" + else: + freq_str = "N/A" + channel = "N/A" + + # Power + max_power = radio.get('max-txpower', None) + power_str = f"{max_power} dBm" if max_power is not None else "N/A" + + # Noise + noise = radio.get('noise', None) + noise_str = f"{noise} dBm" if noise is not None else "N/A" + + # Number of interfaces + num_ifaces = radio.get('num-virtual-interfaces', 0) + + radio_table.row(name, phy_mode, str(channel), freq_str, + power_str, noise_str, num_ifaces) + + radio_table.print() + + def show_system(json): """System information overivew""" if not json.get("ietf-system:system-state"): @@ -3898,6 +4068,8 @@ def main(): show_firewall_service(json_data, args.name) elif args.command == "show-ntp": show_ntp(json_data) + elif args.command == "show-wifi-radio": + show_wifi_radio(json_data) elif args.command == "show-bfd": show_bfd(json_data) elif args.command == "show-bfd-status": diff --git a/src/statd/python/yanger/__main__.py b/src/statd/python/yanger/__main__.py index f8e2477f1..ddffaa09f 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -87,6 +87,9 @@ def dirpath(path): elif args.model == 'ietf-bfd-ip-sh': from . import ietf_bfd_ip_sh yang_data = ietf_bfd_ip_sh.operational() + elif args.model == 'infix-wifi-radio': + from . import infix_wifi_radio + yang_data = infix_wifi_radio.operational() else: common.LOG.warning("Unsupported model %s", args.model) sys.exit(1) diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index 876ab863d..75e530791 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -1,6 +1,7 @@ import datetime import os -import glob +import re +import sys from .common import insert, YangDate from .host import HOST @@ -150,65 +151,44 @@ def normalize_sensor_name(name): def get_wifi_phy_info(): """ - Discover WiFi PHYs and map them to bands and interface names. + Discover WiFi PHYs using iw list command. Returns dict: {phy_name: {band: str, iface: str, description: str}} - Example: {"phy0": {"band": "2.4 GHz", "iface": "wlan0", "description": "WiFi Radio (2.4 GHz)"}} + Example: {"radio0": {"band": "2.4 GHz", "iface": "wlan0", "description": "WiFi Radio (2.4 GHz)"}} """ phy_info = {} try: - # Enumerate PHYs from /sys/class/ieee80211/ - ieee80211_path = "/sys/class/ieee80211" - if not os.path.exists(ieee80211_path): + # Use iw.py to list all PHYs + phys = HOST.run_json(("/usr/libexec/infix/iw.py", "list"), default=[]) + if not phys: return phy_info - for phy in os.listdir(ieee80211_path): - if not phy.startswith("phy"): - continue - - phy_path = os.path.join(ieee80211_path, phy) - info = {"band": "Unknown", "iface": None, "description": None} - - # Try to determine band from device path or hwmon name - # The hwmon device usually tells us: mt7915_phy0, mt7915_phy1, etc. - # We'll check supported frequencies to determine band - try: - # Read supported bands - check if device supports 5 GHz - # Most dual-band chips expose phy0 as 2.4 GHz and phy1 as 5 GHz - device_path = os.path.join(phy_path, "device") - if os.path.exists(device_path): - # Simple heuristic: phy0 is usually 2.4 GHz, phy1 is 5 GHz - # This works for most MediaTek chips (mt7915, mt7921, etc.) - if phy == "phy0": - info["band"] = "2.4 GHz" - elif phy == "phy1": - info["band"] = "5 GHz" - elif phy == "phy2": - info["band"] = "6 GHz" # WiFi 6E - except: - pass - - # Find associated interface by checking which interface has a phy80211 link to this PHY - try: - net_path = "/sys/class/net" - if os.path.exists(net_path): - for iface in os.listdir(net_path): - phy_link = os.path.join(net_path, iface, "phy80211") - if os.path.islink(phy_link): - # Read the link target and extract PHY name - try: - link_target = os.readlink(phy_link) - linked_phy = os.path.basename(link_target) - if linked_phy == phy: - info["iface"] = iface - break - except: - continue - except: - pass - - # Build description + # Initialize PHY info for each PHY + for phy in phys: + phy_info[phy] = {"band": "Unknown", "iface": None, "description": None} + + # Create a mapping from PHY number to PHY name + phy_num_to_name = {} + for phy_name in phy_info.keys(): + # Extract number from radio/phy name (e.g., "0" from "radio0" or "phy0") + num_match = re.search(r'(\d+)$', phy_name) + if num_match: + phy_num = num_match.group(1) + phy_num_to_name[phy_num] = phy_name + + # Find associated virtual interfaces using iw.py dev + dev_map = HOST.run_json(("/usr/libexec/infix/iw.py", "dev"), default={}) + + # dev_map is a dict mapping PHY numbers to list of interfaces + for phy_num, interfaces in dev_map.items(): + phy_name = phy_num_to_name.get(phy_num) + if phy_name and phy_name in phy_info and interfaces: + # Use the first interface + phy_info[phy_name]["iface"] = interfaces[0] + + # Build descriptions + for phy, info in phy_info.items(): if info["iface"] and info["band"] != "Unknown": info["description"] = f"WiFi Radio {info['iface']} ({info['band']})" elif info["band"] != "Unknown": @@ -218,8 +198,6 @@ def get_wifi_phy_info(): else: info["description"] = "WiFi Radio" - phy_info[phy] = info - except Exception: pass @@ -248,7 +226,8 @@ def add_sensor(base_name, sensor_component): device_sensors[base_name].append(sensor_component) try: - hwmon_devices = glob.glob("/sys/class/hwmon/hwmon*") + hwmon_entries = HOST.run(("ls", "/sys/class/hwmon"), default="").split() + hwmon_devices = [os.path.join("/sys/class/hwmon", entry) for entry in hwmon_entries if entry.startswith("hwmon")] for hwmon_path in hwmon_devices: try: @@ -257,6 +236,12 @@ def add_sensor(base_name, sensor_component): continue device_name = HOST.read(name_path).strip() + + # Check if device/name exists (e.g., for WiFi radios) and use that instead + device_name_path = os.path.join(hwmon_path, "device", "name") + if HOST.exists(device_name_path): + device_name = HOST.read(device_name_path).strip() + base_name = normalize_sensor_name(device_name) # Helper to create sensor component with human-readable description @@ -281,7 +266,9 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None): return component # Temperature sensors - for temp_file in glob.glob(os.path.join(hwmon_path, "temp*_input")): + temp_entries = HOST.run(("ls", hwmon_path), default="").split() + temp_files = [os.path.join(hwmon_path, e) for e in temp_entries if e.startswith("temp") and e.endswith("_input")] + for temp_file in temp_files: try: sensor_num = os.path.basename(temp_file).split('_')[0].replace('temp', '') value = int(HOST.read(temp_file).strip()) @@ -298,7 +285,9 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None): continue # Fan sensors (RPM from tachometer) - for fan_file in glob.glob(os.path.join(hwmon_path, "fan*_input")): + fan_entries = HOST.run(("ls", hwmon_path), default="").split() + fan_files = [os.path.join(hwmon_path, e) for e in fan_entries if e.startswith("fan") and e.endswith("_input")] + for fan_file in fan_files: try: sensor_num = os.path.basename(fan_file).split('_')[0].replace('fan', '') value = int(HOST.read(fan_file).strip()) @@ -316,9 +305,11 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None): # PWM fan sensors (duty cycle percentage) # Only add if no fan*_input exists for this device (avoid duplicates) - has_rpm_sensor = bool(glob.glob(os.path.join(hwmon_path, "fan*_input"))) + has_rpm_sensor = bool(fan_files) if not has_rpm_sensor: - for pwm_file in glob.glob(os.path.join(hwmon_path, "pwm[0-9]*")): + pwm_entries = HOST.run(("ls", hwmon_path), default="").split() + pwm_files = [os.path.join(hwmon_path, e) for e in pwm_entries if e.startswith("pwm") and e[3:].replace('_', '').isdigit() if len(e) > 3] + for pwm_file in pwm_files: # Skip pwm*_enable, pwm*_mode, etc. - only process pwm1, pwm2, etc. pwm_basename = os.path.basename(pwm_file) if not pwm_basename.replace('pwm', '').isdigit(): @@ -345,7 +336,9 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None): continue # Voltage sensors - for voltage_file in glob.glob(os.path.join(hwmon_path, "in*_input")): + voltage_entries = HOST.run(("ls", hwmon_path), default="").split() + voltage_files = [os.path.join(hwmon_path, e) for e in voltage_entries if e.startswith("in") and e.endswith("_input")] + for voltage_file in voltage_files: try: sensor_num = os.path.basename(voltage_file).split('_')[0].replace('in', '') value = int(HOST.read(voltage_file).strip()) @@ -363,7 +356,9 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None): continue # Current sensors - for current_file in glob.glob(os.path.join(hwmon_path, "curr*_input")): + current_entries = HOST.run(("ls", hwmon_path), default="").split() + current_files = [os.path.join(hwmon_path, e) for e in current_entries if e.startswith("curr") and e.endswith("_input")] + for current_file in current_files: try: sensor_num = os.path.basename(current_file).split('_')[0].replace('curr', '') value = int(HOST.read(current_file).strip()) @@ -381,7 +376,9 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None): continue # Power sensors - for power_file in glob.glob(os.path.join(hwmon_path, "power*_input")): + power_entries = HOST.run(("ls", hwmon_path), default="").split() + power_files = [os.path.join(hwmon_path, e) for e in power_entries if e.startswith("power") and e.endswith("_input")] + for power_file in power_files: try: sensor_num = os.path.basename(power_file).split('_')[0].replace('power', '') value = int(HOST.read(power_file).strip()) @@ -426,8 +423,8 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None): wifi_info = get_wifi_phy_info() for component in components: name = component.get("name", "") - # Match phy0, phy1, etc. sensors - if name.startswith("phy") and name in wifi_info: + # Match radio0, radio1, etc. sensors + if name.startswith("radio") and name in wifi_info: phy = wifi_info[name] # Add WiFi-specific description component["description"] = phy["description"] @@ -448,7 +445,8 @@ def thermal_sensor_components(): try: # Find all thermal zones - thermal_zones = glob.glob("/sys/class/thermal/thermal_zone*") + thermal_entries = HOST.run(("ls", "/sys/class/thermal"), default="").split() + thermal_zones = [os.path.join("/sys/class/thermal", entry) for entry in thermal_entries if entry.startswith("thermal_zone")] for zone_path in thermal_zones: try: @@ -496,6 +494,179 @@ def thermal_sensor_components(): return components +def get_survey_data(ifname): + """Get channel survey data using iw.py script""" + channels = [] + + try: + survey_data = HOST.run_json(("/usr/libexec/infix/iw.py", "survey", ifname), default=[]) + + for entry in survey_data: + channel = { + "frequency": entry.get("frequency"), + "in-use": entry.get("in_use", False) + } + + # Add optional fields if present + if "noise" in entry: + channel["noise"] = entry["noise"] + if "active_time" in entry: + channel["active-time"] = entry["active_time"] + if "busy_time" in entry: + channel["busy-time"] = entry["busy_time"] + if "receive_time" in entry: + channel["receive-time"] = entry["receive_time"] + if "transmit_time" in entry: + channel["transmit-time"] = entry["transmit_time"] + + channels.append(channel) + + except Exception: + pass + + return channels + + +def get_phy_info(phy_name): + """Get complete PHY information using iw.py script""" + try: + return HOST.run_json(("/usr/libexec/infix/iw.py", "info", phy_name), default={}) + except Exception: + return {} + + +def convert_iw_phy_info_for_yanger(phy_info): + """ + Convert iw.py phy_info format to yanger format. + Input: iw.py format with 'bands', 'driver', 'manufacturer', 'interface_combinations' + Output: yanger format with renamed/restructured fields + """ + result = {"bands": [], "driver": None, "manufacturer": "Unknown", "max-interfaces": {}} + + # Convert bands - iw.py already uses snake_case for capabilities + for band in phy_info.get("bands", []): + band_data = { + "band": str(band.get("band", 0)), + "name": band.get("name", "Unknown") + } + + # Add capability flags (iw.py uses snake_case: ht_capable, vht_capable, he_capable) + if band.get("ht_capable"): + band_data["ht-capable"] = True + if band.get("vht_capable"): + band_data["vht-capable"] = True + if band.get("he_capable"): + band_data["he-capable"] = True + + result["bands"].append(band_data) + + # Copy driver and manufacturer + if phy_info.get("driver"): + result["driver"] = phy_info["driver"] + if phy_info.get("manufacturer"): + result["manufacturer"] = phy_info["manufacturer"] + + # Convert interface combinations to max-interfaces + # Find max AP interfaces from combinations + for comb in phy_info.get("interface_combinations", []): + for limit in comb.get("limits", []): + if "AP" in limit.get("types", []): + ap_max = limit.get("max", 0) + if "ap" not in result["max-interfaces"] or ap_max > result["max-interfaces"]["ap"]: + result["max-interfaces"]["ap"] = ap_max + + return result + + +def wifi_radio_components(): + """ + Create WiFi radio components with complete operational data. + Returns a list of hardware components for WiFi radios. + """ + components = [] + wifi_info = get_wifi_phy_info() + + for phy_name, phy_data in wifi_info.items(): + component = { + "name": phy_name, + "class": "infix-hardware:wifi", + "description": phy_data.get("description", "WiFi Radio") + } + + # Initialize wifi-radio data structure + wifi_radio_data = {} + + # Get complete PHY information from iw.py script + iw_info = get_phy_info(phy_name) + + # Convert iw.py format to yanger format + phy_details = convert_iw_phy_info_for_yanger(iw_info) + + # Add manufacturer to component + if phy_details.get("manufacturer") and phy_details["manufacturer"] != "Unknown": + component["mfg-name"] = phy_details["manufacturer"] + + # Add bands + if phy_details.get("bands"): + wifi_radio_data["bands"] = phy_details["bands"] + + # Add driver + if phy_details.get("driver"): + wifi_radio_data["driver"] = phy_details["driver"] + + # Add max-interfaces + if phy_details.get("max-interfaces"): + wifi_radio_data["max-interfaces"] = phy_details["max-interfaces"] + + # Add max TX power from iw info + if iw_info.get("max_txpower"): + wifi_radio_data["max-txpower"] = iw_info["max_txpower"] + + # Add supported channels from band frequencies + supported_channels = [] + for band in iw_info.get("bands", []): + for freq in band.get("frequencies", []): + # Convert frequency to channel number + if 2412 <= freq <= 2484: + channel = (freq - 2407) // 5 + elif 5170 <= freq <= 5825: + channel = (freq - 5000) // 5 + elif 5955 <= freq <= 7115: + channel = (freq - 5950) // 5 + else: + continue + supported_channels.append(channel) + + if supported_channels: + wifi_radio_data['supported-channels'] = sorted(set(supported_channels)) + + # Count virtual interfaces from iw info + num_ifaces = iw_info.get('num_virtual_interfaces', 0) + wifi_radio_data['num-virtual-interfaces'] = num_ifaces + + # Get survey data if we have an interface + iface = phy_data.get("iface") + if iface: + try: + channels = get_survey_data(iface) + + if channels: + wifi_radio_data["survey"] = { + "channel": channels + } + except Exception: + # If survey fails, continue without survey data + pass + + # Add wifi-radio data to component + if wifi_radio_data: + component["infix-hardware:wifi-radio"] = wifi_radio_data + + components.append(component) + + return components + + def operational(): systemjson = HOST.read_json("/run/system.json") @@ -507,6 +678,7 @@ def operational(): usb_port_components(systemjson) + hwmon_sensor_components() + thermal_sensor_components() + + wifi_radio_components() + [], }, } diff --git a/src/statd/python/yanger/ietf_interfaces/wifi.py b/src/statd/python/yanger/ietf_interfaces/wifi.py index b220f9df8..78a8b6ddf 100644 --- a/src/statd/python/yanger/ietf_interfaces/wifi.py +++ b/src/statd/python/yanger/ietf_interfaces/wifi.py @@ -2,38 +2,213 @@ import json import re -def wifi(ifname): - wifi_data={} + +def detect_wifi_mode(ifname): + """Detect if interface is in AP or Station mode""" + try: + output = HOST.run(tuple(f"iw dev {ifname} info".split()), default="") + for line in output.splitlines(): + if 'type' in line.lower(): + if 'ap' in line.lower(): + return 'ap' + else: + return 'station' + except Exception: + pass + + # Default to station mode + return 'station' + + +def find_primary_interface_from_config(ifname): + """Find primary interface by reading hostapd config files""" + try: + file_list = HOST.run(tuple("ls /etc/hostapd-*.conf".split()), default="") + if not file_list: + return None + + for config_file in file_list.splitlines(): + config_file = config_file.strip() + if not config_file: + continue + + try: + content = HOST.run(tuple(f"cat {config_file}".split()), default="") + if not content: + continue + + if f"interface={ifname}" in content or f"bss={ifname}" in content: + for line in content.splitlines(): + if line.startswith("interface="): + return line.split("=", 1)[1].strip() + except Exception: + continue + except Exception: + pass + return None + + +def wifi_ap(ifname): + """Get operational data for AP mode using hostapd_cli""" + ap_data = {} + + try: + primary_if = find_primary_interface_from_config(ifname) + if not primary_if: + return {} + + data = HOST.run(tuple(f"hostapd_cli -i {primary_if} status".split()), default="") + if not data: + return {} + + # Find our interface's SSID, different for bss and primary, because it is + if ifname == primary_if: + # Primary interface - get ssid[0] or ssid + for line in data.splitlines(): + if "=" in line: + try: + k, v = line.split("=", 1) + if k in ("ssid[0]", "ssid"): + ap_data["ssid"] = v + break + except ValueError: + continue + else: + # Secondary BSS - find in BSS array + bss_idx = None + for line in data.splitlines(): + if "=" in line: + try: + k, v = line.split("=", 1) + if v == ifname and k.startswith("bss["): + bss_idx = k[4:-1] # Extract index from bss[N] + break + except ValueError: + continue + + if bss_idx: + for line in data.splitlines(): + if "=" in line: + try: + k, v = line.split("=", 1) + if k == f"ssid[{bss_idx}]": + ap_data["ssid"] = v + break + except ValueError: + continue + + stations_data = HOST.run(tuple(f"iw dev {ifname} station dump".split()), default="") + stations = parse_iw_stations(stations_data) + + if stations: + ap_data["stations"] = { + "station": stations + } + + except Exception: + pass + + # Nest data inside access-point container to match YANG schema + return { + "access-point": ap_data + } if ap_data else {} + + +def parse_iw_stations(output): + """Parse iw station dump output to get connected stations""" + stations = [] + current_station = None + + for line in output.splitlines(): + line = line.strip() + + # Station line: "Station aa:bb:cc:dd:ee:ff (on wifiX)" + if line.startswith("Station "): + if current_station: + stations.append(current_station) + # Extract MAC address + parts = line.split() + if len(parts) >= 2: + current_station = { + "mac-address": parts[1].lower() + } + elif current_station: + # Parse station attributes + try: + # Lines are in format "key: value" with tabs + if ":" not in line: + continue + + parts = line.split(":", 1) + key = parts[0].strip() + value = parts[1].strip() + + if key == "signal": + # Format: "-42 dBm" or "-42 [-44] dBm" + rssi = int(value.split()[0]) + current_station["rssi"] = rssi + elif key == "connected time": + # Format: "123 seconds" + seconds = int(value.split()[0]) + current_station["connected-time"] = seconds + elif key == "rx packets": + current_station["rx-packets"] = int(value) + elif key == "tx packets": + current_station["tx-packets"] = int(value) + elif key == "rx bytes": + current_station["rx-bytes"] = int(value) + elif key == "tx bytes": + current_station["tx-bytes"] = int(value) + elif key == "tx bitrate": + # Format: "866.7 MBit/s ..." - extract speed and convert to 100kbit/s units + speed_mbps = float(value.split()[0]) + current_station["tx-speed"] = int(speed_mbps * 10) + elif key == "rx bitrate": + # Format: "780.0 MBit/s ..." - extract speed and convert to 100kbit/s units + speed_mbps = float(value.split()[0]) + current_station["rx-speed"] = int(speed_mbps * 10) + except (ValueError, KeyError, IndexError): + # Skip invalid values + continue + + # Add last station + if current_station: + stations.append(current_station) + + return stations + + +def wifi_station(ifname): + """Get operational data for Station mode using wpa_cli""" + station_data = {} try: - data=HOST.run(tuple(f"wpa_cli -i {ifname} status".split()), default="") + data = HOST.run(tuple(f"wpa_cli -i {ifname} status".split()), default="") if data != "": for line in data.splitlines(): try: if "=" not in line: continue - k,v = line.split("=", 1) + k, v = line.split("=", 1) if k == "ssid": - wifi_data["ssid"] = v - if k == "wpa_state" and v == "DISCONNECTED": # wpa_suppicant has most likely restarted, restart scanning - HOST.run(tuple(f"wpa_cli -i {ifname} scan".split()), default="") + station_data["ssid"] = v except ValueError: # Skip malformed lines continue try: - data=HOST.run(tuple(f"wpa_cli -i {ifname} signal_poll".split()), default="FAIL") + data = HOST.run(tuple(f"wpa_cli -i {ifname} signal_poll".split()), default="FAIL") - # signal_poll return FAIL not connected + # signal_poll return FAIL if not connected if data.strip() != "FAIL": for line in data.splitlines(): try: if "=" not in line: continue - k,v = line.strip().split("=", 1) + k, v = line.strip().split("=", 1) if k == "RSSI": - wifi_data["rssi"]=int(v) + station_data["rssi"] = int(v) except (ValueError, KeyError): # Skip malformed lines or invalid integers continue @@ -45,14 +220,28 @@ def wifi(ifname): pass try: - data=HOST.run(tuple(f"wpa_cli -i {ifname} scan_result".split()), default="FAIL") + data = HOST.run(tuple(f"wpa_cli -i {ifname} scan_result".split()), default="FAIL") if data != "FAIL": - wifi_data["scan-results"] = parse_wpa_scan_result(data) + scan_results = parse_wpa_scan_result(data) + if scan_results: + station_data["scan-results"] = scan_results except Exception: # If scan results fail, just omit them pass - return wifi_data + # Always nest data inside station container to match YANG schema + # In scan-only mode, this will be just scan-results with no ssid/rssi + return {"station": station_data} if station_data else {} + + +def wifi(ifname): + """Main entry point - detect mode and return appropriate data""" + mode = detect_wifi_mode(ifname) + + if mode == 'ap': + return wifi_ap(ifname) + else: + return wifi_station(ifname) def parse_wpa_scan_result(scan_output): @@ -106,7 +295,7 @@ def parse_wpa_scan_result(scan_output): # Convert to list and sort by RSSI (best first) result = list(networks.values()) - result.sort(key=lambda x: x['rssi'], reverse=False) + result.sort(key=lambda x: x['rssi'], reverse=True) return result diff --git a/test/case/statd/system/cli/show-hardware b/test/case/statd/system/cli/show-hardware index 841e9ef4a..c8752254d 100644 --- a/test/case/statd/system/cli/show-hardware +++ b/test/case/statd/system/cli/show-hardware @@ -1,10 +1,11 @@ -HARDWARE COMPONENTS  -──────────────────────────────────────────────────────────── +HARDWARE COMPONENTS  +──────────────────────────────────────────────────────────────────────────────────────────────────── Board Information -Model : VM +Model : Standard PC (i440FX + PIIX, 1996) Manufacturer : QEMU -──────────────────────────────────────────────────────────── +Base MAC Address : 00:a0:85:00:03:00 +──────────────────────────────────────────────────────────────────────────────────────────────────── USB Ports -NAME STATE OPER  -USB locked enabled -USB2 locked enabled +NAME STATE OPER +USB1 locked enabled +USB2 locked enabled diff --git a/test/case/statd/system/ietf-hardware.json b/test/case/statd/system/ietf-hardware.json index 637b5c3e8..e93345405 100644 --- a/test/case/statd/system/ietf-hardware.json +++ b/test/case/statd/system/ietf-hardware.json @@ -5,7 +5,7 @@ "name": "mainboard", "class": "iana-hardware:chassis", "mfg-name": "QEMU", - "model-name": "VM", + "model-name": "Standard PC (i440FX + PIIX, 1996)", "infix-hardware:phys-address": "00:a0:85:00:03:00", "state": { "admin-state": "unknown", @@ -21,7 +21,7 @@ "admin-state": "locked", "oper-state": "enabled" }, - "name": "USB", + "name": "USB1", "class": "infix-hardware:usb" }, { diff --git a/test/case/statd/system/ietf-system.json b/test/case/statd/system/ietf-system.json index 404822eed..236e55db4 100644 --- a/test/case/statd/system/ietf-system.json +++ b/test/case/statd/system/ietf-system.json @@ -120,13 +120,13 @@ "search": [] }, "clock": { - "boot-datetime": "2025-04-30T09:47:32+00:00", - "current-datetime": "2025-04-30T09:48:01+00:00" + "boot-datetime": "2025-12-30T17:17:06+00:00", + "current-datetime": "2025-12-30T17:17:57+00:00" }, "platform": { "os-name": "Infix", - "os-version": "v25.04.0-rc1-3-g8daf1571-dirty", - "os-release": "v25.04.0-rc1-3-g8daf1571-dirty", + "os-version": "v25.11.0-124-g44a6078d", + "os-release": "v25.11.0-124-g44a6078d", "machine": "x86_64" }, "infix-system:services": { @@ -385,6 +385,38 @@ } } ] + }, + "infix-system:resource-usage": { + "memory": { + "total": "355076", + "free": "134804", + "available": "225848" + }, + "load-average": { + "load-1min": "0.00", + "load-5min": "0.00", + "load-15min": "0.00" + }, + "filesystem": [ + { + "mount-point": "/", + "size": "142720", + "used": "142720", + "available": "0" + }, + { + "mount-point": "/var", + "size": "86427", + "used": "281", + "available": "79270" + }, + { + "mount-point": "/cfg", + "size": "14073", + "used": "67", + "available": "12860" + } + ] } } } diff --git a/test/case/statd/system/operational.json b/test/case/statd/system/operational.json index cee7cb7da..31b6f2c10 100644 --- a/test/case/statd/system/operational.json +++ b/test/case/statd/system/operational.json @@ -2,10 +2,11 @@ "ietf-hardware:hardware": { "component": [ { - "name": "mainboard", "class": "iana-hardware:chassis", + "infix-hardware:phys-address": "00:a0:85:00:03:00", "mfg-name": "QEMU", - "model-name": "VM", + "model-name": "Standard PC (i440FX + PIIX, 1996)", + "name": "mainboard", "state": { "admin-state": "unknown", "oper-state": "enabled" @@ -17,7 +18,7 @@ }, { "class": "infix-hardware:usb", - "name": "USB", + "name": "USB1", "state": { "admin-state": "locked", "oper-state": "enabled" @@ -49,8 +50,8 @@ }, "ietf-system:system-state": { "clock": { - "boot-datetime": "2025-04-30T09:47:32+00:00", - "current-datetime": "2025-04-30T09:48:01+00:00" + "boot-datetime": "2025-12-30T17:17:06+00:00", + "current-datetime": "2025-12-30T17:17:57+00:00" }, "infix-system:dns-resolver": { "options": {}, @@ -95,30 +96,30 @@ { "available": "0", "mount-point": "/", - "size": "70912", - "used": "70912" + "size": "142720", + "used": "142720" }, { - "available": "79314", + "available": "79270", "mount-point": "/var", - "size": "86459", - "used": "267" + "size": "86427", + "used": "281" }, { - "available": "12861", + "available": "12860", "mount-point": "/cfg", "size": "14073", - "used": "66" + "used": "67" } ], "load-average": { - "load-15min": "0.01", - "load-1min": "0.16", - "load-5min": "0.03" + "load-15min": "0.00", + "load-1min": "0.00", + "load-5min": "0.00" }, "memory": { - "available": "259640", - "free": "187776", + "available": "225848", + "free": "134804", "total": "355076" } }, @@ -449,8 +450,8 @@ "platform": { "machine": "x86_64", "os-name": "Infix", - "os-release": "v25.04.0-rc1-3-g8daf1571-dirty", - "os-version": "v25.04.0-rc1-3-g8daf1571-dirty" + "os-release": "v25.11.0-124-g44a6078d", + "os-version": "v25.11.0-124-g44a6078d" } } -} \ No newline at end of file +} diff --git a/test/case/statd/system/system/rootfs/etc/os-release b/test/case/statd/system/system/rootfs/etc/os-release index 59326afca..5d548c022 100644 --- a/test/case/statd/system/system/rootfs/etc/os-release +++ b/test/case/statd/system/system/rootfs/etc/os-release @@ -1,16 +1,16 @@ NAME="Infix" ID=infix -PRETTY_NAME="Infix OS — Immutable.Friendly.Secure v25.04.0-rc1-3-g8daf1571-dirty" +PRETTY_NAME="Infix OS — Immutable.Friendly.Secure v25.11.0-124-g44a6078d" ID_LIKE="buildroot" -DEFAULT_HOSTNAME=ix -VERSION="v25.04.0-rc1-3-g8daf1571-dirty" -VERSION_ID=v25.04.0-rc1-3-g8daf1571-dirty -BUILD_ID="v25.04.0-rc1-3-g8daf1571-dirty" +DEFAULT_HOSTNAME=infix +VERSION="v25.11.0-124-g44a6078d" +VERSION_ID=v25.11.0-124-g44a6078d +BUILD_ID="v25.11.0-124-g44a6078d" IMAGE_ID="infix-x86_64" ARCHITECTURE="x86_64" HOME_URL=https://github.com/kernelkit/infix/ VENDOR_NAME="KernelKit" -VENDOR_HOME="https://github.com/kernelkit" -DOCUMENTATION_URL="https://github.com/kernelkit/infix/tree/main/doc" +VENDOR_HOME="https://kernelkit.org" +DOCUMENTATION_URL="https://kernelkit.org/infix/" SUPPORT_URL="mailto:kernelkit@googlegroups.com" -INFIX_DESC="Infix is an operating system based on Linux and modeled with YANG. It can be set up both as a switch, with offloading using switchdev, a router with firewalling, or a secure end device. All while supporting advanced networking scenarios and running Docker containers." +INFIX_DESC="Infix is an immutable, friendly, and secure operating system that turns any ARM or x86 device into a powerful, manageable network appliance. Deploy on anything from 5 Raspberry Pi boards to enterprise switches as routers, IoT gateways, or edge devices. Infix models Linux networking features using YANG so you can manage your devices using NETCONF/RESTCONF APIs and focus on your business logic running in isolated containers." diff --git a/test/case/statd/system/system/rootfs/proc/loadavg b/test/case/statd/system/system/rootfs/proc/loadavg new file mode 100644 index 000000000..2d501b4c0 --- /dev/null +++ b/test/case/statd/system/system/rootfs/proc/loadavg @@ -0,0 +1 @@ +0.00 0.00 0.00 2/118 4822 diff --git a/test/case/statd/system/system/rootfs/proc/meminfo b/test/case/statd/system/system/rootfs/proc/meminfo new file mode 100644 index 000000000..3175d96c1 --- /dev/null +++ b/test/case/statd/system/system/rootfs/proc/meminfo @@ -0,0 +1,40 @@ +MemTotal: 355076 kB +MemFree: 134804 kB +MemAvailable: 225848 kB +Buffers: 4556 kB +Cached: 91640 kB +SwapCached: 0 kB +Active: 34452 kB +Inactive: 136752 kB +Active(anon): 1588 kB +Inactive(anon): 75656 kB +Active(file): 32864 kB +Inactive(file): 61096 kB +Unevictable: 0 kB +Mlocked: 0 kB +SwapTotal: 0 kB +SwapFree: 0 kB +Dirty: 68 kB +Writeback: 0 kB +AnonPages: 75068 kB +Mapped: 41816 kB +Shmem: 2232 kB +KReclaimable: 8096 kB +Slab: 29952 kB +SReclaimable: 8096 kB +SUnreclaim: 21856 kB +KernelStack: 1888 kB +PageTables: 2384 kB +SecPageTables: 0 kB +NFS_Unstable: 0 kB +Bounce: 0 kB +WritebackTmp: 0 kB +CommitLimit: 177536 kB +Committed_AS: 279460 kB +VmallocTotal: 34359738367 kB +VmallocUsed: 9580 kB +VmallocChunk: 0 kB +Percpu: 304 kB +DirectMap4k: 37776 kB +DirectMap2M: 350208 kB +DirectMap1G: 0 kB diff --git a/test/case/statd/system/system/rootfs/proc/uptime b/test/case/statd/system/system/rootfs/proc/uptime index a6ecc59c4..af14f4d62 100644 --- a/test/case/statd/system/system/rootfs/proc/uptime +++ b/test/case/statd/system/system/rootfs/proc/uptime @@ -1 +1 @@ -28.16 15.60 +50.81 42.16 diff --git a/test/case/statd/system/system/rootfs/run/system.json b/test/case/statd/system/system/rootfs/run/system.json index 8957fcdb9..c9ad57674 100644 --- a/test/case/statd/system/system/rootfs/run/system.json +++ b/test/case/statd/system/system/rootfs/run/system.json @@ -1 +1 @@ -{"vendor": "QEMU", "product-name": "VM", "part-number": null, "serial-number": null, "mac-address": "00:a0:85:00:03:00", "factory-password-hash": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A", "vpd": {"product": {"board": "product", "available": false, "trusted": true, "data": {}}}, "usb-ports": [{"name": "USB", "path": "/sys/bus/usb/devices/usb1"}, {"name": "USB2", "path": "/sys/bus/usb/devices/usb2"}]} +{"vendor": "QEMU", "product-name": "Standard PC (i440FX + PIIX, 1996)", "product-version": "pc-i440fx-8.0", "part-number": null, "serial-number": null, "mac-address": "00:a0:85:00:03:00", "factory-password-hash": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A", "vpd": {"product": {"board": "product", "available": false, "trusted": true, "data": {}}}, "usb-ports": [{"name": "USB1", "path": "/sys/bus/usb/devices/usb1"}, {"name": "USB2", "path": "/sys/bus/usb/devices/usb2"}]} \ No newline at end of file diff --git a/test/case/statd/system/system/rootfs/sys/bus/usb/devices/usb1/authorized_default b/test/case/statd/system/system/rootfs/sys/bus/usb/devices/usb1/authorized_default index 573541ac9..0cfbf0888 100644 --- a/test/case/statd/system/system/rootfs/sys/bus/usb/devices/usb1/authorized_default +++ b/test/case/statd/system/system/rootfs/sys/bus/usb/devices/usb1/authorized_default @@ -1 +1 @@ -0 +2 diff --git a/test/case/statd/system/system/rootfs/sys/bus/usb/devices/usb2/authorized_default b/test/case/statd/system/system/rootfs/sys/bus/usb/devices/usb2/authorized_default index 573541ac9..0cfbf0888 100644 --- a/test/case/statd/system/system/rootfs/sys/bus/usb/devices/usb2/authorized_default +++ b/test/case/statd/system/system/rootfs/sys/bus/usb/devices/usb2/authorized_default @@ -1 +1 @@ -0 +2 diff --git a/test/case/statd/system/system/run/+usr+libexec+infix+iw.py_list b/test/case/statd/system/system/run/+usr+libexec+infix+iw.py_list new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/test/case/statd/system/system/run/+usr+libexec+infix+iw.py_list @@ -0,0 +1 @@ +[] diff --git a/test/case/statd/system/system/run/df_-k_+ b/test/case/statd/system/system/run/df_-k_+ new file mode 100644 index 000000000..43a19c3b6 --- /dev/null +++ b/test/case/statd/system/system/run/df_-k_+ @@ -0,0 +1,2 @@ +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/root 142720 142720 0 100% / diff --git a/test/case/statd/system/system/run/df_-k_+cfg b/test/case/statd/system/system/run/df_-k_+cfg new file mode 100644 index 000000000..454e2ba38 --- /dev/null +++ b/test/case/statd/system/system/run/df_-k_+cfg @@ -0,0 +1,2 @@ +Filesystem 1K-blocks Used Available Use% Mounted on +cfg-overlay 14073 67 12860 1% /cfg diff --git a/test/case/statd/system/system/run/df_-k_+var b/test/case/statd/system/system/run/df_-k_+var new file mode 100644 index 000000000..6e76e2b72 --- /dev/null +++ b/test/case/statd/system/system/run/df_-k_+var @@ -0,0 +1,2 @@ +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/vda6 86427 281 79270 0% /mnt/var diff --git a/test/case/statd/system/system/run/ls_+sys+class+hwmon b/test/case/statd/system/system/run/ls_+sys+class+hwmon new file mode 100644 index 000000000..e69de29bb diff --git a/test/case/statd/system/system/run/ls_+sys+class+thermal b/test/case/statd/system/system/run/ls_+sys+class+thermal new file mode 100644 index 000000000..680b74c73 --- /dev/null +++ b/test/case/statd/system/system/run/ls_+sys+class+thermal @@ -0,0 +1 @@ +cooling_device0 diff --git a/test/case/statd/system/system/timestamp b/test/case/statd/system/system/timestamp index 7dc8a78de..4ce0cd554 100644 --- a/test/case/statd/system/system/timestamp +++ b/test/case/statd/system/system/timestamp @@ -1,2 +1,2 @@ -1746006481 +1767115077