refactor: another refactor
Some checks failed
Integration Testing / Integration Tests (2024.12.0, 3.11) (push) Failing after 27s
Integration Testing / Integration Tests (2024.12.0, 3.12) (push) Failing after 56s
Integration Testing / Integration Tests (2024.12.0, 3.13) (push) Failing after 1m38s
Integration Testing / Integration Tests (2025.9.4, 3.11) (push) Failing after 19s
Integration Testing / Integration Tests (2025.9.4, 3.12) (push) Failing after 20s
Integration Testing / Integration Tests (2025.9.4, 3.13) (push) Failing after 25s
Code Quality Check / Code Quality Analysis (push) Failing after 20s
Code Quality Check / Security Analysis (push) Failing after 21s

Signed-off-by: Rafal Zielinski <sq4ind@gmail.com>
This commit is contained in:
2025-09-28 17:01:21 +01:00
parent 1aad59c582
commit 554b8ac16b
25 changed files with 464 additions and 543 deletions

View File

@@ -1,4 +1,4 @@
[flake8]
max-line-length = 127
exclude = .git,__pycache__,.venv,venv,.pytest_cache
ignore = E203,W503,E501
ignore = E203,W503,E501

View File

@@ -2,55 +2,86 @@ name: Integration Testing
on:
push:
branches: [ main ]
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test-integration:
name: Test Integration
name: Integration Tests
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.13"]
home-assistant-version: ["2025.9.4"]
python-version: ["3.11", "3.12", "3.13"]
home-assistant-version: ["2024.12.0", "2025.9.4"]
steps:
- name: Checkout
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
id: pip-cache-dir
run: echo "dir=$(pip cache dir)" >> "$GITHUB_OUTPUT"
- name: Cache pip
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache-dir.outputs.dir }}
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-${{ matrix.python-version }}-
${{ runner.os }}-pip-
- name: Install Python dependencies
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install homeassistant==${{ matrix.home-assistant-version }}
pip install -r requirements-dev.txt
- name: Ensure package structure
- name: Ensure Package Structure
run: |
mkdir -p custom_components
touch custom_components/__init__.py
- name: Run tests
- name: Run Unit Tests
run: |
python -m pytest tests/ -v --cov=custom_components/adguard_hub --cov-report=term-missing --asyncio-mode=auto
python -m pytest tests/ -v --tb=short --cov=custom_components/adguard_hub --cov-report=xml --cov-report=term-missing --asyncio-mode=auto
- name: Upload coverage
if: always()
run: echo "Tests completed"
- name: Test Installation
run: |
python -c "
import sys
sys.path.insert(0, 'custom_components')
try:
from adguard_hub import DOMAIN
print(f'✅ Integration can be imported, domain: {DOMAIN}')
except Exception as e:
print(f'❌ Import failed: {e}')
sys.exit(1)
"
- name: Test Manifest Validation
run: |
python -c "
import json
import sys
try:
with open('custom_components/adguard_hub/manifest.json', 'r') as f:
manifest = json.load(f)
required_keys = ['domain', 'name', 'version', 'documentation', 'requirements']
missing = [k for k in required_keys if k not in manifest]
if missing:
print(f'❌ Missing manifest keys: {missing}')
sys.exit(1)
print('✅ Manifest is valid')
except Exception as e:
print(f'❌ Manifest validation failed: {e}')
sys.exit(1)
"
- name: Upload Coverage Reports
uses: actions/upload-artifact@v4
if: matrix.python-version == '3.13' && matrix.home-assistant-version == '2025.9.4'
with:
name: coverage-report
path: coverage.xml

View File

@@ -23,17 +23,48 @@ jobs:
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 black isort
pip install homeassistant==2025.9.4
pip install -r requirements-dev.txt
- name: Code Formatting Check
- name: Code Formatting Check (Black)
run: |
black --check custom_components/ || echo "Code formatting issues found"
black --check custom_components/ tests/
- name: Import Sorting
- name: Import Sorting Check (isort)
run: |
isort --check-only custom_components/ || echo "Import sorting issues found"
isort --check-only --diff custom_components/ tests/
- name: Linting
- name: Linting (flake8)
run: |
flake8 custom_components/ --count --select=E9,F63,F7,F82 --show-source --statistics || echo "Critical linting issues found"
flake8 custom_components/ tests/
- name: Type Checking (mypy)
run: |
mypy custom_components/adguard_hub/ --ignore-missing-imports
continue-on-error: true
security-scan:
name: Security Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Security Tools
run: |
python -m pip install --upgrade pip
pip install bandit safety
- name: Security Check (Bandit)
run: |
bandit -r custom_components/ -ll
- name: Dependency Security Check (Safety)
run: |
pip install -r requirements.txt
safety check

View File

