Skip to content

Commit 818a44d

Browse files
authored
Merge pull request #85 from sirkirby/device-triggers
2 parents 246bb69 + a15452d commit 818a44d

7 files changed

Lines changed: 341 additions & 10 deletions

File tree

.cursor/rules/unr-rules.mdc

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: Purpose of this custom home assistant integration along with general coding practics and scope guidance
2+
description: Purpose of this custom home assistant integration along with general coding practices and scope guidance
33
globs: custom_components/**/*.py
44
alwaysApply: true
55
---
@@ -8,12 +8,12 @@ alwaysApply: true
88

99
- Always keep the code DRY for testability and separation of concerns
1010
- Prefer modern idiomatic Python 3.13 and open source conventions, Leverage type hints, use CONST over hard coded strings
11-
- Prioritize native Home Assistant libraries, like aiounifi, and other core capabilities to respect available resources and to avoid building duplicate fuctionality. Leverage the latest Home Assistant documentation.
11+
- Prioritize native Home Assistant libraries, like aiounifi, and other core capabilities to respect available resources and to avoid building duplicate functionality. Leverage the latest Home Assistant documentation.
1212
- Diagnostics enabled for observability and debugging should be targeted, respecting the resources of the system
13-
- All data retrived and stored from the API should be typed, if not supplied by aiounifi, then a custom type should be created
13+
- All data retrieved and stored from the API should be typed, if not supplied by aiounifi, then a custom type should be created
1414
- When designing a new feature, prefer elegant solutions using established best practices and patterns.
15-
- When fixing a problem or bug, avoid treating the symptop, look for the root cause.
16-
- Details matter, ensure to always preserve existing functionlity unless otherwise instructed.
15+
- When fixing a problem or bug, avoid treating the symptom, look for the root cause.
16+
- Details matter, ensure to always preserve existing functionality unless otherwise instructed.
1717
- KISS
1818

1919
## Notes

.github/copilot-instructions.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Project Overview
2+
3+
This project is a custom home assistant integration to manage UniFi Network policies and rules. It is designed to provide a seamless and efficient way to manage and automate your home network policies and rules within home assistant.
4+
5+
## Project Structure
6+
7+
The project is organized into the following directories:
8+
9+
- `custom_components/unifi_network_rules`: The custom home assistant integration
10+
- - `udm`: Code for interacting with the Unifi Network API
11+
- - `models`: data models
12+
- - `services`: custom services
13+
- - `helpers`: helper functions
14+
- - `utils`: utility functions
15+
- - `manifest.json`: The manifest for the integration.
16+
- `tests`: The test suite.
17+
- `docs`: The documentation.
18+
19+
## Libraries and Frameworks
20+
21+
- `aiounifi`: The library for interacting with the Unifi Network API
22+
- `homeassistant`: The library for interacting with the Home Assistant API
23+
- python 3.13
24+
25+
## Coding Standards
26+
27+
- Always keep the code DRY for testability and separation of concerns
28+
- Prefer modern idiomatic Python 3.13 and open source conventions, Leverage type hints, use CONST over hard coded strings
29+
- Prioritize native Home Assistant libraries, like aiounifi, and other core capabilities to respect available resources and to avoid building duplicate functionality. Leverage the latest Home Assistant documentation.
30+
- Diagnostics enabled for observability and debugging should be targeted, respecting the resources of the system
31+
- All data retrieved and stored from the API should be typed, if not supplied by aiounifi, then a custom type should be created
32+
- When designing a new feature, prefer elegant solutions using established best practices and patterns.
33+
- When fixing a problem or bug, avoid treating the symptom, look for the root cause.
34+
- Details matter, ensure to always preserve existing functionality unless otherwise instructed.
35+
- KISS

custom_components/unifi_network_rules/coordinator.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from aiounifi.models.wlan import Wlan
2323
from aiounifi.models.device import Device
2424

25-
from .const import DOMAIN, LOGGER, CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, DEBUG_WEBSOCKET
25+
from .const import DOMAIN, LOGGER, CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, DEBUG_WEBSOCKET, LOG_TRIGGERS
2626
from .udm import UDMAPI
2727
from .websocket import SIGNAL_WEBSOCKET_MESSAGE, UnifiRuleWebsocket
2828
from .helpers.rule import get_rule_id, get_rule_name, get_rule_enabled, get_child_unique_id
@@ -169,6 +169,145 @@ def check_and_consume_ha_initiated_operation(self, rule_id: str) -> bool:
169169
return True
170170
return False
171171

