Skip to content

Commit ebe4755

Browse files
authored
Trigger support for HA 2025.7 release, performance optimizations, doc updates (#74)
- Enhance UniFi Network Rules integration with CQRS-style operation tracking and delayed verification - Introduced a new mechanism in `coordinator.py` to track Home Assistant initiated operations, attempting to prevent redundant refreshes. - Added delayed verification logic in `switch.py` to ensure state consistency after operations. - Incremented version in `manifest.json` to 3.1.0 to reflect these enhancements. - Revised the README.md to enhance clarity on integration features and usage, including a new section on advanced automations. - Updated the features list to provide a more detailed overview of supported rules and configurations. - Removed the `reset_rate_limit` service from services.yaml and its implementation in system_services.py to streamline functionality. - Remove `reset_rate_limit` service from the integration to streamline functionality and improve code clarity. Updated related imports and constants accordingly. - Revised the README.md to clarify the limitations of the automation UI and provide updated YAML configuration examples. - Enhanced the trigger.py file by removing unused code and improving the validation process for trigger configurations. - Incremented the version in manifest.json to 3.1.0 to reflect these updates. - Modified trigger.py to handle asynchronous action execution more effectively, ensuring proper task scheduling. - Enhanced logging for trigger events to improve debugging and monitoring capabilities. - Updated the security monitoring section to clarify notification creation for firewall policy changes. - Added a new automation example for handling UniFi policy changes with detailed notification messages. - Revised the VPN connection monitoring section to include a specific client reconnection automation and improved messaging for connection status notifications.
1 parent c5a94b8 commit ebe4755

10 files changed

Lines changed: 326 additions & 326 deletions

File tree

README.md

Lines changed: 143 additions & 98 deletions
Large diffs are not rendered by default.

custom_components/unifi_network_rules/const.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,5 @@
179179

180180
# Rate limiting and delays
181181
MIN_REQUEST_INTERVAL = 2.0
182-
STATE_VERIFICATION_SLEEP_SECONDS = 2
182+
STATE_VERIFICATION_SLEEP_SECONDS = 2
183+
SWITCH_DELAYED_VERIFICATION_SLEEP_SECONDS = 20

custom_components/unifi_network_rules/coordinator.py

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

87+
# --- CQRS-style Operation Tracking ---
88+
# This tracks rule_ids for operations initiated within Home Assistant
89+
# to prevent the trigger from causing a redundant refresh and to prevent a potential race condition
90+
self._ha_initiated_operations: Dict[str, float] = {}
91+
8792
# Websocket processing is now handled by trigger system
8893

8994
# Track entities we added or removed
@@ -123,6 +128,47 @@ def __init__(
123128
self._consecutive_errors = 0
124129
self._api_errors = 0
125130

131+
def register_ha_initiated_operation(self, rule_id: str, timeout: int = 15) -> None:
132+
"""Register that a rule change was initiated from HA.
133+
134+
This is called by a switch entity just before it queues an API call.
135+
The trigger system will check this to avoid a redundant refresh.
136+
137+
Args:
138+
rule_id: The ID of the rule being changed.
139+
timeout: How long (in seconds) to keep the registration active.
140+
"""
141+
self._ha_initiated_operations[rule_id] = time.time()
142+
LOGGER.debug("[CQRS] Registered HA-initiated operation for rule_id: %s", rule_id)
143+
144+
# Schedule cleanup to prevent the dictionary from growing indefinitely
145+
# if a corresponding websocket event never arrives.
146+
async def cleanup_op(op_rule_id):
147+
await asyncio.sleep(timeout)
148+
if op_rule_id in self._ha_initiated_operations:
149+
del self._ha_initiated_operations[op_rule_id]
150+
LOGGER.debug("[CQRS] Expired and removed HA-initiated operation for rule_id: %s", op_rule_id)
151+
152+
self.hass.async_create_task(cleanup_op(rule_id))
153+
154+
def check_and_consume_ha_initiated_operation(self, rule_id: str) -> bool:
155+
"""Check if a rule change was HA-initiated and consume the flag.
156+
157+
This is called by the trigger system before it decides to fire a
158+
refresh, to see if the change was expected.
159+
160+
Args:
161+
rule_id: The ID of the rule that changed.
162+
163+
Returns:
164+
True if the operation was initiated from HA, False otherwise.
165+
"""
166+
if rule_id in self._ha_initiated_operations:
167+
LOGGER.debug("[CQRS] Consumed HA-initiated operation for rule_id: %s. Suppressing trigger refresh.", rule_id)
168+
del self._ha_initiated_operations[rule_id]
169+
return True
170+
return False
171+
126172
async def _async_update_data(self) -> Dict[str, List[Any]]:
127173
"""Fetch data from API endpoint."""
128174
# Use a lock to prevent concurrent updates, especially during authentication

custom_components/unifi_network_rules/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@
1515
"aiounifi>=82.0.0",
1616
"orjson>=3.8.0"
1717
],
18-
"version": "3.0.1"
18+
"version": "3.1.0"
1919
}

