Skip to content

Commit 138c719

Browse files
committed
feat: enhance network management with VPN client/server derivation and filtering
- Updated the UnifiRuleUpdateCoordinator to derive VPN clients and servers from existing network data, improving efficiency and accuracy. - Introduced a new helper function to filter out VPN networks, ensuring only suitable networks are exposed as switch entities. - Added methods for updating and toggling network configurations in the API, enhancing network management capabilities. - Improved error handling and logging for network operations.
1 parent 39533d2 commit 138c719

4 files changed

Lines changed: 247 additions & 26 deletions

File tree

custom_components/unifi_network_rules/coordinator.py

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,10 @@ async def _async_update_data(self) -> Dict[str, List[Any]]:
454454
await self._update_traffic_rules_in_dict(rules_data)
455455
await asyncio.sleep(api_call_delay)
456456

457+
# Then networks (source of truth for VPN derivation)
458+
await self._update_networks_in_dict(rules_data)
459+
await asyncio.sleep(api_call_delay)
460+
457461
# Then legacy firewall rules
458462
await self._update_legacy_firewall_rules_in_dict(rules_data)
459463
await asyncio.sleep(api_call_delay)
@@ -462,11 +466,9 @@ async def _async_update_data(self) -> Dict[str, List[Any]]:
462466
await self._update_qos_rules_in_dict(rules_data)
463467
await asyncio.sleep(api_call_delay)
464468

465-
# Then VPN clients
469+
# Then derive VPN clients and servers from networks
466470
await self._update_vpn_clients_in_dict(rules_data)
467471
await asyncio.sleep(api_call_delay)
468-
469-
# Then VPN servers
470472
await self._update_vpn_servers_in_dict(rules_data)
471473
await asyncio.sleep(api_call_delay)
472474

@@ -476,10 +478,6 @@ async def _async_update_data(self) -> Dict[str, List[Any]]:
476478

477479
# Then port profiles
478480
await self._update_port_profiles_in_dict(rules_data)
479-
await asyncio.sleep(api_call_delay)
480-
481-
# Then networks
482-
await self._update_networks_in_dict(rules_data)
483481

484482
# Verify the data is valid - check if we have at least some data in key categories
485483
# This helps prevent entity removal during temporary API errors
@@ -824,12 +822,57 @@ async def _update_qos_rules_in_dict(self, data: Dict[str, List[Any]]) -> None:
824822
await self._update_rule_type_in_dict(data, "qos_rules", self.api.get_qos_rules)
825823

826824
async def _update_vpn_clients_in_dict(self, data: Dict[str, List[Any]]) -> None:
827-
"""Update VPN clients in the given data dictionary."""
828-
await self._update_rule_type_in_dict(data, "vpn_clients", self.api.get_vpn_clients)
825+
"""Update VPN clients from already-fetched networks."""
826+
try:
827+
# Derive from networks if available; otherwise, fall back to API
828+
networks = data.get("networks") or self.networks
829+
if networks:
830+
from .models.vpn_config import VPNConfig
831+
clients = []
832+
for n in networks:
833+
raw = getattr(n, 'raw', {}) if hasattr(n, 'raw') else {}
834+
purpose = raw.get("purpose", "")
835+
vpn_type = raw.get("vpn_type", "")
836+
is_client = purpose == "vpn-client" or vpn_type in ["openvpn-client", "wireguard-client"]
837+
if is_client:
838+
try:
839+
clients.append(VPNConfig(raw))
840+
except Exception as err:
841+
LOGGER.debug("Skipping VPN client conversion error: %s", err)
842+
data["vpn_clients"] = clients
843+
return
844+
# Fallback to API method if networks missing
845+
await self._update_rule_type_in_dict(data, "vpn_clients", self.api.get_vpn_clients)
846+
except Exception as err:
847+
LOGGER.error("Error deriving VPN clients: %s", err)
848+
if "vpn_clients" not in data:
849+
data["vpn_clients"] = []
829850

