Initial commit
Some checks failed
🧪 Integration Testing / 🔧 Test Integration (2023.12.0, 3.11) (push) Successful in 2m11s
🧪 Integration Testing / 🔧 Test Integration (2023.12.0, 3.12) (push) Successful in 2m2s
🧪 Integration Testing / 🔧 Test Integration (2024.1.0, 3.11) (push) Successful in 1m4s
🧪 Integration Testing / 🔧 Test Integration (2024.1.0, 3.12) (push) Successful in 1m19s
🛡️ Code Quality & Security Check / 🔍 Code Quality Analysis (push) Failing after 56s

Signed-off-by: Rafal Zielinski <sq4ind@gmail.com>
This commit is contained in:
2025-09-28 13:30:43 +01:00
commit e29f7c025b
22 changed files with 1148 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
"""
🛡️ AdGuard Control Hub for Home Assistant.
Transform your AdGuard Home into a smart network management powerhouse with
complete client control, service blocking, and automation capabilities.
"""
import asyncio
import logging
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, PLATFORMS, SCAN_INTERVAL, CONF_SSL, CONF_VERIFY_SSL
from .api import AdGuardHomeAPI
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up AdGuard Control Hub from a config entry."""
session = async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True))
api = AdGuardHomeAPI(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
username=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
ssl=entry.data.get(CONF_SSL, False),
session=session,
)
# Test the connection
try:
await api.test_connection()
_LOGGER.info("Successfully connected to AdGuard Home at %s:%s",
entry.data[CONF_HOST], entry.data[CONF_PORT])
except Exception as err:
_LOGGER.error("Failed to connect to AdGuard Home: %s", err)
raise ConfigEntryNotReady(f"Unable to connect: {err}")
# Create update coordinator
coordinator = AdGuardControlHubCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh()
# Store data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
"coordinator": coordinator,
"api": api,
}
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.info("AdGuard Control Hub setup complete")
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload AdGuard Control Hub config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class AdGuardControlHubCoordinator(DataUpdateCoordinator):
"""AdGuard Control Hub data update coordinator."""
def __init__(self, hass: HomeAssistant, api: AdGuardHomeAPI):
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN}_coordinator",
update_interval=timedelta(seconds=SCAN_INTERVAL),
)
self.api = api
self._clients = {}
self._statistics = {}
self._protection_status = {}
async def _async_update_data(self):
"""Fetch data from AdGuard Home."""
try:
# Fetch all data concurrently for better performance
results = await asyncio.gather(
self.api.get_clients(),
self.api.get_statistics(),
self.api.get_status(),
return_exceptions=True,
)
clients, statistics, status = results
# Handle any exceptions
for i, result in enumerate(results):
if isinstance(result, Exception):
endpoint_names = ["clients", "statistics", "status"]
_LOGGER.warning("Error fetching %s: %s", endpoint_names[i], result)
# Update stored data (use empty dict if fetch failed)
self._clients = {
client["name"]: client
for client in (clients.get("clients", []) if not isinstance(clients, Exception) else [])
}
self._statistics = statistics if not isinstance(statistics, Exception) else {}
self._protection_status = status if not isinstance(status, Exception) else {}
return {
"clients": self._clients,
"statistics": self._statistics,
"status": self._protection_status,
}
except Exception as err:
raise UpdateFailed(f"Error communicating with AdGuard Control Hub: {err}")
@property
def clients(self):
"""Return clients data."""
return self._clients
@property
def statistics(self):
"""Return statistics data."""
return self._statistics
@property
def protection_status(self):
"""Return protection status data."""
return self._protection_status

View File

@@ -0,0 +1,142 @@
"""API wrapper for AdGuard Home."""
import logging
from typing import Any
import aiohttp
from aiohttp import BasicAuth
from .const import API_ENDPOINTS
_LOGGER = logging.getLogger(__name__)
class AdGuardHomeAPI:
"""API wrapper for AdGuard Home."""
def __init__(self, host: str, port: int = 3000, username: str = None,
password: str = None, ssl: bool = False, session = None):
self.host = host
self.port = port
self.username = username
self.password = password
self.ssl = ssl
self.session = session
protocol = "https" if ssl else "http"
self.base_url = f"{protocol}://{host}:{port}"
async def _request(self, method: str, endpoint: str, data: dict = None) -> dict:
"""Make an API request."""
url = f"{self.base_url}{endpoint}"
headers = {"Content-Type": "application/json"}
auth = None
if self.username and self.password:
auth = BasicAuth(self.username, self.password)
try:
async with self.session.request(method, url, json=data, headers=headers, auth=auth) as response:
response.raise_for_status()
if response.status == 204 or not response.content_length:
return {}
return await response.json()
except Exception as err:
_LOGGER.error("Error communicating with AdGuard Home: %s", err)
raise
async def test_connection(self) -> bool:
"""Test the connection."""
try:
await self._request("GET", API_ENDPOINTS["status"])
return True
except:
return False
async def get_status(self) -> dict:
"""Get server status."""
return await self._request("GET", API_ENDPOINTS["status"])
async def get_clients(self) -> dict:
"""Get all clients."""
return await self._request("GET", API_ENDPOINTS["clients"])
async def get_statistics(self) -> dict:
"""Get statistics."""
return await self._request("GET", API_ENDPOINTS["stats"])
async def set_protection(self, enabled: bool) -> dict:
"""Enable or disable protection."""
data = {"enabled": enabled}
return await self._request("POST", API_ENDPOINTS["protection"], data)
async def add_client(self, client_data: dict) -> dict:
"""Add a new client."""
return await self._request("POST", API_ENDPOINTS["clients_add"], client_data)
async def update_client(self, client_data: dict) -> dict:
"""Update an existing client."""
return await self._request("POST", API_ENDPOINTS["clients_update"], client_data)
async def delete_client(self, client_name: str) -> dict:
"""Delete a client."""
data = {"name": client_name}
return await self._request("POST", API_ENDPOINTS["clients_delete"], data)
async def get_client_by_name(self, client_name: str) -> dict:
"""Get a specific client by name."""
clients_data = await self.get_clients()
clients = clients_data.get("clients", [])
for client in clients:
if client.get("name") == client_name:
return client
return None
async def update_client_blocked_services(self, client_name: str, blocked_services: list, schedule: dict = None) -> dict:
"""Update blocked services for a specific client."""
client = await self.get_client_by_name(client_name)
if not client:
raise ValueError(f"Client '{client_name}' not found")
# Prepare the blocked services data
if schedule:
blocked_services_data = {
"ids": blocked_services,
"schedule": schedule
}
else:
blocked_services_data = {
"ids": blocked_services,
"schedule": {
"time_zone": "Local"
}
}
# Update the client
update_data = {
"name": client_name,
"data": {
**client,
"blocked_services": blocked_services_data
}
}
return await self.update_client(update_data)
async def toggle_client_service(self, client_name: str, service_id: str, enabled: bool) -> dict:
"""Toggle a specific service for a client."""
client = await self.get_client_by_name(client_name)
if not client:
raise ValueError(f"Client '{client_name}' not found")
# Get current blocked services
blocked_services = client.get("blocked_services", {})
if isinstance(blocked_services, dict):
service_ids = blocked_services.get("ids", [])
else:
# Handle old format (list)
service_ids = blocked_services if blocked_services else []
# Update the service list
if enabled and service_id not in service_ids:
service_ids.append(service_id)
elif not enabled and service_id in service_ids:
service_ids.remove(service_id)
return await self.update_client_blocked_services(client_name, service_ids)

View File

@@ -0,0 +1,86 @@
"""Config flow for AdGuard Control Hub integration."""
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import AdGuardHomeAPI
from .const import CONF_SSL, CONF_VERIFY_SSL, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema({
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
})
async def validate_input(hass, data: dict) -> dict:
"""Validate the user input allows us to connect."""
session = async_get_clientsession(hass, data.get(CONF_VERIFY_SSL, True))
api = AdGuardHomeAPI(
host=data[CONF_HOST],
port=data[CONF_PORT],
username=data.get(CONF_USERNAME),
password=data.get(CONF_PASSWORD),
ssl=data.get(CONF_SSL, False),
session=session,
)
# Test the connection
if not await api.test_connection():
raise CannotConnect
# Get server info
try:
status = await api.get_status()
version = status.get("version", "unknown")
return {
"title": f"AdGuard Control Hub ({data[CONF_HOST]})",
"version": version
}
except Exception as err:
_LOGGER.exception("Unexpected exception: %s", err)
raise CannotConnect from err
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for AdGuard Control Hub."""
VERSION = 1
async def async_step_user(self, user_input: dict[str, Any] | None = None):
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Create unique ID based on host and port
unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info["title"],
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
class CannotConnect(Exception):
"""Error to indicate we cannot connect."""

