From 0f8d75db11b0193ce266ed03eef597d654755617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Hansl=C3=ADk?= Date: Sat, 1 Mar 2025 14:04:30 +0100 Subject: [PATCH 01/10] Implemented groups switches --- custom_components/pi_hole_v6/__init__.py | 3 + custom_components/pi_hole_v6/api.py | 86 ++++++++++++++ custom_components/pi_hole_v6/strings.json | 5 + custom_components/pi_hole_v6/switch.py | 107 +++++++++++++++--- .../pi_hole_v6/translations/en.json | 9 +- 5 files changed, 195 insertions(+), 15 deletions(-) diff --git a/custom_components/pi_hole_v6/__init__.py b/custom_components/pi_hole_v6/__init__.py index 3ff0410..f66afc9 100644 --- a/custom_components/pi_hole_v6/__init__.py +++ b/custom_components/pi_hole_v6/__init__.py @@ -97,6 +97,9 @@ async def async_update_data() -> None: if not isinstance(await api_client.call_padd(), dict): raise ConfigEntryAuthFailed + if not isinstance(await api_client.call_get_groups(), dict): + raise ConfigEntryAuthFailed + coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/custom_components/pi_hole_v6/api.py b/custom_components/pi_hole_v6/api.py index 24de828..e8c6b31 100644 --- a/custom_components/pi_hole_v6/api.py +++ b/custom_components/pi_hole_v6/api.py @@ -29,6 +29,7 @@ class API: cache_blocking: dict[str, Any] = {} cache_padd: dict[str, Any] = {} cache_summary: dict[str, Any] = {} + cache_groups: dict[str, dict[str, Any]] = {} url: str = "" @@ -108,6 +109,8 @@ async def _call( async with asyncio.timeout(600): if method.lower() == "post": request = await self._session.post(url, json=data, headers=headers) + elif method.lower() == "put": + request = await self._session.put(url, json=data, headers=headers) elif method.lower() == "delete": request = await self._session.delete(url, headers=headers) elif method.lower() == "get": @@ -369,3 +372,86 @@ async def call_blocking_disabled( "reason": result["reason"], "data": result["data"], } + + async def call_get_groups(self) -> dict[str, Any]: + """Retrieve the list of Pi-hole groups. + + Returns: + result (dict[str, Any]): A dictionary with the keys "code", "reason", and "data". + + """ + + url: str = "/groups" + + result: dict[str, Any] = await self._call( + url, + action="groups", + method="GET", + ) + + for group in result["data"]["groups"]: + self.cache_groups[group["name"]] = { + "name": group["name"], + "comment": group["comment"], + "enabled": group["enabled"], + } + + return { + "code": result["code"], + "reason": result["reason"], + "data": result["data"], + } + + async def call_group_disable(self, group: str) -> dict[str, Any]: + """Disable Pi-hole group. + + Returns: + result (dict[str, Any]): A dictionary with the keys "code", "reason", and "data". + + """ + + url: str = f"/groups/{group}" + + result: dict[str, Any] = await self._call( + url, + action="group-disable", + method="PUT", + data={ + "name": group, + "comment": self.cache_groups[group]["comment"], + "enabled": False, + }, + ) + + return { + "code": result["code"], + "reason": result["reason"], + "data": result["data"], + } + + async def call_group_enable(self, group: str) -> dict[str, Any]: + """Enable Pi-hole group. + + Returns: + result (dict[str, Any]): A dictionary with the keys "code", "reason", and "data". + + """ + + url: str = f"/groups/{group}" + + result: dict[str, Any] = await self._call( + url, + action="group-disable", + method="PUT", + data={ + "name": group, + "comment": self.cache_groups[group]["comment"], + "enabled": True, + }, + ) + + return { + "code": result["code"], + "reason": result["reason"], + "data": result["data"], + } diff --git a/custom_components/pi_hole_v6/strings.json b/custom_components/pi_hole_v6/strings.json index 51275ba..efb7cb8 100644 --- a/custom_components/pi_hole_v6/strings.json +++ b/custom_components/pi_hole_v6/strings.json @@ -71,6 +71,11 @@ "unit_of_measurement": "[%key:component::pi_hole_v6::entity::sensor::domains_being_blocked::unit_of_measurement%]" } }, + "switch": { + "group": { + "name": "Group {groupName}" + } + }, "update": { "core_update_available": { "name": "Core update available" diff --git a/custom_components/pi_hole_v6/switch.py b/custom_components/pi_hole_v6/switch.py index 33ddf09..41c4505 100644 --- a/custom_components/pi_hole_v6/switch.py +++ b/custom_components/pi_hole_v6/switch.py @@ -12,8 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleV6ConfigEntry +from .api import API as PiholeAPI from .const import SERVICE_DISABLE, SERVICE_DISABLE_ATTR_DURATION, SERVICE_ENABLE from .entity import PiHoleV6Entity from .exceptions import ( @@ -33,9 +35,9 @@ async def async_setup_entry( - hass: HomeAssistant, - entry: PiHoleV6ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, + hass: HomeAssistant, + entry: PiHoleV6ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole V6 switch.""" name = entry.data[CONF_NAME] @@ -48,6 +50,17 @@ async def async_setup_entry( entry.entry_id, ) ] + + for group in hole_data.api.cache_groups: + switches.append( + PiHoleV6Group( + hole_data.api, + hole_data.coordinator, + entry.entry_id, + group, + ) + ) + async_add_entities(switches, True) # register service @@ -110,16 +123,16 @@ async def async_turn(self, action: str, duration: Any = None) -> None: self.schedule_update_ha_state(force_refresh=True) except ( - BadRequestException, - UnauthorizedException, - RequestFailedException, - ForbiddenException, - NotFoundException, - TooManyRequestsException, - ServerErrorException, - BadGatewayException, - ServiceUnavailableException, - GatewayTimeoutException, + BadRequestException, + UnauthorizedException, + RequestFailedException, + ForbiddenException, + NotFoundException, + TooManyRequestsException, + ServerErrorException, + BadGatewayException, + ServiceUnavailableException, + GatewayTimeoutException, ) as err: _LOGGER.error("Unable to %s Pi-hole V6: %s", action, err) @@ -141,3 +154,71 @@ async def async_service_enable(self) -> None: _LOGGER.debug("Enabling Pi-hole '%s'", self.name) await self.async_turn(action="enable") + + +class PiHoleV6Group(PiHoleV6Entity, SwitchEntity): + """Representation of a Pi-hole V6 group.""" + + _attr_icon = "mdi:account-multiple" + _attr_has_entity_name = True + _attr_translation_key = "group" + + def __init__( + self, + api: PiholeAPI, + coordinator: DataUpdateCoordinator, + server_unique_id: str, + group: str, + ) -> None: + super().__init__(api, coordinator, f"Group {group}", server_unique_id) + + self._group = group + + self._attr_translation_placeholders = { + "groupName": group, + } + + @property + def unique_id(self) -> str: + """Return the unique id of the group.""" + return f"{self._server_unique_id}/Group/{self._group}" + + @property + def is_on(self) -> bool: + """Return if the group is on.""" + return self.api.cache_groups[self._group]["enabled"] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the group.""" + await self.async_turn(action="enable") + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the group.""" + await self.async_turn(action="disable") + + async def async_turn(self, action: str) -> None: + """Turn on/off the group.""" + + try: + if action == "enable": + await self.api.call_group_enable(self._group) + + if action == "disable": + await self.api.call_group_disable(self._group) + + await self.async_update() + self.schedule_update_ha_state(force_refresh=True) + + except ( + BadRequestException, + UnauthorizedException, + RequestFailedException, + ForbiddenException, + NotFoundException, + TooManyRequestsException, + ServerErrorException, + BadGatewayException, + ServiceUnavailableException, + GatewayTimeoutException, + ) as err: + _LOGGER.error("Unable to %s Pi-hole V6 group %s: %s", action, self._group, err) diff --git a/custom_components/pi_hole_v6/translations/en.json b/custom_components/pi_hole_v6/translations/en.json index edb134b..fe78aea 100644 --- a/custom_components/pi_hole_v6/translations/en.json +++ b/custom_components/pi_hole_v6/translations/en.json @@ -67,7 +67,12 @@ "unit_of_measurement": "domains" } }, - "update": { + "switch": { + "group": { + "name": "Group {groupName}" + } + }, + "update": { "core_update_available": { "name": "Core update available" }, @@ -95,4 +100,4 @@ "description": "Enables configured Pi-hole(s)." } } -} \ No newline at end of file +} From c41ee85728e6cfdd197e7575d653f9c02113dbd8 Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sat, 1 Mar 2025 23:00:18 +0000 Subject: [PATCH 02/10] Add local development environment --- .devcontainer/devcontainer.json | 94 +++++++++++++++++------ .gitattributes | 1 + .gitignore | 18 +++++ .vscode/launch.json | 17 +++++ .vscode/settings.json | 6 ++ .vscode/tasks.json | 27 +++++++ pyproject.toml | 131 ++++++++++++++++++++++++++++++++ requirements.txt | 2 + 8 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 10a93aa..3d963f2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,26 +1,70 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/python { - "name": "HA Pi-hole V6", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", - - "mounts": [ - "type=bind,source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/vscode/.ssh,readonly" - ] - - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "pip3 install --user -r requirements.txt", - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} + "name": "Pi-hole V6 Integration", + "image": "mcr.microsoft.com/devcontainers/python:dev-3.13", + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "installDirectlyFromGitHubRelease": true, + "version": "latest" + }, + "ghcr.io/devcontainers/features/node:1": { + "nodeGypDependencies": true, + "version": "lts" + }, + "ghcr.io/devcontainers-contrib/features/poetry:2": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/rust:1": {}, + "ghcr.io/devcontainers-extra/features/apt-packages:1": { + "packages": [ + "ffmpeg", + "libturbojpeg0", + "libpcap-dev" + ] + } + }, + "postCreateCommand": "scripts/setup", + "runArgs": [ + "--network=host" + ], + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "ms-python.black-formatter" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "[markdown]": { + "files.trimTrailingWhitespace": false + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + } + } + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8111935 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# artifacts +__pycache__ +.pytest* +*.egg-info +*/build/* +*/dist/* + + +# misc +.coverage +coverage.xml + + +# Home Assistant configuration +config/* +!config/configuration.yaml +.DS_Store +config/configuration.yaml \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6617122 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Home Assistant", + "type": "debugpy", + "request": "launch", + "module": "homeassistant", + "justMyCode": false, + "args": [ + "--debug", + "-c", + "config" + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d99f2f3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "python.formatting.provider": "none" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..13207fd --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,27 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 8123", + "type": "shell", + "command": "scripts/develop", + "problemMatcher": [] + } + ], + "configurations": [ + { + // Example of attaching to local debug server + "name": "Python: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ], + } + ] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0b7d405 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,131 @@ +[tool.poetry] +authors = ["Bastien Gautier "] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3", +] +description = "This custom integration restores compatibility between Home Assistant and Pi-Hole, which is no longer supported by the native integration due to API changes. " +documentation = "https://github.com/bastgau/ha-pi-hole-v6" +homepage = "https://github.com/bastgau/ha-pi-hole-v6" +license = "MIT" +maintainers = ["Bastien Gautier "] +name = "Pi-hole V6 for Home Assistant" +packages = [] +readme = "README.md" +repository = "https://github.com/bastgau/ha-pi-hole-v6" +version = "0.0.0" + +[tool.poetry.dependencies] +homeassistant = "2025.3.0b2" +python = ">=3.13,<3.14" + +[tool.poetry.group.dev.dependencies] +pre-commit = "4.1.0" +pre-commit-hooks = "5.0.0" +pylint = "3.3.4" +ruff = "0.9.7" + +[tool.poetry.urls] +"Bug Tracker" = "https://github.com/bastgau/ha-pi-hole-v6/issues" +Changelog = "https://github.com/bastgau/ha-pi-hole-v6/releases" + +[build-system] +build-backend = "poetry.core.masonry.api" +requires = ["poetry-core"] + +[tool.lint.ruff] +ignore = [ + "ANN101", # Self... explanatory + "ANN401", # Opiniated warning on disallowing dynamically typed expressions + "D203", # Conflicts with other rules + "D213", # Conflicts with other rules + "TID252", # Relative imports + "RUF012", # Just broken + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def + + + # Formatter conflicts + "COM812", + "COM819", + "D206", + "E501", + "ISC001", + "Q000", + "Q001", + "Q002", + "Q003", + "W191", +] +select = ["ALL"] +src = ["custom_components/pi_hole_v6"] + +[tool.link.flake8-import-conventions.extend-aliases] +"homeassistant.helpers.area_registry" = "ar" +"homeassistant.helpers.config_validation" = "cv" +"homeassistant.helpers.device_registry" = "dr" +"homeassistant.helpers.entity_registry" = "er" +"homeassistant.helpers.issue_registry" = "ir" +voluptuous = "vol" + +[tool.lint.isort] +force-sort-within-sections = true +known-first-party = ["homeassistant"] +combine-as-imports = true + +[tool.pylint."MESSAGES CONTROL"] +# Reasons disabled: +# format - handled by ruff +# duplicate-code - unavoidable +# used-before-assignment - false positives with TYPE_CHECKING structures +disable = [ + "abstract-method", + "duplicate-code", + "format", + "unexpected-keyword-arg", + "used-before-assignment", +] + +[tool.mypy] +# Specify the target platform details in config, so your developers are +# free to run mypy on Windows, Linux, or macOS and get consistent +# results. +platform = "linux" +python_version = "3.13" + +# show error messages from unrelated files +follow_imports = "normal" + +# suppress errors about unsatisfied imports +ignore_missing_imports = true + +# be strict +# check_untyped_defs = true +# disallow_any_generics = true +# disallow_incomplete_defs = true +# disallow_subclassing_any = true +# disallow_untyped_calls = true +# disallow_untyped_decorators = true +# disallow_untyped_defs = true +# no_implicit_optional = true +# strict_optional = true +# warn_incomplete_stub = true +# warn_no_return = true +# warn_redundant_casts = true +# warn_return_any = true +# warn_unused_configs = true +# warn_unused_ignores = true + +[tool.ruff.lint.mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..138dc94 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +homeassistant==2025.3.0b2 +ruff==0.9.8 \ No newline at end of file From e4486e6aeb1783f54d855130a8e6f33bca8a538a Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sat, 1 Mar 2025 23:00:59 +0000 Subject: [PATCH 03/10] Improve CI/CD --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ hacs.json | 5 ++++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..98e73fe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: "Release" + +on: + release: + types: + - "published" + +permissions: {} + +jobs: + release: + name: "Release" + runs-on: "ubuntu-latest" + permissions: + contents: write + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4" + + - name: "Adjust version number" + shell: "bash" + run: | + yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ + "${{ github.workspace }}/custom_components/pi_hole_v6/manifest.json" + + - name: "ZIP the integration directory" + shell: "bash" + run: | + cd "${{ github.workspace }}/custom_components/pi_hole_v6" + zip pi_hole_v6.zip -r ./ + + - name: "Upload the ZIP file to the release" + uses: softprops/action-gh-release@v2.2.1 + with: + files: ${{ github.workspace }}/custom_components/pi_hole_v6/pi_hole_v6.zip \ No newline at end of file diff --git a/hacs.json b/hacs.json index 845cb00..c94eae0 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,10 @@ { "name": "Pi-hole V6 Integration", + "filename": "pi_hole_v6.zip", + "hide_default_branch": true, "hacs": "2.0.0", "country": "FR", "homeassistant": "2025.2.9", + "zip_release": true, "render_readme": true - } +} \ No newline at end of file From ecc94c3cd685267437eaffa6651e42b7f2cf9140 Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sat, 1 Mar 2025 23:01:23 +0000 Subject: [PATCH 04/10] Add script helpers --- scripts/develop | 16 ++++++++++++++++ scripts/lint | 7 +++++++ scripts/setup | 7 +++++++ 3 files changed, 30 insertions(+) create mode 100755 scripts/develop create mode 100755 scripts/lint create mode 100755 scripts/setup diff --git a/scripts/develop b/scripts/develop new file mode 100755 index 0000000..fabe19c --- /dev/null +++ b/scripts/develop @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug \ No newline at end of file diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..752d23a --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix \ No newline at end of file diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 0000000..abe537a --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt \ No newline at end of file From 1ea1b821cc62a86ba67d5aad19b3f55054c239ae Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sun, 2 Mar 2025 01:02:58 +0000 Subject: [PATCH 05/10] Update working environment --- .vscode/settings.json | 2 +- pyproject.toml | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index d99f2f3..4d61943 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" + "_editor.defaultFormatter": "ms-python.black-formatter" }, "python.formatting.provider": "none" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0b7d405..d43c6e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,4 +128,7 @@ ignore_missing_imports = true # warn_unused_ignores = true [tool.ruff.lint.mccabe] -max-complexity = 25 \ No newline at end of file +max-complexity = 25 + +[tool.ruff] +target-version = "py313" From 96fe74d8713313954199908945d3e3e9e0ed99e9 Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sun, 2 Mar 2025 01:05:30 +0000 Subject: [PATCH 06/10] Remove useless file --- custom_components/pi_hole_v6/.DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 custom_components/pi_hole_v6/.DS_Store diff --git a/custom_components/pi_hole_v6/.DS_Store b/custom_components/pi_hole_v6/.DS_Store deleted file mode 100644 index fcb4883ba8f38bf74b01c1fa2a83633c8185bf8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKK~4iP478z#74*;}$9$nb2-Wg}egH}(K-#iZfg@7HjSukzj=YH(JD^sqD!~CE zWJ}37j-B<+YU4yiboQ{EicCbLK_e=a0ik(t>YzH0fNE>pNhcR_FE`Q;Ec6#m%zccE zH^}=|w)4lRyPD1~`_927-ZL;FAIF4+)K!D;7uFI?&k_0LWvs33NM~0uv3uT(LO9 z0#OeIdZ^YbhI%;csru!L#nHn>?a5fDKKbMFqV;5~$=pS=WAwp5FtE$Op${jl|7ZA> z1~2)$Au$RDf`Na=fR5UEJHt!W+WO`7SZfp7IU0q2g$e|^m13ZkVhp&CoY Date: Sun, 2 Mar 2025 01:06:31 +0000 Subject: [PATCH 07/10] Add sensor entity and update entity --- custom_components/pi_hole_v6/__init__.py | 5 +- custom_components/pi_hole_v6/api.py | 4 +- custom_components/pi_hole_v6/icons.json | 81 ++++++------ custom_components/pi_hole_v6/sensor.py | 7 + .../pi_hole_v6/translations/en.json | 6 +- custom_components/pi_hole_v6/update.py | 122 ++++++++++++++++++ 6 files changed, 181 insertions(+), 44 deletions(-) create mode 100644 custom_components/pi_hole_v6/update.py diff --git a/custom_components/pi_hole_v6/__init__.py b/custom_components/pi_hole_v6/__init__.py index 3ff0410..744b408 100644 --- a/custom_components/pi_hole_v6/__init__.py +++ b/custom_components/pi_hole_v6/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass import logging +from dataclasses import dataclass from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, Platform @@ -23,7 +23,7 @@ Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, - # Platform.UPDATE, + Platform.UPDATE, ] type PiHoleV6ConfigEntry = ConfigEntry[PiHoleV6Data] @@ -59,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleV6ConfigEntry) -> "DNS Queries Forwarded": "queries_forwarded", "DNS Unique Clients": "unique_clients", "DNS Unique Domains": "unique_domains", + "Remaining until blocking mode": "timer", } @callback diff --git a/custom_components/pi_hole_v6/api.py b/custom_components/pi_hole_v6/api.py index 24de828..5f25f51 100644 --- a/custom_components/pi_hole_v6/api.py +++ b/custom_components/pi_hole_v6/api.py @@ -7,8 +7,8 @@ from socket import gaierror as GaiError from typing import Any -from aiohttp import ClientError, ContentTypeError import requests +from aiohttp import ClientError, ContentTypeError from .exceptions import ( ClientConnectorException, @@ -105,7 +105,7 @@ async def _call( request: requests.Response try: - async with asyncio.timeout(600): + async with asyncio.timeout(60): if method.lower() == "post": request = await self._session.post(url, json=data, headers=headers) elif method.lower() == "delete": diff --git a/custom_components/pi_hole_v6/icons.json b/custom_components/pi_hole_v6/icons.json index c72cd89..1f7e61e 100644 --- a/custom_components/pi_hole_v6/icons.json +++ b/custom_components/pi_hole_v6/icons.json @@ -1,46 +1,49 @@ { - "entity": { - "binary_sensor": { - "status": { - "default": "mdi:pi-hole" - } - }, - "sensor": { - "ads_blocked_today": { - "default": "mdi:close-octagon-outline" - }, - "ads_percentage_today": { - "default": "mdi:close-octagon-outline" - }, - "clients_ever_seen": { - "default": "mdi:account-outline" - }, - "dns_queries_today": { - "default": "mdi:comment-question-outline" - }, - "domains_being_blocked": { - "default": "mdi:block-helper" - }, - "queries_cached": { - "default": "mdi:comment-question-outline" - }, - "queries_forwarded": { - "default": "mdi:comment-question-outline" - }, - "unique_clients": { - "default": "mdi:account-outline" - }, - "unique_domains": { - "default": "mdi:domain" - } + "entity": { + "binary_sensor": { + "status": { + "default": "mdi:pi-hole" } }, - "services": { - "disable": { - "service": "mdi:advertisements" + "sensor": { + "ads_blocked_today": { + "default": "mdi:close-octagon-outline" }, - "enable": { - "service": "mdi:advertisements-off" + "ads_percentage_today": { + "default": "mdi:close-octagon-outline" + }, + "clients_ever_seen": { + "default": "mdi:account-outline" + }, + "dns_queries_today": { + "default": "mdi:comment-question-outline" + }, + "domains_being_blocked": { + "default": "mdi:block-helper" + }, + "queries_cached": { + "default": "mdi:comment-question-outline" + }, + "queries_forwarded": { + "default": "mdi:comment-question-outline" + }, + "unique_clients": { + "default": "mdi:account-outline" + }, + "unique_domains": { + "default": "mdi:domain" + }, + "timer": { + "default": "mdi:timer-outline" } } + }, + "services": { + "disable": { + "service": "mdi:advertisements" + }, + "enable": { + "service": "mdi:advertisements-off" + } } +} \ No newline at end of file diff --git a/custom_components/pi_hole_v6/sensor.py b/custom_components/pi_hole_v6/sensor.py index 07b3fe3..90b9b3e 100644 --- a/custom_components/pi_hole_v6/sensor.py +++ b/custom_components/pi_hole_v6/sensor.py @@ -51,6 +51,10 @@ key="unique_domains", translation_key="unique_domains", ), + SensorEntityDescription( + key="timer", + translation_key="timer", + ), ) @@ -118,5 +122,8 @@ def native_value(self) -> StateType: return self.api.cache_summary["clients"]["active"] case "unique_domains": return self.api.cache_summary["queries"]["unique_domains"] + case "timer": + value: int | None = self.api.cache_blocking["timer"] + return value if value is not None else 0 return "" diff --git a/custom_components/pi_hole_v6/translations/en.json b/custom_components/pi_hole_v6/translations/en.json index edb134b..8791c3b 100644 --- a/custom_components/pi_hole_v6/translations/en.json +++ b/custom_components/pi_hole_v6/translations/en.json @@ -18,7 +18,7 @@ "data_description": { "password": "Password to be used for login", "url": "URL or address of your Pi-hole (ie: https://pi.hole:443/api)" - }, + }, "description": "This integration allows you to retrieve statistics and interact with a Pi-hole V6 system.", "title": "Pi-hole V6 Integration" } @@ -65,6 +65,10 @@ "unique_domains": { "name": "DNS unique domains", "unit_of_measurement": "domains" + }, + "timer": { + "name": "Remaining until blocking mode", + "unit_of_measurement": "seconds" } }, "update": { diff --git a/custom_components/pi_hole_v6/update.py b/custom_components/pi_hole_v6/update.py new file mode 100644 index 0000000..cfe998e --- /dev/null +++ b/custom_components/pi_hole_v6/update.py @@ -0,0 +1,122 @@ +"""Support for update entities of a Pi-hole system.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.update import UpdateEntity, UpdateEntityDescription +from homeassistant.const import CONF_NAME, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import PiHoleV6ConfigEntry +from .api import API as ClientAPI +from .entity import PiHoleV6Entity + + +@dataclass(frozen=True) +class PiHoleV6UpdateEntityDescription(UpdateEntityDescription): + """Describes PiHoleV6 update entity.""" + + installed_version: Callable[[dict], str | None] = lambda api: None + latest_version: Callable[[dict], str | None] = lambda api: None + release_base_url: str | None = None + title: str | None = None + + +UPDATE_ENTITY_TYPES: tuple[PiHoleV6UpdateEntityDescription, ...] = ( + PiHoleV6UpdateEntityDescription( + key="core_update_available", + translation_key="core_update_available", + title="Pi-hole Core", + entity_category=EntityCategory.DIAGNOSTIC, + installed_version=lambda versions: versions.get("core").get("local", {}).get("version", None), + latest_version=lambda versions: versions.get("core").get("remote", {}).get("version", None), + release_base_url="https://github.com/pi-hole/pi-hole/releases/tag", + ), + PiHoleV6UpdateEntityDescription( + key="web_update_available", + translation_key="web_update_available", + title="Pi-hole Web interface", + entity_category=EntityCategory.DIAGNOSTIC, + installed_version=lambda versions: versions.get("web").get("local", {}).get("version", None), + latest_version=lambda versions: versions.get("web").get("remote", {}).get("version", None), + release_base_url="https://github.com/pi-hole/AdminLTE/releases/tag", + ), + PiHoleV6UpdateEntityDescription( + key="ftl_update_available", + translation_key="ftl_update_available", + title="Pi-hole FTL DNS", + entity_category=EntityCategory.DIAGNOSTIC, + installed_version=lambda versions: versions.get("ftl").get("local", {}).get("version", None), + latest_version=lambda versions: versions.get("ftl").get("remote", {}).get("version", None), + release_base_url="https://github.com/pi-hole/FTL/releases/tag", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PiHoleV6ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pi-hole update entities.""" + name = entry.data[CONF_NAME] + hole_data = entry.runtime_data + + async_add_entities( + PiHoleV6UpdateEntity( + hole_data.api, + hole_data.coordinator, + name, + entry.entry_id, + description, + ) + for description in UPDATE_ENTITY_TYPES + ) + + +class PiHoleV6UpdateEntity(PiHoleV6Entity, UpdateEntity): + """Representation of a Pi-hole update entity.""" + + entity_description: PiHoleV6UpdateEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + api: ClientAPI, + coordinator: DataUpdateCoordinator[None], + name: str, + server_unique_id: str, + description: PiHoleV6UpdateEntityDescription, + ) -> None: + """Initialize a Pi-hole update entity.""" + super().__init__(api, coordinator, name, server_unique_id) + self.entity_description = description + + self._attr_unique_id = f"{self._server_unique_id}/{description.key}" + self._attr_title = description.title + + @property + def installed_version(self) -> str | None: + """Version installed and in use.""" + versions: dict[str, Any] = self.api.cache_padd["version"] + if isinstance(versions, dict): + return self.entity_description.installed_version(versions) + return None + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + versions: dict[str, Any] = self.api.cache_padd["version"] + if isinstance(versions, dict): + return self.entity_description.latest_version(versions) + return None + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + return f"{self.entity_description.release_base_url}/{self.latest_version}" From 7d7c7c0538ff352ba1cd3438fee28302be82014e Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sun, 2 Mar 2025 01:42:00 +0000 Subject: [PATCH 08/10] Minor modifications --- custom_components/pi_hole_v6/switch.py | 34 ++++++++++--------- .../pi_hole_v6/translations/en.json | 8 ++--- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/custom_components/pi_hole_v6/switch.py b/custom_components/pi_hole_v6/switch.py index 41c4505..ec2c6c7 100644 --- a/custom_components/pi_hole_v6/switch.py +++ b/custom_components/pi_hole_v6/switch.py @@ -6,11 +6,11 @@ from typing import Any import voluptuous as vol - from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -35,9 +35,9 @@ async def async_setup_entry( - hass: HomeAssistant, - entry: PiHoleV6ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, + hass: HomeAssistant, + entry: PiHoleV6ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole V6 switch.""" name = entry.data[CONF_NAME] @@ -123,16 +123,16 @@ async def async_turn(self, action: str, duration: Any = None) -> None: self.schedule_update_ha_state(force_refresh=True) except ( - BadRequestException, - UnauthorizedException, - RequestFailedException, - ForbiddenException, - NotFoundException, - TooManyRequestsException, - ServerErrorException, - BadGatewayException, - ServiceUnavailableException, - GatewayTimeoutException, + BadRequestException, + UnauthorizedException, + RequestFailedException, + ForbiddenException, + NotFoundException, + TooManyRequestsException, + ServerErrorException, + BadGatewayException, + ServiceUnavailableException, + GatewayTimeoutException, ) as err: _LOGGER.error("Unable to %s Pi-hole V6: %s", action, err) @@ -221,4 +221,6 @@ async def async_turn(self, action: str) -> None: ServiceUnavailableException, GatewayTimeoutException, ) as err: - _LOGGER.error("Unable to %s Pi-hole V6 group %s: %s", action, self._group, err) + _LOGGER.error( + "Unable to %s Pi-hole V6 group %s: %s", action, self._group, err + ) diff --git a/custom_components/pi_hole_v6/translations/en.json b/custom_components/pi_hole_v6/translations/en.json index a2b6d55..cdb32a6 100644 --- a/custom_components/pi_hole_v6/translations/en.json +++ b/custom_components/pi_hole_v6/translations/en.json @@ -71,12 +71,12 @@ "unit_of_measurement": "seconds" } }, - "switch": { + "switch": { "group": { "name": "Group {groupName}" } - }, - "update": { + }, + "update": { "core_update_available": { "name": "Core update available" }, @@ -104,4 +104,4 @@ "description": "Enables configured Pi-hole(s)." } } -} +} \ No newline at end of file From 84ff54e6c531e93e77a7f63b361dcea15dab85ba Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sun, 2 Mar 2025 01:42:14 +0000 Subject: [PATCH 09/10] Update version --- custom_components/pi_hole_v6/manifest.json | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/custom_components/pi_hole_v6/manifest.json b/custom_components/pi_hole_v6/manifest.json index 0a62386..7e6746e 100644 --- a/custom_components/pi_hole_v6/manifest.json +++ b/custom_components/pi_hole_v6/manifest.json @@ -1,14 +1,18 @@ { - "domain": "pi_hole_v6", + "domain": "pi_hole_v6", "name": "Pi-hole V6 Integration", - "codeowners": ["@bastgau"], + "codeowners": [ + "@bastgau" + ], "config_flow": true, "dependencies": [], "documentation": "https://github.com/bastgau/ha-pi-hole-v6", "integration_type": "service", "iot_class": "local_polling", "issue_tracker": "https://github.com/bastgau/ha-pi-hole-V6/issues", - "loggers": ["pi_hole_v6"], + "loggers": [ + "pi_hole_v6" + ], "requirements": [], - "version": "1.4.0" -} + "version": "1.5.0" +} \ No newline at end of file From 5b4f245e83789d3bbf0cf9dddb303ceac3cf5042 Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sun, 2 Mar 2025 03:50:49 +0000 Subject: [PATCH 10/10] Fix group name --- custom_components/pi_hole_v6/strings.json | 7 +------ custom_components/pi_hole_v6/switch.py | 13 +++++++------ custom_components/pi_hole_v6/translations/en.json | 5 ----- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/custom_components/pi_hole_v6/strings.json b/custom_components/pi_hole_v6/strings.json index efb7cb8..3c5ca39 100644 --- a/custom_components/pi_hole_v6/strings.json +++ b/custom_components/pi_hole_v6/strings.json @@ -71,11 +71,6 @@ "unit_of_measurement": "[%key:component::pi_hole_v6::entity::sensor::domains_being_blocked::unit_of_measurement%]" } }, - "switch": { - "group": { - "name": "Group {groupName}" - } - }, "update": { "core_update_available": { "name": "Core update available" @@ -104,4 +99,4 @@ "description": "Enable configured Pi-hole(s)." } } -} +} \ No newline at end of file diff --git a/custom_components/pi_hole_v6/switch.py b/custom_components/pi_hole_v6/switch.py index ec2c6c7..bc038de 100644 --- a/custom_components/pi_hole_v6/switch.py +++ b/custom_components/pi_hole_v6/switch.py @@ -56,6 +56,7 @@ async def async_setup_entry( PiHoleV6Group( hole_data.api, hole_data.coordinator, + name, entry.entry_id, group, ) @@ -160,23 +161,23 @@ class PiHoleV6Group(PiHoleV6Entity, SwitchEntity): """Representation of a Pi-hole V6 group.""" _attr_icon = "mdi:account-multiple" - _attr_has_entity_name = True - _attr_translation_key = "group" def __init__( self, api: PiholeAPI, coordinator: DataUpdateCoordinator, + name: str, server_unique_id: str, group: str, ) -> None: - super().__init__(api, coordinator, f"Group {group}", server_unique_id) + super().__init__(api, coordinator, f"{name} Group {group}", server_unique_id) self._group = group - self._attr_translation_placeholders = { - "groupName": group, - } + @property + def name(self) -> str: + """Return the name of the switch.""" + return self._name @property def unique_id(self) -> str: diff --git a/custom_components/pi_hole_v6/translations/en.json b/custom_components/pi_hole_v6/translations/en.json index cdb32a6..8791c3b 100644 --- a/custom_components/pi_hole_v6/translations/en.json +++ b/custom_components/pi_hole_v6/translations/en.json @@ -71,11 +71,6 @@ "unit_of_measurement": "seconds" } }, - "switch": { - "group": { - "name": "Group {groupName}" - } - }, "update": { "core_update_available": { "name": "Core update available"