Skip to content

Commit 39533d2

Browse files
committed
feat: port profile switches and networks cache; enable reactivation with native_networkconf_id (refs #99, #87)
1 parent 7e56b29 commit 39533d2

7 files changed

Lines changed: 312 additions & 42 deletions

File tree

custom_components/unifi_network_rules/coordinator.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88

99
from homeassistant.core import HomeAssistant, callback
1010
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
11-
from homeassistant.helpers.dispatcher import async_dispatcher_connect, async_dispatcher_send
11+
from homeassistant.helpers.dispatcher import async_dispatcher_send
1212
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1313
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
14-
from homeassistant.config_entries import ConfigEntry
1514
from homeassistant.const import Platform
1615

1716
from aiounifi.models.traffic_route import TrafficRoute
@@ -22,14 +21,16 @@
2221
from aiounifi.models.wlan import Wlan
2322
from aiounifi.models.device import Device
2423

25-
from .const import DOMAIN, LOGGER, CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, DEBUG_WEBSOCKET, LOG_TRIGGERS
24+
from .const import DOMAIN, LOGGER, DEFAULT_UPDATE_INTERVAL, LOG_TRIGGERS
2625
from .udm import UDMAPI
27-
from .websocket import SIGNAL_WEBSOCKET_MESSAGE, UnifiRuleWebsocket
28-
from .helpers.rule import get_rule_id, get_rule_name, get_rule_enabled, get_child_unique_id
26+
from .websocket import UnifiRuleWebsocket
27+
from .helpers.rule import get_rule_id, get_child_unique_id
2928
from .utils.logger import log_data, log_websocket
3029
from .models.firewall_rule import FirewallRule
3130
from .models.qos_rule import QoSRule
3231
from .models.vpn_config import VPNConfig
32+
from .models.port_profile import PortProfile
33+
from .models.network import NetworkConf
3334

3435
# This is a fallback if no update_interval is specified
3536
SCAN_INTERVAL = timedelta(seconds=60)
@@ -110,6 +111,8 @@ def __init__(
110111
self.vpn_clients: List[VPNConfig] = []
111112
self.vpn_servers: List[VPNConfig] = []
112113
self.devices: List[Device] = [] # For LED toggle switches
114+
self.port_profiles: List[PortProfile] = []
115+
self.networks: List[NetworkConf] = []
113116

114117
# For dynamic entity creation
115118
self.async_add_entities_callback: AddEntitiesCallback | None = None
@@ -370,6 +373,8 @@ async def _async_update_data(self) -> Dict[str, List[Any]]:
370373
"vpn_clients": [],
371374
"vpn_servers": [],
372375
"devices": [],
376+
"port_profiles": [],
377+
"networks": [],
373378
}
374379

375380
# Store the previous data to detect deletions and protect against API failures
@@ -418,7 +423,7 @@ async def _async_update_data(self) -> Dict[str, List[Any]]:
418423
LOGGER.warning("Authentication failure detected during initial fetch: %s", error_msg)
419424
# Trigger auth recovery but continue trying other endpoints
420425
if hasattr(self.api, "handle_auth_failure"):
421-
recovery_task = asyncio.create_task(self.api.handle_auth_failure(error_msg))
426+
asyncio.create_task(self.api.handle_auth_failure(error_msg))
422427

423428
# Preserve previous port forwards data if available
424429
if previous_data and "port_forwards" in previous_data and previous_data["port_forwards"]:
@@ -467,6 +472,14 @@ async def _async_update_data(self) -> Dict[str, List[Any]]:
467472

468473
# Then devices (for LED switches)
469474
await self._update_devices_in_dict(rules_data)
475+
await asyncio.sleep(api_call_delay)
476+
477+
# Then port profiles
478+
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)
470483