View File

@@ -0,0 +1,92 @@
"""Constants for the AdGuard Control Hub integration."""
from typing import Final
DOMAIN: Final = "adguard_hub"
MANUFACTURER: Final = "AdGuard Control Hub"
# Configuration
CONF_SSL: Final = "ssl"
CONF_VERIFY_SSL: Final = "verify_ssl"
# Defaults
DEFAULT_PORT: Final = 3000
DEFAULT_SSL: Final = False
DEFAULT_VERIFY_SSL: Final = True
SCAN_INTERVAL: Final = 30
# Platforms
PLATFORMS: Final = [
"switch",
"binary_sensor",
"sensor",
]
# API Endpoints
API_ENDPOINTS: Final = {
"status": "/control/status",
"clients": "/control/clients",
"clients_add": "/control/clients/add",
"clients_update": "/control/clients/update",
"clients_delete": "/control/clients/delete",
"blocked_services_all": "/control/blocked_services/all",
"blocked_services_get": "/control/blocked_services/get",
"blocked_services_update": "/control/blocked_services/update",
"protection": "/control/protection",
"stats": "/control/stats",
}
# Available blocked services with friendly names
BLOCKED_SERVICES: Final = {
# Social Media
"youtube": "YouTube",
"facebook": "Facebook",
"instagram": "Instagram",
"tiktok": "TikTok",
"twitter": "Twitter/X",
"snapchat": "Snapchat",
"reddit": "Reddit",
# Entertainment
"netflix": "Netflix",
"disney_plus": "Disney+",
"spotify": "Spotify",
"twitch": "Twitch",
# Gaming
"gaming": "Gaming Services",
"steam": "Steam",
"epic_games": "Epic Games",
"roblox": "Roblox",
# Shopping
"amazon": "Amazon",
"ebay": "eBay",
# Communication
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"discord": "Discord",
# Other
"adult": "Adult Content",
"gambling": "Gambling Sites",
"torrents": "Torrent Sites",
}
# Service attributes
ATTR_CLIENT_NAME: Final = "client_name"
ATTR_SERVICES: Final = "services"
ATTR_DURATION: Final = "duration"
ATTR_CLIENTS: Final = "clients"
ATTR_CLIENT_PATTERN: Final = "client_pattern"
ATTR_SETTINGS: Final = "settings"
# Icons
ICON_HUB: Final = "mdi:router-network"
ICON_PROTECTION: Final = "mdi:shield"
ICON_PROTECTION_OFF: Final = "mdi:shield-off"
ICON_CLIENT: Final = "mdi:devices"
ICON_CLIENT_OFFLINE: Final = "mdi:devices-off"
ICON_BLOCKED_SERVICE: Final = "mdi:block-helper"
ICON_ALLOWED_SERVICE: Final = "mdi:check-circle"
ICON_STATISTICS: Final = "mdi:chart-line"