custom_components/unifi_network_rules/services.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,6 @@ force_remove_stale:
238238
selector:
239239
boolean:
240240

241-
reset_rate_limit:
242-
name: Reset Rate Limit
243-
description: Reset the rate limiter for the UniFi API connection
244-
245241
websocket_diagnostics:
246242
name: WebSocket Diagnostics
247243
description: Run diagnostics on the WebSocket connection and attempt to repair if needed

custom_components/unifi_network_rules/services/__init__.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from .system_services import (
2929
async_setup_system_services,
3030
async_refresh_service,
31-
async_reset_rate_limit,
3231
async_websocket_diagnostics,
3332
)
3433
from .cleanup_services import (
@@ -49,7 +48,6 @@
4948
SERVICE_SAVE_TEMPLATE,
5049
SERVICE_FORCE_CLEANUP,
5150
SERVICE_FORCE_REMOVE_STALE,
52-
SERVICE_RESET_RATE_LIMIT,
5351
SERVICE_WEBSOCKET_DIAGNOSTICS,
5452
SERVICE_TOGGLE_RULE,
5553
)
@@ -105,7 +103,6 @@ async def async_unload_services(hass: HomeAssistant) -> None:
105103
SERVICE_SAVE_TEMPLATE,
106104
SERVICE_FORCE_CLEANUP,
107105
SERVICE_FORCE_REMOVE_STALE,
108-
SERVICE_RESET_RATE_LIMIT,
109106
SERVICE_WEBSOCKET_DIAGNOSTICS
110107
]
111108

custom_components/unifi_network_rules/services/constants.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
SERVICE_SAVE_TEMPLATE = "save_template"
1111
SERVICE_FORCE_CLEANUP = "force_cleanup"
1212
SERVICE_FORCE_REMOVE_STALE = "force_remove_stale"
13-
SERVICE_RESET_RATE_LIMIT = "reset_rate_limit"
1413
SERVICE_WEBSOCKET_DIAGNOSTICS = "websocket_diagnostics"
1514
SERVICE_TOGGLE_RULE = "toggle_rule"
1615
SERVICE_REFRESH_DATA = "refresh_data"

custom_components/unifi_network_rules/services/system_services.py

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from .constants import (
1515
SERVICE_REFRESH,
1616
SERVICE_REFRESH_DATA,
17-
SERVICE_RESET_RATE_LIMIT,
1817
SERVICE_WEBSOCKET_DIAGNOSTICS,
1918
)
2019

@@ -49,22 +48,6 @@ async def async_refresh_data(hass: HomeAssistant, coordinators: Dict, call: Serv
4948
LOGGER.debug("Refreshing coordinator for entry %s", entry_id)
5049
await coordinator.async_refresh()
5150

52-
async def async_reset_rate_limit(hass: HomeAssistant, coordinators: Dict, call: ServiceCall) -> None:
53-
"""Handle reset rate limit service call."""
54-
# Reset rate limit for all APIs
55-
for entry_data in hass.data[DOMAIN].values():
56-
if "api" in entry_data:
57-
api = entry_data["api"]
58-
if hasattr(api, "reset_rate_limit"):
59-
try:
60-
success = await api.reset_rate_limit()
61-
if success:
62-
LOGGER.info("Rate limit reset successful")
63-
else:
64-
LOGGER.warning("Rate limit reset failed")
65-
except Exception as e:
66-
LOGGER.error("Error resetting rate limit: %s", e)
67-
6851
async def async_websocket_diagnostics(hass: HomeAssistant, coordinators: Dict, call: ServiceCall) -> None:
6952
"""Run diagnostics on WebSocket connections and try to repair if needed."""
7053
results = {}
@@ -213,10 +196,6 @@ async def handle_refresh(call: ServiceCall) -> None:
213196
async def handle_refresh_data(call: ServiceCall) -> None:
214197
await async_refresh_data(hass, coordinators, call)
215198

216-
# Handle the reset rate limit service
217-
async def handle_reset_rate_limit(call: ServiceCall) -> None:
218-
await async_reset_rate_limit(hass, coordinators, call)
219-
220199
# Handle the websocket diagnostics service
221200
async def handle_websocket_diagnostics(call: ServiceCall) -> None:
222201
return await async_websocket_diagnostics(hass, coordinators, call)
@@ -230,10 +209,6 @@ async def handle_websocket_diagnostics(call: ServiceCall) -> None:
230209
DOMAIN, SERVICE_REFRESH_DATA, handle_refresh_data, schema=REFRESH_DATA_SCHEMA
231210
)
232211

233-
hass.services.async_register(
234-
DOMAIN, SERVICE_RESET_RATE_LIMIT, handle_reset_rate_limit, schema=vol.Schema({})
235-
)
236-
237212
hass.services.async_register(
238213
DOMAIN, SERVICE_WEBSOCKET_DIAGNOSTICS, handle_websocket_diagnostics, schema=vol.Schema({})
239214
)

custom_components/unifi_network_rules/switch.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from aiounifi.models.wlan import Wlan
3131
from aiounifi.models.device import Device # For LED toggle
3232