@@ -3,32 +3,68 @@ name: Release
on:
push:
tags:
- 'v*'
- 'v*.*.*'
permissions:
contents: write
jobs:
release:
create-release:
name: Create Release
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get Version
- name: Validate Tag Format
run: |
if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ Invalid tag format. Expected: v1.2.3"
exit 1
fi
echo "✅ Valid semantic version tag: ${{ github.ref_name }}"
- name: Extract Version
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
VERSION=${{ github.ref_name }}
VERSION_NUMBER=${VERSION#v}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "version_number=${VERSION_NUMBER}" >> $GITHUB_OUTPUT
- name: Update Manifest Version
run: |
sed -i 's/"version": ".*"/"version": "${{ steps.version.outputs.version_number }}"/' custom_components/adguard_hub/manifest.json
- name: Run Tests Before Release
run: |
python -m pip install --upgrade pip
pip install homeassistant==2025.9.4
pip install -r requirements-dev.txt
mkdir -p custom_components
touch custom_components/__init__.py
python -m pytest tests/ -v --tb=short
- name: Create Release Archive
run: |
cd custom_components
zip -r ../adguard-control-hub-${{ steps.version.outputs.VERSION }}.zip adguard_hub/
zip -r ../adguard-control-hub-${{ steps.version.outputs.version_number }}.zip adguard_hub/
- name: Generate Release Notes
- name: Generate Changelog
id: changelog
run: |
echo "# AdGuard Control Hub v${{ steps.version.outputs.VERSION }}" > release_notes.md
echo "Complete Home Assistant integration for AdGuard Home" >> release_notes.md
PREVIOUS_TAG=$(git tag --sort=-version:refname | head -2 | tail -1 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
echo "changelog=Initial release of AdGuard Control Hub" >> $GITHUB_OUTPUT
else
echo "changelog=Changes since $PREVIOUS_TAG" >> $GITHUB_OUTPUT
fi
- name: Create Release
run: echo "Release created for version ${{ steps.version.outputs.VERSION }}"
- name: Success Message
run: |
echo "🎉 Release ${{ steps.version.outputs.version }} created!"
echo "📦 Archive: adguard-control-hub-${{ steps.version.outputs.version_number }}.zip"

35
.gitignore vendored
View File

@@ -1,12 +1,35 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.pyc
*.pyo
*.egg-info/
build/
dist/
.eggs/
# Virtual environments
.venv
venv/
__pycache__/
*.pyc
ENV/
env.bak/
venv.bak/
# Testing
.pytest_cache/
.coverage
.mypy_cache/
*.egg-info/
.DS_Store
htmlcov/
.tox/
.nox/
# IDEs
.vscode/
.idea/
*.log
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
*.log

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 AdGuard Control Hub
Copyright (c) 2024 AdGuard Control Hub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,10 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

View File

@@ -2,6 +2,8 @@
**The ultimate Home Assistant integration for AdGuard Home**
Transform your AdGuard Home into a smart network management powerhouse.
## ✨ Features
### 🎯 Smart Client Management
@@ -14,7 +16,7 @@
- Emergency unblock capabilities
- Advanced automation services
### 🏠 Home Assistant Integration
### 🏠 Home Assistant Integration
- Rich entity support: switches, sensors, binary sensors
- Automation-friendly services
- Real-time DNS statistics
@@ -35,6 +37,7 @@
4. Add via Integrations UI
## ⚙️ Configuration
- **Host**: AdGuard Home IP/hostname
- **Port**: Default 3000
- **Username/Password**: Admin credentials
@@ -56,4 +59,5 @@ automation:
```
## 📄 License
MIT License - Made with ❤️ for Home Assistant users!
MIT License - Made with ❤️ for Home Assistant users!

View File

@@ -1 +0,0 @@
"""Custom components for Home Assistant."""

View File

@@ -6,7 +6,7 @@ Transform your AdGuard Home into a smart network management powerhouse.
import asyncio
import logging
from datetime import timedelta
from typing import Dict, Any
from typing import Any, Dict
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
@@ -15,8 +15,8 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import AdGuardHomeAPI, AdGuardConnectionError
from .const import DOMAIN, PLATFORMS, SCAN_INTERVAL, CONF_SSL, CONF_VERIFY_SSL
from .api import AdGuardHomeAPI, AdGuardConnectionError, AdGuardHomeError
from .const import CONF_SSL, CONF_VERIFY_SSL, DOMAIN, PLATFORMS, SCAN_INTERVAL
from .services import AdGuardControlHubServices
_LOGGER = logging.getLogger(__name__)
@@ -43,19 +43,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.info(
"Successfully connected to AdGuard Home at %s:%s",
entry.data[CONF_HOST],
entry.data[CONF_HOST],
entry.data[CONF_PORT]
)
except Exception as err:
except AdGuardHomeError as err:
_LOGGER.error("Failed to connect to AdGuard Home: %s", err)
raise ConfigEntryNotReady(f"Unable to connect: {err}") from err
except Exception as err:
_LOGGER.exception("Unexpected error connecting to AdGuard Home")
raise ConfigEntryNotReady(f"Unexpected error: {err}") from err
# Create update coordinator
coordinator = AdGuardControlHubCoordinator(hass, api)
try:
await coordinator.async_config_entry_first_refresh()
except Exception as err:
except UpdateFailed as err:
_LOGGER.error("Failed to perform initial data refresh: %s", err)
raise ConfigEntryNotReady(f"Failed to fetch initial data: {err}") from err
@@ -72,17 +75,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except Exception as err:
_LOGGER.error("Failed to set up platforms: %s", err)
# Clean up on failure
hass.data[DOMAIN].pop(entry.entry_id)
hass.data[DOMAIN].pop(entry.entry_id, None)
raise ConfigEntryNotReady(f"Failed to set up platforms: {err}") from err
# Register services (only once)
if not hass.services.has_service(DOMAIN, "block_services"):
services_key = f"{DOMAIN}_services"
if services_key not in hass.data:
services = AdGuardControlHubServices(hass)
services.register_services()
hass.data.setdefault(f"{DOMAIN}_services", services)
hass.data[services_key] = services
_LOGGER.info("AdGuard Control Hub setup complete for %s:%s",
entry.data[CONF_HOST], entry.data[CONF_PORT])
_LOGGER.info("AdGuard Control Hub setup complete")
return True
@@ -92,14 +95,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok:
# Remove this entry's data
hass.data[DOMAIN].pop(entry.entry_id)
hass.data[DOMAIN].pop(entry.entry_id, None)
# Unregister services if this was the last entry
if not hass.data[DOMAIN]:
services = hass.data.get(f"{DOMAIN}_services")
services_key = f"{DOMAIN}_services"
services = hass.data.get(services_key)
if services:
services.unregister_services()
hass.data.pop(f"{DOMAIN}_services", None)
hass.data.pop(services_key, None)
hass.data.pop(DOMAIN, None)
return unload_ok
@@ -108,7 +112,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
class AdGuardControlHubCoordinator(DataUpdateCoordinator):
"""AdGuard Control Hub data update coordinator."""
def __init__(self, hass: HomeAssistant, api: AdGuardHomeAPI):
def __init__(self, hass: HomeAssistant, api: AdGuardHomeAPI) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
@@ -134,7 +138,7 @@ class AdGuardControlHubCoordinator(DataUpdateCoordinator):
results = await asyncio.gather(*tasks, return_exceptions=True)
clients, statistics, status = results
# Update stored data (use empty dict if fetch failed)
# Update stored data (use previous data if fetch failed)
if not isinstance(clients, Exception):
self._clients = {
client["name"]: client

View File

@@ -13,22 +13,18 @@ _LOGGER = logging.getLogger(__name__)
class AdGuardHomeError(Exception):
"""Base exception for AdGuard Home API."""
pass
class AdGuardConnectionError(AdGuardHomeError):
"""Exception for connection errors."""
pass
class AdGuardAuthError(AdGuardHomeError):
"""Exception for authentication errors."""
pass
class AdGuardNotFoundError(AdGuardHomeError):
"""Exception for not found errors."""
pass
class AdGuardHomeAPI:
@@ -43,7 +39,7 @@ class AdGuardHomeAPI:
ssl: bool = False,
session: Optional[aiohttp.ClientSession] = None,
timeout: int = 10,
):
) -> None:
"""Initialize the API wrapper."""
self.host = host
self.port = port
@@ -107,11 +103,13 @@ class AdGuardHomeAPI:
return {"response": text}
except asyncio.TimeoutError as err:
raise AdGuardConnectionError(f"Timeout: {err}")
raise AdGuardConnectionError(f"Timeout: {err}") from err
except ClientError as err:
raise AdGuardConnectionError(f"Client error: {err}")
raise AdGuardConnectionError(f"Client error: {err}") from err
except Exception as err:
raise AdGuardHomeError(f"Unexpected error: {err}")
if isinstance(err, AdGuardHomeError):
raise
raise AdGuardHomeError(f"Unexpected error: {err}") from err
async def test_connection(self) -> bool:
"""Test the connection to AdGuard Home."""

View File

@@ -1,6 +1,7 @@
"""Constants for the AdGuard Control Hub integration."""
from typing import Final
# Integration details
DOMAIN: Final = "adguard_hub"
MANUFACTURER: Final = "AdGuard Control Hub"
@@ -17,7 +18,7 @@ SCAN_INTERVAL: Final = 30
# Platforms
PLATFORMS: Final = [
"switch",
"binary_sensor",
"binary_sensor",
"sensor",
]

View File

@@ -1,14 +1,14 @@
{
"domain": "adguard_hub",
"name": "AdGuard Control Hub",
"codeowners": ["@sq4ind"],
"config_flow": true,
"dependencies": [],
"documentation": "https://git.sq4ind.eu/sq4ind/adguard-control-hub",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": [
"aiohttp>=3.8.0"
],
"version": "1.0.0"
"domain": "adguard_hub",
"name": "AdGuard Control Hub",
"codeowners": ["@sq4ind"],
"config_flow": true,
"dependencies": [],
"documentation": "https://git.sq4ind.eu/sq4ind/adguard-control-hub",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": [
"aiohttp>=3.8.0"
],
"version": "1.0.0"
}

View File

@@ -63,7 +63,7 @@ class AdGuardQueriesCounterSensor(AdGuardBaseSensor):
self._attr_native_unit_of_measurement = "queries"
@property
def native_value(self) -> int | None:
def native_value(self):
"""Return the state of the sensor."""
stats = self.coordinator.statistics
return stats.get("num_dns_queries", 0)
@@ -77,12 +77,12 @@ class AdGuardBlockedCounterSensor(AdGuardBaseSensor):
super().__init__(coordinator, api)
self._attr_unique_id = f"{api.host}_{api.port}_blocked_queries"
self._attr_name = "AdGuard Blocked Queries"
self._attr_icon = "mdi:shield-check"
self._attr_icon = ICON_STATISTICS
self._attr_state_class = SensorStateClass.TOTAL_INCREASING
self._attr_native_unit_of_measurement = "queries"
@property
def native_value(self) -> int | None:
def native_value(self):
"""Return the state of the sensor."""
stats = self.coordinator.statistics
return stats.get("num_blocked_filtering", 0)
@@ -96,19 +96,19 @@ class AdGuardBlockingPercentageSensor(AdGuardBaseSensor):
super().__init__(coordinator, api)
self._attr_unique_id = f"{api.host}_{api.port}_blocking_percentage"
self._attr_name = "AdGuard Blocking Percentage"
self._attr_icon = "mdi:percent"
self._attr_icon = ICON_STATISTICS
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = PERCENTAGE
@property
def native_value(self) -> float | None:
def native_value(self):
"""Return the state of the sensor."""
stats = self.coordinator.statistics
total_queries = stats.get("num_dns_queries", 0)
blocked_queries = stats.get("num_blocked_filtering", 0)
if total_queries == 0:
return 0
return 0.0
percentage = (blocked_queries / total_queries) * 100
return round(percentage, 2)
@@ -122,11 +122,11 @@ class AdGuardClientCountSensor(AdGuardBaseSensor):
super().__init__(coordinator, api)
self._attr_unique_id = f"{api.host}_{api.port}_clients_count"
self._attr_name = "AdGuard Clients Count"
self._attr_icon = "mdi:account-multiple"
self._attr_icon = ICON_STATISTICS
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = "clients"
@property
def native_value(self) -> int | None:
def native_value(self):
"""Return the state of the sensor."""
return len(self.coordinator.clients)

View File

@@ -48,16 +48,10 @@ class AdGuardControlHubServices:
self.hass.services.register(
DOMAIN, "emergency_unblock", self.emergency_unblock, schema=SCHEMA_EMERGENCY_UNBLOCK
)
self.hass.services.register(DOMAIN, "add_client", self.add_client)
self.hass.services.register(DOMAIN, "remove_client", self.remove_client)
self.hass.services.register(DOMAIN, "bulk_update_clients", self.bulk_update_clients)
def unregister_services(self) -> None:
"""Unregister all services."""
services = [
"block_services", "unblock_services", "emergency_unblock",
"add_client", "remove_client", "bulk_update_clients"
]
services = ["block_services", "unblock_services", "emergency_unblock"]
for service in services:
if self.hass.services.has_service(DOMAIN, service):
@@ -125,29 +119,3 @@ class AdGuardControlHubServices:
asyncio.create_task(delayed_enable())
except Exception as err:
_LOGGER.error("Failed to execute emergency unblock: %s", err)
async def add_client(self, call: ServiceCall) -> None:
"""Add a new client."""
client_data = dict(call.data)
for entry_data in self.hass.data[DOMAIN].values():
api: AdGuardHomeAPI = entry_data["api"]
try:
await api.add_client(client_data)
_LOGGER.info("Successfully added client: %s", client_data.get("name"))
except Exception as err:
_LOGGER.error("Failed to add client: %s", err)
async def remove_client(self, call: ServiceCall) -> None:
"""Remove a client."""
client_name = call.data.get("name")
for entry_data in self.hass.data[DOMAIN].values():
api: AdGuardHomeAPI = entry_data["api"]
try:
await api.delete_client(client_name)
_LOGGER.info("Successfully removed client: %s", client_name)
except Exception as err:
_LOGGER.error("Failed to remove client: %s", err)
async def bulk_update_clients(self, call: ServiceCall) -> None:
"""Bulk update clients."""
_LOGGER.info("Bulk update clients called")

View File

@@ -1,27 +1,27 @@
{
"config": {
"step": {
"user": {
"title": "AdGuard Control Hub",
"description": "Configure your AdGuard Home connection",
"data": {
"host": "Host",
"port": "Port",
"username": "Username",
"password": "Password",
"ssl": "Use SSL",
"verify_ssl": "Verify SSL Certificate"
}
}
},
"error": {
"cannot_connect": "Failed to connect to AdGuard Home",
"invalid_auth": "Invalid username or password",
"timeout": "Connection timeout",
"unknown": "Unexpected error occurred"
},
"abort": {
"already_configured": "AdGuard Control Hub is already configured"
"config": {
"step": {
"user": {
"title": "AdGuard Control Hub",
"description": "Configure your AdGuard Home connection",
"data": {
"host": "Host",
"port": "Port",
"username": "Username",
"password": "Password",
"ssl": "Use SSL",
"verify_ssl": "Verify SSL Certificate"
}
}
},
"error": {
"cannot_connect": "Failed to connect to AdGuard Home",
"invalid_auth": "Invalid username or password",
"timeout": "Connection timeout",
"unknown": "Unexpected error occurred"
},
"abort": {
"already_configured": "AdGuard Control Hub is already configured"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "AdGuard Control Hub",
"content_in_root": false,
"filename": "adguard_hub",
"homeassistant": "2025.1.0",
"iot_class": "Local Polling"
"name": "AdGuard Control Hub",
"content_in_root": false,
"filename": "adguard_hub",
"homeassistant": "2024.12.0",
"iot_class": "Local Polling"
}

31
info.md
View File

@@ -1,11 +1,26 @@
# AdGuard Control Hub
# 🛡️ AdGuard Control Hub
Complete Home Assistant integration for AdGuard Home network management.
**The ultimate Home Assistant integration for AdGuard Home**
## Features
- Smart client management
- Service blocking controls
- Real-time statistics
- Emergency unblock capabilities
Transform your AdGuard Home into a smart network management powerhouse with comprehensive Home Assistant integration.
Install via HACS or manually extract to `custom_components/adguard_hub/`
## ✨ Features
- **Smart Client Management**: Automatic discovery and control
- **Service Blocking**: Per-client service restrictions
- **Real-time Monitoring**: DNS statistics and performance metrics
- **Home Assistant Integration**: Full entity support and automations
## 🚀 Installation
### HACS Installation (Recommended)
1. Add custom repository: `https://git.sq4ind.eu/sq4ind/adguard-control-hub`
2. Install "AdGuard Control Hub"
3. Restart Home Assistant
4. Configure via UI
## 📋 Requirements
- Home Assistant 2024.12.0+
- AdGuard Home with API access
Made with ❤️ for the Home Assistant community!

View File

@@ -1,11 +1,22 @@
[tool.black]
line-length = 127
target-version = ['py313']
target-version = ['py311', 'py312', 'py313']
[tool.isort]
profile = "black"
line_length = 127
[tool.mypy]
python_version = "3.13"
ignore_missing_imports = true
python_version = "3.11"
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--cov=custom_components.adguard_hub",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-fail-under=80",
"--asyncio-mode=auto",
"-v"
]

View File

@@ -1,9 +1,12 @@
# Development dependencies
black==24.3.0
flake8==7.0.0
isort==5.13.2
mypy==1.9.0
pytest==8.1.1
pytest-homeassistant-custom-component==0.13.281
pytest-cov==5.0.0
homeassistant==2025.9.4
black>=24.3.0
flake8>=7.0.0
isort>=5.13.2
mypy>=1.9.0
pytest>=8.1.1
pytest-homeassistant-custom-component>=0.13.281
pytest-cov>=5.0.0
pytest-asyncio>=0.23.0
homeassistant==2025.9.4
bandit>=1.7.5
safety>=3.0.0

View File

@@ -1,5 +1,6 @@
homeassistant==2025.9.4
pytest
pytest-homeassistant-custom-component
pytest-asyncio
pytest-cov
pytest>=8.1.1
pytest-homeassistant-custom-component>=0.13.281
pytest-asyncio>=0.23.0
pytest-cov>=5.0.0
aiohttp>=3.8.0

View File

@@ -1 +1 @@
"""Tests for AdGuard Control Hub integration."""
"""Tests for AdGuard Control Hub."""

View File

@@ -1,5 +1,12 @@
"""Test configuration and fixtures."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from custom_components.adguard_hub.api import AdGuardHomeAPI
from custom_components.adguard_hub.const import DOMAIN
@pytest.fixture(autouse=True)
@@ -8,9 +15,76 @@ def auto_enable_custom_integrations(enable_custom_integrations):
yield
@pytest.fixture(autouse=True)
def mock_platform_setup():
"""Mock platform setup to avoid actual platform loading."""
from unittest.mock import patch
with patch("homeassistant.config_entries.ConfigEntry.async_forward_entry_setups"):
yield
@pytest.fixture
def mock_config_entry():
"""Mock config entry for testing."""
return ConfigEntry(
version=1,
minor_version=1,
domain=DOMAIN,
title="Test AdGuard Control Hub",
data={
CONF_HOST: "192.168.1.100",
CONF_PORT: 3000,
CONF_USERNAME: "admin",
CONF_PASSWORD: "password",
},
options={},
source=SOURCE_USER,
entry_id="test_entry_id",
unique_id="192.168.1.100:3000",
)
@pytest.fixture
def mock_api():
"""Mock AdGuard Home API."""
api = MagicMock(spec=AdGuardHomeAPI)
api.host = "192.168.1.100"
api.port = 3000
api.test_connection = AsyncMock(return_value=True)
api.get_status = AsyncMock(return_value={
"protection_enabled": True,
"version": "v0.107.0",
"dns_port": 53,
"running": True,
})
api.get_clients = AsyncMock(return_value={
"clients": [
{
"name": "test_client",
"ids": ["192.168.1.50"],
"filtering_enabled": True,
"blocked_services": {"ids": ["youtube"]},
}
]
})
api.get_statistics = AsyncMock(return_value={
"num_dns_queries": 10000,
"num_blocked_filtering": 1500,
"avg_processing_time": 2.5,
"filtering_rules_count": 75000,
})
api.get_client_by_name = AsyncMock(return_value={
"name": "test_client",
"ids": ["192.168.1.50"],
"filtering_enabled": True,
"blocked_services": {"ids": ["youtube"]},
})
api.set_protection = AsyncMock(return_value={"success": True})
return api
@pytest.fixture
def mock_hass():
"""Mock Home Assistant instance."""
hass = MagicMock(spec=HomeAssistant)
hass.data = {}
hass.services = MagicMock()
hass.services.has_service = MagicMock(return_value=False)
hass.services.register = MagicMock()
hass.services.remove = MagicMock()
hass.config_entries = MagicMock()
hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True)
hass.config_entries.async_unload_platforms = AsyncMock(return_value=True)
return hass

View File

@@ -4,80 +4,50 @@ from unittest.mock import AsyncMock, MagicMock
from custom_components.adguard_hub.api import AdGuardHomeAPI
@pytest.fixture
def mock_session():
"""Mock aiohttp session with proper async context manager."""
session = MagicMock()
response = MagicMock()
response.raise_for_status = MagicMock()
response.json = AsyncMock(return_value={"status": "ok"})
response.status = 200
response.content_length = 100
class TestAdGuardHomeAPI:
"""Test the AdGuard Home API wrapper."""
# Properly mock the async context manager
context_manager = MagicMock()
context_manager.__aenter__ = AsyncMock(return_value=response)
context_manager.__aexit__ = AsyncMock(return_value=None)
def test_api_initialization(self):
"""Test API initialization."""
api = AdGuardHomeAPI(
host="192.168.1.100",
port=3000,
username="admin",
password="password",
ssl=True,
)
session.request = MagicMock(return_value=context_manager)
return session
@pytest.mark.asyncio
async def test_api_connection(mock_session):
"""Test API connection."""
api = AdGuardHomeAPI(
host="test-host",
port=3000,
username="admin",
password="password",
session=mock_session
)
result = await api.test_connection()
assert result is True
mock_session.request.assert_called()
@pytest.mark.asyncio
async def test_api_get_status(mock_session):
"""Test getting status."""
api = AdGuardHomeAPI(
host="test-host",
port=3000,
session=mock_session
)
status = await api.get_status()
assert status == {"status": "ok"}
mock_session.request.assert_called()
@pytest.mark.asyncio
async def test_api_context_manager():
"""Test API as async context manager."""
async with AdGuardHomeAPI(host="test-host", port=3000) as api:
assert api is not None
assert api.host == "test-host"
assert api.host == "192.168.1.100"
assert api.port == 3000
assert api.username == "admin"
assert api.password == "password"
assert api.ssl is True
assert api.base_url == "https://192.168.1.100:3000"
@pytest.mark.asyncio
async def test_api_context_manager(self):
"""Test API as async context manager."""
async with AdGuardHomeAPI(host="192.168.1.100", port=3000) as api:
assert api is not None
assert api.host == "192.168.1.100"
assert api.port == 3000
@pytest.mark.asyncio
async def test_api_error_handling():
"""Test API error handling."""
# Test with a session that raises an exception
session = MagicMock()
context_manager = MagicMock()
context_manager.__aenter__ = AsyncMock(side_effect=Exception("Connection error"))
context_manager.__aexit__ = AsyncMock(return_value=None)
session.request = MagicMock(return_value=context_manager)
@pytest.mark.asyncio
async def test_test_connection_success(self):
"""Test successful connection test."""
session = MagicMock()
response = MagicMock()
response.status = 200
response.json = AsyncMock(return_value={"protection_enabled": True})
response.raise_for_status = MagicMock()
response.content_length = 100
api = AdGuardHomeAPI(
host="test-host",
port=3000,
session=session
)
context_manager = MagicMock()
context_manager.__aenter__ = AsyncMock(return_value=response)
context_manager.__aexit__ = AsyncMock(return_value=None)
session.request = MagicMock(return_value=context_manager)
with pytest.raises(Exception):
await api.get_status()
api = AdGuardHomeAPI(host="192.168.1.100", session=session)
result = await api.test_connection()
assert result is True

View File

@@ -1,71 +0,0 @@
"""Test config flow for AdGuard Control Hub."""
import pytest
from unittest.mock import AsyncMock, patch
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from custom_components.adguard_hub.const import DOMAIN
@pytest.mark.asyncio
async def test_config_flow_success(hass: HomeAssistant):
"""Test successful config flow."""
with patch("custom_components.adguard_hub.config_flow.validate_input") as mock_validate:
mock_validate.return_value = {
"title": "AdGuard Control Hub (192.168.1.100)",
"host": "192.168.1.100",
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
@pytest.mark.asyncio
async def test_config_flow_cannot_connect(hass: HomeAssistant):
"""Test config flow with connection error."""
from custom_components.adguard_hub.config_flow import CannotConnect
with patch("custom_components.adguard_hub.config_flow.validate_input") as mock_validate:
mock_validate.side_effect = CannotConnect("Connection failed")
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={
"host": "invalid-host",
"port": 3000,
"username": "admin",
"password": "password",
},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"]["base"] == "cannot_connect"
@pytest.mark.asyncio
async def test_config_flow_invalid_auth(hass: HomeAssistant):
"""Test config flow with authentication error."""
from custom_components.adguard_hub.config_flow import InvalidAuth
with patch("custom_components.adguard_hub.config_flow.validate_input") as mock_validate:
mock_validate.side_effect = InvalidAuth("Auth failed")
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={
"host": "192.168.1.100",
"port": 3000,
"username": "wrong",
"password": "wrong",
},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"]["base"] == "invalid_auth"

View File

@@ -1,228 +1,48 @@
"""Test the complete AdGuard Control Hub integration."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from unittest.mock import MagicMock, patch
from homeassistant.exceptions import ConfigEntryNotReady
from custom_components.adguard_hub import async_setup_entry, async_unload_entry
from custom_components.adguard_hub.api import AdGuardHomeAPI
from custom_components.adguard_hub.const import DOMAIN
@pytest.fixture
def mock_config_entry():
"""Mock config entry compatible with HA 2025.9.4."""
return ConfigEntry(
version=1,
minor_version=1,
domain=DOMAIN,
title="Test AdGuard",
data={
CONF_HOST: "192.168.1.100",
CONF_PORT: 3000,
CONF_USERNAME: "admin",
CONF_PASSWORD: "password",
},
options={},
source=SOURCE_USER,
entry_id="test_entry_id",
unique_id="192.168.1.100:3000",
discovery_keys=set(),
subentries_data={},
)
class TestIntegrationSetup:
"""Test integration setup and unload."""
@pytest.mark.asyncio
async def test_setup_entry_success(self, mock_hass, mock_config_entry, mock_api):
"""Test successful setup of config entry."""
with patch("custom_components.adguard_hub.AdGuardHomeAPI", return_value=mock_api), patch("custom_components.adguard_hub.async_get_clientsession"):
@pytest.fixture
def mock_api():
"""Mock API instance."""
api = MagicMock(spec=AdGuardHomeAPI)
api.host = "192.168.1.100"
api.port = 3000
api.test_connection = AsyncMock(return_value=True)
api.get_status = AsyncMock(return_value={
"protection_enabled": True,
"version": "v0.107.0",
"dns_port": 53,
"running": True,
})
api.get_clients = AsyncMock(return_value={
"clients": [
{
"name": "test_client",
"ids": ["192.168.1.50"],
"filtering_enabled": True,
"blocked_services": {"ids": ["youtube"]},
result = await async_setup_entry(mock_hass, mock_config_entry)
assert result is True
assert DOMAIN in mock_hass.data
assert mock_config_entry.entry_id in mock_hass.data[DOMAIN]
@pytest.mark.asyncio
async def test_setup_entry_connection_failure(self, mock_hass, mock_config_entry):
"""Test setup failure due to connection error."""
mock_api = MagicMock()
mock_api.test_connection = pytest.AsyncMock(return_value=False)
with patch("custom_components.adguard_hub.AdGuardHomeAPI", return_value=mock_api), patch("custom_components.adguard_hub.async_get_clientsession"):
with pytest.raises(ConfigEntryNotReady):
await async_setup_entry(mock_hass, mock_config_entry)
@pytest.mark.asyncio
async def test_unload_entry_success(self, mock_hass, mock_config_entry):
"""Test successful unloading of config entry."""
mock_hass.data[DOMAIN] = {
mock_config_entry.entry_id: {
"coordinator": MagicMock(),
"api": MagicMock(),
}
]
})
api.get_statistics = AsyncMock(return_value={
"num_dns_queries": 1000,
"num_blocked_filtering": 100,
"num_dns_queries_today": 500,
"num_blocked_filtering_today": 50,
"filtering_rules_count": 50000,
"avg_processing_time": 2.5,
})
return api
@pytest.mark.asyncio
async def test_setup_entry_success(hass: HomeAssistant, mock_config_entry, mock_api):
"""Test successful setup of config entry."""
with patch("custom_components.adguard_hub.AdGuardHomeAPI", return_value=mock_api), \
patch("custom_components.adguard_hub.async_get_clientsession"), \
patch.object(hass.config_entries, "async_forward_entry_setups", return_value=True):
result = await async_setup_entry(hass, mock_config_entry)
assert result is True
assert DOMAIN in hass.data
assert mock_config_entry.entry_id in hass.data[DOMAIN]
assert "coordinator" in hass.data[DOMAIN][mock_config_entry.entry_id]
assert "api" in hass.data[DOMAIN][mock_config_entry.entry_id]
@pytest.mark.asyncio
async def test_setup_entry_connection_failure(hass: HomeAssistant, mock_config_entry):
"""Test setup failure due to connection error."""
mock_api = MagicMock(spec=AdGuardHomeAPI)
mock_api.test_connection = AsyncMock(return_value=False)
with patch("custom_components.adguard_hub.AdGuardHomeAPI", return_value=mock_api), \
patch("custom_components.adguard_hub.async_get_clientsession"):
with pytest.raises(Exception): # Should raise ConfigEntryNotReady
await async_setup_entry(hass, mock_config_entry)
@pytest.mark.asyncio
async def test_unload_entry(hass: HomeAssistant, mock_config_entry):
"""Test unloading of config entry."""
# Set up initial data
hass.data[DOMAIN] = {
mock_config_entry.entry_id: {
"coordinator": MagicMock(),
"api": MagicMock(),
}
}
with patch.object(hass.config_entries, "async_unload_platforms", return_value=True):
result = await async_unload_entry(hass, mock_config_entry)
result = await async_unload_entry(mock_hass, mock_config_entry)
assert result is True
assert mock_config_entry.entry_id not in hass.data[DOMAIN]
@pytest.mark.asyncio
async def test_coordinator_data_update(hass: HomeAssistant, mock_api):
"""Test coordinator data update functionality."""
from custom_components.adguard_hub import AdGuardControlHubCoordinator
coordinator = AdGuardControlHubCoordinator(hass, mock_api)
# Test successful data update
data = await coordinator._async_update_data()
assert "clients" in data
assert "statistics" in data
assert "status" in data
assert "test_client" in data["clients"]
assert data["statistics"]["num_dns_queries"] == 1000
assert data["status"]["protection_enabled"] is True
@pytest.mark.asyncio
async def test_api_error_handling(mock_api):
"""Test API error handling."""
from custom_components.adguard_hub.api import AdGuardConnectionError, AdGuardAuthError
# Test connection error
mock_api.get_status = AsyncMock(side_effect=AdGuardConnectionError("Connection failed"))
with pytest.raises(AdGuardConnectionError):
await mock_api.get_status()
# Test auth error
mock_api.get_clients = AsyncMock(side_effect=AdGuardAuthError("Auth failed"))
with pytest.raises(AdGuardAuthError):
await mock_api.get_clients()
def test_services_registration(hass: HomeAssistant):
"""Test that services are properly registered."""
from custom_components.adguard_hub.services import AdGuardControlHubServices
# Create services without async context
services = AdGuardControlHubServices(hass)
services.register_services()
# Check that services are registered
assert hass.services.has_service(DOMAIN, "block_services")
assert hass.services.has_service(DOMAIN, "unblock_services")
assert hass.services.has_service(DOMAIN, "emergency_unblock")
assert hass.services.has_service(DOMAIN, "add_client")
assert hass.services.has_service(DOMAIN, "remove_client")
assert hass.services.has_service(DOMAIN, "bulk_update_clients")
# Clean up
services.unregister_services()
def test_blocked_services_constants():
"""Test that blocked services are properly defined."""
from custom_components.adguard_hub.const import BLOCKED_SERVICES
assert "youtube" in BLOCKED_SERVICES
assert "netflix" in BLOCKED_SERVICES
assert "gaming" in BLOCKED_SERVICES
assert "facebook" in BLOCKED_SERVICES
# Check friendly names are defined
assert BLOCKED_SERVICES["youtube"] == "YouTube"
assert BLOCKED_SERVICES["netflix"] == "Netflix"
def test_api_endpoints():
"""Test that API endpoints are properly defined."""
from custom_components.adguard_hub.const import API_ENDPOINTS
required_endpoints = [
"status", "clients", "stats", "protection",
"clients_add", "clients_update", "clients_delete"
]
for endpoint in required_endpoints:
assert endpoint in API_ENDPOINTS
assert API_ENDPOINTS[endpoint].startswith("/")
@pytest.mark.asyncio
async def test_client_operations(mock_api):
"""Test client add/update/delete operations."""
# Test add client
client_data = {
"name": "new_client",
"ids": ["192.168.1.200"],
"filtering_enabled": True,
}
mock_api.add_client = AsyncMock(return_value={"success": True})
result = await mock_api.add_client(client_data)
assert result["success"] is True
# Test update client
update_data = {
"name": "new_client",
"data": {"filtering_enabled": False}
}
mock_api.update_client = AsyncMock(return_value={"success": True})
result = await mock_api.update_client(update_data)
assert result["success"] is True
# Test delete client
mock_api.delete_client = AsyncMock(return_value={"success": True})
result = await mock_api.delete_client("new_client")
assert result["success"] is True
assert mock_config_entry.entry_id not in mock_hass.data[DOMAIN]