View File

@@ -0,0 +1,15 @@
{
"domain": "adguard_hub",
"name": "AdGuard Control Hub",
"codeowners": ["@your-gitea-username"],
"config_flow": true,
"dependencies": [],
"documentation": "https://your-gitea-domain.com/your-username/adguard-control-hub",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://your-gitea-domain.com/your-username/adguard-control-hub/issues",
"requirements": [
"aiohttp>=3.8.0"
],
"version": "1.0.0"
}

View File

@@ -0,0 +1,38 @@
"""Services for AdGuard Control Hub integration."""
import logging
from homeassistant.core import HomeAssistant
from .api import AdGuardHomeAPI
_LOGGER = logging.getLogger(__name__)
async def async_register_services(hass: HomeAssistant, api: AdGuardHomeAPI) -> None:
"""Register integration services."""
async def emergency_unblock_service(call):
"""Emergency unblock service."""
duration = call.data.get("duration", 300)
clients = call.data.get("clients", ["all"])
try:
if "all" in clients:
await api.set_protection(False)
_LOGGER.info("Emergency unblock activated globally for %d seconds", duration)
else:
_LOGGER.info("Emergency unblock activated for clients: %s", clients)
except Exception as err:
_LOGGER.error("Failed to execute emergency unblock: %s", err)
raise
# Register emergency unblock service
hass.services.async_register(
"adguard_hub",
"emergency_unblock",
emergency_unblock_service
)
_LOGGER.info("AdGuard Control Hub services registered")
async def async_unregister_services(hass: HomeAssistant) -> None:
"""Unregister integration services."""
hass.services.async_remove("adguard_hub", "emergency_unblock")
_LOGGER.info("AdGuard Control Hub services unregistered")

