diff --git a/.flake8 b/.flake8 index addfd3a..397cc83 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] max-line-length = 127 exclude = .git,__pycache__,.venv,venv,.pytest_cache -ignore = E203,W503,E501 \ No newline at end of file +ignore = E203,W503,E501 diff --git a/.gitea/workflows/integration-test.yml b/.gitea/workflows/integration-test.yml index e266b90..6f2bf23 100644 --- a/.gitea/workflows/integration-test.yml +++ b/.gitea/workflows/integration-test.yml @@ -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 diff --git a/.gitea/workflows/quality-check.yml b/.gitea/workflows/quality-check.yml index 077923c..0130c5d 100644 --- a/.gitea/workflows/quality-check.yml +++ b/.gitea/workflows/quality-check.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index fd57bc0..aa5e35b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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" diff --git a/.gitignore b/.gitignore index cf41114..4edb1f7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db +*.log diff --git a/LICENSE b/LICENSE index dfb8590..e544958 100644 --- a/LICENSE +++ b/LICENSE @@ -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. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 144e1c0..a39e4d3 100644 --- a/README.md +++ b/README.md @@ -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! \ No newline at end of file + +MIT License - Made with ❤️ for Home Assistant users! diff --git a/custom_components/__init__.py b/custom_components/__init__.py deleted file mode 100644 index 235e7d1..0000000 --- a/custom_components/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Custom components for Home Assistant.""" \ No newline at end of file diff --git a/custom_components/adguard_hub/__init__.py b/custom_components/adguard_hub/__init__.py index 0b8e9d5..507f952 100644 --- a/custom_components/adguard_hub/__init__.py +++ b/custom_components/adguard_hub/__init__.py @@ -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 diff --git a/custom_components/adguard_hub/api.py b/custom_components/adguard_hub/api.py index 0c59f38..0a64581 100644 --- a/custom_components/adguard_hub/api.py +++ b/custom_components/adguard_hub/api.py @@ -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.""" diff --git a/custom_components/adguard_hub/const.py b/custom_components/adguard_hub/const.py index 43c392d..4021c4a 100644 --- a/custom_components/adguard_hub/const.py +++ b/custom_components/adguard_hub/const.py @@ -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", ] diff --git a/custom_components/adguard_hub/manifest.json b/custom_components/adguard_hub/manifest.json index b462811..ed709bb 100644 --- a/custom_components/adguard_hub/manifest.json +++ b/custom_components/adguard_hub/manifest.json @@ -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" } \ No newline at end of file diff --git a/custom_components/adguard_hub/sensor.py b/custom_components/adguard_hub/sensor.py index 98df3a2..33b35e7 100644 --- a/custom_components/adguard_hub/sensor.py +++ b/custom_components/adguard_hub/sensor.py @@ -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) diff --git a/custom_components/adguard_hub/services.py b/custom_components/adguard_hub/services.py index 292053f..50b8e68 100644 --- a/custom_components/adguard_hub/services.py +++ b/custom_components/adguard_hub/services.py @@ -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") diff --git a/custom_components/adguard_hub/strings.json b/custom_components/adguard_hub/strings.json index 6366e71..053efb0 100644 --- a/custom_components/adguard_hub/strings.json +++ b/custom_components/adguard_hub/strings.json @@ -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" } + } } \ No newline at end of file diff --git a/hacs.json b/hacs.json index 8d70a4f..75c0cfe 100644 --- a/hacs.json +++ b/hacs.json @@ -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" } \ No newline at end of file diff --git a/info.md b/info.md index 4deb8dc..b346bb6 100644 --- a/info.md +++ b/info.md @@ -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/` \ No newline at end of file +## ✨ 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! diff --git a/pyproject.toml b/pyproject.toml index a5b007a..c73642e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 \ No newline at end of file +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" +] diff --git a/requirements-dev.txt b/requirements-dev.txt index 884fd7a..eccf045 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 \ No newline at end of file +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 diff --git a/requirements.txt b/requirements.txt index 6c6df70..85fd069 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ homeassistant==2025.9.4 -pytest -pytest-homeassistant-custom-component -pytest-asyncio -pytest-cov \ No newline at end of file +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 diff --git a/tests/__init__.py b/tests/__init__.py index 4a41b11..0a23a51 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests for AdGuard Control Hub integration.""" \ No newline at end of file +"""Tests for AdGuard Control Hub.""" diff --git a/tests/conftest.py b/tests/conftest.py index 3eebe59..892a784 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index 53bc3e9..7635254 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py deleted file mode 100644 index 23b10bc..0000000 --- a/tests/test_config_flow.py +++ /dev/null @@ -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" diff --git a/tests/test_integration.py b/tests/test_integration.py index aa7c378..2bdfd49 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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]