830851
async def _update_vpn_servers_in_dict(self, data: Dict[str, List[Any]]) -> None:
831-
"""Update VPN servers in the given data dictionary."""
832-
await self._update_rule_type_in_dict(data, "vpn_servers", self.api.get_vpn_servers)
852+
"""Update VPN servers from already-fetched networks."""
853+
try:
854+
networks = data.get("networks") or self.networks
855+
if networks:
856+
from .models.vpn_config import VPNConfig
857+
servers = []
858+
for n in networks:
859+
raw = getattr(n, 'raw', {}) if hasattr(n, 'raw') else {}
860+
purpose = raw.get("purpose", "")
861+
vpn_type = raw.get("vpn_type", "")
862+
is_server = purpose == "vpn-server" or vpn_type in ["openvpn-server", "wireguard-server"]
863+
if is_server:
864+
try:
865+
servers.append(VPNConfig(raw))
866+
except Exception as err:
867+
LOGGER.debug("Skipping VPN server conversion error: %s", err)
868+
data["vpn_servers"] = servers
869+
return
870+
# Fallback to API method if networks missing
871+
await self._update_rule_type_in_dict(data, "vpn_servers", self.api.get_vpn_servers)
872+
except Exception as err:
873+
LOGGER.error("Error deriving VPN servers: %s", err)
874+
if "vpn_servers" not in data:
875+
data["vpn_servers"] = []
833876

834877
async def _update_devices_in_dict(self, data: Dict[str, List[Any]]) -> None:
835878
"""Update devices in the data dictionary."""

custom_components/unifi_network_rules/helpers/rule.py

Lines changed: 104 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ..models.firewall_rule import FirewallRule
1717
from ..models.qos_rule import QoSRule
1818
from ..models.vpn_config import VPNConfig
19+
from ..models.network import NetworkConf
1920
from ..models.port_profile import PortProfile
2021
from ..const import DOMAIN
2122

@@ -36,9 +37,6 @@ def get_rule_enabled(rule: Any) -> bool:
3637
# Check different rule types and return appropriate enabled status
3738
if isinstance(rule, (PortForward, TrafficRoute, FirewallPolicy, TrafficRule, Wlan, QoSRule, VPNConfig)):
3839
return getattr(rule, "enabled", False)
39-
# Port Profile enabled state (computed)
40-
if isinstance(rule, PortProfile):
41-
return rule.enabled
4240

4341
# Special handling for Device LED state
4442
if isinstance(rule, Device):
@@ -50,6 +48,14 @@ def get_rule_enabled(rule: Any) -> bool:
5048
return led_state != 'off' # True if not explicitly turned off
5149
return True # Default to enabled if no override info
5250

51+
# Networks enabled (corporate LAN typically has 'enabled')
52+
if isinstance(rule, NetworkConf):
53+
return rule.enabled
54+
55+
# Port profile enabled state
56+
if isinstance(rule, PortProfile):
57+
return rule.enabled
58+
5359
# For dictionaries, try common enabled attributes
5460
if isinstance(rule, dict):
5561
return rule.get("enabled", False)
@@ -158,7 +164,15 @@ def get_rule_id(rule: Any) -> str | None:
158164
LOGGER.warning("Device without mac attribute: %s", rule)
159165
return None
160166

161-
# Port Profile unique id
167+
# Handle NetworkConf
168+
if isinstance(rule, NetworkConf):
169+
if rule.id:
170+
return f"unr_network_{rule.id}"
171+
else:
172+
LOGGER.warning("NetworkConf without id attribute: %s", rule)
173+
return None
174+
175+
# Handle PortProfile
162176
if isinstance(rule, PortProfile):
163177
if rule.id:
164178
return f"unr_port_profile_{rule.id}"
@@ -199,7 +213,10 @@ def get_rule_prefix(rule_type: str) -> str:
199213
"qos_rules": "QoS",
200214
"wlans": "WLAN",
201215
"devices": "Device",
202-
"port_profiles": "Port Profile"
216+
"port_profiles": "Port Profile",
217+
# For networks we return an empty prefix because the descriptive
218+
# name will already include the desired label (e.g., WAN1, VLAN 1).
219+
"networks": ""
203220
}
204221

205222
return rule_types.get(rule_type, "Rule")
@@ -345,13 +362,6 @@ def extract_descriptive_name(rule: Any, coordinator=None) -> str | None:
345362
return f"{vpn_type} VPN"
346363

347364
return None
348-
349-
elif isinstance(rule, PortProfile):
350-
# For port profiles, prefer the name
351-
name = rule.name
352-
if name:
353-
return name
354-
return None
355365

