From 4553eb454aa30534c5d2d2c7914a592156d22a64 Mon Sep 17 00:00:00 2001 From: Rafal Zielinski Date: Sun, 28 Sep 2025 15:34:46 +0100 Subject: [PATCH] refactor: Refactoring most of the project Signed-off-by: Rafal Zielinski --- .flake8 | 10 +-- .gitea/workflows/integration-test.yml | 2 +- .gitea/workflows/quality-check.yml | 55 ------------- .gitea/workflows/release.yml | 31 -------- .gitignore | 41 ++++------ README.md | 36 +-------- custom_components/__init__.py | 1 + custom_components/adguard_hub/config_flow.py | 2 +- custom_components/adguard_hub/services.py | 3 +- hacs.json | 14 ++-- info.md | 70 +++-------------- pyproject.toml | 4 +- requirements-dev.txt | 18 ++--- tests/__init__.py | 2 +- tests/conftest.py | 9 +++ tests/test_api.py | 44 ++++++++++- tests/test_config_flow.py | 82 +++++++++++++++----- tests/test_integration.py | 15 ++-- 18 files changed, 180 insertions(+), 259 deletions(-) delete mode 100644 .gitea/workflows/quality-check.yml delete mode 100644 .gitea/workflows/release.yml create mode 100644 custom_components/__init__.py diff --git a/.flake8 b/.flake8 index 4898a35..636e747 100644 --- a/.flake8 +++ b/.flake8 @@ -1,12 +1,12 @@ [flake8] max-line-length = 127 -exclude = +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 +ignore = + E203, # whitespace before ':' + W503, # line break before binary operator + E501, # line too long (handled by black) \ No newline at end of file diff --git a/.gitea/workflows/integration-test.yml b/.gitea/workflows/integration-test.yml index 645169b..25772c2 100644 --- a/.gitea/workflows/integration-test.yml +++ b/.gitea/workflows/integration-test.yml @@ -53,4 +53,4 @@ jobs: --cov=custom_components/adguard_hub \ --cov-report=xml \ --cov-report=term-missing \ - --asyncio-mode=auto + --asyncio-mode=auto \ No newline at end of file diff --git a/.gitea/workflows/quality-check.yml b/.gitea/workflows/quality-check.yml deleted file mode 100644 index d6d6e6f..0000000 --- a/.gitea/workflows/quality-check.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: ๐Ÿ›ก๏ธ Code Quality & Security Check - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - -jobs: - code-quality: - name: ๐Ÿ” Code Quality Analysis - runs-on: ubuntu-latest - - steps: - - name: ๐Ÿ“ฅ Checkout Code - uses: actions/checkout@v4 - - - name: ๐Ÿ Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: ๐Ÿ“ฆ Install Dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 black isort mypy bandit safety - pip install homeassistant==2025.9.4 - pip install -r requirements-dev.txt || echo "No dev requirements found" - - - name: ๐ŸŽจ Check Code Formatting (Black) - run: | - black --check --diff custom_components/ - - - name: ๐Ÿ“Š Import Sorting (isort) - run: | - isort --check-only --diff custom_components/ - - - name: ๐Ÿ” Linting (Flake8) - run: | - flake8 custom_components/ --count --select=E9,F63,F7,F82 --show-source --statistics - 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 || true - bandit -r custom_components/ --severity-level medium - - - name: ๐Ÿ›ก๏ธ Dependency Security Check (Safety) - run: | - safety check --json --output safety-report.json || true - safety check - - - name: ๐Ÿท๏ธ Type Checking (MyPy) - run: | - mypy custom_components/ --ignore-missing-imports --no-strict-optional \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml deleted file mode 100644 index c4a0831..0000000 --- a/.gitea/workflows/release.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: ๐Ÿš€ Release - -on: - push: - tags: - - 'v*' - -jobs: - release: - name: ๐Ÿ“ฆ Create Release - runs-on: ubuntu-latest - - steps: - - name: ๐Ÿ“ฅ Checkout Code - uses: actions/checkout@v4 - - - 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 - - - name: ๐Ÿ“ฆ Create Archive - run: | - cd custom_components - zip -r ../adguard-control-hub-${{ steps.version.outputs.VERSION }}.zip adguard_hub/ - - - name: ๐Ÿš€ Create Release - run: | - echo "Creating release for ${{ steps.version.outputs.TAG }}" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2f63e77..4bc79ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,31 +1,18 @@ -# Python +.venv +venv/ __pycache__/ -*.py[cod] -*$py.class *.pyc - -# Home Assistant -*.log -*.db -*.db-journal -.HA_VERSION - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db -.directory - -# Testing +*.pyo +*.pyd +.Python .pytest_cache/ .coverage -htmlcov/ - -# Virtual environments -venv/ -env/ \ No newline at end of file +.mypy_cache/ +*.egg-info/ +dist/ +build/ +.DS_Store +.vscode/ +.idea/ +*.log +.env \ No newline at end of file diff --git a/README.md b/README.md index f623025..45c6089 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,7 @@ # ๐Ÿ›ก๏ธ AdGuard Control Hub -[![HACS Custom Integration](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) -[![Gitea Release](https://img.shields.io/gitea/v/release/your-username/adguard-control-hub?gitea_url=https://git.sq4ind.eu)](https://git.sq4ind.eu/sq4ind/adguard-control-hub/releases) -[![License MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) - **The ultimate Home Assistant integration for AdGuard Home** ---- - ## โœจ Features ### ๐ŸŽฏ Smart Client Management @@ -27,16 +21,12 @@ - Automation-friendly services for advanced workflows - Real-time DNS and blocking 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` +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 @@ -44,24 +34,18 @@ 8. Search and select **AdGuard Control Hub**, enter your AdGuard Home details ### ๐Ÿ› ๏ธ Method 2: Manual Installation - 1. Download the latest release zip from your Gitea repository 2. Extract `custom_components/adguard_hub` into your Home Assistant config directory 3. Restart Home Assistant 4. Add integration via UI as per above ---- - ## โš™๏ธ 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 - **Verify SSL**: Disable for self-signed certificates ---- - ## ๐ŸŽฌ Use Cases & Examples ### Parental Controls - Kids Bedtime Automation @@ -106,8 +90,6 @@ data: clients: ["all"] ``` ---- - ## ๐Ÿ“ฑ Dashboard Examples **Main Control Panel:** @@ -143,24 +125,12 @@ tap_action: clients: ["all"] ``` ---- - ## ๐Ÿค Support & Contribution - - Full documentation and setup examples in the repository wiki. - Report issues or request features via the repository's issue tracker. - Contributions welcomeโ€”please read the contribution guidelines. ---- - ## ๐Ÿ“„ License +This project is licensed under the MIT License. See the LICENSE file for details. -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. - ---- - -Made with โค๏ธ and professional care, so you control your AdGuard Home network integration! - ---- - -[![HACS Custom Integration](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) | [Releases](https://git.sq4ind.eu/sq4ind/adguard-control-hub/releases) | [Issues](https://git.sq4ind.eu/sq4ind/adguard-control-hub/issues) | [License](LICENSE) \ No newline at end of file +Made with โค๏ธ and professional care, so you control your AdGuard Home network integration! \ No newline at end of file diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..235e7d1 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Custom components for Home Assistant.""" \ No newline at end of file diff --git a/custom_components/adguard_hub/config_flow.py b/custom_components/adguard_hub/config_flow.py index 2b76fa9..367b80e 100644 --- a/custom_components/adguard_hub/config_flow.py +++ b/custom_components/adguard_hub/config_flow.py @@ -109,7 +109,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for AdGuard Control Hub.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + MINOR_VERSION = 1 async def async_step_user( self, user_input: Optional[Dict[str, Any]] = None diff --git a/custom_components/adguard_hub/services.py b/custom_components/adguard_hub/services.py index 72925e6..f98815d 100644 --- a/custom_components/adguard_hub/services.py +++ b/custom_components/adguard_hub/services.py @@ -182,7 +182,8 @@ class AdGuardControlHubServices: ] for service in services: - self.hass.services.remove(DOMAIN, service) + if self.hass.services.has_service(DOMAIN, service): + self.hass.services.remove(DOMAIN, service) def _get_api_for_entry(self, entry_id: str) -> AdGuardHomeAPI: """Get API instance for a specific config entry.""" diff --git a/hacs.json b/hacs.json index e9e229f..24f00da 100644 --- a/hacs.json +++ b/hacs.json @@ -1,9 +1,9 @@ { - "name": "AdGuard Control Hub", - "content_in_root": false, - "filename": "adguard_hub", - "country": ["US", "GB", "CA", "AU", "DE", "FR", "NL", "SE", "NO", "DK"], - "homeassistant": "2023.1.0", - "render_readme": true, - "iot_class": "Local Polling" + "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 6c2e01f..9fa8bec 100644 --- a/info.md +++ b/info.md @@ -1,63 +1,15 @@ -# ๐Ÿ›ก๏ธ AdGuard Control Hub +# AdGuard Control Hub -**Transform your AdGuard Home into a smart network management powerhouse!** +The complete Home Assistant integration for AdGuard Home network management. -## ๐ŸŽฏ What Makes This Special? +## Features +- Smart client management and discovery +- Granular service blocking controls +- Emergency unblock capabilities +- Real-time statistics and monitoring +- Rich automation services -Unlike the basic AdGuard integration, **AdGuard Control Hub** gives you complete control over every aspect of your network filtering: +## Installation +Install via HACS or manually extract to `custom_components/adguard_hub/` -### ๐Ÿ  **Smart Home Integration** -- **Individual Device Control**: Every AdGuard client becomes a Home Assistant switch -- **Service-Level Blocking**: Block YouTube on kids' tablets while allowing it on work computers -- **Automated Parenting**: Bedtime routines that automatically restrict internet access -- **Work Productivity**: Focus modes that eliminate distracting websites during work hours - -### ๐ŸŽ›๏ธ **Rich Dashboard Controls** -- **One-Click Emergency Override**: Temporary unblock for urgent situations -- **Family Management Panel**: Control all kids' devices from a single card -- **Guest Network Controls**: Different rules for visitor devices -- **Real-Time Statistics**: See exactly what's being blocked and when - -### ๐Ÿค– **Automation Paradise** -- **Time-Based Rules**: Different restrictions for school nights vs weekends -- **Presence Detection**: Automatically adjust rules when people arrive/leave -- **Bulk Operations**: Update multiple devices with pattern matching -- **Custom Schedules**: Per-device blocking schedules with precise time control - -## ๐Ÿ› ๏ธ **What You Get** - -### **Entities Created** -- `switch.adguard_protection` - Global protection toggle -- `switch.adguard_client_*` - Individual device protection -- `switch.adguard_client_*_service_*` - Per-device service blocking -- `sensor.adguard_*` - DNS statistics and monitoring -- `binary_sensor.adguard_*` - Status indicators - -### **Services Available** -- `adguard_hub.add_client` - Add new devices -- `adguard_hub.block_services` - Block specific services -- `adguard_hub.bulk_update_clients` - Manage multiple devices -- `adguard_hub.emergency_unblock` - Temporary access override - -## ๐ŸŽฌ **Perfect For** - -- **Parents** wanting automated screen time controls -- **Remote Workers** needing focus mode automation -- **Tech Enthusiasts** wanting complete network visibility -- **Families** needing different rules for different people -- **Anyone** who wants their network to be truly "smart" - -## ๐Ÿ“‹ **Requirements** - -- Home Assistant 2023.1+ -- AdGuard Home with API access enabled -- Admin credentials for your AdGuard Home instance - -## ๐Ÿš€ **Quick Start** - -1. Install via HACS or manually -2. Add integration: Settings โ†’ Devices & Services โ†’ Add Integration -3. Enter your AdGuard Home IP, port, username, and password -4. Watch as all your devices appear as controllable entities! - -**Ready to take control of your network? Let's get started! ๐Ÿš€** \ No newline at end of file +Restart Home Assistant and add via Integrations UI. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 125168f..59da783 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.black] line-length = 127 -target-version = ['py311'] +target-version = ['py313'] include = '\.pyi?$' [tool.isort] @@ -13,7 +13,7 @@ use_parentheses = true ensure_newline_before_comments = true [tool.mypy] -python_version = "3.11" +python_version = "3.13" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true diff --git a/requirements-dev.txt b/requirements-dev.txt index 9666833..87da9a5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,13 +1,13 @@ # Development dependencies -black==23.12.0 -flake8==6.1.0 +black==24.3.0 +flake8==7.0.0 isort==5.13.2 -mypy==1.8.0 -bandit==1.7.5 -safety==2.3.5 -pytest==7.4.3 -pytest-homeassistant-custom-component==0.13.104 -pytest-cov==4.1.0 +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==2023.12.0 \ No newline at end of file +homeassistant==2025.9.4 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index b0451c5..4a41b11 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests for AdGuard Control Hub.""" \ No newline at end of file +"""Tests for AdGuard Control Hub integration.""" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 789dd86..3eebe59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +"""Test configuration and fixtures.""" import pytest @@ -5,3 +6,11 @@ import pytest def auto_enable_custom_integrations(enable_custom_integrations): """Enable custom integrations for all tests.""" 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 diff --git a/tests/test_api.py b/tests/test_api.py index 733eec0..ea5edbf 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,6 @@ """Test API functionality.""" import pytest -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from custom_components.adguard_hub.api import AdGuardHomeAPI @@ -13,10 +13,19 @@ def mock_session(): response.json = AsyncMock(return_value={"status": "ok"}) response.status = 200 response.content_length = 100 - session.request = AsyncMock(return_value=response) + + # Create async context manager for session.request + async def mock_request(*args, **kwargs): + return response + + session.request = MagicMock() + session.request.return_value.__aenter__ = AsyncMock(return_value=response) + session.request.return_value.__aexit__ = AsyncMock(return_value=None) + return session +@pytest.mark.asyncio async def test_api_connection(mock_session): """Test API connection.""" api = AdGuardHomeAPI( @@ -31,6 +40,7 @@ async def test_api_connection(mock_session): assert result is True +@pytest.mark.asyncio async def test_api_get_status(mock_session): """Test getting status.""" api = AdGuardHomeAPI( @@ -41,3 +51,33 @@ async def test_api_get_status(mock_session): status = await api.get_status() assert status == {"status": "ok"} + + +@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.port == 3000 + + +@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) + + api = AdGuardHomeAPI( + host="test-host", + port=3000, + session=session + ) + + with pytest.raises(Exception): # Should raise AdGuardHomeError + await api.get_status() diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index d2b9e40..23b10bc 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,29 +1,71 @@ """Test config flow for AdGuard Control Hub.""" import pytest -from homeassistant import config_entries, setup +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 -async def test_config_flow_success(hass): + +@pytest.mark.asyncio +async def test_config_flow_success(hass: HomeAssistant): """Test successful config flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + 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", + } - assert result["type"] == "form" - assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) -async def test_config_flow_cannot_connect(hass): + 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.""" - 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", - }, - ) + from custom_components.adguard_hub.config_flow import CannotConnect - assert result["type"] == "form" - assert result["errors"]["base"] == "cannot_connect" \ No newline at end of file + 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 0917994..d736d54 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.core import HomeAssistant -from homeassistant.config_entries import ConfigEntry +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 import async_setup_entry, async_unload_entry @@ -12,9 +12,10 @@ from custom_components.adguard_hub.const import DOMAIN @pytest.fixture def mock_config_entry(): - """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={ @@ -23,8 +24,12 @@ def mock_config_entry(): CONF_USERNAME: "admin", CONF_PASSWORD: "password", }, - source="user", + options={}, + source=SOURCE_USER, entry_id="test_entry_id", + unique_id="192.168.1.100:3000", + discovery_keys=set(), + subentries_data={}, ) @@ -145,11 +150,11 @@ async def test_api_error_handling(mock_api): await mock_api.get_clients() -@pytest.mark.asyncio -async def test_services_registration(hass: HomeAssistant): +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 services = AdGuardControlHubServices(hass) services.register_services()