View File

@@ -0,0 +1,27 @@
{
"config": {
"step": {
"user": {
"title": "AdGuard Control Hub",
"description": "Connect to your AdGuard Home instance for complete network control",
"data": {
"host": "AdGuard Home IP Address",
"port": "Port (usually 3000)",
"username": "Admin Username",
"password": "Admin Password",
"ssl": "Use HTTPS connection",
"verify_ssl": "Verify SSL certificate"
}
}
},
"error": {
"cannot_connect": "Failed to connect to AdGuard Home. Check IP address, port, and credentials.",
"invalid_auth": "Invalid username or password. Please check your admin credentials.",
"unknown": "Unexpected error occurred. Please check logs for details."
},
"abort": {
"already_configured": "This AdGuard Home instance is already configured",
"cannot_connect": "Cannot connect to AdGuard Home"
}
}
}

View File

@@ -0,0 +1,89 @@
"""Switch platform for AdGuard Control Hub integration."""
import logging
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AdGuardControlHubCoordinator
from .api import AdGuardHomeAPI
from .const import DOMAIN, ICON_PROTECTION, ICON_PROTECTION_OFF, ICON_CLIENT, MANUFACTURER
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
"""Set up AdGuard Control Hub switch platform."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
api = hass.data[DOMAIN][config_entry.entry_id]["api"]
entities = []
# Add global protection switch
entities.append(AdGuardProtectionSwitch(coordinator, api))
# Add client switches
for client_name in coordinator.clients.keys():
entities.append(AdGuardClientSwitch(coordinator, api, client_name))
async_add_entities(entities)
class AdGuardBaseSwitch(CoordinatorEntity, SwitchEntity):
"""Base class for AdGuard switches."""
def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI):
super().__init__(coordinator)
self.api = api
self._attr_device_info = {
"identifiers": {(DOMAIN, f"{api.host}:{api.port}")},
"name": f"AdGuard Control Hub ({api.host})",
"manufacturer": MANUFACTURER,
"model": "AdGuard Home",
}
class AdGuardProtectionSwitch(AdGuardBaseSwitch):
"""Switch to control global AdGuard protection."""
def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI):
super().__init__(coordinator, api)
self._attr_unique_id = f"{api.host}_{api.port}_protection"
self._attr_name = "AdGuard Protection"
@property
def is_on(self) -> bool:
return self.coordinator.protection_status.get("protection_enabled", False)
@property
def icon(self) -> str:
return ICON_PROTECTION if self.is_on else ICON_PROTECTION_OFF
async def async_turn_on(self, **kwargs):
await self.api.set_protection(True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs):
await self.api.set_protection(False)
await self.coordinator.async_request_refresh()
class AdGuardClientSwitch(AdGuardBaseSwitch):
"""Switch to control client-specific protection."""
def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI, client_name: str):
super().__init__(coordinator, api)
self.client_name = client_name
self._attr_unique_id = f"{api.host}_{api.port}_client_{client_name}"
self._attr_name = f"AdGuard {client_name}"
self._attr_icon = ICON_CLIENT
@property
def is_on(self) -> bool:
client = self.coordinator.clients.get(self.client_name, {})
return client.get("filtering_enabled", True)
async def async_turn_on(self, **kwargs):
# This would update client settings - simplified for basic functionality
_LOGGER.info("Would enable protection for %s", self.client_name)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs):
# This would update client settings - simplified for basic functionality
_LOGGER.info("Would disable protection for %s", self.client_name)
await self.coordinator.async_request_refresh()