Initial commit
Some checks failed
🧪 Integration Testing / 🔧 Test Integration (2023.12.0, 3.11) (push) Successful in 2m11s
🧪 Integration Testing / 🔧 Test Integration (2023.12.0, 3.12) (push) Successful in 2m2s
🧪 Integration Testing / 🔧 Test Integration (2024.1.0, 3.11) (push) Successful in 1m4s
🧪 Integration Testing / 🔧 Test Integration (2024.1.0, 3.12) (push) Successful in 1m19s
🛡️ Code Quality & Security Check / 🔍 Code Quality Analysis (push) Failing after 56s
Some checks failed
🧪 Integration Testing / 🔧 Test Integration (2023.12.0, 3.11) (push) Successful in 2m11s
🧪 Integration Testing / 🔧 Test Integration (2023.12.0, 3.12) (push) Successful in 2m2s
🧪 Integration Testing / 🔧 Test Integration (2024.1.0, 3.11) (push) Successful in 1m4s
🧪 Integration Testing / 🔧 Test Integration (2024.1.0, 3.12) (push) Successful in 1m19s
🛡️ Code Quality & Security Check / 🔍 Code Quality Analysis (push) Failing after 56s
Signed-off-by: Rafal Zielinski <sq4ind@gmail.com>
This commit is contained in:
12
.flake8
Normal file
12
.flake8
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[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)
|
34
.gitea/workflows/integration-test.yml
Normal file
34
.gitea/workflows/integration-test.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: 🧪 Integration Testing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-integration:
|
||||||
|
name: 🔧 Test Integration
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ['3.11', '3.12']
|
||||||
|
home-assistant-version: ['2023.12.0', '2024.1.0']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🐍 Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: 📦 Install Home Assistant ${{ matrix.home-assistant-version }}
|
||||||
|
run: |
|
||||||
|
pip install homeassistant==${{ matrix.home-assistant-version }}
|
||||||
|
pip install pytest pytest-homeassistant-custom-component
|
||||||
|
|
||||||
|
- name: 🧪 Run Integration Tests
|
||||||
|
run: |
|
||||||
|
python -m pytest tests/ -v --cov=custom_components/adguard_hub --cov-report=xml
|
55
.gitea/workflows/quality-check.yml
Normal file
55
.gitea/workflows/quality-check.yml
Normal file
@@ -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@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==2023.12.0
|
||||||
|
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
|
31
.gitea/workflows/release.yml
Normal file
31
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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 }}"
|
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Python
|
||||||
|
__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
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 AdGuard Control Hub
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
166
README.md
Normal file
166
README.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# 🛡️ AdGuard Control Hub
|
||||||
|
|
||||||
|
[](https://github.com/custom-components/hacs)
|
||||||
|
[](https://git.sq4ind.eu/sq4ind/adguard-control-hub/releases)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
**The ultimate Home Assistant integration for AdGuard Home**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
### 🎯 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
|
||||||
|
|
||||||
|
### 🛡️ 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
|
||||||
|
|
||||||
|
### 🏠 Home Assistant Ecosystem Integration
|
||||||
|
- Rich entity support: switches, sensors, binary sensors
|
||||||
|
- Beautiful and customizable Lovelace dashboard cards
|
||||||
|
- 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`
|
||||||
|
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 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
|
||||||
|
```yaml
|
||||||
|
automation:
|
||||||
|
- alias: "Kids Bedtime - Block Entertainment"
|
||||||
|
trigger:
|
||||||
|
platform: time
|
||||||
|
at: "20:00:00"
|
||||||
|
action:
|
||||||
|
service: adguard_hub.block_services
|
||||||
|
data:
|
||||||
|
client_name: "Kids iPad"
|
||||||
|
services:
|
||||||
|
- 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](LICENSE) file for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made with ❤️ and professional care, so you control your AdGuard Home network integration!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[](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)
|
134
custom_components/adguard_hub/__init__.py
Normal file
134
custom_components/adguard_hub/__init__.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
🛡️ 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.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
from .const import DOMAIN, PLATFORMS, SCAN_INTERVAL, CONF_SSL, CONF_VERIFY_SSL
|
||||||
|
from .api import AdGuardHomeAPI
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up AdGuard Control Hub from a config entry."""
|
||||||
|
session = async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True))
|
||||||
|
|
||||||
|
api = AdGuardHomeAPI(
|
||||||
|
host=entry.data[CONF_HOST],
|
||||||
|
port=entry.data[CONF_PORT],
|
||||||
|
username=entry.data.get(CONF_USERNAME),
|
||||||
|
password=entry.data.get(CONF_PASSWORD),
|
||||||
|
ssl=entry.data.get(CONF_SSL, False),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test the connection
|
||||||
|
try:
|
||||||
|
await api.test_connection()
|
||||||
|
_LOGGER.info("Successfully connected to AdGuard Home at %s:%s",
|
||||||
|
entry.data[CONF_HOST], entry.data[CONF_PORT])
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Failed to connect to AdGuard Home: %s", err)
|
||||||
|
raise ConfigEntryNotReady(f"Unable to connect: {err}")
|
||||||
|
|
||||||
|
# Create update coordinator
|
||||||
|
coordinator = AdGuardControlHubCoordinator(hass, api)
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
# Store data
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
|
"coordinator": coordinator,
|
||||||
|
"api": api,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set up platforms
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
_LOGGER.info("AdGuard Control Hub setup complete")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload AdGuard Control Hub config entry."""
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
if unload_ok:
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
class AdGuardControlHubCoordinator(DataUpdateCoordinator):
|
||||||
|
"""AdGuard Control Hub data update coordinator."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, api: AdGuardHomeAPI):
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name=f"{DOMAIN}_coordinator",
|
||||||
|
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||||
|
)
|
||||||
|
self.api = api
|
||||||
|
self._clients = {}
|
||||||
|
self._statistics = {}
|
||||||
|
self._protection_status = {}
|
||||||
|
|
||||||
|
async def _async_update_data(self):
|
||||||
|
"""Fetch data from AdGuard Home."""
|
||||||
|
try:
|
||||||
|
# Fetch all data concurrently for better performance
|
||||||
|
results = await asyncio.gather(
|
||||||
|
self.api.get_clients(),
|
||||||
|
self.api.get_statistics(),
|
||||||
|
self.api.get_status(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
clients, statistics, status = results
|
||||||
|
|
||||||
|
# Handle any exceptions
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
endpoint_names = ["clients", "statistics", "status"]
|
||||||
|
_LOGGER.warning("Error fetching %s: %s", endpoint_names[i], result)
|
||||||
|
|
||||||
|
# Update stored data (use empty dict if fetch failed)
|
||||||
|
self._clients = {
|
||||||
|
client["name"]: client
|
||||||
|
for client in (clients.get("clients", []) if not isinstance(clients, Exception) else [])
|
||||||
|
}
|
||||||
|
self._statistics = statistics if not isinstance(statistics, Exception) else {}
|
||||||
|
self._protection_status = status if not isinstance(status, Exception) else {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"clients": self._clients,
|
||||||
|
"statistics": self._statistics,
|
||||||
|
"status": self._protection_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
raise UpdateFailed(f"Error communicating with AdGuard Control Hub: {err}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clients(self):
|
||||||
|
"""Return clients data."""
|
||||||
|
return self._clients
|
||||||
|
|
||||||
|
@property
|
||||||
|
def statistics(self):
|
||||||
|
"""Return statistics data."""
|
||||||
|
return self._statistics
|
||||||
|
|
||||||
|
@property
|
||||||
|
def protection_status(self):
|
||||||
|
"""Return protection status data."""
|
||||||
|
return self._protection_status
|
142
custom_components/adguard_hub/api.py
Normal file
142
custom_components/adguard_hub/api.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""API wrapper for AdGuard Home."""
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import BasicAuth
|
||||||
|
from .const import API_ENDPOINTS
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class AdGuardHomeAPI:
|
||||||
|
"""API wrapper for AdGuard Home."""
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int = 3000, username: str = None,
|
||||||
|
password: str = None, ssl: bool = False, session = None):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.ssl = ssl
|
||||||
|
self.session = session
|
||||||
|
protocol = "https" if ssl else "http"
|
||||||
|
self.base_url = f"{protocol}://{host}:{port}"
|
||||||
|
|
||||||
|
async def _request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
||||||
|
"""Make an API request."""
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
auth = None
|
||||||
|
if self.username and self.password:
|
||||||
|
auth = BasicAuth(self.username, self.password)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self.session.request(method, url, json=data, headers=headers, auth=auth) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.status == 204 or not response.content_length:
|
||||||
|
return {}
|
||||||
|
return await response.json()
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error communicating with AdGuard Home: %s", err)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def test_connection(self) -> bool:
|
||||||
|
"""Test the connection."""
|
||||||
|
try:
|
||||||
|
await self._request("GET", API_ENDPOINTS["status"])
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_status(self) -> dict:
|
||||||
|
"""Get server status."""
|
||||||
|
return await self._request("GET", API_ENDPOINTS["status"])
|
||||||
|
|
||||||
|
async def get_clients(self) -> dict:
|
||||||
|
"""Get all clients."""
|
||||||
|
return await self._request("GET", API_ENDPOINTS["clients"])
|
||||||
|
|
||||||
|
async def get_statistics(self) -> dict:
|
||||||
|
"""Get statistics."""
|
||||||
|
return await self._request("GET", API_ENDPOINTS["stats"])
|
||||||
|
|
||||||
|
async def set_protection(self, enabled: bool) -> dict:
|
||||||
|
"""Enable or disable protection."""
|
||||||
|
data = {"enabled": enabled}
|
||||||
|
return await self._request("POST", API_ENDPOINTS["protection"], data)
|
||||||
|
|
||||||
|
async def add_client(self, client_data: dict) -> dict:
|
||||||
|
"""Add a new client."""
|
||||||
|
return await self._request("POST", API_ENDPOINTS["clients_add"], client_data)
|
||||||
|
|
||||||
|
async def update_client(self, client_data: dict) -> dict:
|
||||||
|
"""Update an existing client."""
|
||||||
|
return await self._request("POST", API_ENDPOINTS["clients_update"], client_data)
|
||||||
|
|
||||||
|
async def delete_client(self, client_name: str) -> dict:
|
||||||
|
"""Delete a client."""
|
||||||
|
data = {"name": client_name}
|
||||||
|
return await self._request("POST", API_ENDPOINTS["clients_delete"], data)
|
||||||
|
|
||||||
|
async def get_client_by_name(self, client_name: str) -> dict:
|
||||||
|
"""Get a specific client by name."""
|
||||||
|
clients_data = await self.get_clients()
|
||||||
|
clients = clients_data.get("clients", [])
|
||||||
|
|
||||||
|
for client in clients:
|
||||||
|
if client.get("name") == client_name:
|
||||||
|
return client
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_client_blocked_services(self, client_name: str, blocked_services: list, schedule: dict = None) -> dict:
|
||||||
|
"""Update blocked services for a specific client."""
|
||||||
|
client = await self.get_client_by_name(client_name)
|
||||||
|
if not client:
|
||||||
|
raise ValueError(f"Client '{client_name}' not found")
|
||||||
|
|
||||||
|
# Prepare the blocked services data
|
||||||
|
if schedule:
|
||||||
|
blocked_services_data = {
|
||||||
|
"ids": blocked_services,
|
||||||
|
"schedule": schedule
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
blocked_services_data = {
|
||||||
|
"ids": blocked_services,
|
||||||
|
"schedule": {
|
||||||
|
"time_zone": "Local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update the client
|
||||||
|
update_data = {
|
||||||
|
"name": client_name,
|
||||||
|
"data": {
|
||||||
|
**client,
|
||||||
|
"blocked_services": blocked_services_data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.update_client(update_data)
|
||||||
|
|
||||||
|
async def toggle_client_service(self, client_name: str, service_id: str, enabled: bool) -> dict:
|
||||||
|
"""Toggle a specific service for a client."""
|
||||||
|
client = await self.get_client_by_name(client_name)
|
||||||
|
if not client:
|
||||||
|
raise ValueError(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 old format (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)
|
86
custom_components/adguard_hub/config_flow.py
Normal file
86
custom_components/adguard_hub/config_flow.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Config flow for AdGuard Control Hub integration."""
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from .api import AdGuardHomeAPI
|
||||||
|
from .const import CONF_SSL, CONF_VERIFY_SSL, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema({
|
||||||
|
vol.Required(CONF_HOST): str,
|
||||||
|
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
|
||||||
|
vol.Optional(CONF_USERNAME): str,
|
||||||
|
vol.Optional(CONF_PASSWORD): str,
|
||||||
|
vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
|
||||||
|
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def validate_input(hass, data: dict) -> dict:
|
||||||
|
"""Validate the user input allows us to connect."""
|
||||||
|
session = async_get_clientsession(hass, data.get(CONF_VERIFY_SSL, True))
|
||||||
|
|
||||||
|
api = AdGuardHomeAPI(
|
||||||
|
host=data[CONF_HOST],
|
||||||
|
port=data[CONF_PORT],
|
||||||
|
username=data.get(CONF_USERNAME),
|
||||||
|
password=data.get(CONF_PASSWORD),
|
||||||
|
ssl=data.get(CONF_SSL, False),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test the connection
|
||||||
|
if not await api.test_connection():
|
||||||
|
raise CannotConnect
|
||||||
|
|
||||||
|
# Get server info
|
||||||
|
try:
|
||||||
|
status = await api.get_status()
|
||||||
|
version = status.get("version", "unknown")
|
||||||
|
return {
|
||||||
|
"title": f"AdGuard Control Hub ({data[CONF_HOST]})",
|
||||||
|
"version": version
|
||||||
|
}
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.exception("Unexpected exception: %s", err)
|
||||||
|
raise CannotConnect from err
|
||||||
|
|
||||||
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for AdGuard Control Hub."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
try:
|
||||||
|
info = await validate_input(self.hass, user_input)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
# Create unique ID based on host and port
|
||||||
|
unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
|
||||||
|
await self.async_set_unique_id(unique_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=info["title"],
|
||||||
|
data=user_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
class CannotConnect(Exception):
|
||||||
|
"""Error to indicate we cannot connect."""
|
92
custom_components/adguard_hub/const.py
Normal file
92
custom_components/adguard_hub/const.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Constants for the AdGuard Control Hub integration."""
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
DOMAIN: Final = "adguard_hub"
|
||||||
|
MANUFACTURER: Final = "AdGuard Control Hub"
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONF_SSL: Final = "ssl"
|
||||||
|
CONF_VERIFY_SSL: Final = "verify_ssl"
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
DEFAULT_PORT: Final = 3000
|
||||||
|
DEFAULT_SSL: Final = False
|
||||||
|
DEFAULT_VERIFY_SSL: Final = True
|
||||||
|
SCAN_INTERVAL: Final = 30
|
||||||
|
|
||||||
|
# Platforms
|
||||||
|
PLATFORMS: Final = [
|
||||||
|
"switch",
|
||||||
|
"binary_sensor",
|
||||||
|
"sensor",
|
||||||
|
]
|
||||||
|
|
||||||
|
# API Endpoints
|
||||||
|
API_ENDPOINTS: Final = {
|
||||||
|
"status": "/control/status",
|
||||||
|
"clients": "/control/clients",
|
||||||
|
"clients_add": "/control/clients/add",
|
||||||
|
"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
|
||||||
|
BLOCKED_SERVICES: Final = {
|
||||||
|
# Social Media
|
||||||
|
"youtube": "YouTube",
|
||||||
|
"facebook": "Facebook",
|
||||||
|
"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
|
||||||
|
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"
|
15
custom_components/adguard_hub/manifest.json
Normal file
15
custom_components/adguard_hub/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"domain": "adguard_hub",
|
||||||
|
"name": "AdGuard Control Hub",
|
||||||
|
"codeowners": ["@your-gitea-username"],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": [],
|
||||||
|
"documentation": "https://your-gitea-domain.com/your-username/adguard-control-hub",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"issue_tracker": "https://your-gitea-domain.com/your-username/adguard-control-hub/issues",
|
||||||
|
"requirements": [
|
||||||
|
"aiohttp>=3.8.0"
|
||||||
|
],
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
38
custom_components/adguard_hub/services.py
Normal file
38
custom_components/adguard_hub/services.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Services for AdGuard Control Hub integration."""
|
||||||
|
import logging
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from .api import AdGuardHomeAPI
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def async_register_services(hass: HomeAssistant, api: AdGuardHomeAPI) -> None:
|
||||||
|
"""Register integration services."""
|
||||||
|
|
||||||
|
async def emergency_unblock_service(call):
|
||||||
|
"""Emergency unblock service."""
|
||||||
|
duration = call.data.get("duration", 300)
|
||||||
|
clients = call.data.get("clients", ["all"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "all" in clients:
|
||||||
|
await api.set_protection(False)
|
||||||
|
_LOGGER.info("Emergency unblock activated globally for %d seconds", duration)
|
||||||
|
else:
|
||||||
|
_LOGGER.info("Emergency unblock activated for clients: %s", clients)
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Failed to execute emergency unblock: %s", err)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Register emergency unblock service
|
||||||
|
hass.services.async_register(
|
||||||
|
"adguard_hub",
|
||||||
|
"emergency_unblock",
|
||||||
|
emergency_unblock_service
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.info("AdGuard Control Hub services registered")
|
||||||
|
|
||||||
|
async def async_unregister_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Unregister integration services."""
|
||||||
|
hass.services.async_remove("adguard_hub", "emergency_unblock")
|
||||||
|
_LOGGER.info("AdGuard Control Hub services unregistered")
|
27
custom_components/adguard_hub/strings.json
Normal file
27
custom_components/adguard_hub/strings.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "AdGuard Control Hub",
|
||||||
|
"description": "Connect to your AdGuard Home instance for complete network control",
|
||||||
|
"data": {
|
||||||
|
"host": "AdGuard Home IP Address",
|
||||||
|
"port": "Port (usually 3000)",
|
||||||
|
"username": "Admin Username",
|
||||||
|
"password": "Admin Password",
|
||||||
|
"ssl": "Use HTTPS connection",
|
||||||
|
"verify_ssl": "Verify SSL certificate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect to AdGuard Home. Check IP address, port, and credentials.",
|
||||||
|
"invalid_auth": "Invalid username or password. Please check your admin credentials.",
|
||||||
|
"unknown": "Unexpected error occurred. Please check logs for details."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "This AdGuard Home instance is already configured",
|
||||||
|
"cannot_connect": "Cannot connect to AdGuard Home"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
89
custom_components/adguard_hub/switch.py
Normal file
89
custom_components/adguard_hub/switch.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Switch platform for AdGuard Control Hub integration."""
|
||||||
|
import logging
|
||||||
|
from homeassistant.components.switch import SwitchEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
from . import AdGuardControlHubCoordinator
|
||||||
|
from .api import AdGuardHomeAPI
|
||||||
|
from .const import DOMAIN, ICON_PROTECTION, ICON_PROTECTION_OFF, ICON_CLIENT, MANUFACTURER
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||||
|
"""Set up AdGuard Control Hub switch platform."""
|
||||||
|
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))
|
||||||
|
|
||||||
|
# Add client switches
|
||||||
|
for client_name in coordinator.clients.keys():
|
||||||
|
entities.append(AdGuardClientSwitch(coordinator, api, client_name))
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
class AdGuardBaseSwitch(CoordinatorEntity, SwitchEntity):
|
||||||
|
"""Base class for AdGuard switches."""
|
||||||
|
|
||||||
|
def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI):
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.api = api
|
||||||
|
self._attr_device_info = {
|
||||||
|
"identifiers": {(DOMAIN, f"{api.host}:{api.port}")},
|
||||||
|
"name": f"AdGuard Control Hub ({api.host})",
|
||||||
|
"manufacturer": MANUFACTURER,
|
||||||
|
"model": "AdGuard Home",
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdGuardProtectionSwitch(AdGuardBaseSwitch):
|
||||||
|
"""Switch to control global AdGuard protection."""
|
||||||
|
|
||||||
|
def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI):
|
||||||
|
super().__init__(coordinator, api)
|
||||||
|
self._attr_unique_id = f"{api.host}_{api.port}_protection"
|
||||||
|
self._attr_name = "AdGuard Protection"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return self.coordinator.protection_status.get("protection_enabled", False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str:
|
||||||
|
return ICON_PROTECTION if self.is_on else ICON_PROTECTION_OFF
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs):
|
||||||
|
await self.api.set_protection(True)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs):
|
||||||
|
await self.api.set_protection(False)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
class AdGuardClientSwitch(AdGuardBaseSwitch):
|
||||||
|
"""Switch to control client-specific protection."""
|
||||||
|
|
||||||
|
def __init__(self, coordinator: AdGuardControlHubCoordinator, api: AdGuardHomeAPI, client_name: str):
|
||||||
|
super().__init__(coordinator, api)
|
||||||
|
self.client_name = client_name
|
||||||
|
self._attr_unique_id = f"{api.host}_{api.port}_client_{client_name}"
|
||||||
|
self._attr_name = f"AdGuard {client_name}"
|
||||||
|
self._attr_icon = ICON_CLIENT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
client = self.coordinator.clients.get(self.client_name, {})
|
||||||
|
return client.get("filtering_enabled", True)
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs):
|
||||||
|
# This would update client settings - simplified for basic functionality
|
||||||
|
_LOGGER.info("Would enable protection for %s", self.client_name)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs):
|
||||||
|
# This would update client settings - simplified for basic functionality
|
||||||
|
_LOGGER.info("Would disable protection for %s", self.client_name)
|
||||||
|
await self.coordinator.async_request_refresh()
|
9
hacs.json
Normal file
9
hacs.json
Normal file
@@ -0,0 +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"
|
||||||
|
}
|
63
info.md
Normal file
63
info.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 🛡️ AdGuard Control Hub
|
||||||
|
|
||||||
|
**Transform your AdGuard Home into a smart network management powerhouse!**
|
||||||
|
|
||||||
|
## 🎯 What Makes This Special?
|
||||||
|
|
||||||
|
Unlike the basic AdGuard integration, **AdGuard Control Hub** gives you complete control over every aspect of your network filtering:
|
||||||
|
|
||||||
|
### 🏠 **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! 🚀**
|
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[tool.black]
|
||||||
|
line-length = 127
|
||||||
|
target-version = ['py311']
|
||||||
|
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.11"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
ignore_missing_imports = true
|
13
requirements-dev.txt
Normal file
13
requirements-dev.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Development dependencies
|
||||||
|
black==23.12.0
|
||||||
|
flake8==6.1.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
|
||||||
|
|
||||||
|
# Home Assistant testing
|
||||||
|
homeassistant==2023.12.0
|
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for AdGuard Control Hub."""
|
40
tests/test_api.py
Normal file
40
tests/test_api.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Test API functionality."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from custom_components.adguard_hub.api import AdGuardHomeAPI
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_session():
|
||||||
|
"""Mock aiohttp session."""
|
||||||
|
session = MagicMock()
|
||||||
|
response = MagicMock()
|
||||||
|
response.raise_for_status = MagicMock()
|
||||||
|
response.json = AsyncMock(return_value={"status": "ok"})
|
||||||
|
response.status = 200
|
||||||
|
response.content_length = 100
|
||||||
|
session.request = AsyncMock(return_value=response)
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def test_api_connection(mock_session):
|
||||||
|
"""Test API connection."""
|
||||||
|
api = AdGuardHomeAPI(
|
||||||
|
host="test-host",
|
||||||
|
port=3000,
|
||||||
|
username="admin",
|
||||||
|
password="password",
|
||||||
|
session=mock_session
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await api.test_connection()
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
async def test_api_get_status(mock_session):
|
||||||
|
"""Test getting status."""
|
||||||
|
api = AdGuardHomeAPI(
|
||||||
|
host="test-host",
|
||||||
|
port=3000,
|
||||||
|
session=mock_session
|
||||||
|
)
|
||||||
|
|
||||||
|
status = await api.get_status()
|
||||||
|
assert status == {"status": "ok"}
|
29
tests/test_config_flow.py
Normal file
29
tests/test_config_flow.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Test config flow for AdGuard Control Hub."""
|
||||||
|
import pytest
|
||||||
|
from homeassistant import config_entries, setup
|
||||||
|
from custom_components.adguard_hub.const import DOMAIN
|
||||||
|
|
||||||
|
async def test_config_flow_success(hass):
|
||||||
|
"""Test successful config flow."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
|
||||||
|
async def test_config_flow_cannot_connect(hass):
|
||||||
|
"""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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"]["base"] == "cannot_connect"
|
Reference in New Issue
Block a user