Skip to content

Commit 57a77d4

Browse files
authored
Merge pull request #104 from sirkirby/dev
New Networks, Port Profiles, Curated network lists, Smart Polling & Trigger system
2 parents 82b213f + b23e9be commit 57a77d4

81 files changed

Lines changed: 6939 additions & 3759 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/rules/unr-rules.mdc

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ globs: custom_components/**/*.py
44
alwaysApply: true
55
---
66

7-
## Rules
7+
# Rules
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
@@ -15,6 +15,7 @@ alwaysApply: true
1515
- When fixing a problem or bug, avoid treating the symptom, look for the root cause.
1616
- Details matter, ensure to always preserve existing functionality unless otherwise instructed.
1717
- KISS
18+
- Maintain consistent code hygiene by addressing code and markdown linting errors and warnings.
1819

1920
## Notes
2021

@@ -25,4 +26,4 @@ alwaysApply: true
2526

2627
## Purpose
2728

28-
The focus of this home assistant custom integration is to extend the functionality of the Unifi Network application within the home assistant ecosystem. Build upon the home assistant unifi core integration and the aiounifi library. Bundling and enabling advanced automation, customizable backup and restore, and to be generally useful for homelab and home automation environments. Some examples: enhancing guest network access. Managing child device access. Promoting good security practices. This is open source and should remain accessible and usable by a wide variety of users and supported devices.
29+
The focus of this home assistant custom integration is to extend the functionality of the Unifi Network application within the home assistant ecosystem. Build upon the home assistant unifi core integration and the aiounifi library. Bundling and enabling advanced automation, customizable backup and restore, and to be generally useful for homelab and home automation environments. Some examples: enhancing guest network access. Managing child device access. Promoting good security practices. This is open source and should remain accessible and usable by a wide variety of users and supported devices.

.pylintrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[MESSAGES CONTROL]
2+
disable=line-too-long,trailing-whitespace,broad-exception-caught

README.md

Lines changed: 326 additions & 115 deletions
Large diffs are not rendered by default.

cspell.json

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,58 @@
11
{
2-
"version": "0.2",
2+
"version": "0.2",
33
"language": "en",
4+
"ignorePaths": [
5+
"**/translations/*.json"
6+
],
47
"words": [
58
"homeassistant",
69
"hass",
710
"UDMAPI",
8-
"unifi"
11+
"unifi",
12+
"aiounifi",
13+
"clientsession",
14+
"wlans",
15+
"Wlans",
16+
"WLAN",
17+
"wlan",
18+
"Wlan",
19+
"Ubiquiti",
20+
"unsub",
21+
"trafficroutes",
22+
"trafficrules",
23+
"firewallrule",
24+
"wlanconf",
25+
"portforward",
26+
"networkconf",
27+
"portconf",
28+
"wlanrate",
29+
"radiusprofile",
30+
"wansla",
31+
"firewallgroup",
32+
"networkconfs",
33+
"mgmt",
34+
"keepalive",
35+
"retryable",
36+
"cooldown",
37+
"Cooldown",
38+
"gmtime",
39+
"traefik",
40+
"networkgroup",
41+
"Aiounifi",
42+
"automations",
43+
"hacs",
44+
"HACS",
45+
"Mjolnir",
46+
"asyncio",
47+
"codeowners",
48+
"sirkirby",
49+
"aiohttp",
50+
"orjson",
51+
"aabbccddeeff",
52+
"elif",
53+
"chrisiphone",
54+
"venv",
55+
"pytest",
56+
"toggleable"
957
]
1058
}

custom_components/unifi_network_rules/__init__.py

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
"""Support for UniFi Network Rules."""
22
from __future__ import annotations
3-
from typing import Any, Dict, List
4-
from datetime import timedelta
5-
import logging
6-
import importlib
3+
from typing import Any
74
import asyncio
8-
import async_timeout
95
import voluptuous as vol
106

117
from homeassistant.config_entries import ConfigEntry
128
from homeassistant.core import HomeAssistant, callback
13-
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_VERIFY_SSL, Platform, CONF_PORT
14-
from homeassistant.helpers.typing import ConfigType
15-
from homeassistant.exceptions import ConfigEntryNotReady
16-
from homeassistant.helpers import config_validation as cv, entity_platform
9+
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_VERIFY_SSL
10+
from homeassistant.helpers import config_validation as cv
1711
from homeassistant.helpers.dispatcher import async_dispatcher_send
18-
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
19-
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2012
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
2113

2214
# Import interface classes from aiounifi
@@ -29,8 +21,6 @@
2921

