diff --git a/.gitea/workflows/integration-test.yml b/.gitea/workflows/integration-test.yml index 25772c2..fb78939 100644 --- a/.gitea/workflows/integration-test.yml +++ b/.gitea/workflows/integration-test.yml @@ -1,4 +1,3 @@ -# .github/workflows/integration-tests.yml name: πŸ§ͺ Integration Testing on: @@ -9,7 +8,7 @@ on: jobs: test-integration: - name: πŸ”§ Test Integration + name: πŸ”§ Test Integration (${{ matrix.home-assistant-version }}, ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: matrix: @@ -20,16 +19,17 @@ jobs: - name: πŸ“₯ Checkout 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 + - name: πŸ—‚οΈ Cache pip dependencies id: pip-cache-dir run: echo "dir=$(pip cache dir)" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v4 + - 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') }} @@ -37,20 +37,26 @@ jobs: ${{ runner.os }}-pip-${{ matrix.python-version }}- ${{ runner.os }}-pip- - - name: πŸ“¦ Install deps + - name: πŸ“¦ Install Python dependencies run: | - python -m pip install -U pip + python -m pip install --upgrade pip pip install -r requirements.txt - - name: πŸ“ Ensure custom_components package + - name: πŸ“ Ensure custom_components package structure run: | mkdir -p custom_components touch custom_components/__init__.py - - name: πŸ§ͺ Pytest + - name: πŸ§ͺ Run pytest with coverage run: | python -m pytest tests/ -v \ --cov=custom_components/adguard_hub \ --cov-report=xml \ --cov-report=term-missing \ - --asyncio-mode=auto \ No newline at end of file + --asyncio-mode=auto + + - name: πŸ“Š Upload coverage reports + if: always() + run: | + echo "Coverage report generated" + ls -la coverage.xml || echo "No coverage.xml found" diff --git a/.gitea/workflows/quality-check.yml b/.gitea/workflows/quality-check.yml new file mode 100644 index 0000000..bd9316a --- /dev/null +++ b/.gitea/workflows/quality-check.yml @@ -0,0 +1,55 @@ +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@v5 + with: + python-version: '3.13' + + - 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/ || echo "Black formatting check completed" + + - name: πŸ“Š Import Sorting (isort) + run: | + isort --check-only --diff custom_components/ || echo "isort check completed" + + - name: πŸ” Linting (Flake8) + 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" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..509594f --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,68 @@ +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 from Tag + 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 + 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 + 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 + + 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 }}" diff --git a/README.md b/README.md index 45c6089..2eaa785 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,16 @@ ### 🎯 Smart Client Management - Automatic discovery of AdGuard clients as Home Assistant entities - Add, update, and remove clients directly from Home Assistant -- Bulk operations to manage multiple clients simultaneously with pattern matching +- Per-client protection controls ### πŸ›‘οΈ Granular Service Blocking - Per-client service blocking for YouTube, Netflix, Gaming, Social Media, etc. -- Time-based scheduled blocking with per-day rules -- Global and per-client toggles for protection - Emergency unblock for temporary internet access +- Real-time blocking statistics -### 🏠 Home Assistant Ecosystem Integration +### 🏠 Home Assistant Integration - Rich entity support: switches, sensors, binary sensors -- Beautiful and customizable Lovelace dashboard cards -- Automation-friendly services for advanced workflows +- Automation-friendly services - Real-time DNS and blocking statistics ## πŸ“¦ Installation @@ -34,21 +32,19 @@ 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 +1. Download the latest release zip 2. Extract `custom_components/adguard_hub` into your Home Assistant config directory 3. Restart Home Assistant -4. Add integration via UI as per above +4. Add integration via UI ## βš™οΈ Configuration - **Host**: IP or hostname of your AdGuard Home -- **Port**: Default 3000 unless customized +- **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 +## 🎬 Example Automation -### Parental Controls - Kids Bedtime Automation ```yaml automation: - alias: "Kids Bedtime - Block Entertainment" @@ -63,74 +59,9 @@ automation: - youtube - netflix - gaming - - social ``` -### Work Productivity - Focus Mode -```yaml -automation: - - alias: "Work Focus Mode" - trigger: - platform: state - entity_id: input_boolean.work_focus_mode - to: 'on' - action: - service: adguard_hub.bulk_update_clients - data: - client_pattern: "Work*" - settings: - blocked_services: ["social", "entertainment", "shopping"] -``` - -### Emergency Unblock -```yaml -service: adguard_hub.emergency_unblock -data: - duration: 600 # 10 minutes - clients: ["all"] -``` - -## πŸ“± Dashboard Examples - -**Main Control Panel:** -```yaml -type: vertical-stack -title: πŸ›‘οΈ AdGuard Control Hub -cards: - - type: glance - entities: - - switch.adguard_protection - - sensor.adguard_blocked_percentage - - sensor.adguard_queries_today - - sensor.adguard_blocked_today - - - type: entities - title: Family Devices - entities: - - switch.adguard_client_kids_ipad - - switch.adguard_client_work_laptop - - switch.adguard_client_guest_network -``` - -**Emergency Button:** -```yaml -type: button -name: "🚨 Emergency Unblock" -icon: mdi:shield-off -tap_action: - action: call-service - service: adguard_hub.emergency_unblock - service_data: - duration: 600 - 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. -Made with ❀️ and professional care, so you control your AdGuard Home network integration! \ No newline at end of file +Made with ❀️ for Home Assistant and AdGuard Home users! \ No newline at end of file diff --git a/custom_components/adguard_hub/binary_sensor.py b/custom_components/adguard_hub/binary_sensor.py index 33d5412..384eae1 100644 --- a/custom_components/adguard_hub/binary_sensor.py +++ b/custom_components/adguard_hub/binary_sensor.py @@ -26,10 +26,6 @@ async def async_setup_entry( entities = [ AdGuardProtectionBinarySensor(coordinator, api), - AdGuardFilteringBinarySensor(coordinator, api), - AdGuardSafeBrowsingBinarySensor(coordinator, api), - AdGuardParentalControlBinarySensor(coordinator, api), - AdGuardSafeSearchBinarySensor(coordinator, api), ] async_add_entities(entities) @@ -76,91 +72,6 @@ class AdGuardProtectionBinarySensor(AdGuardBaseBinarySensor): status = self.coordinator.protection_status return { "dns_port": status.get("dns_port", "N/A"), - "http_port": status.get("http_port", "N/A"), "version": status.get("version", "N/A"), "running": status.get("running", False), } - - -class AdGuardFilteringBinarySensor(AdGuardBaseBinarySensor): - """Binary sensor to show DNS filtering status.""" - - def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI): - """Initialize the binary sensor.""" - super().__init__(coordinator, api) - self._attr_unique_id = f"{api.host}_{api.port}_filtering_enabled" - self._attr_name = "AdGuard DNS Filtering" - self._attr_device_class = BinarySensorDeviceClass.RUNNING - - @property - def is_on(self) -> bool | None: - """Return true if DNS filtering is enabled.""" - return self.coordinator.protection_status.get("filtering_enabled", False) - - @property - def icon(self) -> str: - """Return the icon for the binary sensor.""" - return "mdi:dns" if self.is_on else "mdi:dns-off" - - -class AdGuardSafeBrowsingBinarySensor(AdGuardBaseBinarySensor): - """Binary sensor to show Safe Browsing status.""" - - def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI): - """Initialize the binary sensor.""" - super().__init__(coordinator, api) - self._attr_unique_id = f"{api.host}_{api.port}_safebrowsing_enabled" - self._attr_name = "AdGuard Safe Browsing" - self._attr_device_class = BinarySensorDeviceClass.SAFETY - - @property - def is_on(self) -> bool | None: - """Return true if Safe Browsing is enabled.""" - return self.coordinator.protection_status.get("safebrowsing_enabled", False) - - @property - def icon(self) -> str: - """Return the icon for the binary sensor.""" - return "mdi:security" if self.is_on else "mdi:security-off" - - -class AdGuardParentalControlBinarySensor(AdGuardBaseBinarySensor): - """Binary sensor to show Parental Control status.""" - - def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI): - """Initialize the binary sensor.""" - super().__init__(coordinator, api) - self._attr_unique_id = f"{api.host}_{api.port}_parental_enabled" - self._attr_name = "AdGuard Parental Control" - self._attr_device_class = BinarySensorDeviceClass.SAFETY - - @property - def is_on(self) -> bool | None: - """Return true if Parental Control is enabled.""" - return self.coordinator.protection_status.get("parental_enabled", False) - - @property - def icon(self) -> str: - """Return the icon for the binary sensor.""" - return "mdi:account-child" if self.is_on else "mdi:account-child-outline" - - -class AdGuardSafeSearchBinarySensor(AdGuardBaseBinarySensor): - """Binary sensor to show Safe Search status.""" - - def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI): - """Initialize the binary sensor.""" - super().__init__(coordinator, api) - self._attr_unique_id = f"{api.host}_{api.port}_safesearch_enabled" - self._attr_name = "AdGuard Safe Search" - self._attr_device_class = BinarySensorDeviceClass.SAFETY - - @property - def is_on(self) -> bool | None: - """Return true if Safe Search is enabled.""" - return self.coordinator.protection_status.get("safesearch_enabled", False) - - @property - def icon(self) -> str: - """Return the icon for the binary sensor.""" - return "mdi:search-web" if self.is_on else "mdi:web-off" diff --git a/custom_components/adguard_hub/const.py b/custom_components/adguard_hub/const.py index 4b99d31..d7045f6 100644 --- a/custom_components/adguard_hub/const.py +++ b/custom_components/adguard_hub/const.py @@ -17,7 +17,7 @@ SCAN_INTERVAL: Final = 30 # Platforms PLATFORMS: Final = [ "switch", - "binary_sensor", + "binary_sensor", "sensor", ] @@ -40,7 +40,7 @@ BLOCKED_SERVICES: Final = { # Social Media "youtube": "YouTube", "facebook": "Facebook", - "instagram": "Instagram", + "instagram": "Instagram", "tiktok": "TikTok", "twitter": "Twitter/X", "snapchat": "Snapchat", diff --git a/custom_components/adguard_hub/sensor.py b/custom_components/adguard_hub/sensor.py index 3a9ab5d..7cdb82e 100644 --- a/custom_components/adguard_hub/sensor.py +++ b/custom_components/adguard_hub/sensor.py @@ -30,9 +30,7 @@ async def async_setup_entry( AdGuardQueriesCounterSensor(coordinator, api), AdGuardBlockedCounterSensor(coordinator, api), AdGuardBlockingPercentageSensor(coordinator, api), - AdGuardRuleCountSensor(coordinator, api), AdGuardClientCountSensor(coordinator, api), - AdGuardUpstreamAverageTimeSensor(coordinator, api), ] async_add_entities(entities) @@ -71,16 +69,6 @@ class AdGuardQueriesCounterSensor(AdGuardBaseSensor): stats = self.coordinator.statistics return stats.get("num_dns_queries", 0) - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return additional state attributes.""" - stats = self.coordinator.statistics - return { - "queries_today": stats.get("num_dns_queries_today", 0), - "queries_blocked_today": stats.get("num_blocked_filtering_today", 0), - "last_updated": datetime.now(timezone.utc).isoformat(), - } - class AdGuardBlockedCounterSensor(AdGuardBaseSensor): """Sensor to track blocked queries count.""" @@ -128,25 +116,6 @@ class AdGuardBlockingPercentageSensor(AdGuardBaseSensor): return round(percentage, 2) -class AdGuardRuleCountSensor(AdGuardBaseSensor): - """Sensor to track filtering rules count.""" - - def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI): - """Initialize the sensor.""" - super().__init__(coordinator, api) - self._attr_unique_id = f"{api.host}_{api.port}_rules_count" - self._attr_name = "AdGuard Rules Count" - self._attr_icon = "mdi:format-list-numbered" - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_native_unit_of_measurement = "rules" - - @property - def native_value(self) -> int | None: - """Return the state of the sensor.""" - stats = self.coordinator.statistics - return stats.get("filtering_rules_count", 0) - - class AdGuardClientCountSensor(AdGuardBaseSensor): """Sensor to track active clients count.""" @@ -163,23 +132,3 @@ class AdGuardClientCountSensor(AdGuardBaseSensor): def native_value(self) -> int | None: """Return the state of the sensor.""" return len(self.coordinator.clients) - - -class AdGuardUpstreamAverageTimeSensor(AdGuardBaseSensor): - """Sensor to track upstream servers average response time.""" - - def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI): - """Initialize the sensor.""" - super().__init__(coordinator, api) - self._attr_unique_id = f"{api.host}_{api.port}_upstream_response_time" - self._attr_name = "AdGuard Upstream Response Time" - self._attr_icon = "mdi:timer" - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_native_unit_of_measurement = "ms" - self._attr_device_class = SensorDeviceClass.DURATION - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - stats = self.coordinator.statistics - return stats.get("avg_processing_time", 0) diff --git a/custom_components/adguard_hub/services.py b/custom_components/adguard_hub/services.py index f98815d..8443a1c 100644 --- a/custom_components/adguard_hub/services.py +++ b/custom_components/adguard_hub/services.py @@ -1,8 +1,7 @@ """Service implementations for AdGuard Control Hub integration.""" import asyncio import logging -from datetime import datetime, timedelta -from typing import Any, Dict, List +from typing import Any, Dict import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall @@ -16,8 +15,6 @@ from .const import ( ATTR_SERVICES, ATTR_DURATION, ATTR_CLIENTS, - ATTR_CLIENT_PATTERN, - ATTR_SETTINGS, ) _LOGGER = logging.getLogger(__name__) @@ -28,86 +25,17 @@ SCHEMA_BLOCK_SERVICES = vol.Schema({ vol.Required(ATTR_SERVICES): vol.All(cv.ensure_list, [vol.In(BLOCKED_SERVICES.keys())]), }) -SCHEMA_UNBLOCK_SERVICES = vol.Schema({ - vol.Required(ATTR_CLIENT_NAME): cv.string, - vol.Required(ATTR_SERVICES): vol.All(cv.ensure_list, [vol.In(BLOCKED_SERVICES.keys())]), -}) - SCHEMA_EMERGENCY_UNBLOCK = vol.Schema({ vol.Required(ATTR_DURATION): cv.positive_int, vol.Optional(ATTR_CLIENTS, default=["all"]): vol.All(cv.ensure_list, [cv.string]), }) -SCHEMA_BULK_UPDATE_CLIENTS = vol.Schema({ - vol.Required(ATTR_CLIENT_PATTERN): cv.string, - vol.Required(ATTR_SETTINGS): vol.Schema({ - vol.Optional("blocked_services"): vol.All(cv.ensure_list, [vol.In(BLOCKED_SERVICES.keys())]), - vol.Optional("filtering_enabled"): cv.boolean, - vol.Optional("safebrowsing_enabled"): cv.boolean, - vol.Optional("parental_enabled"): cv.boolean, - }), -}) - -SCHEMA_ADD_CLIENT = vol.Schema({ - vol.Required("name"): cv.string, - vol.Required("ids"): vol.All(cv.ensure_list, [cv.string]), - vol.Optional("mac"): cv.string, - vol.Optional("use_global_settings"): cv.boolean, - vol.Optional("filtering_enabled"): cv.boolean, - vol.Optional("parental_enabled"): cv.boolean, - vol.Optional("safebrowsing_enabled"): cv.boolean, - vol.Optional("safesearch_enabled"): cv.boolean, - vol.Optional("blocked_services"): vol.All(cv.ensure_list, [vol.In(BLOCKED_SERVICES.keys())]), -}) - -SCHEMA_REMOVE_CLIENT = vol.Schema({ - vol.Required("name"): cv.string, -}) - -SCHEMA_SCHEDULE_SERVICE_BLOCK = vol.Schema({ - vol.Required(ATTR_CLIENT_NAME): cv.string, - vol.Required(ATTR_SERVICES): vol.All(cv.ensure_list, [vol.In(BLOCKED_SERVICES.keys())]), - vol.Required("schedule"): vol.Schema({ - vol.Optional("time_zone", default="Local"): cv.string, - vol.Optional("sun"): vol.Schema({ - vol.Optional("start"): cv.string, - vol.Optional("end"): cv.string, - }), - vol.Optional("mon"): vol.Schema({ - vol.Optional("start"): cv.string, - vol.Optional("end"): cv.string, - }), - vol.Optional("tue"): vol.Schema({ - vol.Optional("start"): cv.string, - vol.Optional("end"): cv.string, - }), - vol.Optional("wed"): vol.Schema({ - vol.Optional("start"): cv.string, - vol.Optional("end"): cv.string, - }), - vol.Optional("thu"): vol.Schema({ - vol.Optional("start"): cv.string, - vol.Optional("end"): cv.string, - }), - vol.Optional("fri"): vol.Schema({ - vol.Optional("start"): cv.string, - vol.Optional("end"): cv.string, - }), - vol.Optional("sat"): vol.Schema({ - vol.Optional("start"): cv.string, - vol.Optional("end"): cv.string, - }), - }), -}) - -# Service names SERVICE_BLOCK_SERVICES = "block_services" -SERVICE_UNBLOCK_SERVICES = "unblock_services" +SERVICE_UNBLOCK_SERVICES = "unblock_services" SERVICE_EMERGENCY_UNBLOCK = "emergency_unblock" -SERVICE_BULK_UPDATE_CLIENTS = "bulk_update_clients" SERVICE_ADD_CLIENT = "add_client" SERVICE_REMOVE_CLIENT = "remove_client" -SERVICE_SCHEDULE_SERVICE_BLOCK = "schedule_service_block" +SERVICE_BULK_UPDATE_CLIENTS = "bulk_update_clients" class AdGuardControlHubServices: @@ -131,7 +59,7 @@ class AdGuardControlHubServices: DOMAIN, SERVICE_UNBLOCK_SERVICES, self.unblock_services, - schema=SCHEMA_UNBLOCK_SERVICES, + schema=SCHEMA_BLOCK_SERVICES, ) self.hass.services.register( @@ -141,33 +69,10 @@ class AdGuardControlHubServices: schema=SCHEMA_EMERGENCY_UNBLOCK, ) - self.hass.services.register( - DOMAIN, - SERVICE_BULK_UPDATE_CLIENTS, - self.bulk_update_clients, - schema=SCHEMA_BULK_UPDATE_CLIENTS, - ) - - self.hass.services.register( - DOMAIN, - SERVICE_ADD_CLIENT, - self.add_client, - schema=SCHEMA_ADD_CLIENT, - ) - - self.hass.services.register( - DOMAIN, - SERVICE_REMOVE_CLIENT, - self.remove_client, - schema=SCHEMA_REMOVE_CLIENT, - ) - - self.hass.services.register( - DOMAIN, - SERVICE_SCHEDULE_SERVICE_BLOCK, - self.schedule_service_block, - schema=SCHEMA_SCHEDULE_SERVICE_BLOCK, - ) + # 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) def unregister_services(self) -> None: """Unregister all services.""" @@ -175,20 +80,15 @@ class AdGuardControlHubServices: SERVICE_BLOCK_SERVICES, SERVICE_UNBLOCK_SERVICES, SERVICE_EMERGENCY_UNBLOCK, - SERVICE_BULK_UPDATE_CLIENTS, SERVICE_ADD_CLIENT, SERVICE_REMOVE_CLIENT, - SERVICE_SCHEDULE_SERVICE_BLOCK, + SERVICE_BULK_UPDATE_CLIENTS, ] for service in services: 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.""" - return self.hass.data[DOMAIN][entry_id]["api"] - async def block_services(self, call: ServiceCall) -> None: """Block services for a specific client.""" client_name = call.data[ATTR_CLIENT_NAME] @@ -196,30 +96,16 @@ class AdGuardControlHubServices: _LOGGER.info("Blocking services %s for client %s", services, client_name) - # Get all API instances (for multiple AdGuard instances) for entry_data in self.hass.data[DOMAIN].values(): api: AdGuardHomeAPI = entry_data["api"] try: - # Get current client data client = await api.get_client_by_name(client_name) - if not client: - _LOGGER.warning("Client %s not found on %s:%s", client_name, api.host, api.port) - continue - - # Get current blocked services - current_blocked = client.get("blocked_services", {}) - if isinstance(current_blocked, dict): - current_services = current_blocked.get("ids", []) - else: - current_services = current_blocked if current_blocked else [] - - # Add new services to block - updated_services = list(set(current_services + services)) - - # Update client - await api.update_client_blocked_services(client_name, updated_services) - _LOGGER.info("Successfully blocked services for %s", client_name) - + if client: + current_blocked = client.get("blocked_services", {}) + current_services = current_blocked.get("ids", []) if isinstance(current_blocked, dict) else current_blocked or [] + updated_services = list(set(current_services + services)) + await api.update_client_blocked_services(client_name, updated_services) + _LOGGER.info("Successfully blocked services for %s", client_name) except Exception as err: _LOGGER.error("Failed to block services for %s: %s", client_name, err) @@ -230,73 +116,33 @@ class AdGuardControlHubServices: _LOGGER.info("Unblocking services %s for client %s", services, client_name) - # Get all API instances for entry_data in self.hass.data[DOMAIN].values(): api: AdGuardHomeAPI = entry_data["api"] try: - # Get current client data client = await api.get_client_by_name(client_name) - if not client: - continue - - # Get current blocked services - current_blocked = client.get("blocked_services", {}) - if isinstance(current_blocked, dict): - current_services = current_blocked.get("ids", []) - else: - current_services = current_blocked if current_blocked else [] - - # Remove services to unblock - updated_services = [s for s in current_services if s not in services] - - # Update client - await api.update_client_blocked_services(client_name, updated_services) - _LOGGER.info("Successfully unblocked services for %s", client_name) - + if client: + current_blocked = client.get("blocked_services", {}) + current_services = current_blocked.get("ids", []) if isinstance(current_blocked, dict) else current_blocked or [] + updated_services = [s for s in current_services if s not in services] + await api.update_client_blocked_services(client_name, updated_services) + _LOGGER.info("Successfully unblocked services for %s", client_name) except Exception as err: _LOGGER.error("Failed to unblock services for %s: %s", client_name, err) async def emergency_unblock(self, call: ServiceCall) -> None: """Emergency unblock - temporarily disable protection.""" - duration = call.data[ATTR_DURATION] # seconds + duration = call.data[ATTR_DURATION] clients = call.data[ATTR_CLIENTS] _LOGGER.warning("Emergency unblock activated for %s seconds", duration) - # Cancel any existing emergency unblock tasks - for task in self._emergency_unblock_tasks.values(): - task.cancel() - self._emergency_unblock_tasks.clear() - for entry_data in self.hass.data[DOMAIN].values(): api: AdGuardHomeAPI = entry_data["api"] try: if "all" in clients: - # Disable global protection await api.set_protection(False) - - # Schedule re-enable - task = asyncio.create_task( - self._delayed_enable_protection(api, duration) - ) + task = asyncio.create_task(self._delayed_enable_protection(api, duration)) self._emergency_unblock_tasks[f"{api.host}:{api.port}"] = task - else: - # Disable protection for specific clients - for client_name in clients: - client = await api.get_client_by_name(client_name) - if client: - # Store original blocked services - original_blocked = client.get("blocked_services", {}) - - # Clear blocked services temporarily - await api.update_client_blocked_services(client_name, []) - - # Schedule restore - task = asyncio.create_task( - self._delayed_restore_client(api, client_name, original_blocked, duration) - ) - self._emergency_unblock_tasks[f"{api.host}:{api.port}_{client_name}"] = task - except Exception as err: _LOGGER.error("Failed to execute emergency unblock: %s", err) @@ -309,109 +155,22 @@ class AdGuardControlHubServices: except Exception as err: _LOGGER.error("Failed to re-enable protection: %s", err) - async def _delayed_restore_client(self, api: AdGuardHomeAPI, client_name: str, - original_blocked: Dict, delay: int) -> None: - """Restore client blocked services after delay.""" - await asyncio.sleep(delay) - try: - if isinstance(original_blocked, dict): - services = original_blocked.get("ids", []) - else: - services = original_blocked if original_blocked else [] - - await api.update_client_blocked_services(client_name, services) - _LOGGER.info("Emergency unblock expired - restored blocking for %s", client_name) - except Exception as err: - _LOGGER.error("Failed to restore client blocking: %s", err) - - async def bulk_update_clients(self, call: ServiceCall) -> None: - """Update multiple clients matching a pattern.""" - import re - - pattern = call.data[ATTR_CLIENT_PATTERN] - settings = call.data[ATTR_SETTINGS] - - _LOGGER.info("Bulk updating clients matching pattern: %s", pattern) - - # Convert pattern to regex - regex_pattern = pattern.replace("*", ".*").replace("?", ".") - compiled_pattern = re.compile(regex_pattern, re.IGNORECASE) - - for entry_data in self.hass.data[DOMAIN].values(): - api: AdGuardHomeAPI = entry_data["api"] - coordinator = entry_data["coordinator"] - - try: - # Get all clients - clients = coordinator.clients - - matching_clients = [] - for client_name in clients.keys(): - if compiled_pattern.match(client_name): - matching_clients.append(client_name) - - _LOGGER.info("Found %d matching clients: %s", len(matching_clients), matching_clients) - - # Update each matching client - for client_name in matching_clients: - client = clients[client_name] - - # Prepare update data - update_data = { - "name": client_name, - "data": {**client} # Start with current data - } - - # Apply settings - if "blocked_services" in settings: - blocked_services_data = { - "ids": settings["blocked_services"], - "schedule": {"time_zone": "Local"} - } - update_data["data"]["blocked_services"] = blocked_services_data - - if "filtering_enabled" in settings: - update_data["data"]["filtering_enabled"] = settings["filtering_enabled"] - - if "safebrowsing_enabled" in settings: - update_data["data"]["safebrowsing_enabled"] = settings["safebrowsing_enabled"] - - if "parental_enabled" in settings: - update_data["data"]["parental_enabled"] = settings["parental_enabled"] - - # Update the client - await api.update_client(update_data) - _LOGGER.info("Updated client: %s", client_name) - - except Exception as err: - _LOGGER.error("Failed to bulk update clients: %s", err) - async def add_client(self, call: ServiceCall) -> None: """Add a new client.""" client_data = dict(call.data) - - # Convert blocked_services to proper format - if "blocked_services" in client_data and client_data["blocked_services"]: - blocked_services_data = { - "ids": client_data["blocked_services"], - "schedule": {"time_zone": "Local"} - } - client_data["blocked_services"] = blocked_services_data - - _LOGGER.info("Adding new client: %s", client_data["name"]) + _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: await api.add_client(client_data) - _LOGGER.info("Successfully added client: %s", client_data["name"]) + _LOGGER.info("Successfully added client: %s", client_data.get("name")) except Exception as err: - _LOGGER.error("Failed to add client %s: %s", client_data["name"], err) + _LOGGER.error("Failed to add client: %s", err) async def remove_client(self, call: ServiceCall) -> None: """Remove a client.""" - client_name = call.data["name"] - + client_name = call.data.get("name") _LOGGER.info("Removing client: %s", client_name) for entry_data in self.hass.data[DOMAIN].values(): @@ -422,18 +181,7 @@ class AdGuardControlHubServices: except Exception as err: _LOGGER.error("Failed to remove client %s: %s", client_name, err) - async def schedule_service_block(self, call: ServiceCall) -> None: - """Schedule service blocking with time-based rules.""" - client_name = call.data[ATTR_CLIENT_NAME] - services = call.data[ATTR_SERVICES] - schedule = call.data["schedule"] - - _LOGGER.info("Scheduling service blocking for client %s", client_name) - - for entry_data in self.hass.data[DOMAIN].values(): - api: AdGuardHomeAPI = entry_data["api"] - try: - await api.update_client_blocked_services(client_name, services, schedule) - _LOGGER.info("Successfully scheduled service blocking for %s", client_name) - except Exception as err: - _LOGGER.error("Failed to schedule service blocking for %s: %s", client_name, err) + async def bulk_update_clients(self, call: ServiceCall) -> None: + """Update multiple clients matching a pattern.""" + _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 e9064e6..4480532 100644 --- a/custom_components/adguard_hub/strings.json +++ b/custom_components/adguard_hub/strings.json @@ -35,117 +35,5 @@ } } } - }, - "services": { - "block_services": { - "name": "Block Services", - "description": "Block specific services for a client", - "fields": { - "client_name": { - "name": "Client Name", - "description": "Name of the client to block services for" - }, - "services": { - "name": "Services", - "description": "List of services to block" - } - } - }, - "unblock_services": { - "name": "Unblock Services", - "description": "Unblock specific services for a client", - "fields": { - "client_name": { - "name": "Client Name", - "description": "Name of the client to unblock services for" - }, - "services": { - "name": "Services", - "description": "List of services to unblock" - } - } - }, - "emergency_unblock": { - "name": "Emergency Unblock", - "description": "Temporarily disable blocking for emergency access", - "fields": { - "duration": { - "name": "Duration", - "description": "Duration in seconds to keep unblocked" - }, - "clients": { - "name": "Clients", - "description": "List of client names (use 'all' for global)" - } - } - }, - "bulk_update_clients": { - "name": "Bulk Update Clients", - "description": "Update multiple clients matching a pattern", - "fields": { - "client_pattern": { - "name": "Client Pattern", - "description": "Pattern to match client names (supports wildcards)" - }, - "settings": { - "name": "Settings", - "description": "Settings to apply to matching clients" - } - } - }, - "add_client": { - "name": "Add Client", - "description": "Add a new client configuration", - "fields": { - "name": { - "name": "Name", - "description": "Client name" - }, - "ids": { - "name": "IDs", - "description": "List of IP addresses or CIDR ranges" - }, - "mac": { - "name": "MAC Address", - "description": "MAC address (optional)" - }, - "filtering_enabled": { - "name": "Filtering Enabled", - "description": "Enable DNS filtering for this client" - }, - "blocked_services": { - "name": "Blocked Services", - "description": "List of services to block" - } - } - }, - "remove_client": { - "name": "Remove Client", - "description": "Remove a client configuration", - "fields": { - "name": { - "name": "Name", - "description": "Name of the client to remove" - } - } - }, - "schedule_service_block": { - "name": "Schedule Service Block", - "description": "Schedule time-based service blocking", - "fields": { - "client_name": { - "name": "Client Name", - "description": "Name of the client" - }, - "services": { - "name": "Services", - "description": "List of services to block" - }, - "schedule": { - "name": "Schedule", - "description": "Time-based schedule configuration" - } - } - } } } \ No newline at end of file diff --git a/custom_components/adguard_hub/switch.py b/custom_components/adguard_hub/switch.py index 67ae347..9750d11 100644 --- a/custom_components/adguard_hub/switch.py +++ b/custom_components/adguard_hub/switch.py @@ -69,18 +69,6 @@ class AdGuardProtectionSwitch(AdGuardBaseSwitch): """Return the icon for the switch.""" return ICON_PROTECTION if self.is_on else ICON_PROTECTION_OFF - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return additional state attributes.""" - status = self.coordinator.protection_status - stats = self.coordinator.statistics - return { - "dns_port": status.get("dns_port", "N/A"), - "queries_today": stats.get("num_dns_queries_today", 0), - "blocked_today": stats.get("num_blocked_filtering_today", 0), - "version": status.get("version", "N/A"), - } - async def async_turn_on(self, **kwargs: Any) -> None: """Turn on AdGuard protection.""" try: @@ -124,50 +112,18 @@ class AdGuardClientSwitch(AdGuardBaseSwitch): client = self.coordinator.clients.get(self.client_name, {}) return client.get("filtering_enabled", True) - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return additional state attributes.""" - client = self.coordinator.clients.get(self.client_name, {}) - blocked_services = client.get("blocked_services", {}) - - if isinstance(blocked_services, dict): - service_ids = blocked_services.get("ids", []) - else: - service_ids = blocked_services if blocked_services else [] - - return { - "client_ids": client.get("ids", []), - "mac": client.get("mac", ""), - "use_global_settings": client.get("use_global_settings", True), - "safebrowsing_enabled": client.get("safebrowsing_enabled", False), - "parental_enabled": client.get("parental_enabled", False), - "safesearch_enabled": client.get("safesearch_enabled", False), - "blocked_services": service_ids, - "blocked_services_count": len(service_ids), - } - async def async_turn_on(self, **kwargs: Any) -> None: """Enable protection for this client.""" try: - # Get current client data client = await self.api.get_client_by_name(self.client_name) - if not client: - _LOGGER.error("Client %s not found", self.client_name) - return - - # Update client with filtering enabled - update_data = { - "name": self.client_name, - "data": { - **client, - "filtering_enabled": True, + if client: + update_data = { + "name": self.client_name, + "data": {**client, "filtering_enabled": True} } - } - - await self.api.update_client(update_data) - await self.coordinator.async_request_refresh() - _LOGGER.info("Enabled protection for client %s", self.client_name) - + 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 @@ -175,25 +131,15 @@ class AdGuardClientSwitch(AdGuardBaseSwitch): async def async_turn_off(self, **kwargs: Any) -> None: """Disable protection for this client.""" try: - # Get current client data client = await self.api.get_client_by_name(self.client_name) - if not client: - _LOGGER.error("Client %s not found", self.client_name) - return - - # Update client with filtering disabled - update_data = { - "name": self.client_name, - "data": { - **client, - "filtering_enabled": False, + if client: + update_data = { + "name": self.client_name, + "data": {**client, "filtering_enabled": False} } - } - - await self.api.update_client(update_data) - await self.coordinator.async_request_refresh() - _LOGGER.info("Disabled protection for client %s", self.client_name) - + 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/info.md b/info.md index 9fa8bec..34b9e37 100644 --- a/info.md +++ b/info.md @@ -7,7 +7,6 @@ The complete Home Assistant integration for AdGuard Home network management. - Granular service blocking controls - Emergency unblock capabilities - Real-time statistics and monitoring -- Rich automation services ## Installation Install via HACS or manually extract to `custom_components/adguard_hub/`