Skip to content

Commit 214d0f0

Browse files
committed
Implement trigger system for UniFi Network Rules
This update introduces a new trigger system to handle events related to UniFi Network rules, enhancing the integration's responsiveness to configuration changes. Key changes include: - Addition of a `triggers.yaml` file defining various triggers for rule events (enabled, disabled, changed, deleted). - Refactoring of the websocket message handling to integrate with the new trigger system, allowing for more efficient processing of rule-related events. - Updates to the `coordinator.py` and `trigger.py` files to support the new trigger logic and improve logging for debugging purposes. - Minor adjustments to the `manifest.json` to include the new trigger platform. This enhancement improves the overall functionality and user experience of the UniFi Network Rules integration.
1 parent 7337b9e commit 214d0f0

7 files changed

Lines changed: 400 additions & 241 deletions

File tree

custom_components/unifi_network_rules/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
LOG_API_CALLS: Final = False # API requests and responses
5858
LOG_DATA_UPDATES: Final = False # Data refresh and update cycles
5959
LOG_ENTITY_CHANGES: Final = False # Entity addition/removal/state changes
60+
LOG_TRIGGERS: Final = False # Trigger detection and firing logs - TEMPORARILY ENABLED FOR DEBUG
6061

6162
# For backwards compatibility - will be removed in a future update
6263
# Use LOG_WEBSOCKET instead

custom_components/unifi_network_rules/coordinator.py