172+
def fire_device_trigger_via_dispatcher(self, device_id: str, device_name: str, change_type: str, old_state: Any = None, new_state: Any = None) -> None:
173+
"""Fire device_changed triggers using Home Assistant's dispatcher pattern.
174+
175+
This method dispatches device change events that trigger instances can listen for.
176+
Uses the same dispatcher pattern as other coordinator events for consistency.
177+
178+
Args:
179+
device_id: The ID of the device that changed (e.g., MAC address)
180+
device_name: Human-readable name of the device
181+
change_type: Type of change (e.g., "led_toggled", "reboot")
182+
old_state: Previous state of the device property
183+
new_state: New state of the device property
184+
"""
185+
if LOG_TRIGGERS:
186+
LOGGER.info("🔥 COORDINATOR: Dispatching device trigger for %s (%s): %s",
187+
device_name, device_id, change_type)
188+
189+
# Prepare trigger data
190+
trigger_data = {
191+
"device_id": device_id,
192+
"device_name": device_name,
193+
"change_type": change_type,
194+
"old_state": old_state,
195+
"new_state": new_state,
196+
"trigger_type": "device_changed"
197+
}
198+
199+
# Dispatch via Home Assistant's dispatcher system
200+
try:
201+
# Get entry_id for this coordinator
202+
entry_id = self.config_entry.entry_id if self.config_entry else "unknown"
203+
signal_name = f"{DOMAIN}_device_trigger_{entry_id}"
204+
205+
async_dispatcher_send(self.hass, signal_name, trigger_data)
206+
207+
if LOG_TRIGGERS:
208+
LOGGER.info("✅ COORDINATOR: Dispatched device trigger signal: %s", signal_name)
209+
210+
except Exception as err:
211+
LOGGER.error("Error dispatching device trigger: %s", err)
212+
213+
def _check_for_device_state_changes(self, previous_data: Dict[str, List[Any]], new_data: Dict[str, List[Any]]) -> None:
214+
"""Check for device state changes and fire device triggers accordingly.
215+
216+
This provides eventual consistency by detecting device changes during regular
217+
coordinator polling cycles, in case websocket events are not received.
218+
219+
Args:
220+
previous_data: The previous coordinator data
221+
new_data: The current coordinator data
222+
"""
223+
if not previous_data or not new_data:
224+
LOGGER.debug("Skipping device state change detection - no previous or new data")
225+
return
226+
227+
previous_devices = previous_data.get("devices", [])
228+
new_devices = new_data.get("devices", [])
229+
230+
if not previous_devices and not new_devices:
231+
return # No devices to compare
232+
233+
# Create lookup dictionaries by device MAC for efficient comparison
234+
previous_device_states = {}
235+
for device in previous_devices:
236+
try:
237+
device_id = getattr(device, 'mac', getattr(device, 'id', None))
238+
if device_id:
239+
previous_device_states[device_id] = {
240+
'led_override': getattr(device, 'led_override', None),
241+
'name': getattr(device, 'name', f"Device {device_id}"),
242+
'state': getattr(device, 'state', 1) # Connection state
243+
}
244+
except Exception as err:
245+
LOGGER.warning("Error processing previous device state: %s", err)
246+
247+
new_device_states = {}
248+
for device in new_devices:
249+
try:
250+
device_id = getattr(device, 'mac', getattr(device, 'id', None))
251+
if device_id:
252+
new_device_states[device_id] = {
253+
'led_override': getattr(device, 'led_override', None),
254+
'name': getattr(device, 'name', f"Device {device_id}"),
255+
'state': getattr(device, 'state', 1) # Connection state
256+
}
257+
except Exception as err:
258+
LOGGER.warning("Error processing new device state: %s", err)
259+
260+
# Compare device states and fire triggers for changes
261+
all_device_ids = set(previous_device_states.keys()) | set(new_device_states.keys())
262+
263+
for device_id in all_device_ids:
264+
previous_state = previous_device_states.get(device_id)
265+
new_state = new_device_states.get(device_id)
266+
267+
# Skip if device was just added or removed (handled elsewhere)
268+
if not previous_state or not new_state:
269+
continue
270+
271+
# Check for LED state changes
272+
prev_led = previous_state.get('led_override')
273+
new_led = new_state.get('led_override')
274+
275+
if prev_led != new_led:
276+
device_name = new_state.get('name', f"Device {device_id}")
277+
278+
if LOG_TRIGGERS:
279+
LOGGER.info("🔍 DEVICE STATE CHANGE DETECTED: %s (%s) LED: %s → %s",
280+
device_name, device_id, prev_led, new_led)
281+
282+
# Fire device trigger via dispatcher
283+
self.fire_device_trigger_via_dispatcher(
284+
device_id=device_id,
285+
device_name=device_name,
286+
change_type="led_toggled",
287+
old_state=prev_led,
288+
new_state=new_led
289+
)
290+
291+
# Check for connection state changes
292+
prev_connection = previous_state.get('state')
293+
new_connection = new_state.get('state')
294+
295+
if prev_connection != new_connection:
296+
device_name = new_state.get('name', f"Device {device_id}")
297+
298+
if LOG_TRIGGERS:
299+
LOGGER.info("🔍 DEVICE CONNECTION CHANGE DETECTED: %s (%s) State: %s → %s",
300+
device_name, device_id, prev_connection, new_connection)
301+
302+
# Fire device trigger via dispatcher
303+
self.fire_device_trigger_via_dispatcher(
304+
device_id=device_id,
305+
device_name=device_name,
306+
change_type="connection_changed",
307+
old_state=prev_connection,
308+
new_state=new_connection
309+
)
310+
172311
async def _async_update_data(self) -> Dict[str, List[Any]]:
173312
"""Fetch data from API endpoint."""
174313
# Use a lock to prevent concurrent updates, especially during authentication
@@ -417,6 +556,9 @@ async def _async_update_data(self) -> Dict[str, List[Any]]:
417556
# --- Check for DELETED Entities ---
418557
self._check_for_deleted_rules(rules_data)
419558