471484
# Verify the data is valid - check if we have at least some data in key categories
472485
# This helps prevent entity removal during temporary API errors
@@ -582,6 +595,8 @@ async def _async_update_data(self) -> Dict[str, List[Any]]:
582595
self.vpn_clients = rules_data.get("vpn_clients", [])
583596
self.vpn_servers = rules_data.get("vpn_servers", [])
584597
self.devices = rules_data.get("devices", [])
598+
self.port_profiles = rules_data.get("port_profiles", [])
599+
self.networks = rules_data.get("networks", [])
585600

586601
LOGGER.info("Rule collections after refresh: Port Forwards=%d, Traffic Routes=%d, Firewall Policies=%d, Traffic Rules=%d, Legacy Firewall Rules=%d, WLANs=%d, QoS Rules=%d, VPN Clients=%d, VPN Servers=%d, Devices=%d",
587602
len(self.port_forwards),
@@ -601,10 +616,11 @@ async def _async_update_data(self) -> Dict[str, List[Any]]:
601616
LOGGER.error("Error updating coordinator data: %s", err)
602617

603618
# Check if this is an authentication error
604-
auth_error = False
619+
# Track auth errors for control flow only (no use afterwards)
620+
_auth_error = False
605621
error_str = str(err).lower()
606622
if "401 unauthorized" in error_str or "403 forbidden" in error_str:
607-
auth_error = True
623+
_auth_error = True
608624
self._auth_failures += 1
609625
self._authentication_in_progress = True
610626
try:
@@ -659,6 +675,8 @@ def _check_for_deleted_rules(self, new_data: Dict[str, List[Any]]) -> None:
659675
"wlans",
660676
"vpn_clients",
661677
"vpn_servers",
678+
"port_profiles",
679+
"networks",
662680
]
663681

664682
for rule_type in all_rule_sources_types:
@@ -845,6 +863,49 @@ async def _update_devices_in_dict(self, data: Dict[str, List[Any]]) -> None:
845863
if not hasattr(self, 'devices'):
846864
self.devices = []
847865

