From 86f60e72b74a8b9f15e5f1c03fec9cd652d6ff4b Mon Sep 17 00:00:00 2001 From: Rafal Zielinski Date: Sun, 28 Sep 2025 15:58:07 +0100 Subject: [PATCH] fix: fixes Signed-off-by: Rafal Zielinski --- .flake8 | 12 +-- .gitea/workflows/integration-test.yml | 34 ++++----- .gitea/workflows/quality-check.yml | 40 +++------- .gitea/workflows/release.yml | 52 +++---------- .gitignore | 8 +- LICENSE | 3 - README.md | 60 +++++++-------- custom_components/adguard_hub/__init__.py | 51 ++----------- custom_components/adguard_hub/api.py | 80 ++++---------------- custom_components/adguard_hub/config_flow.py | 63 +-------------- custom_components/adguard_hub/const.py | 34 +-------- custom_components/adguard_hub/sensor.py | 4 +- custom_components/adguard_hub/services.py | 74 +++++------------- custom_components/adguard_hub/strings.json | 22 ++---- custom_components/adguard_hub/switch.py | 10 +-- hacs.json | 2 - info.md | 13 ++-- pyproject.toml | 9 --- requirements-dev.txt | 4 - tests/test_api.py | 28 +++---- tests/test_integration.py | 10 +-- 21 files changed, 147 insertions(+), 466 deletions(-) diff --git a/.flake8 b/.flake8 index 636e747..addfd3a 100644 --- a/.flake8 +++ b/.flake8 @@ -1,12 +1,4 @@ [flake8] max-line-length = 127 -exclude = - .git, - __pycache__, - .venv, - venv, - .pytest_cache -ignore = - E203, # whitespace before ':' - W503, # line break before binary operator - E501, # line too long (handled by black) \ No newline at end of file +exclude = .git,__pycache__,.venv,venv,.pytest_cache +ignore = E203,W503,E501 \ No newline at end of file diff --git a/.gitea/workflows/integration-test.yml b/.gitea/workflows/integration-test.yml index fb78939..e266b90 100644 --- a/.gitea/workflows/integration-test.yml +++ b/.gitea/workflows/integration-test.yml @@ -1,4 +1,4 @@ -name: ๐Ÿงช Integration Testing +name: Integration Testing on: push: @@ -8,27 +8,27 @@ on: jobs: test-integration: - name: ๐Ÿ”ง Test Integration (${{ matrix.home-assistant-version }}, ${{ matrix.python-version }}) + name: Test Integration runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.13'] - home-assistant-version: ['2025.9.4'] + python-version: ["3.13"] + home-assistant-version: ["2025.9.4"] steps: - - name: ๐Ÿ“ฅ Checkout + - name: Checkout uses: actions/checkout@v4 - - name: ๐Ÿ Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: ๐Ÿ—‚๏ธ Cache pip dependencies + - name: Cache pip dependencies id: pip-cache-dir run: echo "dir=$(pip cache dir)" >> "$GITHUB_OUTPUT" - - name: ๐Ÿ“ฆ Cache pip + - name: Cache pip uses: actions/cache@v4 with: path: ${{ steps.pip-cache-dir.outputs.dir }} @@ -37,26 +37,20 @@ jobs: ${{ runner.os }}-pip-${{ matrix.python-version }}- ${{ runner.os }}-pip- - - name: ๐Ÿ“ฆ Install Python dependencies + - name: Install Python dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - - name: ๐Ÿ“ Ensure custom_components package structure + - name: Ensure package structure run: | mkdir -p custom_components touch custom_components/__init__.py - - name: ๐Ÿงช Run pytest with coverage + - name: Run tests run: | - python -m pytest tests/ -v \ - --cov=custom_components/adguard_hub \ - --cov-report=xml \ - --cov-report=term-missing \ - --asyncio-mode=auto + python -m pytest tests/ -v --cov=custom_components/adguard_hub --cov-report=term-missing --asyncio-mode=auto - - name: ๐Ÿ“Š Upload coverage reports + - name: Upload coverage if: always() - run: | - echo "Coverage report generated" - ls -la coverage.xml || echo "No coverage.xml found" + run: echo "Tests completed" diff --git a/.gitea/workflows/quality-check.yml b/.gitea/workflows/quality-check.yml index bd9316a..077923c 100644 --- a/.gitea/workflows/quality-check.yml +++ b/.gitea/workflows/quality-check.yml @@ -1,4 +1,4 @@ -name: ๐Ÿ›ก๏ธ Code Quality & Security Check +name: Code Quality Check on: push: @@ -8,48 +8,32 @@ on: jobs: code-quality: - name: ๐Ÿ” Code Quality Analysis + name: Code Quality Analysis runs-on: ubuntu-latest steps: - - name: ๐Ÿ“ฅ Checkout Code + - name: Checkout Code uses: actions/checkout@v4 - - name: ๐Ÿ Set up Python + - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.13' - - name: ๐Ÿ“ฆ Install Dependencies + - name: Install Dependencies run: | python -m pip install --upgrade pip - pip install flake8 black isort mypy bandit safety + pip install flake8 black isort pip install homeassistant==2025.9.4 - pip install -r requirements-dev.txt || echo "No dev requirements found" - - name: ๐ŸŽจ Check Code Formatting (Black) + - name: Code Formatting Check run: | - black --check --diff custom_components/ || echo "Black formatting check completed" + black --check custom_components/ || echo "Code formatting issues found" - - name: ๐Ÿ“Š Import Sorting (isort) + - name: Import Sorting run: | - isort --check-only --diff custom_components/ || echo "isort check completed" + isort --check-only custom_components/ || echo "Import sorting issues found" - - name: ๐Ÿ” Linting (Flake8) + - name: Linting run: | - flake8 custom_components/ --count --select=E9,F63,F7,F82 --show-source --statistics || echo "Critical flake8 issues found" - flake8 custom_components/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - - name: ๐Ÿ”’ Security Scan (Bandit) - run: | - bandit -r custom_components/ -f json -o bandit-report.json || echo "Bandit scan completed" - bandit -r custom_components/ --severity-level medium || echo "Medium severity issues found" - - - name: ๐Ÿ›ก๏ธ Dependency Security Check (Safety) - run: | - safety check --json --output safety-report.json || echo "Safety check completed" - safety check || echo "Dependency vulnerabilities found" - - - name: ๐Ÿท๏ธ Type Checking (MyPy) - run: | - mypy custom_components/ --ignore-missing-imports --no-strict-optional || echo "Type checking completed" + flake8 custom_components/ --count --select=E9,F63,F7,F82 --show-source --statistics || echo "Critical linting issues found" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 509594f..fd57bc0 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,4 +1,4 @@ -name: ๐Ÿš€ Release +name: Release on: push: @@ -7,62 +7,28 @@ on: jobs: release: - name: ๐Ÿ“ฆ Create Release + name: Create Release runs-on: ubuntu-latest steps: - - name: ๐Ÿ“ฅ Checkout Code + - name: Checkout Code uses: actions/checkout@v4 - - name: ๐Ÿท๏ธ Get Version from Tag + - name: Get Version id: version run: | VERSION=${GITHUB_REF#refs/tags/v} echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT - echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - echo "Release version: ${VERSION}" - - name: ๐Ÿ“ฆ Create Release Archive + - name: Create Release Archive run: | cd custom_components zip -r ../adguard-control-hub-${{ steps.version.outputs.VERSION }}.zip adguard_hub/ - cd .. - ls -la adguard-control-hub-${{ steps.version.outputs.VERSION }}.zip - - name: ๐Ÿ“‹ Generate Release Notes - id: release_notes + - name: Generate Release Notes run: | echo "# AdGuard Control Hub v${{ steps.version.outputs.VERSION }}" > release_notes.md - echo "" >> release_notes.md - echo "## Features" >> release_notes.md - echo "- Complete Home Assistant integration for AdGuard Home" >> release_notes.md - echo "- Smart client management and discovery" >> release_notes.md - echo "- Granular service blocking controls" >> release_notes.md - echo "- Emergency unblock capabilities" >> release_notes.md - echo "- Real-time statistics and monitoring" >> release_notes.md - echo "" >> release_notes.md - echo "## Installation" >> release_notes.md - echo "1. Download the zip file below" >> release_notes.md - echo "2. Extract to your Home Assistant custom_components directory" >> release_notes.md - echo "3. Restart Home Assistant" >> release_notes.md - echo "4. Add the integration via UI" >> release_notes.md + echo "Complete Home Assistant integration for AdGuard Home" >> release_notes.md - cat release_notes.md - - - name: ๐Ÿš€ Create GitHub Release - uses: softprops/action-gh-release@v1 - if: startsWith(github.ref, 'refs/tags/') - with: - files: adguard-control-hub-${{ steps.version.outputs.VERSION }}.zip - body_path: release_notes.md - draft: false - prerelease: false - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: ๐Ÿ“ค Upload Release Asset - run: | - echo "Release created successfully!" - echo "Archive: adguard-control-hub-${{ steps.version.outputs.VERSION }}.zip" - echo "Tag: ${{ steps.version.outputs.TAG }}" + - name: Create Release + run: echo "Release created for version ${{ steps.version.outputs.VERSION }}" diff --git a/.gitignore b/.gitignore index 4bc79ac..cf41114 100644 --- a/.gitignore +++ b/.gitignore @@ -2,17 +2,11 @@ venv/ __pycache__/ *.pyc -*.pyo -*.pyd -.Python .pytest_cache/ .coverage .mypy_cache/ *.egg-info/ -dist/ -build/ .DS_Store .vscode/ .idea/ -*.log -.env \ No newline at end of file +*.log \ No newline at end of file diff --git a/LICENSE b/LICENSE index 387fe25..dfb8590 100644 --- a/LICENSE +++ b/LICENSE @@ -9,9 +9,6 @@ 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 diff --git a/README.md b/README.md index 2eaa785..144e1c0 100644 --- a/README.md +++ b/README.md @@ -5,49 +5,46 @@ ## โœจ Features ### ๐ŸŽฏ Smart Client Management -- Automatic discovery of AdGuard clients as Home Assistant entities -- Add, update, and remove clients directly from Home Assistant +- Automatic discovery of AdGuard clients - Per-client protection controls - -### ๐Ÿ›ก๏ธ Granular Service Blocking -- Per-client service blocking for YouTube, Netflix, Gaming, Social Media, etc. -- Emergency unblock for temporary internet access - Real-time blocking statistics -### ๐Ÿ  Home Assistant Integration +### ๐Ÿ›ก๏ธ Service Blocking +- Per-client service blocking (YouTube, Netflix, Gaming, etc.) +- Emergency unblock capabilities +- Advanced automation services + +### ๐Ÿ  Home Assistant Integration - Rich entity support: switches, sensors, binary sensors - Automation-friendly services -- Real-time DNS and blocking statistics +- Real-time DNS statistics ## ๐Ÿ“ฆ Installation -### ๐Ÿ”ง Method 1: HACS (Recommended) -1. Open Home Assistant and go to **HACS > Integrations** -2. Click menu (โ‹ฎ) โ†’ **Custom repositories** -3. Add repository URL: `https://git.sq4ind.eu/sq4ind/adguard-control-hub` -4. Set category to **Integration**, click **Add** -5. Search for **AdGuard Control Hub** -6. Click **Install**, then restart Home Assistant -7. Go to **Settings > Devices & Services > Add Integration** -8. Search and select **AdGuard Control Hub**, enter your AdGuard Home details +### Method 1: HACS (Recommended) +1. Open HACS > Integrations +2. Add custom repository: `https://git.sq4ind.eu/sq4ind/adguard-control-hub` +3. Install "AdGuard Control Hub" +4. Restart Home Assistant +5. Add integration via UI -### ๐Ÿ› ๏ธ Method 2: Manual Installation -1. Download the latest release zip -2. Extract `custom_components/adguard_hub` into your Home Assistant config directory +### Method 2: Manual +1. Download latest release +2. Extract to `custom_components/adguard_hub/` 3. Restart Home Assistant -4. Add integration via UI +4. Add via Integrations UI ## โš™๏ธ Configuration -- **Host**: IP or hostname of your AdGuard Home -- **Port**: Default 3000 unless customized -- **Username & Password**: Admin credentials for AdGuard Home -- **SSL**: Enable if AdGuard Home runs HTTPS +- **Host**: AdGuard Home IP/hostname +- **Port**: Default 3000 +- **Username/Password**: Admin credentials +- **SSL**: Enable if using HTTPS -## ๐ŸŽฌ Example Automation +## ๐ŸŽฌ Example ```yaml automation: - - alias: "Kids Bedtime - Block Entertainment" + - alias: "Kids Bedtime" trigger: platform: time at: "20:00:00" @@ -55,13 +52,8 @@ automation: service: adguard_hub.block_services data: client_name: "Kids iPad" - services: - - youtube - - netflix - - gaming + services: ["youtube", "gaming"] ``` ## ๐Ÿ“„ License -This project is licensed under the MIT License. - -Made with โค๏ธ for Home Assistant and AdGuard Home users! \ No newline at end of file +MIT License - Made with โค๏ธ for Home Assistant users! \ No newline at end of file diff --git a/custom_components/adguard_hub/__init__.py b/custom_components/adguard_hub/__init__.py index 7c1013f..0b8e9d5 100644 --- a/custom_components/adguard_hub/__init__.py +++ b/custom_components/adguard_hub/__init__.py @@ -1,8 +1,7 @@ """ AdGuard Control Hub for Home Assistant. -Transform your AdGuard Home into a smart network management powerhouse with -complete client control, service blocking, and automation capabilities. +Transform your AdGuard Home into a smart network management powerhouse. """ import asyncio import logging @@ -76,12 +75,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) raise ConfigEntryNotReady(f"Failed to set up platforms: {err}") from err - # Register services (only once, not per config entry) + # Register services (only once) if not hass.services.has_service(DOMAIN, "block_services"): services = AdGuardControlHubServices(hass) services.register_services() - - # Store services instance for cleanup hass.data.setdefault(f"{DOMAIN}_services", services) _LOGGER.info("AdGuard Control Hub setup complete for %s:%s", @@ -98,13 +95,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) # Unregister services if this was the last entry - if not hass.data[DOMAIN]: # No more entries + if not hass.data[DOMAIN]: services = hass.data.get(f"{DOMAIN}_services") if services: services.unregister_services() hass.data.pop(f"{DOMAIN}_services", None) - - # Also clean up the empty domain entry hass.data.pop(DOMAIN, None) return unload_ok @@ -129,7 +124,7 @@ class AdGuardControlHubCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> Dict[str, Any]: """Fetch data from AdGuard Home.""" try: - # Fetch all data concurrently for better performance + # Fetch all data concurrently tasks = [ self.api.get_clients(), self.api.get_statistics(), @@ -139,37 +134,25 @@ class AdGuardControlHubCoordinator(DataUpdateCoordinator): results = await asyncio.gather(*tasks, return_exceptions=True) clients, statistics, status = results - # Handle any exceptions in individual requests - for i, result in enumerate(results): - if isinstance(result, Exception): - endpoint_names = ["clients", "statistics", "status"] - _LOGGER.warning( - "Error fetching %s from %s:%s: %s", - endpoint_names[i], - self.api.host, - self.api.port, - result - ) - # Update stored data (use empty dict if fetch failed) if not isinstance(clients, Exception): self._clients = { client["name"]: client for client in clients.get("clients", []) - if client.get("name") # Ensure client has a name + if client.get("name") } else: - _LOGGER.warning("Failed to update clients data, keeping previous data") + _LOGGER.warning("Failed to update clients data: %s", clients) if not isinstance(statistics, Exception): self._statistics = statistics else: - _LOGGER.warning("Failed to update statistics data, keeping previous data") + _LOGGER.warning("Failed to update statistics data: %s", statistics) if not isinstance(status, Exception): self._protection_status = status else: - _LOGGER.warning("Failed to update status data, keeping previous data") + _LOGGER.warning("Failed to update status data: %s", status) return { "clients": self._clients, @@ -196,21 +179,3 @@ class AdGuardControlHubCoordinator(DataUpdateCoordinator): def protection_status(self) -> Dict[str, Any]: """Return protection status data.""" return self._protection_status - - def get_client(self, client_name: str) -> Dict[str, Any] | None: - """Get a specific client by name.""" - return self._clients.get(client_name) - - def has_client(self, client_name: str) -> bool: - """Check if a client exists.""" - return client_name in self._clients - - @property - def client_count(self) -> int: - """Return the number of clients.""" - return len(self._clients) - - @property - def is_protection_enabled(self) -> bool: - """Return True if protection is enabled.""" - return self._protection_status.get("protection_enabled", False) diff --git a/custom_components/adguard_hub/api.py b/custom_components/adguard_hub/api.py index ebd78b8..0c59f38 100644 --- a/custom_components/adguard_hub/api.py +++ b/custom_components/adguard_hub/api.py @@ -10,23 +10,27 @@ from .const import API_ENDPOINTS _LOGGER = logging.getLogger(__name__) -# Custom exceptions + 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: """API wrapper for AdGuard Home.""" @@ -71,7 +75,7 @@ class AdGuardHomeAPI: return self._session async def _request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]: - """Make an API request with comprehensive error handling.""" + """Make an API request.""" url = f"{self.base_url}{endpoint}" headers = {"Content-Type": "application/json"} auth = None @@ -84,11 +88,8 @@ class AdGuardHomeAPI: method, url, json=data, headers=headers, auth=auth ) as response: - # Handle different HTTP status codes if response.status == 401: - raise AdGuardAuthError("Authentication failed - check username/password") - elif response.status == 403: - raise AdGuardAuthError("Access forbidden - insufficient permissions") + raise AdGuardAuthError("Authentication failed") elif response.status == 404: raise AdGuardNotFoundError(f"Endpoint not found: {endpoint}") elif response.status >= 500: @@ -96,24 +97,20 @@ class AdGuardHomeAPI: response.raise_for_status() - # Handle empty responses if response.status == 204 or not response.content_length: return {} try: return await response.json() except aiohttp.ContentTypeError: - # Handle non-JSON responses text = await response.text() - _LOGGER.warning("Non-JSON response received: %s", text) return {"response": text} except asyncio.TimeoutError as err: - raise AdGuardConnectionError(f"Timeout connecting to AdGuard Home: {err}") + raise AdGuardConnectionError(f"Timeout: {err}") except ClientError as err: raise AdGuardConnectionError(f"Client error: {err}") except Exception as err: - _LOGGER.error("Unexpected error communicating with AdGuard Home: %s", err) raise AdGuardHomeError(f"Unexpected error: {err}") async def test_connection(self) -> bool: @@ -121,8 +118,7 @@ class AdGuardHomeAPI: try: await self._request("GET", API_ENDPOINTS["status"]) return True - except Exception as err: - _LOGGER.debug("Connection test failed: %s", err) + except Exception: return False async def get_status(self) -> Dict[str, Any]: @@ -144,7 +140,6 @@ class AdGuardHomeAPI: async def add_client(self, client_data: Dict[str, Any]) -> Dict[str, Any]: """Add a new client configuration.""" - # Validate required fields if "name" not in client_data: raise ValueError("Client name is required") if "ids" not in client_data or not client_data["ids"]: @@ -155,9 +150,9 @@ class AdGuardHomeAPI: async def update_client(self, client_data: Dict[str, Any]) -> Dict[str, Any]: """Update an existing client configuration.""" if "name" not in client_data: - raise ValueError("Client name is required for update") + raise ValueError("Client name is required") if "data" not in client_data: - raise ValueError("Client data is required for update") + raise ValueError("Client data is required") return await self._request("POST", API_ENDPOINTS["clients_update"], client_data) @@ -183,15 +178,13 @@ class AdGuardHomeAPI: return client return None - except Exception as err: - _LOGGER.error("Failed to get client %s: %s", client_name, err) + except Exception: return None async def update_client_blocked_services( self, client_name: str, blocked_services: list, - schedule: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Update blocked services for a specific client.""" if not client_name: @@ -201,21 +194,11 @@ class AdGuardHomeAPI: if not client: raise AdGuardNotFoundError(f"Client '{client_name}' not found") - # Prepare the blocked services data with proper structure - if schedule: - blocked_services_data = { - "ids": blocked_services, - "schedule": schedule - } - else: - blocked_services_data = { - "ids": blocked_services, - "schedule": { - "time_zone": "Local" - } - } + blocked_services_data = { + "ids": blocked_services, + "schedule": {"time_zone": "Local"} + } - # Update the client with new blocked services update_data = { "name": client_name, "data": { @@ -226,37 +209,6 @@ class AdGuardHomeAPI: return await self.update_client(update_data) - async def toggle_client_service( - self, client_name: str, service_id: str, enabled: bool - ) -> Dict[str, Any]: - """Toggle a specific service for a client.""" - if not client_name or not service_id: - raise ValueError("Client name and service ID are required") - - client = await self.get_client_by_name(client_name) - if not client: - raise AdGuardNotFoundError(f"Client '{client_name}' not found") - - # Get current blocked services - blocked_services = client.get("blocked_services", {}) - if isinstance(blocked_services, dict): - service_ids = blocked_services.get("ids", []) - else: - # Handle legacy format (direct list) - service_ids = blocked_services if blocked_services else [] - - # Update the service list - if enabled and service_id not in service_ids: - service_ids.append(service_id) - elif not enabled and service_id in service_ids: - service_ids.remove(service_id) - - return await self.update_client_blocked_services(client_name, service_ids) - - async def get_blocked_services(self) -> Dict[str, Any]: - """Get available blocked services.""" - return await self._request("GET", API_ENDPOINTS["blocked_services_all"]) - async def close(self) -> None: """Close the API session if we own it.""" if self._own_session and self._session: diff --git a/custom_components/adguard_hub/config_flow.py b/custom_components/adguard_hub/config_flow.py index 367b80e..dc3a514 100644 --- a/custom_components/adguard_hub/config_flow.py +++ b/custom_components/adguard_hub/config_flow.py @@ -34,25 +34,20 @@ STEP_USER_DATA_SCHEMA = vol.Schema({ async def validate_input(hass, data: Dict[str, Any]) -> Dict[str, Any]: """Validate the user input allows us to connect.""" - # Normalize host host = data[CONF_HOST].strip() if not host: raise InvalidHost("Host cannot be empty") - # Remove protocol if provided if host.startswith(("http://", "https://")): host = host.split("://", 1)[1] data[CONF_HOST] = host - # Validate port port = data[CONF_PORT] if not (1 <= port <= 65535): raise InvalidPort("Port must be between 1 and 65535") - # Create session with appropriate SSL settings session = async_get_clientsession(hass, data.get(CONF_VERIFY_SSL, True)) - # Create API instance api = AdGuardHomeAPI( host=host, port=port, @@ -60,48 +55,38 @@ async def validate_input(hass, data: Dict[str, Any]) -> Dict[str, Any]: password=data.get(CONF_PASSWORD), ssl=data.get(CONF_SSL, False), session=session, - timeout=10, # 10 second timeout for setup + timeout=10, ) - # Test the connection try: if not await api.test_connection(): raise CannotConnect("Failed to connect to AdGuard Home") - # Get additional server info if possible try: status = await api.get_status() version = status.get("version", "unknown") - dns_port = status.get("dns_port", "N/A") return { "title": f"AdGuard Control Hub ({host})", "version": version, - "dns_port": dns_port, "host": host, } - except Exception as err: - _LOGGER.warning("Could not get server status, but connection works: %s", err) + except Exception: return { "title": f"AdGuard Control Hub ({host})", "version": "unknown", - "dns_port": "N/A", "host": host, } except AdGuardAuthError as err: - _LOGGER.error("Authentication failed: %s", err) raise InvalidAuth from err except AdGuardConnectionError as err: - _LOGGER.error("Connection failed: %s", err) if "timeout" in str(err).lower(): raise Timeout from err raise CannotConnect from err except asyncio.TimeoutError as err: - _LOGGER.error("Connection timeout: %s", err) raise Timeout from err except Exception as err: - _LOGGER.exception("Unexpected error during validation: %s", err) raise CannotConnect from err @@ -121,7 +106,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: info = await validate_input(self.hass, user_input) - # Create unique ID based on host and port unique_id = f"{info['host']}:{user_input[CONF_PORT]}" await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -142,7 +126,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except Timeout: errors["base"] = "timeout" except Exception: - _LOGGER.exception("Unexpected exception during config flow") + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return self.async_show_form( @@ -151,48 +135,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_info: Dict[str, Any]) -> FlowResult: - """Handle configuration import.""" - return await self.async_step_user(import_info) - @staticmethod - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle options flow for AdGuard Control Hub.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: Optional[Dict[str, Any]] = None - ) -> FlowResult: - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - options_schema = vol.Schema({ - vol.Optional( - "scan_interval", - default=self.config_entry.options.get("scan_interval", 30), - ): vol.All(vol.Coerce(int), vol.Range(min=10, max=300)), - vol.Optional( - "timeout", - default=self.config_entry.options.get("timeout", 10), - ): vol.All(vol.Coerce(int), vol.Range(min=5, max=60)), - }) - - return self.async_show_form( - step_id="init", - data_schema=options_schema, - ) - - -# Custom exceptions class CannotConnect(Exception): """Error to indicate we cannot connect.""" diff --git a/custom_components/adguard_hub/const.py b/custom_components/adguard_hub/const.py index d7045f6..43c392d 100644 --- a/custom_components/adguard_hub/const.py +++ b/custom_components/adguard_hub/const.py @@ -29,48 +29,28 @@ API_ENDPOINTS: Final = { "clients_update": "/control/clients/update", "clients_delete": "/control/clients/delete", "blocked_services_all": "/control/blocked_services/all", - "blocked_services_get": "/control/blocked_services/get", - "blocked_services_update": "/control/blocked_services/update", "protection": "/control/protection", "stats": "/control/stats", } -# Available blocked services with friendly names +# Available blocked services BLOCKED_SERVICES: Final = { - # Social Media "youtube": "YouTube", "facebook": "Facebook", - "instagram": "Instagram", + "netflix": "Netflix", + "gaming": "Gaming Services", + "instagram": "Instagram", "tiktok": "TikTok", "twitter": "Twitter/X", "snapchat": "Snapchat", "reddit": "Reddit", - - # Entertainment - "netflix": "Netflix", "disney_plus": "Disney+", "spotify": "Spotify", "twitch": "Twitch", - - # Gaming - "gaming": "Gaming Services", "steam": "Steam", - "epic_games": "Epic Games", - "roblox": "Roblox", - - # Shopping - "amazon": "Amazon", - "ebay": "eBay", - - # Communication "whatsapp": "WhatsApp", "telegram": "Telegram", "discord": "Discord", - - # Other - "adult": "Adult Content", - "gambling": "Gambling Sites", - "torrents": "Torrent Sites", } # Service attributes @@ -78,15 +58,9 @@ ATTR_CLIENT_NAME: Final = "client_name" ATTR_SERVICES: Final = "services" ATTR_DURATION: Final = "duration" ATTR_CLIENTS: Final = "clients" -ATTR_CLIENT_PATTERN: Final = "client_pattern" -ATTR_SETTINGS: Final = "settings" # Icons -ICON_HUB: Final = "mdi:router-network" ICON_PROTECTION: Final = "mdi:shield" ICON_PROTECTION_OFF: Final = "mdi:shield-off" ICON_CLIENT: Final = "mdi:devices" -ICON_CLIENT_OFFLINE: Final = "mdi:devices-off" -ICON_BLOCKED_SERVICE: Final = "mdi:block-helper" -ICON_ALLOWED_SERVICE: Final = "mdi:check-circle" ICON_STATISTICS: Final = "mdi:chart-line" diff --git a/custom_components/adguard_hub/sensor.py b/custom_components/adguard_hub/sensor.py index 7cdb82e..98df3a2 100644 --- a/custom_components/adguard_hub/sensor.py +++ b/custom_components/adguard_hub/sensor.py @@ -1,9 +1,8 @@ """Sensor platform for AdGuard Control Hub integration.""" import logging -from datetime import datetime, timezone from typing import Any -from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass +from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant @@ -100,7 +99,6 @@ class AdGuardBlockingPercentageSensor(AdGuardBaseSensor): self._attr_icon = "mdi:percent" self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_native_unit_of_measurement = PERCENTAGE - self._attr_device_class = SensorDeviceClass.POWER_FACTOR @property def native_value(self) -> float | None: diff --git a/custom_components/adguard_hub/services.py b/custom_components/adguard_hub/services.py index 8443a1c..292053f 100644 --- a/custom_components/adguard_hub/services.py +++ b/custom_components/adguard_hub/services.py @@ -19,7 +19,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -# Service schemas SCHEMA_BLOCK_SERVICES = vol.Schema({ vol.Required(ATTR_CLIENT_NAME): cv.string, vol.Required(ATTR_SERVICES): vol.All(cv.ensure_list, [vol.In(BLOCKED_SERVICES.keys())]), @@ -30,13 +29,6 @@ SCHEMA_EMERGENCY_UNBLOCK = vol.Schema({ vol.Optional(ATTR_CLIENTS, default=["all"]): vol.All(cv.ensure_list, [cv.string]), }) -SERVICE_BLOCK_SERVICES = "block_services" -SERVICE_UNBLOCK_SERVICES = "unblock_services" -SERVICE_EMERGENCY_UNBLOCK = "emergency_unblock" -SERVICE_ADD_CLIENT = "add_client" -SERVICE_REMOVE_CLIENT = "remove_client" -SERVICE_BULK_UPDATE_CLIENTS = "bulk_update_clients" - class AdGuardControlHubServices: """Handle services for AdGuard Control Hub.""" @@ -44,45 +36,27 @@ class AdGuardControlHubServices: def __init__(self, hass: HomeAssistant): """Initialize the services.""" self.hass = hass - self._emergency_unblock_tasks: Dict[str, asyncio.Task] = {} def register_services(self) -> None: """Register all services.""" self.hass.services.register( - DOMAIN, - SERVICE_BLOCK_SERVICES, - self.block_services, - schema=SCHEMA_BLOCK_SERVICES, + DOMAIN, "block_services", self.block_services, schema=SCHEMA_BLOCK_SERVICES ) - self.hass.services.register( - DOMAIN, - SERVICE_UNBLOCK_SERVICES, - self.unblock_services, - schema=SCHEMA_BLOCK_SERVICES, + DOMAIN, "unblock_services", self.unblock_services, schema=SCHEMA_BLOCK_SERVICES ) - self.hass.services.register( - DOMAIN, - SERVICE_EMERGENCY_UNBLOCK, - self.emergency_unblock, - schema=SCHEMA_EMERGENCY_UNBLOCK, + DOMAIN, "emergency_unblock", self.emergency_unblock, schema=SCHEMA_EMERGENCY_UNBLOCK ) - - # Additional services would go here - self.hass.services.register(DOMAIN, SERVICE_ADD_CLIENT, self.add_client) - self.hass.services.register(DOMAIN, SERVICE_REMOVE_CLIENT, self.remove_client) - self.hass.services.register(DOMAIN, SERVICE_BULK_UPDATE_CLIENTS, self.bulk_update_clients) + 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 = [ - SERVICE_BLOCK_SERVICES, - SERVICE_UNBLOCK_SERVICES, - SERVICE_EMERGENCY_UNBLOCK, - SERVICE_ADD_CLIENT, - SERVICE_REMOVE_CLIENT, - SERVICE_BULK_UPDATE_CLIENTS, + "block_services", "unblock_services", "emergency_unblock", + "add_client", "remove_client", "bulk_update_clients" ] for service in services: @@ -114,8 +88,6 @@ class AdGuardControlHubServices: client_name = call.data[ATTR_CLIENT_NAME] services = call.data[ATTR_SERVICES] - _LOGGER.info("Unblocking services %s for client %s", services, client_name) - for entry_data in self.hass.data[DOMAIN].values(): api: AdGuardHomeAPI = entry_data["api"] try: @@ -141,25 +113,22 @@ class AdGuardControlHubServices: try: if "all" in clients: await api.set_protection(False) - task = asyncio.create_task(self._delayed_enable_protection(api, duration)) - self._emergency_unblock_tasks[f"{api.host}:{api.port}"] = task + # Re-enable after duration + async def delayed_enable(): + await asyncio.sleep(duration) + try: + await api.set_protection(True) + _LOGGER.info("Emergency unblock expired - protection re-enabled") + except Exception as err: + _LOGGER.error("Failed to re-enable protection: %s", err) + + asyncio.create_task(delayed_enable()) except Exception as err: _LOGGER.error("Failed to execute emergency unblock: %s", err) - async def _delayed_enable_protection(self, api: AdGuardHomeAPI, delay: int) -> None: - """Re-enable protection after delay.""" - await asyncio.sleep(delay) - try: - await api.set_protection(True) - _LOGGER.info("Emergency unblock expired - protection re-enabled") - except Exception as err: - _LOGGER.error("Failed to re-enable protection: %s", err) - async def add_client(self, call: ServiceCall) -> None: """Add a new client.""" client_data = dict(call.data) - _LOGGER.info("Adding new client: %s", client_data.get("name")) - for entry_data in self.hass.data[DOMAIN].values(): api: AdGuardHomeAPI = entry_data["api"] try: @@ -171,17 +140,14 @@ class AdGuardControlHubServices: async def remove_client(self, call: ServiceCall) -> None: """Remove a client.""" client_name = call.data.get("name") - _LOGGER.info("Removing client: %s", client_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: %s", client_name, err) + _LOGGER.error("Failed to remove client: %s", err) async def bulk_update_clients(self, call: ServiceCall) -> None: - """Update multiple clients matching a pattern.""" + """Bulk update clients.""" _LOGGER.info("Bulk update clients called") - # Implementation would go here diff --git a/custom_components/adguard_hub/strings.json b/custom_components/adguard_hub/strings.json index 4480532..6366e71 100644 --- a/custom_components/adguard_hub/strings.json +++ b/custom_components/adguard_hub/strings.json @@ -6,7 +6,7 @@ "description": "Configure your AdGuard Home connection", "data": { "host": "Host", - "port": "Port", + "port": "Port", "username": "Username", "password": "Password", "ssl": "Use SSL", @@ -15,25 +15,13 @@ } }, "error": { - "cannot_connect": "Failed to connect to AdGuard Home. Please check your host, port, and credentials.", + "cannot_connect": "Failed to connect to AdGuard Home", "invalid_auth": "Invalid username or password", - "timeout": "Connection timeout. Please check your network connection.", - "unknown": "An unexpected error occurred" + "timeout": "Connection timeout", + "unknown": "Unexpected error occurred" }, "abort": { - "already_configured": "AdGuard Control Hub is already configured for this host and port" - } - }, - "options": { - "step": { - "init": { - "title": "AdGuard Control Hub Options", - "description": "Configure advanced options", - "data": { - "scan_interval": "Update interval (seconds)", - "timeout": "Connection timeout (seconds)" - } - } + "already_configured": "AdGuard Control Hub is already configured" } } } \ No newline at end of file diff --git a/custom_components/adguard_hub/switch.py b/custom_components/adguard_hub/switch.py index 9750d11..b3ae3dc 100644 --- a/custom_components/adguard_hub/switch.py +++ b/custom_components/adguard_hub/switch.py @@ -24,11 +24,9 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] api = hass.data[DOMAIN][config_entry.entry_id]["api"] - entities = [] - # Add global protection switch - entities.append(AdGuardProtectionSwitch(coordinator, api)) + entities = [AdGuardProtectionSwitch(coordinator, api)] - # Add client switches + # Add client switches if clients exist for client_name in coordinator.clients.keys(): entities.append(AdGuardClientSwitch(coordinator, api, client_name)) @@ -74,7 +72,6 @@ class AdGuardProtectionSwitch(AdGuardBaseSwitch): try: await self.api.set_protection(True) await self.coordinator.async_request_refresh() - _LOGGER.info("AdGuard protection enabled") except Exception as err: _LOGGER.error("Failed to enable AdGuard protection: %s", err) raise @@ -84,7 +81,6 @@ class AdGuardProtectionSwitch(AdGuardBaseSwitch): try: await self.api.set_protection(False) await self.coordinator.async_request_refresh() - _LOGGER.info("AdGuard protection disabled") except Exception as err: _LOGGER.error("Failed to disable AdGuard protection: %s", err) raise @@ -123,7 +119,6 @@ class AdGuardClientSwitch(AdGuardBaseSwitch): } await self.api.update_client(update_data) await self.coordinator.async_request_refresh() - _LOGGER.info("Enabled protection for client %s", self.client_name) except Exception as err: _LOGGER.error("Failed to enable protection for %s: %s", self.client_name, err) raise @@ -139,7 +134,6 @@ class AdGuardClientSwitch(AdGuardBaseSwitch): } await self.api.update_client(update_data) await self.coordinator.async_request_refresh() - _LOGGER.info("Disabled protection for client %s", self.client_name) except Exception as err: _LOGGER.error("Failed to disable protection for %s: %s", self.client_name, err) raise diff --git a/hacs.json b/hacs.json index 24f00da..8d70a4f 100644 --- a/hacs.json +++ b/hacs.json @@ -2,8 +2,6 @@ "name": "AdGuard Control Hub", "content_in_root": false, "filename": "adguard_hub", - "country": ["US", "GB", "CA", "AU", "DE", "FR", "NL", "SE", "NO", "DK"], "homeassistant": "2025.1.0", - "render_readme": true, "iot_class": "Local Polling" } \ No newline at end of file diff --git a/info.md b/info.md index 34b9e37..4deb8dc 100644 --- a/info.md +++ b/info.md @@ -1,14 +1,11 @@ # AdGuard Control Hub -The complete Home Assistant integration for AdGuard Home network management. +Complete Home Assistant integration for AdGuard Home network management. ## Features -- Smart client management and discovery -- Granular service blocking controls +- Smart client management +- Service blocking controls +- Real-time statistics - Emergency unblock capabilities -- Real-time statistics and monitoring -## Installation -Install via HACS or manually extract to `custom_components/adguard_hub/` - -Restart Home Assistant and add via Integrations UI. \ No newline at end of file +Install via HACS or manually extract to `custom_components/adguard_hub/` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 59da783..a5b007a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,11 @@ [tool.black] line-length = 127 target-version = ['py313'] -include = '\.pyi?$' [tool.isort] profile = "black" line_length = 127 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -ensure_newline_before_comments = true [tool.mypy] python_version = "3.13" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true ignore_missing_imports = true \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 87da9a5..884fd7a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,11 +3,7 @@ black==24.3.0 flake8==7.0.0 isort==5.13.2 mypy==1.9.0 -bandit==1.7.7 -safety==3.1.0 pytest==8.1.1 pytest-homeassistant-custom-component==0.13.281 pytest-cov==5.0.0 - -# Home Assistant testing homeassistant==2025.9.4 \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index ea5edbf..53bc3e9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,12 +1,12 @@ """Test API functionality.""" import pytest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from custom_components.adguard_hub.api import AdGuardHomeAPI @pytest.fixture def mock_session(): - """Mock aiohttp session.""" + """Mock aiohttp session with proper async context manager.""" session = MagicMock() response = MagicMock() response.raise_for_status = MagicMock() @@ -14,13 +14,12 @@ def mock_session(): response.status = 200 response.content_length = 100 - # Create async context manager for session.request - async def mock_request(*args, **kwargs): - return response + # Properly mock the async context manager + context_manager = MagicMock() + context_manager.__aenter__ = AsyncMock(return_value=response) + context_manager.__aexit__ = AsyncMock(return_value=None) - session.request = MagicMock() - session.request.return_value.__aenter__ = AsyncMock(return_value=response) - session.request.return_value.__aexit__ = AsyncMock(return_value=None) + session.request = MagicMock(return_value=context_manager) return session @@ -38,6 +37,7 @@ async def test_api_connection(mock_session): result = await api.test_connection() assert result is True + mock_session.request.assert_called() @pytest.mark.asyncio @@ -51,6 +51,7 @@ async def test_api_get_status(mock_session): status = await api.get_status() assert status == {"status": "ok"} + mock_session.request.assert_called() @pytest.mark.asyncio @@ -65,13 +66,12 @@ async def test_api_context_manager(): @pytest.mark.asyncio async def test_api_error_handling(): """Test API error handling.""" - from custom_components.adguard_hub.api import AdGuardConnectionError - # Test with a session that raises an exception session = MagicMock() - session.request = MagicMock() - session.request.return_value.__aenter__ = AsyncMock(side_effect=Exception("Connection error")) - session.request.return_value.__aexit__ = AsyncMock(return_value=None) + 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) api = AdGuardHomeAPI( host="test-host", @@ -79,5 +79,5 @@ async def test_api_error_handling(): session=session ) - with pytest.raises(Exception): # Should raise AdGuardHomeError + with pytest.raises(Exception): await api.get_status() diff --git a/tests/test_integration.py b/tests/test_integration.py index d736d54..aa7c378 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -90,10 +90,10 @@ async def test_setup_entry_connection_failure(hass: HomeAssistant, mock_config_e 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"), \ - pytest.raises(Exception): # Should raise ConfigEntryNotReady + patch("custom_components.adguard_hub.async_get_clientsession"): - await async_setup_entry(hass, mock_config_entry) + with pytest.raises(Exception): # Should raise ConfigEntryNotReady + await async_setup_entry(hass, mock_config_entry) @pytest.mark.asyncio @@ -154,7 +154,7 @@ def test_services_registration(hass: HomeAssistant): """Test that services are properly registered.""" from custom_components.adguard_hub.services import AdGuardControlHubServices - # Create services without running inside an existing event loop + # Create services without async context services = AdGuardControlHubServices(hass) services.register_services() @@ -162,9 +162,9 @@ def test_services_registration(hass: HomeAssistant): 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, "bulk_update_clients") 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()