33-
from .const import DOMAIN, MANUFACTURER
33+
from .const import DOMAIN, MANUFACTURER, SWITCH_DELAYED_VERIFICATION_SLEEP_SECONDS
3434
from .helpers.rule import sanitize_entity_id
3535
from .coordinator import UnifiRuleUpdateCoordinator
3636
from .helpers.rule import (
@@ -708,13 +708,14 @@ async def _async_toggle_rule(self, enable: bool) -> None:
708708
LOGGER.debug("Adding rule %s to pending operations queue with target state: %s",
709709
self._rule_id, enable)
710710

711+
# Register the operation with the coordinator to prevent redundant refreshes.
712+
self.coordinator.register_ha_initiated_operation(self._rule_id)
713+
711714
# Define callback to handle operation completion
712715
async def handle_operation_complete(future):
713716
"""Handle operation completion."""
714717
try:
715718
success = future.result()
716-
LOGGER.debug("Operation completed for rule %s with result: %s",
717-
self._rule_id, success)
718719

719720
if not success:
720721
# Revert optimistic state if failed
@@ -726,25 +727,23 @@ async def handle_operation_complete(future):
726727
# On success, refresh the optimistic state timestamp to prevent premature clearing
727728
self._optimistic_timestamp = time.time()
728729
self.async_write_ha_state()
729-
730-
# Request refresh to update state from backend
731-
await self.coordinator.async_request_refresh()
732-
733-
# Improve rapid toggling experience by reducing delay and adding direct updates
734-
if success:
735-
async def delayed_verify():
736-
# Reduced wait time for faster feedback
737-
await asyncio.sleep(1) # Reduced from 2 seconds
738-
# Request refresh first
739-
await self.coordinator.async_request_refresh()
740-
# Force a state update immediately after refresh
741-
self.async_write_ha_state()
742-
# Also notify on a dispatcher channel for anyone listening
743-
from homeassistant.helpers.dispatcher import async_dispatcher_send
744-
async_dispatcher_send(self.hass, f"{DOMAIN}_entity_update_{self._rule_id}")
745-
LOGGER.debug("Performed verification refresh for rule %s", self._rule_id)
730+
731+
# --- Smart Verification Task ---
732+
# This task acts as a safety net. It waits a few seconds and then checks
733+
# if the change was confirmed by the trigger system (which consumes the
734+
# HA-initiated operation flag). If not, it forces a refresh.
735+
async def delayed_verification():
736+
await asyncio.sleep(SWITCH_DELAYED_VERIFICATION_SLEEP_SECONDS) # Wait 7 seconds for websocket event
737+
if self.coordinator.check_and_consume_ha_initiated_operation(self._rule_id):
738+
# If the flag was still present, it means the trigger system
739+
# did NOT get a websocket event. We must refresh.
740+
LOGGER.warning("Delayed verification: Trigger did not receive websocket event for %s. Forcing refresh.", self._rule_id)
741+
await self.coordinator.async_request_refresh()
742+
else:
743+
# The flag was already consumed, so the trigger worked correctly.
744+
LOGGER.debug("Delayed verification: Trigger confirmed change for %s. No refresh needed.", self._rule_id)
746745

747-
asyncio.create_task(delayed_verify())
746+
self.hass.async_create_task(delayed_verification())
748747

749748
except Exception as err:
750749
# Check if this is an auth error
@@ -1423,6 +1422,9 @@ async def _async_toggle_rule(self, enable: bool) -> None:
14231422
kill_switch_operation_id = f"{self._rule_id}_kill_switch"
14241423
self.coordinator._pending_operations[kill_switch_operation_id] = enable
14251424

1425+
# Register the operation with the coordinator to prevent redundant refreshes.
1426+
self.coordinator.register_ha_initiated_operation(self._rule_id)
1427+
14261428
# Queue the toggle operation
14271429
try:
14281430
# Get the toggle function from the API client
@@ -1437,8 +1439,18 @@ async def handle_operation_complete(future):
14371439
result = future.result()
14381440
if result:
14391441
LOGGER.debug("Successfully toggled kill switch for %s", self.name)
1440-
# Request a data update
1441-
await self.coordinator.async_request_refresh()
1442+
# --- Smart Verification Task ---
1443+
# See parent class for detailed explanation of this safety net.
1444+
async def delayed_verification():
1445+
await asyncio.sleep(SWITCH_DELAYED_VERIFICATION_SLEEP_SECONDS)
1446+
kill_switch_id = self._rule_id # The unique ID for the kill switch
1447+
if self.coordinator.check_and_consume_ha_initiated_operation(kill_switch_id):
1448+
LOGGER.warning("Delayed verification for kill switch %s failed. Forcing refresh.", kill_switch_id)
1449+
await self.coordinator.async_request_refresh()
1450+
else:
1451+
LOGGER.debug("Delayed verification for kill switch %s confirmed.", kill_switch_id)
1452+
1453+
self.hass.async_create_task(delayed_verification())
14421454
else:
14431455
LOGGER.error("Failed to toggle kill switch for %s", self.name)
14441456
# Revert optimistic state on failure

0 commit comments

Comments
 (0)