866+
async def _update_port_profiles_in_dict(self, data: Dict[str, List[Any]]) -> None:
867+
"""Update port profiles in the data dictionary and convert to typed objects."""
868+
try:
869+
LOGGER.info("Fetching port profiles...")
870+
future = await self.api.queue_api_operation(self.api.get_port_profiles)
871+
profiles = await future if hasattr(future, "__await__") else future
872+
typed: List[PortProfile] = []
873+
for item in profiles or []:
874+
try:
875+
typed.append(PortProfile(item))
876+
except Exception as err:
877+
LOGGER.warning("Error converting port profile: %s", err)
878+
data["port_profiles"] = typed
879+
self.port_profiles = typed
880+
LOGGER.info("Updated %d port profiles", len(typed))
881+
except Exception as err:
882+
LOGGER.error("Failed to update port profiles: %s", err)
883+
data["port_profiles"] = []
884+
if not hasattr(self, 'port_profiles'):
885+
self.port_profiles = []
886+
887+
async def _update_networks_in_dict(self, data: Dict[str, List[Any]]) -> None:
888+
"""Update networks list in the data dictionary and convert to typed objects."""
889+
try:
890+
LOGGER.info("Fetching networks...")
891+
future = await self.api.queue_api_operation(self.api.get_networks)
892+
networks = await future if hasattr(future, "__await__") else future
893+
typed: List[NetworkConf] = []
894+
for item in networks or []:
895+
try:
896+
# item is already NetworkConf from API layer
897+
typed.append(item)
898+
except Exception as err:
899+
LOGGER.warning("Error converting network: %s", err)
900+
data["networks"] = typed
901+
self.networks = typed
902+
LOGGER.info("Updated %d networks", len(typed))
903+
except Exception as err:
904+
LOGGER.error("Failed to update networks: %s", err)
905+
data["networks"] = []
906+
if not hasattr(self, 'networks'):
907+
self.networks = []
908+
848909
async def _update_rule_type(self, rule_type: str, fetch_method: Callable) -> None:
849910
"""Update a specific rule type in self.data.
850911
@@ -994,7 +1055,7 @@ async def _force_refresh_with_cache_clear(self) -> None:
9941055
log_websocket("Starting forced refresh after rule change detected")
9951056

9961057
# Store previous data for deletion detection
997-
previous_data = self.data.copy() if self.data else {}
1058+
_unused_previous = self.data.copy() if self.data else {}
9981059

9991060
# Clear the API cache to ensure we get fresh data
10001061
# But do so without disrupting authentication

custom_components/unifi_network_rules/helpers/rule.py

Lines changed: 21 additions & 1 deletion
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.port_profile import PortProfile
1920
from ..const import DOMAIN
2021

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

3943
# Special handling for Device LED state
4044
if isinstance(rule, Device):
@@ -154,6 +158,14 @@ def get_rule_id(rule: Any) -> str | None:
154158
LOGGER.warning("Device without mac attribute: %s", rule)
155159
return None
156160

161+
# Port Profile unique id
162+
if isinstance(rule, PortProfile):
163+
if rule.id:
164+
return f"unr_port_profile_{rule.id}"
165+
else:
166+
LOGGER.warning("PortProfile without id attribute: %s", rule)
167+
return None
168+
157169
# Dictionary fallback - this should not happen with properly typed data
158170
if isinstance(rule, dict):
159171
_id = rule.get("_id") or rule.get("id")
@@ -186,7 +198,8 @@ def get_rule_prefix(rule_type: str) -> str:
186198
"legacy_firewall_rules": "Legacy Rule",
187199
"qos_rules": "QoS",
188200
"wlans": "WLAN",
189-
"devices": "Device"
201+
"devices": "Device",
202+
"port_profiles": "Port Profile"
190203
}
191204

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

334347
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
335355

336356
elif isinstance(rule, Device):
337357
# For devices, return the device name for LED switches
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Typed model for UniFi network (networkconf) entries."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Any, Dict
7+
8+
9+
@dataclass
10+
class NetworkConf:
11+
raw: Dict[str, Any]
12+
13+
@property
14+
def id(self) -> str:
15+
return str(self.raw.get("_id") or self.raw.get("id") or "")
16+
17+
@property
18+
def name(self) -> str:
19+
return str(self.raw.get("name") or self.raw.get("attr_hidden_id") or f"Network {self.id}")
20+
21+
@property
22+
def purpose(self) -> str:
23+
return str(self.raw.get("purpose") or "")
24+
25+
@property
26+
def enabled(self) -> bool:
27+
# Some networkconfs may not have enabled; treat presence of ip_subnet or WAN purpose as enabled
28+
if "enabled" in self.raw:
29+
return bool(self.raw.get("enabled"))
30+
return True
31+
32+
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Typed model for UniFi Ethernet Port Profile used by UNR.
2+
3+
Keeps raw dict from controller but exposes typed accessors for id, name,
4+
and computed enabled state that we use for switch entities.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from dataclasses import dataclass
10+
from typing import Any, Dict, Optional
11+
12+
13+
@dataclass
14+
class PortProfile:
15+
"""Represents a UniFi Port Profile with helper accessors.
16+
17+
Enabled is a computed concept for UNR: a profile is considered "enabled"
18+
when it has a native network assigned and management VLAN is not blocked.
19+
"""
20+
21+
raw: Dict[str, Any]
22+
23+
@property
24+
def id(self) -> str:
25+
return str(self.raw.get("_id") or self.raw.get("id") or "")
26+
27+
@property
28+
def name(self) -> str:
29+
return str(self.raw.get("name") or self.raw.get("description") or f"Port Profile {self.id}")
30+
31+
@property
32+
def enabled(self) -> bool:
33+
native = self.raw.get("native_networkconf_id")
34+
tagged_mgmt = self.raw.get("tagged_vlan_mgmt")
35+
# Treat as enabled if a native network is configured and mgmt VLANs are not blocked
36+
return bool(native) and tagged_mgmt not in {"block_all", "block-custom"}
37+
38+
def to_dict(self) -> Dict[str, Any]:
39+
"""Return a shallow copy of the raw dict suitable for updates."""
40+
return dict(self.raw)
41+
42+

0 commit comments

Comments
 (0)