3022
from .const import (
3123
DOMAIN,
32-
CONF_UPDATE_INTERVAL,
33-
DEFAULT_UPDATE_INTERVAL,
3424
DEFAULT_SITE,
3525
LOGGER,
3626
PLATFORMS,
@@ -41,9 +31,8 @@
4131
from .udm.api import UDMAPI
4232

4333
# Import local modules at the module level - ORDER MATTERS HERE
44-
from .websocket import UnifiRuleWebsocket
4534
from .coordinator import UnifiRuleUpdateCoordinator
46-
from .services import async_setup_services, async_unload_services
35+
from .services import async_setup_services
4736
from .helpers.rule import get_rule_id
4837

4938
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -91,18 +80,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
9180
# Initialize interfaces
9281
await api.controller.initialize()
9382

94-
# Create websocket
95-
websocket_handler = UnifiRuleWebsocket(hass, api, entry.entry_id)
83+
# Get smart polling configuration from entry options
84+
smart_polling_config = {
85+
"base_interval": entry.options.get("base_interval", 300),
86+
"active_interval": entry.options.get("active_interval", 30),
87+
"realtime_interval": entry.options.get("realtime_interval", 10),
88+
"activity_timeout": entry.options.get("activity_timeout", 120),
89+
"debounce_seconds": entry.options.get("debounce_seconds", 10),
90+
"optimistic_timeout": entry.options.get("optimistic_timeout", 15),
91+
}
9692

97-
# Setup the coordinator
98-
coordinator = UnifiRuleUpdateCoordinator(hass, api, websocket_handler)
93+
# Setup the coordinator with smart polling
94+
# Use baseline interval from smart polling config for the coordinator
95+
baseline_interval = smart_polling_config.get("base_interval", 300)
96+
coordinator = UnifiRuleUpdateCoordinator(
97+
hass,
98+
api,
99+
update_interval=baseline_interval, # Use baseline for coordinator's built-in polling
100+
smart_polling_config=smart_polling_config
101+
)
99102

100103
# Explicitly set the config_entry reference
101104
coordinator.config_entry = entry
102105

103-
# Connect the websocket handler to the coordinator
104-
websocket_handler.set_callback(coordinator._handle_websocket_message)
105-
LOGGER.debug("Set websocket handler callback to coordinator's message handler")
106+
# WebSocket removed - using smart polling only
107+
LOGGER.info("Smart polling architecture enabled - WebSocket disabled")
106108

107109
# Define entity removal callback
108110
@callback
@@ -135,7 +137,6 @@ async def create_entity_wrapper(rule_type: str, rule: Any) -> bool:
135137
hass.data[DOMAIN][entry.entry_id] = {
136138
"api": api,
137139
"coordinator": coordinator,
138-
"websocket": websocket_handler,
139140
}
140141

141142
# Start initial data refresh
@@ -144,13 +145,8 @@ async def create_entity_wrapper(rule_type: str, rule: Any) -> bool:
144145
# Setup platforms
145146
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
146147

147-
# Start the websocket connection
148-
try:
149-
websocket_handler.start()
150-
LOGGER.info("WebSocket connection established successfully")
151-
except Exception as ws_err: # pylint: disable=broad-except
152-
LOGGER.error("Error setting up WebSocket: %s", ws_err)
153-
# Continue even if WebSocket fails - we can still use polling
148+
# WebSocket removed - smart polling handles all updates
149+
LOGGER.info("Integration initialized with smart polling architecture")
154150

155151
# Register the coordinator with the services registry
156152
if "services" in hass.data[DOMAIN] and "register_coordinator" in hass.data[DOMAIN]["services"]:
@@ -207,7 +203,7 @@ def _handle_entity_registry_updated(event):
207203
return
208204

209205
entity_id = event.data.get("entity_id", "")
210-
if not entity_id.startswith("switch.") or not DOMAIN in entity_id:
206+
if not entity_id.startswith("switch.") or DOMAIN not in entity_id:
211207
return
212208

213209
unique_id = event.data.get("unique_id", "")
@@ -255,7 +251,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
255251
if unload_ok:
256252
# Clean up data if unload was successful
257253
api: UDMAPI = entry_data["api"]
258-
websocket: UnifiRuleWebsocket = entry_data["websocket"]
259254
coordinator: UnifiRuleUpdateCoordinator = entry_data["coordinator"]
260255

261256
# Clean up additional listeners if they exist
@@ -265,7 +260,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
265260

266261
# Clean up everything
267262
await coordinator.async_shutdown()
268-
await websocket.async_stop()
269263
await api.cleanup()
270264

271265
# Remove entry from hass.data
@@ -382,6 +376,9 @@ async def async_create_entity(hass: HomeAssistant, rule_type: str, rule: Any) ->
382376
elif rule_type == "vpn_clients":
383377
from .switch import UnifiVPNClientSwitch
384378
entity = UnifiVPNClientSwitch(coordinator, rule, rule_type, config_entry_id)
379+
elif rule_type == "vpn_servers":
380+
from .switch import UnifiVPNServerSwitch
381+
entity = UnifiVPNServerSwitch(coordinator, rule, rule_type, config_entry_id)
385382
else:
386383
LOGGER.warning("Unknown rule type for entity creation: %s", rule_type)
387384
return False
@@ -445,4 +442,4 @@ async def async_create_entity(hass: HomeAssistant, rule_type: str, rule: Any) ->
445442
LOGGER.error("Error creating entity: %s", err)
446443
return False
447444

448-
return False
445+
return False

custom_components/unifi_network_rules/config_flow.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,25 @@
99
from homeassistant import config_entries
1010
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL
1111
from homeassistant.data_entry_flow import FlowResult
12-
from homeassistant.exceptions import HomeAssistantError
13-
from homeassistant.core import HomeAssistant
12+
from homeassistant.core import HomeAssistant, callback
1413

1514
from .const import (
1615
DOMAIN,
1716
CONF_UPDATE_INTERVAL,
1817
DEFAULT_UPDATE_INTERVAL,
1918
CONF_SITE,
2019
DEFAULT_SITE,
21-
LOGGER
20+
LOGGER,
21+
CONF_SMART_POLLING_BASE_INTERVAL,
22+
CONF_SMART_POLLING_ACTIVE_INTERVAL,
23+
CONF_SMART_POLLING_REALTIME_INTERVAL,
24+
CONF_SMART_POLLING_ACTIVITY_TIMEOUT,
25+
CONF_SMART_POLLING_DEBOUNCE_SECONDS,
26+
CONF_SMART_POLLING_OPTIMISTIC_TIMEOUT,
2227
)
2328
from .udm import CannotConnect, InvalidAuth, UDMAPI
2429

2530
from aiounifi.errors import (
26-
AiounifiException,
2731
LoginRequired,
2832
RequestError,
2933
ResponseError,
@@ -113,4 +117,73 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Flo
113117

114118
return self.async_show_form(
115119
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
116-
)
120+
)
121+
122+
@staticmethod
123+
@callback
124+
def async_get_options_flow(config_entry):
125+
"""Get the options flow."""
126+
return UnifiNetworkRulesOptionsFlowHandler(config_entry)
127+
128+
129+
class UnifiNetworkRulesOptionsFlowHandler(config_entries.OptionsFlow):
130+
"""Handle options flow for UniFi Network Rules."""
131+
132+
def __init__(self, config_entry):
133+
"""Initialize options flow."""
134+
self.config_entry = config_entry
135+
136+
async def async_step_init(self, user_input=None):
137+
"""Handle options flow."""
138+
if user_input is not None:
139+
return self.async_create_entry(title="", data=user_input)
140+
141+
# Get current options with defaults
142+
options = self.config_entry.options
143+
144+
# Smart polling configuration schema
145+
options_schema = vol.Schema({
146+
vol.Optional(
147+
CONF_SMART_POLLING_BASE_INTERVAL,
148+
default=options.get(CONF_SMART_POLLING_BASE_INTERVAL, 300),
149+
description="Base polling interval when idle (seconds)"
150+
): vol.All(int, vol.Range(min=30, max=3600)),
151+
152+
vol.Optional(
153+
CONF_SMART_POLLING_ACTIVE_INTERVAL,
154+
default=options.get(CONF_SMART_POLLING_ACTIVE_INTERVAL, 30),
155+
description="Active polling interval during user activity (seconds)"
156+
): vol.All(int, vol.Range(min=10, max=300)),
157+
158+
vol.Optional(
159+
CONF_SMART_POLLING_REALTIME_INTERVAL,
160+
default=options.get(CONF_SMART_POLLING_REALTIME_INTERVAL, 10),
161+
description="Real-time polling interval during changes (seconds)"
162+
): vol.All(int, vol.Range(min=5, max=60)),
163+
164+
vol.Optional(
165+
CONF_SMART_POLLING_ACTIVITY_TIMEOUT,
166+
default=options.get(CONF_SMART_POLLING_ACTIVITY_TIMEOUT, 120),
167+
description="Time to return to base interval after activity (seconds)"
168+
): vol.All(int, vol.Range(min=60, max=600)),
169+
170+
vol.Optional(
171+
CONF_SMART_POLLING_DEBOUNCE_SECONDS,
172+
default=options.get(CONF_SMART_POLLING_DEBOUNCE_SECONDS, 10),
173+
description="Debounce window for rapid changes (seconds)"
174+
): vol.All(int, vol.Range(min=5, max=60)),
175+
176+
vol.Optional(
177+
CONF_SMART_POLLING_OPTIMISTIC_TIMEOUT,
178+
default=options.get(CONF_SMART_POLLING_OPTIMISTIC_TIMEOUT, 15),
179+
description="Maximum optimistic state duration (seconds)"
180+
): vol.All(int, vol.Range(min=10, max=60)),
181+
})
182+
183+
return self.async_show_form(
184+
step_id="init",
185+
data_schema=options_schema,
186+
description_placeholders={
187+
"name": self.config_entry.title
188+
}
189+
)

0 commit comments

Comments
 (0)