diff --git a/supervisor/api/network.py b/supervisor/api/network.py index f93749236..3880fea16 100644 --- a/supervisor/api/network.py +++ b/supervisor/api/network.py @@ -12,6 +12,8 @@ from ..const import ( ATTR_ACCESSPOINTS, ATTR_ADDRESS, ATTR_AUTH, + ATTR_BAND, + ATTR_CHANNEL, ATTR_CONNECTED, ATTR_DNS, ATTR_DOCKER, @@ -52,7 +54,7 @@ from ..host.configuration import ( VlanConfig, WifiConfig, ) -from ..host.const import AuthMethod, InterfaceType, WifiMode +from ..host.const import AuthMethod, InterfaceType, WifiBand, WifiMode from .utils import api_process, api_validate _SCHEMA_IPV4_CONFIG = vol.Schema( @@ -79,6 +81,8 @@ _SCHEMA_WIFI_CONFIG = vol.Schema( vol.Optional(ATTR_AUTH): vol.Coerce(AuthMethod), vol.Optional(ATTR_SSID): str, vol.Optional(ATTR_PSK): str, + vol.Optional(ATTR_BAND): vol.Coerce(WifiBand), + vol.Optional(ATTR_CHANNEL): vol.Coerce(int), } ) @@ -112,6 +116,8 @@ def wifi_struct(config: WifiConfig) -> dict[str, Any]: ATTR_AUTH: config.auth, ATTR_SSID: config.ssid, ATTR_SIGNAL: config.signal, + ATTR_BAND: config.band, + ATTR_CHANNEL: config.channel, } @@ -227,6 +233,8 @@ class APINetwork(CoreSysAttributes): config.get(ATTR_AUTH, AuthMethod.OPEN), config.get(ATTR_PSK, None), None, + config.get(ATTR_BAND, None), + config.get(ATTR_CHANNEL, None), ) elif key == ATTR_ENABLED: interface.enabled = config diff --git a/supervisor/const.py b/supervisor/const.py index 42a7fee4a..541195a34 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -119,6 +119,7 @@ ATTR_BACKUP_POST = "backup_post" ATTR_BACKUP_PRE = "backup_pre" ATTR_BACKUPS = "backups" ATTR_BACKUPS_EXCLUDE_DATABASE = "backups_exclude_database" +ATTR_BAND = "band" ATTR_BLK_READ = "blk_read" ATTR_BLK_WRITE = "blk_write" ATTR_BOARD = "board" diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py index 82c75d8f3..829e75888 100644 --- a/supervisor/dbus/const.py +++ b/supervisor/dbus/const.py @@ -204,6 +204,7 @@ class InterfaceMethod(StrEnum): MANUAL = "manual" DISABLED = "disabled" LINK_LOCAL = "link-local" + SHARED = "shared" class ConnectionType(StrEnum): diff --git a/supervisor/dbus/network/configuration.py b/supervisor/dbus/network/configuration.py index b78855f85..b5bad90af 100644 --- a/supervisor/dbus/network/configuration.py +++ b/supervisor/dbus/network/configuration.py @@ -33,6 +33,8 @@ class WirelessProperties: assigned_mac: str | None mode: str | None powersave: int | None + band: str | None + channel: int | None @dataclass(slots=True) diff --git a/supervisor/dbus/network/setting/__init__.py b/supervisor/dbus/network/setting/__init__.py index 5bf1dbad2..cad57afb2 100644 --- a/supervisor/dbus/network/setting/__init__.py +++ b/supervisor/dbus/network/setting/__init__.py @@ -48,6 +48,8 @@ CONF_ATTR_802_WIRELESS_MODE = "mode" CONF_ATTR_802_WIRELESS_ASSIGNED_MAC = "assigned-mac-address" CONF_ATTR_802_WIRELESS_SSID = "ssid" CONF_ATTR_802_WIRELESS_POWERSAVE = "powersave" +CONF_ATTR_802_WIRELESS_BAND = "band" +CONF_ATTR_802_WIRELESS_CHANNEL = "channel" CONF_ATTR_802_WIRELESS_SECURITY_AUTH_ALG = "auth-alg" CONF_ATTR_802_WIRELESS_SECURITY_KEY_MGMT = "key-mgmt" CONF_ATTR_802_WIRELESS_SECURITY_PSK = "psk" @@ -234,6 +236,8 @@ class NetworkSetting(DBusInterface): data[CONF_ATTR_802_WIRELESS].get(CONF_ATTR_802_WIRELESS_ASSIGNED_MAC), data[CONF_ATTR_802_WIRELESS].get(CONF_ATTR_802_WIRELESS_MODE), data[CONF_ATTR_802_WIRELESS].get(CONF_ATTR_802_WIRELESS_POWERSAVE), + data[CONF_ATTR_802_WIRELESS].get(CONF_ATTR_802_WIRELESS_BAND), + data[CONF_ATTR_802_WIRELESS].get(CONF_ATTR_802_WIRELESS_CHANNEL), ) if CONF_ATTR_802_WIRELESS_SECURITY in data: diff --git a/supervisor/dbus/network/setting/generate.py b/supervisor/dbus/network/setting/generate.py index 554c5b230..b0aae6b00 100644 --- a/supervisor/dbus/network/setting/generate.py +++ b/supervisor/dbus/network/setting/generate.py @@ -15,6 +15,8 @@ from . import ( CONF_ATTR_802_ETHERNET_ASSIGNED_MAC, CONF_ATTR_802_WIRELESS, CONF_ATTR_802_WIRELESS_ASSIGNED_MAC, + CONF_ATTR_802_WIRELESS_BAND, + CONF_ATTR_802_WIRELESS_CHANNEL, CONF_ATTR_802_WIRELESS_MODE, CONF_ATTR_802_WIRELESS_POWERSAVE, CONF_ATTR_802_WIRELESS_SECURITY, @@ -50,6 +52,19 @@ if TYPE_CHECKING: from ....host.configuration import Interface +def _get_address_data(ipv4setting) -> Variant: + address_data = [] + for address in ipv4setting.address: + address_data.append( + { + "address": Variant("s", str(address.ip)), + "prefix": Variant("u", int(address.with_prefixlen.split("/")[-1])), + } + ) + + return Variant("aa{sv}", address_data) + + def _get_ipv4_connection_settings(ipv4setting) -> dict: ipv4 = {} if not ipv4setting or ipv4setting.method == InterfaceMethod.AUTO: @@ -58,19 +73,12 @@ def _get_ipv4_connection_settings(ipv4setting) -> dict: ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "disabled") elif ipv4setting.method == InterfaceMethod.STATIC: ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "manual") - - address_data = [] - for address in ipv4setting.address: - address_data.append( - { - "address": Variant("s", str(address.ip)), - "prefix": Variant("u", int(address.with_prefixlen.split("/")[-1])), - } - ) - - ipv4[CONF_ATTR_IPV4_ADDRESS_DATA] = Variant("aa{sv}", address_data) + ipv4[CONF_ATTR_IPV4_ADDRESS_DATA] = _get_address_data(ipv4setting) if ipv4setting.gateway: ipv4[CONF_ATTR_IPV4_GATEWAY] = Variant("s", str(ipv4setting.gateway)) + elif ipv4setting.method == InterfaceMethod.SHARED: + ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "shared") + ipv4[CONF_ATTR_IPV4_ADDRESS_DATA] = _get_address_data(ipv4setting) else: raise RuntimeError("Invalid IPv4 InterfaceMethod") @@ -199,13 +207,21 @@ def get_connection_from_interface( elif interface.type == InterfaceType.WIRELESS: wireless = { CONF_ATTR_802_WIRELESS_ASSIGNED_MAC: Variant("s", "preserve"), - CONF_ATTR_802_WIRELESS_MODE: Variant("s", "infrastructure"), + CONF_ATTR_802_WIRELESS_MODE: Variant( + "s", (interface.wifi and interface.wifi.mode) or "infrastructure" + ), CONF_ATTR_802_WIRELESS_POWERSAVE: Variant("i", 1), } if interface.wifi and interface.wifi.ssid: wireless[CONF_ATTR_802_WIRELESS_SSID] = Variant( "ay", interface.wifi.ssid.encode("UTF-8") ) + if interface.wifi and interface.wifi.band: + wireless[CONF_ATTR_802_WIRELESS_BAND] = Variant("s", interface.wifi.band) + if interface.wifi and interface.wifi.channel: + wireless[CONF_ATTR_802_WIRELESS_CHANNEL] = Variant( + "u", interface.wifi.channel + ) conn[CONF_ATTR_802_WIRELESS] = wireless diff --git a/supervisor/host/configuration.py b/supervisor/host/configuration.py index cfce58831..bd19bf8dd 100644 --- a/supervisor/host/configuration.py +++ b/supervisor/host/configuration.py @@ -12,7 +12,7 @@ from ..dbus.const import ( ) from ..dbus.network.connection import NetworkConnection from ..dbus.network.interface import NetworkInterface -from .const import AuthMethod, InterfaceMethod, InterfaceType, WifiMode +from .const import AuthMethod, InterfaceMethod, InterfaceType, WifiBand, WifiMode @dataclass(slots=True) @@ -55,6 +55,8 @@ class WifiConfig: auth: AuthMethod psk: str | None signal: int | None + band: WifiBand | None + channel: int | None @dataclass(slots=True) @@ -191,6 +193,7 @@ class Interface: NMInterfaceMethod.DISABLED: InterfaceMethod.DISABLED, NMInterfaceMethod.MANUAL: InterfaceMethod.STATIC, NMInterfaceMethod.LINK_LOCAL: InterfaceMethod.DISABLED, + NMInterfaceMethod.SHARED: InterfaceMethod.SHARED, } return mapping.get(method, InterfaceMethod.DISABLED) @@ -237,6 +240,12 @@ class Interface: if inet.settings.wireless.mode: mode = WifiMode(inet.settings.wireless.mode) + # Band and Channel + band = channel = None + if mode == WifiMode.AP: + band = WifiBand(inet.settings.wireless.band) + channel = inet.settings.wireless.channel + # Signal if inet.wireless and inet.wireless.active: signal = inet.wireless.active.strength @@ -249,10 +258,12 @@ class Interface: auth, psk, signal, + band, + channel, ) @staticmethod - def _map_nm_vlan(inet: NetworkInterface) -> WifiConfig | None: + def _map_nm_vlan(inet: NetworkInterface) -> VlanConfig | None: """Create mapping to nm vlan property.""" if inet.type != DeviceType.VLAN or not inet.settings: return None diff --git a/supervisor/host/const.py b/supervisor/host/const.py index 9c3a9dc4a..314087761 100644 --- a/supervisor/host/const.py +++ b/supervisor/host/const.py @@ -13,6 +13,7 @@ class InterfaceMethod(StrEnum): DISABLED = "disabled" STATIC = "static" AUTO = "auto" + SHARED = "shared" class InterfaceType(StrEnum): @@ -31,6 +32,13 @@ class AuthMethod(StrEnum): WPA_PSK = "wpa-psk" +class WifiBand(StrEnum): + """Wifi band.""" + + A = "a" + BG = "bg" + + class WifiMode(StrEnum): """Wifi mode.""" diff --git a/tests/api/test_network.py b/tests/api/test_network.py index e609df410..0c88d1831 100644 --- a/tests/api/test_network.py +++ b/tests/api/test_network.py @@ -276,6 +276,31 @@ async def test_api_network_interface_update_wifi_error(api_client: TestClient): ) +async def test_api_network_interface_update_wifi_bad_channel(api_client: TestClient): + """Test network interface WiFi API error handling for bad channel.""" + # Simulate frontend WiFi interface edit where the user selects a bad channel. + resp = await api_client.post( + f"/network/interface/{TEST_INTERFACE_WLAN_NAME}/update", + json={ + "enabled": True, + "ipv4": { + "method": "shared", + "address": ["10.42.0.1/24"], + }, + "ipv6": { + "method": "auto", + }, + "wifi": {"mode": "ap", "ssid": "HotSpot", "band": "bg", "channel": 17}, + }, + ) + result = await resp.json() + assert result["result"] == "error" + assert ( + result["message"] + == "Can't create config and activate wlan0: 802-11-wireless.channel: '17' is not a valid channel" + ) + + async def test_api_network_interface_update_remove(api_client: TestClient): """Test network manager api.""" resp = await api_client.post( diff --git a/tests/dbus_service_mocks/network_manager.py b/tests/dbus_service_mocks/network_manager.py index cc224dd04..5a2b9ba30 100644 --- a/tests/dbus_service_mocks/network_manager.py +++ b/tests/dbus_service_mocks/network_manager.py @@ -240,6 +240,15 @@ class NetworkManager(DBusServiceMock): "org.freedesktop.NetworkManager.Device.InvalidConnection", "A 'wireless' setting with a valid SSID is required if no AP path was given.", ) + if ( + "channel" in connection["802-11-wireless"] + and connection["802-11-wireless"]["channel"].value > 14 + ): + raise DBusError( + "org.freedesktop.NetworkManager.Device.InvalidConnection", + # this is the actual error from NetworkManager + f"802-11-wireless.channel: '{connection['802-11-wireless']['channel'].value}' is not a valid channel", + ) return [ "/org/freedesktop/NetworkManager/Settings/1",