356366
elif isinstance(rule, Device):
357367
# For devices, return the device name for LED switches
@@ -366,6 +376,41 @@ def extract_descriptive_name(rule: Any, coordinator=None) -> str | None:
366376
elif isinstance(rule, dict):
367377
# For dictionaries, try common name attributes
368378
return rule.get("name") or rule.get("description")
379+
380+
elif isinstance(rule, NetworkConf):
381+
# Build specialized names for networks:
382+
# - WAN: "WAN<idx> <name>" when attr_hidden_id starts with WAN or purpose==wan
383+
# - LAN/Corporate: "VLAN <vlan_id> <name>" when vlan_enabled and vlan id available
384+
# - Special case: name exactly "WAN Magic" becomes "UniFi WAN Magic"
385+
raw = getattr(rule, "raw", {}) if hasattr(rule, "raw") else {}
386+
name = raw.get("name") or rule.name
387+
hidden_id = raw.get("attr_hidden_id", "") or ""
388+
purpose = raw.get("purpose", "") or ""
389+
390+
# Special case first
391+
if name == "WAN Magic":
392+
return "UniFi WAN Magic"
393+
394+
# WAN naming
395+
if purpose == "wan" or (isinstance(hidden_id, str) and hidden_id.upper().startswith("WAN")):
396+
# Extract index from WAN/WAN2/WAN3 ... when present
397+
suffix = ""
398+
if isinstance(hidden_id, str) and len(hidden_id) > 3 and hidden_id.upper().startswith("WAN"):
399+
suffix = hidden_id[3:] # characters after WAN
400+
wan_label = f"WAN{suffix}" if suffix else "WAN"
401+
return f"{wan_label} {name}".strip()
402+
403+
# LAN/VLAN naming
404+
vlan_id = raw.get("vlan") or raw.get("vlan_id")
405+
if raw.get("vlan_enabled") and vlan_id is not None:
406+
return f"VLAN {vlan_id} {name}".strip()
407+
408+
# Default LAN (no VLAN) naming
409+
if (purpose == "corporate" or (isinstance(hidden_id, str) and hidden_id.upper() == "LAN")) and not raw.get("vlan_enabled"):
410+
return f"LAN {name}".strip()
411+
412+
# Default: return name as-is
413+
return name
369414

370415
# For other types, try common attributes
371416
if hasattr(rule, "name"):
@@ -394,6 +439,25 @@ def get_rule_name(rule: Any, coordinator=None) -> str | None:
394439
rule_type = "qos_rules"
395440
elif isinstance(rule, Device):
396441
rule_type = "devices"
442+
elif isinstance(rule, NetworkConf):
443+
# Decide if this network should be exposed as a switch entity.
444+
# Omit VPN networks since we already have VPN switches.
445+
raw = getattr(rule, "raw", {}) if hasattr(rule, "raw") else {}
446+
purpose = str(raw.get("purpose", "")).lower()
447+
vpn_type = str(raw.get("vpn_type", "")).lower()
448+
is_vpn = (
449+
purpose.startswith("vpn")
450+
or purpose in {"remote-user-vpn", "vpn-client", "vpn-server"}
451+
or "vpn" in vpn_type
452+
or "wireguard" in vpn_type
453+
or "openvpn" in vpn_type
454+
)
455+
if is_vpn:
456+
rule_type = None # signal to caller there is no switch type
457+
else:
458+
rule_type = "networks"
459+
elif isinstance(rule, PortProfile):
460+
rule_type = "port_profiles"
397461
elif isinstance(rule, dict) and "type" in rule:
398462
rule_type = rule.get("type")
399463

