Add API-Reference
495
API-Reference.-.md
Normal file
495
API-Reference.-.md
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
# 🛠️ API Reference
|
||||||
|
|
||||||
|
Technical documentation for developers working with AdGuard Control Hub. This covers the internal API, entity structure, and integration architecture.
|
||||||
|
|
||||||
|
## 📡 AdGuard Home API Integration
|
||||||
|
|
||||||
|
### Supported API Endpoints
|
||||||
|
|
||||||
|
The integration interacts with these AdGuard Home API endpoints:
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/control/status` | GET | Server status and configuration |
|
||||||
|
| `/control/clients` | GET | List all configured clients |
|
||||||
|
| `/control/clients/add` | POST | Add new client |
|
||||||
|
| `/control/clients/update` | POST | Update existing client |
|
||||||
|
| `/control/clients/delete` | POST | Remove client |
|
||||||
|
| `/control/stats` | GET | DNS query statistics |
|
||||||
|
| `/control/protection` | POST | Enable/disable protection |
|
||||||
|
| `/control/blocked_services/all` | GET | Available blocked services |
|
||||||
|
| `/control/blocked_services/get` | GET | Client blocked services |
|
||||||
|
| `/control/blocked_services/update` | POST | Update blocked services |
|
||||||
|
|
||||||
|
### API Authentication
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Basic authentication used for all API calls
|
||||||
|
from aiohttp import BasicAuth
|
||||||
|
|
||||||
|
auth = BasicAuth(username, password)
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
# API wrapper error handling pattern
|
||||||
|
async def _request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
||||||
|
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 aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error("API request failed: %s", err)
|
||||||
|
raise
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.error("API request timeout")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Integration Architecture
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
adguard_hub/
|
||||||
|
├── __init__.py # Main integration setup
|
||||||
|
├── api.py # AdGuard Home API wrapper
|
||||||
|
├── config_flow.py # Configuration UI flow
|
||||||
|
├── const.py # Constants and configuration
|
||||||
|
├── switch.py # Switch platform implementation
|
||||||
|
├── sensor.py # Sensor platform (optional)
|
||||||
|
├── binary_sensor.py # Binary sensor platform (optional)
|
||||||
|
├── services.py # Custom services
|
||||||
|
└── strings.json # UI strings and translations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Home Assistant] --> B[AdGuard Control Hub]
|
||||||
|
B --> C[Update Coordinator]
|
||||||
|
C --> D[AdGuard Home API]
|
||||||
|
D --> E[AdGuard Home Server]
|
||||||
|
|
||||||
|
C --> F[Switch Platform]
|
||||||
|
C --> G[Sensor Platform]
|
||||||
|
C --> H[Binary Sensor Platform]
|
||||||
|
|
||||||
|
F --> I[Entity Registry]
|
||||||
|
G --> I
|
||||||
|
H --> I
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Coordinator Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AdGuardControlHubCoordinator(DataUpdateCoordinator):
|
||||||
|
"""Manage data fetching from AdGuard Home."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, api: AdGuardHomeAPI):
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name=f"{DOMAIN}_coordinator",
|
||||||
|
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||||
|
)
|
||||||
|
self.api = api
|
||||||
|
|
||||||
|
async def _async_update_data(self):
|
||||||
|
"""Fetch data from AdGuard Home."""
|
||||||
|
try:
|
||||||
|
# Concurrent API calls for better performance
|
||||||
|
results = await asyncio.gather(
|
||||||
|
self.api.get_clients(),
|
||||||
|
self.api.get_statistics(),
|
||||||
|
self.api.get_status(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process and return structured data
|
||||||
|
return self._process_api_results(results)
|
||||||
|
except Exception as err:
|
||||||
|
raise UpdateFailed(f"Error fetching data: {err}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 Entity System
|
||||||
|
|
||||||
|
### Entity Types and Hierarchy
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Base entity class
|
||||||
|
class AdGuardBaseEntity(CoordinatorEntity):
|
||||||
|
"""Base class for AdGuard entities."""
|
||||||
|
|
||||||
|
def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI):
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.api = api
|
||||||
|
self._attr_device_info = self._create_device_info()
|
||||||
|
|
||||||
|
def _create_device_info(self) -> dict:
|
||||||
|
"""Create device info for entity grouping."""
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, f"{self.api.host}:{self.api.port}")},
|
||||||
|
"name": f"AdGuard Control Hub ({self.api.host})",
|
||||||
|
"manufacturer": MANUFACTURER,
|
||||||
|
"model": "AdGuard Home",
|
||||||
|
"sw_version": self.coordinator.protection_status.get("version"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Switch Entities
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Global protection switch
|
||||||
|
class AdGuardProtectionSwitch(AdGuardBaseEntity, SwitchEntity):
|
||||||
|
"""Control global AdGuard protection."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
return f"{self.api.host}_{self.api.port}_protection"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return self.coordinator.protection_status.get("protection_enabled", False)
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs):
|
||||||
|
await self.api.set_protection(True)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
# Client-specific switches
|
||||||
|
class AdGuardClientSwitch(AdGuardBaseEntity, SwitchEntity):
|
||||||
|
"""Control per-client protection."""
|
||||||
|
|
||||||
|
def __init__(self, coordinator, api, client_name: str):
|
||||||
|
super().__init__(coordinator, api)
|
||||||
|
self.client_name = client_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
return f"{self.api.host}_{self.api.port}_client_{self.client_name}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sensor Entities
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AdGuardStatsSensor(AdGuardBaseEntity, SensorEntity):
|
||||||
|
"""DNS statistics sensor."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> int:
|
||||||
|
return self.coordinator.statistics.get(self.stat_key, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_class(self) -> str:
|
||||||
|
return SensorDeviceClass.TIMESTAMP if "time" in self.stat_key else None
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Custom Services
|
||||||
|
|
||||||
|
### Service Registration
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def async_register_services(hass: HomeAssistant, api: AdGuardHomeAPI) -> None:
|
||||||
|
"""Register integration services."""
|
||||||
|
|
||||||
|
async def emergency_unblock_service(call):
|
||||||
|
"""Handle 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)
|
||||||
|
# Schedule re-enabling after duration
|
||||||
|
hass.async_create_task(_restore_protection_after_delay(duration))
|
||||||
|
else:
|
||||||
|
# Handle specific clients
|
||||||
|
for client_name in clients:
|
||||||
|
await api.temporary_disable_client(client_name, duration)
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Emergency unblock failed: %s", err)
|
||||||
|
raise HomeAssistantError(f"Service call failed: {err}")
|
||||||
|
|
||||||
|
# Register service with schema validation
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"emergency_unblock",
|
||||||
|
emergency_unblock_service,
|
||||||
|
schema=EMERGENCY_UNBLOCK_SCHEMA,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Schemas
|
||||||
|
|
||||||
|
```python
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
|
# Service parameter validation schemas
|
||||||
|
EMERGENCY_UNBLOCK_SCHEMA = vol.Schema({
|
||||||
|
vol.Optional("duration", default=300): cv.positive_int,
|
||||||
|
vol.Optional("clients", default=["all"]): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
})
|
||||||
|
|
||||||
|
BLOCK_SERVICES_SCHEMA = vol.Schema({
|
||||||
|
vol.Required("client_name"): cv.string,
|
||||||
|
vol.Required("services"): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
vol.Optional("schedule"): vol.Schema({
|
||||||
|
vol.Required("enabled"): cv.boolean,
|
||||||
|
vol.Optional("time_zone", default="Local"): cv.string,
|
||||||
|
vol.Optional("mon"): TIME_RANGE_SCHEMA,
|
||||||
|
vol.Optional("tue"): TIME_RANGE_SCHEMA,
|
||||||
|
# ... other days
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
TIME_RANGE_SCHEMA = vol.Schema({
|
||||||
|
vol.Required("start"): cv.time,
|
||||||
|
vol.Required("end"): cv.time,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Data Models
|
||||||
|
|
||||||
|
### Client Data Structure
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class AdGuardClient:
|
||||||
|
"""Represent an AdGuard Home client."""
|
||||||
|
name: str
|
||||||
|
ids: list[str]
|
||||||
|
use_global_settings: bool = True
|
||||||
|
use_global_blocked_services: bool = True
|
||||||
|
blocked_services: list[str] = field(default_factory=list)
|
||||||
|
filtering_enabled: bool = True
|
||||||
|
parental_enabled: bool = False
|
||||||
|
safebrowsing_enabled: bool = False
|
||||||
|
safesearch_enabled: bool = False
|
||||||
|
upstream_dns: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(cls, data: dict) -> "AdGuardClient":
|
||||||
|
"""Create client from API response."""
|
||||||
|
return cls(
|
||||||
|
name=data.get("name", ""),
|
||||||
|
ids=data.get("ids", []),
|
||||||
|
use_global_settings=data.get("use_global_settings", True),
|
||||||
|
# ... map other fields
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_api_request(self) -> dict:
|
||||||
|
"""Convert to API request format."""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"ids": self.ids,
|
||||||
|
"use_global_settings": self.use_global_settings,
|
||||||
|
# ... map all fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Statistics Data Structure
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class AdGuardStatistics:
|
||||||
|
"""AdGuard Home DNS statistics."""
|
||||||
|
dns_queries: int
|
||||||
|
blocked_filtering: int
|
||||||
|
replaced_safebrowsing: int
|
||||||
|
replaced_safesearch: int
|
||||||
|
replaced_parental: int
|
||||||
|
avg_processing_time: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocked_percentage(self) -> float:
|
||||||
|
"""Calculate blocking percentage."""
|
||||||
|
if self.dns_queries == 0:
|
||||||
|
return 0.0
|
||||||
|
return (self.blocked_filtering / self.dns_queries) * 100
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Configuration Flow
|
||||||
|
|
||||||
|
### Configuration Steps
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle configuration flow."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Handle user input."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
try:
|
||||||
|
# Validate connection
|
||||||
|
info = await self._validate_input(user_input)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except InvalidAuth:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
else:
|
||||||
|
# Create config entry
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=info["title"],
|
||||||
|
data=user_input
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=self._get_schema(user_input),
|
||||||
|
errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _validate_input(self, data: dict) -> dict:
|
||||||
|
"""Validate user input and test connection."""
|
||||||
|
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=async_get_clientsession(self.hass),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
if not await api.test_connection():
|
||||||
|
raise CannotConnect
|
||||||
|
|
||||||
|
# Get server info for title
|
||||||
|
status = await api.get_status()
|
||||||
|
return {"title": f"AdGuard Control Hub ({data[CONF_HOST]})"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing Framework
|
||||||
|
|
||||||
|
### Unit Test Structure
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from custom_components.adguard_hub.api import AdGuardHomeAPI
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_api():
|
||||||
|
"""Mock API for testing."""
|
||||||
|
api = MagicMock(spec=AdGuardHomeAPI)
|
||||||
|
api.get_status = AsyncMock(return_value={"protection_enabled": True})
|
||||||
|
api.get_clients = AsyncMock(return_value={"clients": []})
|
||||||
|
return api
|
||||||
|
|
||||||
|
async def test_protection_switch_on(hass: HomeAssistant, mock_api):
|
||||||
|
"""Test protection switch turns on correctly."""
|
||||||
|
coordinator = AdGuardControlHubCoordinator(hass, mock_api)
|
||||||
|
switch = AdGuardProtectionSwitch(coordinator, mock_api)
|
||||||
|
|
||||||
|
await switch.async_turn_on()
|
||||||
|
|
||||||
|
mock_api.set_protection.assert_called_once_with(True)
|
||||||
|
coordinator.async_request_refresh.assert_called_once()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def test_integration_setup(hass: HomeAssistant):
|
||||||
|
"""Test integration setup."""
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
CONF_HOST: "192.168.1.100",
|
||||||
|
CONF_PORT: 3000,
|
||||||
|
CONF_USERNAME: "admin",
|
||||||
|
CONF_PASSWORD: "password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("custom_components.adguard_hub.api.AdGuardHomeAPI.test_connection", return_value=True):
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
|
||||||
|
assert config_entry.state == config_entries.ConfigEntryState.LOADED
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Debugging and Logging
|
||||||
|
|
||||||
|
### Logging Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Log levels for different scenarios
|
||||||
|
_LOGGER.debug("Detailed debugging information")
|
||||||
|
_LOGGER.info("General information")
|
||||||
|
_LOGGER.warning("Warning about potential issues")
|
||||||
|
_LOGGER.error("Error occurred but recoverable")
|
||||||
|
_LOGGER.exception("Exception occurred with traceback")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Monitoring
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
def monitor_performance(func):
|
||||||
|
"""Decorator to monitor function performance."""
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
duration = time.time() - start_time
|
||||||
|
_LOGGER.debug("%s completed in %.2fs", func.__name__, duration)
|
||||||
|
return result
|
||||||
|
except Exception as err:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
_LOGGER.error("%s failed after %.2fs: %s", func.__name__, duration, err)
|
||||||
|
raise
|
||||||
|
return wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Metrics and Monitoring
|
||||||
|
|
||||||
|
### Integration Health Metrics
|
||||||
|
|
||||||
|
```python
|
||||||
|
class HealthMetrics:
|
||||||
|
"""Track integration health metrics."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_calls_total = 0
|
||||||
|
self.api_calls_failed = 0
|
||||||
|
self.last_successful_update = None
|
||||||
|
self.last_error = None
|
||||||
|
|
||||||
|
def record_api_call(self, success: bool, error: Exception = None):
|
||||||
|
"""Record API call metrics."""
|
||||||
|
self.api_calls_total += 1
|
||||||
|
if success:
|
||||||
|
self.last_successful_update = datetime.now()
|
||||||
|
else:
|
||||||
|
self.api_calls_failed += 1
|
||||||
|
self.last_error = str(error) if error else "Unknown error"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_rate(self) -> float:
|
||||||
|
"""Calculate API call success rate."""
|
||||||
|
if self.api_calls_total == 0:
|
||||||
|
return 0.0
|
||||||
|
return ((self.api_calls_total - self.api_calls_failed) / self.api_calls_total) * 100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**For more technical details**, see the source code in the repository or the [Development Guide](Development) for setting up a development environment.
|
||||||
Reference in New Issue
Block a user