Lines changed: 12 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def __init__(
8484
self._update_in_progress = False
8585
self._has_data = False
8686

87+
# Websocket processing is now handled by trigger system
88+
8789
# Track entities we added or removed
8890
# By unique ID rather than the objects themselves
8991
self.known_unique_ids: Set[str] = set()
@@ -780,193 +782,17 @@ async def _update_rule_type_in_dict(self, target_data: Dict[str, List[Any]], rul
780782

781783
@callback
782784
def _handle_websocket_message(self, message: dict[str, Any]) -> None:
783-
"""Handle a message from the WebSocket connection."""
784-
try:
785-
if not message:
786-
return
785+
"""Handle a message from the WebSocket connection.
786+
787+
NOTE: This method is now primarily handled by the trigger system.
788+
Triggers detect config changes and dispatch refreshes directly via _dispatch_coordinator_refresh().
789+
This keeps the detection logic DRY and centralized in one place.
790+
"""
791+
# The trigger system now handles all websocket message detection and dispatches
792+
# coordinator refreshes when config changes are detected. This method is kept
793+
# for backward compatibility but should only be called as a fallback.
794+
pass
787795

788-
# Get message meta data if available
789-
meta = message.get("meta", {})
790-
msg_type = meta.get("message", "")
791-
msg_data = message.get("data", {})
792-
793-
# Use string representation of message to quickly determine the message type
794-
message_str = str(message).lower()
795-
796-
# Process delete events first
797-
if "delete" in message_str and ("event" in message or "events" in message_str):
798-
# This looks like a deletion event, check for IDs being removed
799-
LOGGER.debug("WebSocket deletion event: %s", message)
800-
801-
# Attempt to match deletion events to entities
802-
pass
803-
804-
# Check if any key event data exists (log even if we don't process it)
805-
if any(key in message_str for key in ["rule", "policy", "route", "forward", "nat", "traffic", "port"]):
806-
# These keywords might indicate a rule-related event
807-
LOGGER.debug("WebSocket rule event: %s", message)
808-
809-
# Map keywords to rule types
810-
rule_type_keywords = {
811-
"firewall_policies": ["policy", "security", "firewall"],
812-
"traffic_rules": ["traffic", "traffic_rules"],
813-
"port_forwards": ["port", "forward", "nat"],
814-
"traffic_routes": ["route", "traffic"],
815-
"legacy_firewall_rules": ["firewall", "rule", "allow", "deny"],
816-
"qos_rules": ["qos", "quality", "service"],
817-
"vpn_clients": ["vpn", "client"],
818-
"vpn_servers": ["vpn", "server"],
819-
}
820-
821-
# Check if this message might relate to rule changes
822-
should_refresh = False
823-
refresh_reason = None
824-
rule_type_affected = None
825-
826-
# Configuration changes and provisioning often relate to rule updates
827-
if "cfgversion" in str(message).lower() or "provisioned" in str(message).lower():
828-
should_refresh = True
829-
refresh_reason = "Configuration version change detected"
830-
831-
# Direct rule-related events
832-
elif any(word in msg_type.lower() for word in ["firewall", "rule", "policy", "route", "forward", "qos"]):
833-
should_refresh = True
834-
refresh_reason = f"Rule-related event type: {msg_type}"
835-
836-
# Try to determine the specific rule type affected
837-
for rule_type, keywords in rule_type_keywords.items():
838-
if any(keyword in msg_type.lower() for keyword in keywords):
839-
rule_type_affected = rule_type
840-
break
841-
842-
# General CRUD operations that might indicate rule changes
843-
elif any(op in msg_type.lower() for op in ["add", "delete", "update", "remove"]):
844-
# Check if the operation relates to any rule types
845-
message_str = str(message).lower()
846-
847-
# Special handling for port forwards vs device port tables
848-
if "port" in message_str:
849-
# Check if this is a device port_table update (which is distinct from port forwards)
850-
if "port_table" in message_str and not any(kw in message_str for kw in ["port_forward", "portforward", "nat"]):
851-
# Skip false positive port_table updates that aren't related to port forwarding
852-
log_websocket("Skipping CRUD operation for port_table (not related to port forwards)")
853-
return
854-
855-
for rule_type, keywords in rule_type_keywords.items():
856-
if any(keyword in message_str for keyword in keywords):
857-
# For port_forwards, require more specific keywords to avoid false positives
858-
if rule_type == "port_forwards" and not any(kw in message_str for kw in ["port_forward", "portforward", "nat"]):
859-
continue
860-
861-
should_refresh = True
862-
rule_type_affected = rule_type
863-
refresh_reason = f"CRUD operation detected for {rule_type}"
864-
break
865-
866-
# Device updates - only process if they contain config changes
867-
elif "device" in msg_type.lower() and "update" in msg_type.lower():
868-
# Only refresh for specific configuration changes
869-
message_str = str(message).lower()
870-
config_keywords = ["config", "firewall", "rule", "policy", "route", "qos"]
871-
872-
# Skip updating for commonly noisy device state update patterns
873-
if isinstance(msg_data, list) and len(msg_data) == 1:
874-
# Skip purely device state updates - these don't affect configurations
875-
if set(msg_data[0].keys()).issubset({"state", "upgrade_state", "provisioned_at"}):
876-
log_websocket("Skipping refresh for routine device state update: %s",
877-
set(msg_data[0].keys()))
878-
return
879-
880-
# Check if this is specifically a port_table update (which is not related to port forwards)
881-
if "port_table" in message_str and not any(kw in message_str for kw in ["port_forward", "portforward", "nat"]):
882-
# Skip device updates that only contain port_table information without port forwarding references
883-
log_websocket("Skipping refresh for device update with port_table (not related to port forwards)")
884-
return
885-
886-
# Check if this contains configuration version changes (accept these)
887-
if "cfgversion" in message_str:
888-
should_refresh = True
889-
refresh_reason = "Device update with configuration version change"
890-
# Otherwise, be more selective about what triggers refreshes
891-
elif any(keyword in message_str for keyword in config_keywords):
892-
should_refresh = True
893-
refresh_reason = "Device update with potential rule changes"
894-
else:
895-
# Not all device updates need a refresh - skip ones without config changes
896-
log_websocket("Skipping refresh for device update without rule-related changes")
897-
return
898-
899-
# Check for QoS-specific event patterns that might not be caught by other checks
900-
if not should_refresh and "qos" in str(message).lower():
901-
should_refresh = True
902-
rule_type_affected = "qos_rules"
903-
refresh_reason = "QoS-related event detected"
904-
log_websocket("QoS-specific event detected: %s", msg_type)
905-
906-
if should_refresh:
907-
# Use a semaphore to prevent multiple concurrent refreshes
908-
if not hasattr(self, '_refresh_semaphore'):
909-
self._refresh_semaphore = asyncio.Semaphore(1)
910-
911-
# Only proceed if we can acquire the semaphore
912-
if self._refresh_semaphore.locked():
913-
log_websocket("Skipping refresh as one is already in progress")
914-
return
915-
916-
# Should prevent rapid-fire refreshes during switch operations
917-
if not hasattr(self, '_min_ws_refresh_interval'):
918-
self._min_ws_refresh_interval = 1.5
919-
920-
if not hasattr(self, '_last_ws_refresh'):
921-
self._last_ws_refresh = 0
922-
923-
if not hasattr(self, '_pending_ws_refresh'):
924-
self._pending_ws_refresh = False
925-
926-
if not hasattr(self, '_ws_refresh_task'):
927-
self._ws_refresh_task = None
928-
929-
current_time = time.time()
930-
if current_time - self._last_ws_refresh < self._min_ws_refresh_interval:
931-
log_websocket(
932-
"Debouncing refresh request (last refresh was %0.1f seconds ago)",
933-
current_time - self._last_ws_refresh
934-
)
935-
936-
# Cancel any pending refresh task
937-
if self._ws_refresh_task and not self._ws_refresh_task.done():
938-
self._ws_refresh_task.cancel()
939-
940-
# Schedule a delayed refresh if one isn't already pending
941-
if not self._pending_ws_refresh:
942-
self._pending_ws_refresh = True
943-
delay = self._min_ws_refresh_interval - (current_time - self._last_ws_refresh)
944-
945-
async def delayed_refresh():
946-
await asyncio.sleep(delay)
947-
self._pending_ws_refresh = False
948-
log_websocket("Executing delayed refresh after debounce period")
949-
# Use the standard refresh workflow for all rule types
950-
await self._controlled_refresh_wrapper()
951-
952-
self._ws_refresh_task = self.hass.async_create_task(delayed_refresh())
953-
return
954-
955-
# Update last refresh timestamp
956-
self._last_ws_refresh = current_time
957-
958-
# Log refresh events at INFO level for visibility
959-
LOGGER.info("Refreshing data due to: %s (rule type: %s)",
960-
refresh_reason, rule_type_affected or "unknown")
961-
962-
# Use the standard refresh workflow for all rule types
963-
self.hass.async_create_task(self._controlled_refresh_wrapper())
964-
elif DEBUG_WEBSOCKET:
965-
# Only log non-refreshing messages when debug is enabled
966-
log_websocket("No refresh triggered for message type: %s", msg_type)
967-
except Exception as err:
968-
LOGGER.error("Error handling websocket message: %s", err)
969-
970796
async def _controlled_refresh_wrapper(self):
971797
"""Wrapper for the controlled refresh process to ensure proper semaphore handling."""
972798
# Acquire semaphore before starting refresh

custom_components/unifi_network_rules/manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"iot_class": "local_push",
1010
"issue_tracker": "https://github.com/sirkirby/unifi-network-rules/issues",
1111
"loggers": ["aiounifi"],
12+
"platforms": ["switch", "trigger"],
1213
"quality_scale": "custom",
1314
"requirements": [
1415
"aiohttp",

custom_components/unifi_network_rules/switch.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -612,9 +612,6 @@ def is_on(self) -> bool:
612612

613613
rule = self._get_current_rule()
614614
if rule is None:
615-
# Add log for debugging is_on when rule is None
616-
# entity_id_for_log = self.entity_id or self.unique_id
617-
# LOGGER.debug("%s(%s): is_on check - rule is None, returning False.", type(self).__name__, entity_id_for_log)
618615
return False
619616

620617
return get_rule_enabled(rule)

0 commit comments

Comments
 (0)