@@ -602,4 +666,31 @@ def is_our_entity(entity_entry, domain=DOMAIN) -> bool:
602666
"""
603667
# Check if entity's platform matches our domain
604668
# This property cannot be changed by users
605-
return entity_entry.platform == domain
669+
return entity_entry.platform == domain
670+
671+
672+
# --- Network helpers ---
673+
def is_vpn_network(network: Any) -> bool:
674+
"""Return True if a network (dict or NetworkConf) represents a VPN entity.
675+
676+
Detects both purpose values and vpn_type variants (OpenVPN/WireGuard).
677+
"""
678+
raw = getattr(network, "raw", {}) if hasattr(network, "raw") else (network if isinstance(network, dict) else {})
679+
purpose = str(raw.get("purpose", "")).lower()
680+
vpn_type = str(raw.get("vpn_type", "")).lower()
681+
return (
682+
purpose.startswith("vpn")
683+
or purpose in {"remote-user-vpn", "vpn-client", "vpn-server"}
684+
or "vpn" in vpn_type
685+
or "wireguard" in vpn_type
686+
or "openvpn" in vpn_type
687+
)
688+
689+
690+
def filter_switchable_networks(networks: list[Any]) -> list[Any]:
691+
"""Filter out VPN networks; keep networks suitable for switch entities."""
692+
try:
693+
return [n for n in networks if not is_vpn_network(n)]
694+
except Exception:
695+
# Fail-safe: if anything goes wrong, return original list
696+
return networks

custom_components/unifi_network_rules/switch.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@
3030
get_child_entity_name,
3131
get_child_unique_id,
3232
get_child_entity_id,
33-
extract_descriptive_name
33+
extract_descriptive_name,
34+
filter_switchable_networks,
3435
)
3536
from .models.vpn_config import VPNConfig
3637
from .models.port_profile import PortProfile
38+
from .models.network import NetworkConf
3739

3840
LOGGER = logging.getLogger(__name__)
3941

@@ -110,6 +112,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
110112
("vpn_clients", coordinator.vpn_clients, UnifiVPNClientSwitch),
111113
("vpn_servers", coordinator.vpn_servers, UnifiVPNServerSwitch),
112114
("port_profiles", coordinator.port_profiles, UnifiPortProfileSwitch),
115+
# Filter networks to exclude all VPN forms (purpose and vpn_type)
116+
("networks", filter_switchable_networks(coordinator.networks or []), UnifiNetworkSwitch),
113117
]
114118

115119
for rule_type, rules, entity_class in all_rule_sources:
@@ -1823,4 +1827,57 @@ def extra_state_attributes(self) -> Dict[str, Any]:
18231827
attrs["tagged_vlan_mgmt"] = raw.get("tagged_vlan_mgmt")
18241828
attrs["op_mode"] = raw.get("op_mode")
18251829
attrs["poe_mode"] = raw.get("poe_mode")
1830+
return attrs
1831+
1832+
class UnifiNetworkSwitch(UnifiRuleSwitch):
1833+
"""Switch to enable/disable a UniFi Network (LAN)."""
1834+
1835+
def __init__(
1836+
self,
1837+
coordinator: UnifiRuleUpdateCoordinator,
1838+
rule_data: NetworkConf,
1839+
rule_type: str = "networks",
1840+
entry_id: str | None = None,
1841+
) -> None:
1842+
super().__init__(coordinator, rule_data, rule_type, entry_id)
1843+
self._attr_icon = "mdi:lan"
1844+
1845+
async def _async_toggle_rule(self, enable: bool) -> None:
1846+
network = self._get_current_rule()
1847+
if network is None:
1848+
raise HomeAssistantError(f"Cannot find network with ID: {self._rule_id}")
1849+
1850+
# Optimistic
1851+
self.mark_pending_operation(enable)
1852+
self.async_write_ha_state()
1853+
1854+
async def handle_operation_complete(f):
1855+
try:
1856+
ok = f.result()
1857+
if not ok:
1858+
self.mark_pending_operation(not enable)
1859+
self.async_write_ha_state()
1860+
except Exception:
1861+
self.mark_pending_operation(not enable)
1862+
self.async_write_ha_state()
1863+
1864+
# Queue via API
1865+
async def toggle_wrapper(n: NetworkConf):
1866+
# Force desired enabled in payload
1867+
n.raw["enabled"] = enable
1868+
return await self.coordinator.api.update_network(n)
1869+
1870+
future = await self.coordinator.api.queue_api_operation(toggle_wrapper, network)
1871+
future.add_done_callback(lambda f: self.hass.async_create_task(handle_operation_complete(f)))
1872+
1873+
@property
1874+
def extra_state_attributes(self) -> Dict[str, Any]:
1875+
attrs: Dict[str, Any] = {}
1876+
net = self._get_current_rule()
1877+
if net and hasattr(net, "raw"):
1878+
raw = net.raw
1879+
attrs["purpose"] = raw.get("purpose")
1880+
attrs["ip_subnet"] = raw.get("ip_subnet")
1881+
attrs["vlan_enabled"] = raw.get("vlan_enabled")
1882+
attrs["networkgroup"] = raw.get("networkgroup")
18261883
return attrs

0 commit comments

Comments
 (0)