559+
# --- Check for Device State Changes ---
560+
self._check_for_device_state_changes(previous_data, rules_data)
561+
420562
# --- Discover and Add NEW Entities ---
421563
await self._discover_and_add_new_entities(rules_data)
422564

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.1.0"
18+
"version": "3.2.0"
1919
}

custom_components/unifi_network_rules/switch.py

Lines changed: 33 additions & 2 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, SWITCH_DELAYED_VERIFICATION_SLEEP_SECONDS
33+
from .const import DOMAIN, MANUFACTURER, SWITCH_DELAYED_VERIFICATION_SLEEP_SECONDS, LOG_TRIGGERS
3434
from .helpers.rule import sanitize_entity_id
3535
from .coordinator import UnifiRuleUpdateCoordinator
3636
from .helpers.rule import (
@@ -743,7 +743,9 @@ async def delayed_verification():
743743
# The flag was already consumed, so the trigger worked correctly.
744744
LOGGER.debug("Delayed verification: Trigger confirmed change for %s. No refresh needed.", self._rule_id)
745745

746-
self.hass.async_create_task(delayed_verification())
746+
# Skip delayed verification for device LED toggles since we use immediate trigger + polling detection
747+
if self._rule_type != "devices":
748+
self.hass.async_create_task(delayed_verification())
747749

748750
except Exception as err:
749751
# Check if this is an auth error
@@ -1579,6 +1581,35 @@ def _handle_coordinator_update(self) -> None:
15791581
# Call parent update (handles optimistic state and availability)
15801582
super()._handle_coordinator_update()
15811583

1584+
async def _async_toggle_rule(self, enable: bool) -> None:
1585+
"""Override toggle for LED devices to add immediate trigger firing."""
1586+
# Fire immediate device trigger for optimistic response
1587+
try:
1588+
device_name = getattr(self._device, 'name', f"Device {self._rule_id}")
1589+
device_id = getattr(self._device, 'mac', self._rule_id)
1590+
1591+
# Use existing CQRS pattern to track this HA-initiated operation
1592+
self.coordinator.register_ha_initiated_operation(device_id)
1593+
1594+
if LOG_TRIGGERS:
1595+
LOGGER.info("🔥 LED IMMEDIATE TRIGGER: Firing device trigger for %s (%s): %s → %s",
1596+
device_name, device_id, not enable, enable)
1597+
1598+
# Fire immediate device trigger via dispatcher
1599+
self.coordinator.fire_device_trigger_via_dispatcher(
1600+
device_id=device_id,
1601+
device_name=device_name,
1602+
change_type="led_toggled",
1603+
old_state=not enable,
1604+
new_state=enable
1605+
)
1606+
1607+
except Exception as trigger_err:
1608+
LOGGER.error("Error firing immediate device trigger for LED toggle: %s", trigger_err)
1609+
1610+
# Call parent toggle method to handle the actual API operation
1611+
await super()._async_toggle_rule(enable)
1612+
15821613
@property
15831614
def extra_state_attributes(self) -> Dict[str, Any]:
15841615
"""Return entity specific state attributes."""

0 commit comments

Comments
 (0)