From a42697672d6a82a3ef0aeb29bb3b94cbbe118e06 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Sun, 26 Jan 2025 12:03:42 -0500 Subject: [PATCH 1/4] Create hotspot - add IP method "shared" - new WiFi parameters "band" and "channel" NetworkManager provides the magic --- supervisor/api/network.py | 10 ++++++++- supervisor/const.py | 1 + supervisor/dbus/const.py | 1 + supervisor/dbus/network/configuration.py | 2 ++ supervisor/dbus/network/setting/__init__.py | 4 ++++ supervisor/dbus/network/setting/generate.py | 23 ++++++++++++++++++--- supervisor/host/configuration.py | 15 ++++++++++++-- supervisor/host/const.py | 8 +++++++ 8 files changed, 58 insertions(+), 6 deletions(-) diff --git a/supervisor/api/network.py b/supervisor/api/network.py index f9374923644..3880fea16d3 100644 --- a/supervisor/api/network.py +++ b/supervisor/api/network.py @@ -12,6 +12,8 @@ ATTR_ACCESSPOINTS, ATTR_ADDRESS, ATTR_AUTH, + ATTR_BAND, + ATTR_CHANNEL, ATTR_CONNECTED, ATTR_DNS, ATTR_DOCKER, @@ -52,7 +54,7 @@ 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 @@ 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 @@ async def interface_update(self, request: web.Request) -> None: 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 7d1c829e1af..77b13603f25 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -119,6 +119,7 @@ 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 82c75d8f3d3..829e75888ca 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 b78855f85b1..b5bad90afad 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 5bf1dbad2e6..cad57afb2ba 100644 --- a/supervisor/dbus/network/setting/__init__.py +++ b/supervisor/dbus/network/setting/__init__.py @@ -48,6 +48,8 @@ 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 @@ async def reload(self): 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 554c5b230e7..0488d4ac995 100644 --- a/supervisor/dbus/network/setting/generate.py +++ b/supervisor/dbus/network/setting/generate.py @@ -15,6 +15,8 @@ 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, @@ -56,8 +58,12 @@ def _get_ipv4_connection_settings(ipv4setting) -> dict: ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "auto") elif ipv4setting.method == InterfaceMethod.DISABLED: ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "disabled") - elif ipv4setting.method == InterfaceMethod.STATIC: - ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "manual") + elif ipv4setting.method in {InterfaceMethod.STATIC, InterfaceMethod.SHARED}: + ipv4[CONF_ATTR_IPV4_METHOD] = ( + Variant("s", "manual") + if ipv4setting.method == InterfaceMethod.STATIC + else Variant("s", "shared") + ) address_data = [] for address in ipv4setting.address: @@ -199,13 +205,24 @@ 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.mode + if interface.wifi and interface.wifi.mode + else "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 cfce58831fe..bd19bf8dded 100644 --- a/supervisor/host/configuration.py +++ b/supervisor/host/configuration.py @@ -12,7 +12,7 @@ ) 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 @@ def _map_nm_method(method: str) -> InterfaceMethod: 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 @@ def _map_nm_wifi(inet: NetworkInterface) -> WifiConfig | None: 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 @@ def _map_nm_wifi(inet: NetworkInterface) -> WifiConfig | None: 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 9c3a9dc4ada..314087761e1 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.""" From f180392dbd9ab9c6e2ad0049abf78f8ccddef42f Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:50:53 -0500 Subject: [PATCH 2/4] SHARED **is** the gateway --- supervisor/dbus/network/setting/generate.py | 36 +++++++++++---------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/supervisor/dbus/network/setting/generate.py b/supervisor/dbus/network/setting/generate.py index 0488d4ac995..3a02415763e 100644 --- a/supervisor/dbus/network/setting/generate.py +++ b/supervisor/dbus/network/setting/generate.py @@ -52,31 +52,33 @@ 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: ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "auto") elif ipv4setting.method == InterfaceMethod.DISABLED: ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "disabled") - elif ipv4setting.method in {InterfaceMethod.STATIC, InterfaceMethod.SHARED}: - ipv4[CONF_ATTR_IPV4_METHOD] = ( - Variant("s", "manual") - if ipv4setting.method == InterfaceMethod.STATIC - else Variant("s", "shared") - ) - - 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) + elif ipv4setting.method == InterfaceMethod.STATIC: + ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "manual") + 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") From 7f941bc1547eef445e3bf2ff6943a04e8aeb4c36 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Fri, 31 Jan 2025 10:52:12 -0500 Subject: [PATCH 3/4] test invalid channel value use the actual error message from NetworkManager --- tests/api/test_network.py | 25 +++++++++++++++++++++ tests/dbus_service_mocks/network_manager.py | 9 ++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/api/test_network.py b/tests/api/test_network.py index e609df410b4..0c88d18318b 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 c0ebaeab893..6ce0c8c20e8 100644 --- a/tests/dbus_service_mocks/network_manager.py +++ b/tests/dbus_service_mocks/network_manager.py @@ -239,6 +239,15 @@ def AddAndActivateConnection( "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", From 1eb7566d882d1398a1d0c4554f2fe0ca29b31876 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Thu, 6 Feb 2025 07:52:18 -0500 Subject: [PATCH 4/4] @coderabbit: simplify logic --- supervisor/dbus/network/setting/generate.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/supervisor/dbus/network/setting/generate.py b/supervisor/dbus/network/setting/generate.py index 3a02415763e..b0aae6b0029 100644 --- a/supervisor/dbus/network/setting/generate.py +++ b/supervisor/dbus/network/setting/generate.py @@ -208,10 +208,7 @@ def get_connection_from_interface( wireless = { CONF_ATTR_802_WIRELESS_ASSIGNED_MAC: Variant("s", "preserve"), CONF_ATTR_802_WIRELESS_MODE: Variant( - "s", - interface.wifi.mode - if interface.wifi and interface.wifi.mode - else "infrastructure", + "s", (interface.wifi and interface.wifi.mode) or "infrastructure" ), CONF_ATTR_802_WIRELESS_POWERSAVE: Variant("i", 1), }