Compare commits

..

1 Commits

Author SHA1 Message Date
Erik
65f073ca15 Remove support for installing Python dependencies in the config dir 2026-04-14 09:05:43 +02:00
1225 changed files with 13358 additions and 43164 deletions

View File

@@ -186,11 +186,15 @@ If `CHANGE_TYPE` IS "Breaking change" or "Deprecation", keep the `## Breaking ch
## Step 10: Push Branch and Create PR
Push the branch with upstream tracking, and create a PR against `home-assistant/core` with the generated title and body:
```bash
# Get branch name and GitHub username
BRANCH=$(git branch --show-current)
PUSH_REMOTE=$(git config "branch.$BRANCH.remote" 2>/dev/null || git remote | head -1)
GITHUB_USER=$(gh api user --jq .login 2>/dev/null || git remote get-url "$PUSH_REMOTE" | sed -E 's#.*[:/]([^/]+)/([^/]+)(\.git)?$#\1#')
# Create PR (gh pr create pushes the branch automatically)
gh pr create --repo home-assistant/core --base dev \
--head "$GITHUB_USER:$BRANCH" \
--draft \
--title "TITLE_HERE" \
--body "$(cat <<'EOF'

205
.github/renovate.json vendored
View File

@@ -1,205 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"enabledManagers": [
"pep621",
"pip_requirements",
"pre-commit",
"homeassistant-manifest"
],
"pre-commit": {
"enabled": true
},
"pip_requirements": {
"managerFilePatterns": [
"/(^|/)requirements[\\w_-]*\\.txt$/",
"/(^|/)homeassistant/package_constraints\\.txt$/"
]
},
"homeassistant-manifest": {
"managerFilePatterns": [
"/^homeassistant/components/[^/]+/manifest\\.json$/"
]
},
"minimumReleaseAge": "7 days",
"prConcurrentLimit": 10,
"prHourlyLimit": 2,
"schedule": ["before 6am"],
"semanticCommits": "disabled",
"commitMessageAction": "Update",
"commitMessageTopic": "{{depName}}",
"commitMessageExtra": "to {{newVersion}}",
"automerge": false,
"vulnerabilityAlerts": {
"enabled": false
},
"packageRules": [
{
"description": "Deny all by default — allowlist below re-enables specific packages",
"matchPackageNames": ["*"],
"enabled": false
},
{
"description": "Core runtime dependencies (allowlisted)",
"matchPackageNames": [
"aiohttp",
"aiohttp-fast-zlib",
"aiohttp_cors",
"aiohttp-asyncmdnsresolver",
"yarl",
"httpx",
"requests",
"urllib3",
"certifi",
"orjson",
"PyYAML",
"Jinja2",
"cryptography",
"pyOpenSSL",
"PyJWT",
"SQLAlchemy",
"Pillow",
"attrs",
"uv",
"voluptuous",
"voluptuous-serialize",
"voluptuous-openapi",
"zeroconf"
],
"enabled": true,
"labels": ["dependency", "core"]
},
{
"description": "Common Python utilities (allowlisted)",
"matchPackageNames": [
"astral",
"atomicwrites-homeassistant",
"audioop-lts",
"awesomeversion",
"bcrypt",
"ciso8601",
"cronsim",
"defusedxml",
"fnv-hash-fast",
"getmac",
"ical",
"ifaddr",
"lru-dict",
"mutagen",
"propcache",
"pyserial",
"python-slugify",
"PyTurboJPEG",
"securetar",
"standard-aifc",
"standard-telnetlib",
"ulid-transform",
"url-normalize",
"xmltodict"
],
"enabled": true,
"labels": ["dependency"]
},
{
"description": "Home Assistant ecosystem packages (core-maintained, no cooldown)",
"matchPackageNames": [
"hassil",
"home-assistant-bluetooth",
"home-assistant-frontend",
"home-assistant-intents",
"infrared-protocols"
],
"enabled": true,
"minimumReleaseAge": null,
"labels": ["dependency", "core"]
},
{
"description": "Test dependencies (allowlisted)",
"matchPackageNames": [
"pytest",
"pytest-asyncio",
"pytest-aiohttp",
"pytest-cov",
"pytest-freezer",
"pytest-github-actions-annotate-failures",
"pytest-socket",
"pytest-sugar",
"pytest-timeout",
"pytest-unordered",
"pytest-picked",
"pytest-xdist",
"pylint",
"pylint-per-file-ignores",
"astroid",
"coverage",
"freezegun",
"syrupy",
"respx",
"requests-mock",
"ruff",
"codespell",
"yamllint",
"zizmor"
],
"enabled": true,
"labels": ["dependency"]
},
{
"description": "For types-* stubs, only allow patch updates. Major/minor bumps track the upstream runtime package version and must be manually coordinated with the corresponding pin.",
"matchPackageNames": ["/^types-/"],
"matchUpdateTypes": ["patch"],
"enabled": true,
"labels": ["dependency"]
},
{
"description": "Pre-commit hook repos (allowlisted, matched by owner/repo)",
"matchPackageNames": [
"astral-sh/ruff-pre-commit",
"codespell-project/codespell",
"adrienverge/yamllint",
"zizmorcore/zizmor-pre-commit"
],
"enabled": true,
"labels": ["dependency"]
},
{
"description": "Group ruff pre-commit hook with its PyPI twin into one PR",
"matchPackageNames": ["astral-sh/ruff-pre-commit", "ruff"],
"groupName": "ruff",
"groupSlug": "ruff"
},
{
"description": "Group codespell pre-commit hook with its PyPI twin into one PR",
"matchPackageNames": ["codespell-project/codespell", "codespell"],
"groupName": "codespell",
"groupSlug": "codespell"
},
{
"description": "Group yamllint pre-commit hook with its PyPI twin into one PR",
"matchPackageNames": ["adrienverge/yamllint", "yamllint"],
"groupName": "yamllint",
"groupSlug": "yamllint"
},
{
"description": "Group zizmor pre-commit hook with its PyPI twin into one PR",
"matchPackageNames": ["zizmorcore/zizmor-pre-commit", "zizmor"],
"groupName": "zizmor",
"groupSlug": "zizmor"
},
{
"description": "Group pylint with astroid (their versions are linked and must move together)",
"matchPackageNames": ["pylint", "astroid"],
"groupName": "pylint",
"groupSlug": "pylint"
}
]
}

View File

@@ -108,7 +108,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -119,7 +119,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -344,13 +344,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -499,7 +499,7 @@ jobs:
python -m build
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
@@ -523,14 +523,14 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -543,7 +543,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile

View File

@@ -50,11 +50,9 @@ env:
# - 10.10.3 is the latest (as of 6 Feb 2023)
# 10.11 is the latest long-term-support
# - 10.11.2 is the version currently shipped with Synology (as of 11 Oct 2023)
# 11.4 is an LTS with support until May 2029
# - 11.4.9 is used in Alpine 3.23 (used in latest HA base images as of 11 Apr 2026)
# mysql 8.0.32 does not always behave the same as MariaDB
# and some queries that work on MariaDB do not work on MySQL
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mariadb:11.4.9','mysql:8.0.32']"
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mysql:8.0.32']"
# 12 is the oldest supported version
# - 12.14 is the latest (as of 9 Feb 2023)
# 15 is the latest version
@@ -1064,9 +1062,7 @@ jobs:
- 3306:3306
env:
MYSQL_ROOT_PASSWORD: password
options: >-
--health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi"
--health-interval=5s --health-timeout=2s --health-retries=3
options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3
needs:
- info
- base

1
.gitignore vendored
View File

@@ -142,6 +142,5 @@ pytest_buckets.txt
# AI tooling
.claude/settings.local.json
.claude/worktrees/
.serena/

View File

@@ -8,7 +8,7 @@ repos:
- id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
rev: v2.4.1
hooks:
- id: codespell
args:
@@ -36,7 +36,7 @@ repos:
- --branch=master
- --branch=rc
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.38.0
rev: v1.37.1
hooks:
- id: yamllint
- repo: https://github.com/rbubley/mirrors-prettier

View File

@@ -332,7 +332,6 @@ homeassistant.components.letpot.*
homeassistant.components.lg_infrared.*
homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.*
homeassistant.components.liebherr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
homeassistant.components.linkplay.*

30
CODEOWNERS generated
View File

@@ -418,8 +418,6 @@ CLAUDE.md @home-assistant/core
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duckdns/ @tr4nt0r
/tests/components/duckdns/ @tr4nt0r
/homeassistant/components/duco/ @ronaldvdmeer
/tests/components/duco/ @ronaldvdmeer
/homeassistant/components/duotecno/ @cereal2nd
/tests/components/duotecno/ @cereal2nd
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
@@ -489,8 +487,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50 @roberty99
/homeassistant/components/epic_games_store/ @Quentame
/tests/components/epic_games_store/ @Quentame
/homeassistant/components/epic_games_store/ @hacf-fr @Quentame
/tests/components/epic_games_store/ @hacf-fr @Quentame
/homeassistant/components/epion/ @lhgravendeel
/tests/components/epion/ @lhgravendeel
/homeassistant/components/epson/ @pszafer
@@ -505,8 +503,6 @@ CLAUDE.md @home-assistant/core
/tests/components/essent/ @jaapp
/homeassistant/components/eufylife_ble/ @bdr99
/tests/components/eufylife_ble/ @bdr99
/homeassistant/components/eurotronic_cometblue/ @rikroe
/tests/components/eurotronic_cometblue/ @rikroe
/homeassistant/components/event/ @home-assistant/core
/tests/components/event/ @home-assistant/core
/homeassistant/components/evohome/ @zxdavb
@@ -566,8 +562,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/fortios/ @kimfrellsen
/homeassistant/components/foscam/ @Foscam-wangzhengyu
/tests/components/foscam/ @Foscam-wangzhengyu
/homeassistant/components/freebox/ @hacf-fr/reviewers @Quentame
/tests/components/freebox/ @hacf-fr/reviewers @Quentame
/homeassistant/components/freebox/ @hacf-fr @Quentame
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415
/homeassistant/components/freshr/ @SierraNL
@@ -1057,8 +1053,8 @@ CLAUDE.md @home-assistant/core
/tests/components/met/ @danielhiversen
/homeassistant/components/met_eireann/ @DylanGore
/tests/components/met_eireann/ @DylanGore
/homeassistant/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/homeassistant/components/meteo_lt/ @xE1H
/tests/components/meteo_lt/ @xE1H
/homeassistant/components/meteoalarm/ @rolfberkenbosch
@@ -1150,8 +1146,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/netatmo/ @cgtobi
/tests/components/netatmo/ @cgtobi
/homeassistant/components/netdata/ @fabaff
/homeassistant/components/netgear/ @Quentame @starkillerOG
/tests/components/netgear/ @Quentame @starkillerOG
/homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG
/tests/components/netgear/ @hacf-fr @Quentame @starkillerOG
/homeassistant/components/netgear_lte/ @tkdrob
/tests/components/netgear_lte/ @tkdrob
/homeassistant/components/network/ @home-assistant/core
@@ -1694,8 +1690,8 @@ CLAUDE.md @home-assistant/core
/tests/components/syncthing/ @zhulik
/homeassistant/components/syncthru/ @nielstron
/tests/components/syncthru/ @nielstron
/homeassistant/components/synology_dsm/ @Quentame @mib1185
/tests/components/synology_dsm/ @Quentame @mib1185
/homeassistant/components/synology_dsm/ @hacf-fr @Quentame @mib1185
/tests/components/synology_dsm/ @hacf-fr @Quentame @mib1185
/homeassistant/components/synology_srm/ @aerialls
/homeassistant/components/system_bridge/ @timmo001
/tests/components/system_bridge/ @timmo001
@@ -1828,8 +1824,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifi_discovery/ @RaHehl
/tests/components/unifi_discovery/ @RaHehl
/homeassistant/components/unifiled/ @florisvdk
/homeassistant/components/unifiprotect/ @RaHehl
/tests/components/unifiprotect/ @RaHehl
@@ -1877,8 +1871,8 @@ CLAUDE.md @home-assistant/core
/tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/homeassistant/components/vicare/ @CFenner @lackas
/tests/components/vicare/ @CFenner @lackas
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
/homeassistant/components/victron_ble/ @rajlaud
/tests/components/victron_ble/ @rajlaud
/homeassistant/components/victron_gx/ @tomer-w

19
Dockerfile generated
View File

@@ -19,22 +19,25 @@ ENV \
UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
WORKDIR /usr/src
# Home Assistant S6-Overlay
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
## Setup Home Assistant Core dependencies
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv at the version pinned in the requirements file
&& pip3 install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{print $2}' homeassistant/requirements.txt)" \
&& uv pip install \
# Install uv
&& pip3 install uv==0.11.1
WORKDIR /usr/src
## Setup Home Assistant Core dependencies
COPY requirements.txt homeassistant/
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
RUN \
uv pip install \
--no-build \
-r homeassistant/requirements.txt
@@ -48,7 +51,7 @@ RUN \
-r homeassistant/requirements_all.txt
## Setup Home Assistant Core
COPY --parents LICENSE* README* homeassistant/ pyproject.toml homeassistant/
COPY . homeassistant/
RUN \
uv pip install \
-e ./homeassistant \

View File

@@ -11,10 +11,21 @@ import threading
from .backup_restore import restore_backup
from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__
from .util.package import is_docker_env, is_virtual_env
FAULT_LOG_FILENAME = "home-assistant.log.fault"
def validate_environment() -> None:
"""Validate that Home Assistant is started from a container or a venv."""
if not is_virtual_env() and not is_docker_env():
print(
"Home Assistant must be run in a Python virtual environment or a container.",
file=sys.stderr,
)
sys.exit(1)
def validate_os() -> None:
"""Validate that Home Assistant is running in a supported operating system."""
if not sys.platform.startswith(("darwin", "linux")):
@@ -40,8 +51,6 @@ def ensure_config_path(config_dir: str) -> None:
"""Validate the configuration directory."""
from . import config as config_util # noqa: PLC0415
lib_dir = os.path.join(config_dir, "deps")
# Test if configuration directory exists
if not os.path.isdir(config_dir):
if config_dir != config_util.get_default_config_dir():
@@ -65,17 +74,6 @@ def ensure_config_path(config_dir: str) -> None:
)
sys.exit(1)
# Test if library directory exists
if not os.path.isdir(lib_dir):
try:
os.mkdir(lib_dir)
except OSError as ex:
print(
f"Fatal Error: Unable to create library directory {lib_dir}: {ex}",
file=sys.stderr,
)
sys.exit(1)
def get_arguments() -> argparse.Namespace:
"""Get parsed passed in arguments."""
@@ -168,6 +166,7 @@ def check_threads() -> None:
def main() -> int:
"""Start Home Assistant."""
validate_python()
validate_environment()
args = get_arguments()

View File

@@ -7,31 +7,23 @@ to speed up the process.
from __future__ import annotations
from collections.abc import Container, Iterable, Sequence
from datetime import timedelta
from functools import lru_cache
from typing import Any, override
from functools import lru_cache, partial
from typing import Any
from jwt import DecodeError, PyJWK, PyJWS, PyJWT
from jwt.algorithms import AllowedPublicKeys
from jwt.types import Options
from jwt import DecodeError, PyJWS, PyJWT
from homeassistant.util.json import json_loads
JWT_TOKEN_CACHE_SIZE = 16
MAX_TOKEN_SIZE = 8192
_NO_VERIFY_OPTIONS = Options(
verify_signature=False,
verify_exp=False,
verify_nbf=False,
verify_iat=False,
verify_aud=False,
verify_iss=False,
verify_sub=False,
verify_jti=False,
require=[],
)
_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss", "sub", "jti")
_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | {
"require": []
}
_NO_VERIFY_OPTIONS = {f"verify_{key}": False for key in _VERIFY_KEYS}
class _PyJWSWithLoadCache(PyJWS):
@@ -46,6 +38,9 @@ class _PyJWSWithLoadCache(PyJWS):
return super()._load(jwt)
_jws = _PyJWSWithLoadCache()
@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)
def _decode_payload(json_payload: str) -> dict[str, Any]:
"""Decode the payload from a JWS dictionary."""
@@ -61,12 +56,21 @@ def _decode_payload(json_payload: str) -> dict[str, Any]:
class _PyJWTWithVerify(PyJWT):
"""PyJWT with a fast decode implementation."""
def __init__(self) -> None:
"""Initialize the PyJWT instance."""
# We require exp and iat claims to be present
super().__init__(Options(require=["exp", "iat"]))
# Override the _jws instance with our cached version
self._jws = _PyJWSWithLoadCache()
def decode_payload(
self, jwt: str, key: str, options: dict[str, Any], algorithms: list[str]
) -> dict[str, Any]:
"""Decode a JWT's payload."""
if len(jwt) > MAX_TOKEN_SIZE:
# Avoid caching impossible tokens
raise DecodeError("Token too large")
return _decode_payload(
_jws.decode_complete(
jwt=jwt,
key=key,
algorithms=algorithms,
options=options,
)["payload"]
)
def verify_and_decode(
self,
@@ -75,70 +79,37 @@ class _PyJWTWithVerify(PyJWT):
algorithms: list[str],
issuer: str | None = None,
leeway: float | timedelta = 0,
options: Options | None = None,
options: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Verify a JWT's signature and claims."""
return self.decode(
merged_options = {**_VERIFY_OPTIONS, **(options or {})}
payload = self.decode_payload(
jwt=jwt,
key=key,
options=merged_options,
algorithms=algorithms,
)
# These should never be missing since we verify them
# but this is an additional safeguard to make sure
# nothing slips through.
assert "exp" in payload, "exp claim is required"
assert "iat" in payload, "iat claim is required"
self._validate_claims(
payload=payload,
options=merged_options,
issuer=issuer,
leeway=leeway,
options=options,
)
@override
def decode(
self,
jwt: str | bytes,
key: AllowedPublicKeys | PyJWK | str | bytes = "",
algorithms: Sequence[str] | None = None,
options: Options | None = None,
verify: bool | None = None,
detached_payload: bytes | None = None,
audience: str | Iterable[str] | None = None,
subject: str | None = None,
issuer: str | Container[str] | None = None,
leeway: float | timedelta = 0,
**kwargs: Any,
) -> dict[str, Any]:
"""Decode a JWT, verifying the signature and claims."""
if len(jwt) > MAX_TOKEN_SIZE:
# Avoid caching impossible tokens
raise DecodeError("Token too large")
return super().decode(
jwt=jwt,
key=key,
algorithms=algorithms,
options=options,
verify=verify,
detached_payload=detached_payload,
audience=audience,
subject=subject,
issuer=issuer,
leeway=leeway,
**kwargs,
)
@override
def _decode_payload(self, decoded: dict[str, Any]) -> dict[str, Any]:
return _decode_payload(decoded["payload"])
return payload
_jwt = _PyJWTWithVerify()
verify_and_decode = _jwt.verify_and_decode
@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)
def unverified_hs256_token_decode(jwt: str) -> dict[str, Any]:
"""Decode a JWT without verifying the signature."""
return _jwt.decode(
jwt=jwt,
key="",
algorithms=["HS256"],
options=_NO_VERIFY_OPTIONS,
unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)(
partial(
_jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS
)
)
__all__ = [
"unverified_hs256_token_decode",

View File

@@ -108,7 +108,7 @@ from .setup import (
from .util.async_ import create_eager_task
from .util.hass_dict import HassKey
from .util.logging import async_activate_log_queue_handler
from .util.package import async_get_user_site, is_docker_env, is_virtual_env
from .util.package import is_docker_env
from .util.system_info import is_official_image
with contextlib.suppress(ImportError):
@@ -353,9 +353,6 @@ async def async_setup_hass(
err,
)
else:
if not is_virtual_env():
await async_mount_local_lib_path(runtime_config.config_dir)
if hass.config.safe_mode:
_LOGGER.info("Starting in safe mode")
@@ -704,17 +701,6 @@ class _RotatingFileHandlerWithoutShouldRollOver(RotatingFileHandler):
return False
async def async_mount_local_lib_path(config_dir: str) -> str:
"""Add local library to Python Path.
This function is a coroutine.
"""
deps_dir = os.path.join(config_dir, "deps")
if (lib_dir := await async_get_user_site(deps_dir)) not in sys.path:
sys.path.insert(0, lib_dir)
return deps_dir
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
"""Get domains of components to set up."""
# The common config section [homeassistant] could be filtered here,

View File

@@ -6,7 +6,6 @@
"unifi",
"unifi_access",
"unifi_direct",
"unifi_discovery",
"unifiled",
"unifiprotect"
]

View File

@@ -6,11 +6,10 @@ from typing import Final
from homeassistant.const import STATE_OFF, STATE_ON
CONF_READ_TIMEOUT: Final = "timeout"
CONF_WRITE_TIMEOUT: Final = "write_timeout"
DEFAULT_NAME: Final = "Acer Projector"
DEFAULT_READ_TIMEOUT: Final = 1
DEFAULT_TIMEOUT: Final = 1
DEFAULT_WRITE_TIMEOUT: Final = 1
ECO_MODE: Final = "ECO Mode"

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.2.2"]
"requirements": ["pyserial==3.5"]
}

View File

@@ -6,7 +6,7 @@ import logging
import re
from typing import Any
from serialx import Serial, SerialException
import serial
import voluptuous as vol
from homeassistant.components.switch import (
@@ -16,22 +16,21 @@ from homeassistant.components.switch import (
from homeassistant.const import (
CONF_FILENAME,
CONF_NAME,
CONF_TIMEOUT,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
CMD_DICT,
CONF_READ_TIMEOUT,
CONF_WRITE_TIMEOUT,
DEFAULT_NAME,
DEFAULT_READ_TIMEOUT,
DEFAULT_TIMEOUT,
DEFAULT_WRITE_TIMEOUT,
ECO_MODE,
ICON,
@@ -46,7 +45,7 @@ PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_FILENAME): cv.isdevice,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_READ_TIMEOUT, default=DEFAULT_READ_TIMEOUT): cv.positive_int,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
vol.Optional(
CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT
): cv.positive_int,
@@ -63,10 +62,10 @@ def setup_platform(
"""Connect with serial port and return Acer Projector."""
serial_port = config[CONF_FILENAME]
name = config[CONF_NAME]
read_timeout = config[CONF_READ_TIMEOUT]
timeout = config[CONF_TIMEOUT]
write_timeout = config[CONF_WRITE_TIMEOUT]
add_entities([AcerSwitch(serial_port, name, read_timeout, write_timeout)], True)
add_entities([AcerSwitch(serial_port, name, timeout, write_timeout)], True)
class AcerSwitch(SwitchEntity):
@@ -78,14 +77,14 @@ class AcerSwitch(SwitchEntity):
self,
serial_port: str,
name: str,
read_timeout: int,
timeout: int,
write_timeout: int,
) -> None:
"""Init of the Acer projector."""
self.serial = serial.Serial(
port=serial_port, timeout=timeout, write_timeout=write_timeout
)
self._serial_port = serial_port
self._read_timeout = read_timeout
self._write_timeout = write_timeout
self._attr_name = name
self._attributes = {
LAMP_HOURS: STATE_UNKNOWN,
@@ -95,26 +94,22 @@ class AcerSwitch(SwitchEntity):
def _write_read(self, msg: str) -> str:
"""Write to the projector and read the return."""
ret = ""
# Sometimes the projector won't answer for no reason or the projector
# was disconnected during runtime.
# This way the projector can be reconnected and will still work
try:
with Serial.from_url(
self._serial_port,
read_timeout=self._read_timeout,
write_timeout=self._write_timeout,
) as serial:
serial.write(msg.encode("utf-8"))
# Size is an experience value there is no real limit.
# AFAIK there is no limit and no end character so we will usually
# need to wait for timeout
return serial.read_until(size=20).decode("utf-8")
except (OSError, SerialException, TimeoutError) as exc:
raise HomeAssistantError(
f"Problem communicating with {self._serial_port}"
) from exc
if not self.serial.is_open:
self.serial.open()
self.serial.write(msg.encode("utf-8"))
# Size is an experience value there is no real limit.
# AFAIK there is no limit and no end character so we will usually
# need to wait for timeout
ret = self.serial.read_until(size=20).decode("utf-8")
except serial.SerialException:
_LOGGER.error("Problem communicating with %s", self._serial_port)
self.serial.close()
return ret
def _write_read_format(self, msg: str) -> str:
"""Write msg, obtain answer and format output."""

View File

@@ -1,7 +1,11 @@
"""The Actron Air integration."""
from actron_neo_api import ActronAirAPI, ActronAirAPIError, ActronAirAuthError
from actron_neo_api.models.system import ActronAirSystemInfo
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
)
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
@@ -21,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
"""Set up Actron Air integration from a config entry."""
api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN])
systems: list[ActronAirSystemInfo] = []
systems: list[ActronAirACSystem] = []
try:
systems = await api.get_ac_systems()
@@ -40,9 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
_LOGGER.debug("Setting up coordinator for system: %s", system.serial)
_LOGGER.debug("Setting up coordinator for system: %s", system["serial"])
await coordinator.async_config_entry_first_refresh()
system_coordinators[system.serial] = coordinator
system_coordinators[system["serial"]] = coordinator
entry.runtime_data = ActronAirRuntimeData(
api=api,

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity, actron_air_command
from .entity import ActronAirAcEntity, ActronAirZoneEntity, handle_actron_api_errors
PARALLEL_UPDATES = 0
@@ -136,19 +136,19 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._status.user_aircon_settings.temperature_setpoint_cool_c
@actron_air_command
@handle_actron_api_errors
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
@actron_air_command
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
@actron_air_command
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
@@ -212,13 +212,13 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._zone.temperature_setpoint_cool_c
@actron_air_command
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
is_enabled = hvac_mode != HVACMode.OFF
await self._zone.enable(is_enabled)
@actron_air_command
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))

View File

@@ -38,10 +38,10 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("OAuth2 flow failed: %s", err)
return self.async_abort(reason="oauth2_error")
self._device_code = device_code_response.device_code
self._user_code = device_code_response.user_code
self._verification_uri = device_code_response.verification_uri_complete
self._expires_minutes = str(device_code_response.expires_in // 60)
self._device_code = device_code_response["device_code"]
self._user_code = device_code_response["user_code"]
self._verification_uri = device_code_response["verification_uri_complete"]
self._expires_minutes = str(device_code_response["expires_in"] // 60)
async def _wait_for_authorization() -> None:
"""Wait for the user to authorize the device."""

View File

@@ -6,12 +6,12 @@ from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
ActronAirStatus,
)
from actron_neo_api.models.system import ActronAirSystemInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -38,7 +38,7 @@ class ActronAirRuntimeData:
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
"""System coordinator for Actron Air integration."""
def __init__(
@@ -46,7 +46,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
hass: HomeAssistant,
entry: ActronAirConfigEntry,
api: ActronAirAPI,
system: ActronAirSystemInfo,
system: ActronAirACSystem,
) -> None:
"""Initialize the coordinator."""
super().__init__(
@@ -57,7 +57,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
config_entry=entry,
)
self.system = system
self.serial_number = system.serial
self.serial_number = system["serial"]
self.api = api
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()

View File

@@ -1,35 +0,0 @@
"""Diagnostics support for Actron Air."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from .coordinator import ActronAirConfigEntry
TO_REDACT = {CONF_API_TOKEN, "master_serial", "serial_number", "serial"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinators: dict[int, Any] = {}
for idx, coordinator in enumerate(entry.runtime_data.system_coordinators.values()):
coordinators[idx] = {
"system": async_redact_data(
coordinator.system.model_dump(mode="json"), TO_REDACT
),
"status": async_redact_data(
coordinator.data.model_dump(mode="json", exclude={"last_known_state"}),
TO_REDACT,
),
}
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"coordinators": coordinators,
}

View File

@@ -14,14 +14,10 @@ from .const import DOMAIN
from .coordinator import ActronAirSystemCoordinator
def actron_air_command[_EntityT: ActronAirEntity, **_P](
def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorator for Actron Air API calls.
Handles ActronAirAPIError exceptions, and requests a coordinator update
to update the status of the devices as soon as possible.
"""
"""Decorate Actron Air API calls to handle ActronAirAPIError exceptions."""
@wraps(func)
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
@@ -34,7 +30,6 @@ def actron_air_command[_EntityT: ActronAirEntity, **_P](
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
self.coordinator.async_set_updated_data(self.coordinator.data)
return wrapper

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.0"]
"requirements": ["actron-neo-api==0.4.1"]
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update.
@@ -54,12 +54,18 @@ rules:
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
entity-category:
status: exempt
comment: This integration does not use entity categories.
entity-device-class:
status: exempt
comment: This integration does not use entity device classes.
entity-disabled-by-default:
status: exempt
comment: Not required for this integration at this stage.
entity-translations: todo
exception-translations: done
icon-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, actron_air_command
from .entity import ActronAirAcEntity, handle_actron_api_errors
PARALLEL_UPDATES = 0
@@ -105,12 +105,12 @@ class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
@actron_air_command
@handle_actron_api_errors
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
@actron_air_command
@handle_actron_api_errors
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

View File

@@ -3,7 +3,6 @@
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold type"
},
"conditions": {
@@ -250,9 +249,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -273,9 +269,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Carbon monoxide cleared"
@@ -286,9 +279,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -300,9 +290,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Carbon monoxide detected"
@@ -312,9 +299,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Gas cleared"
@@ -324,9 +308,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Gas detected"
@@ -346,9 +327,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -370,9 +348,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -394,9 +369,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -418,9 +390,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -442,9 +411,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -466,9 +432,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -490,9 +453,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -514,9 +474,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -528,9 +485,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Smoke cleared"
@@ -540,9 +494,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Smoke detected"
@@ -562,9 +513,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -586,9 +534,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -610,9 +555,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}

View File

@@ -9,11 +9,6 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
# --- Unit lists for multi-unit pollutants ---
@@ -168,7 +163,6 @@
# Binary sensor detected/cleared trigger fields
.trigger_binary_fields: &trigger_binary_fields
behavior: *trigger_behavior
for: *trigger_for
# --- Binary sensor targets ---
@@ -300,7 +294,6 @@ co_crossed_threshold:
target: *target_co_sensor
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -327,7 +320,6 @@ co2_crossed_threshold:
target: *target_co2
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -352,7 +344,6 @@ pm1_crossed_threshold:
target: *target_pm1
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -377,7 +368,6 @@ pm25_crossed_threshold:
target: *target_pm25
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -402,7 +392,6 @@ pm4_crossed_threshold:
target: *target_pm4
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -427,7 +416,6 @@ pm10_crossed_threshold:
target: *target_pm10
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -454,7 +442,6 @@ ozone_crossed_threshold:
target: *target_ozone
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -483,7 +470,6 @@ voc_crossed_threshold:
target: *target_voc
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -512,7 +498,6 @@ voc_ratio_crossed_threshold:
target: *target_voc_ratio
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -541,7 +526,6 @@ no_crossed_threshold:
target: *target_no
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -570,7 +554,6 @@ no2_crossed_threshold:
target: *target_no2
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -597,7 +580,6 @@ n2o_crossed_threshold:
target: *target_n2o
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -624,7 +606,6 @@ so2_crossed_threshold:
target: *target_so2
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -1,8 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_armed": {
@@ -235,9 +234,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed"
@@ -247,9 +243,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed away"
@@ -259,9 +252,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed home"
@@ -271,9 +261,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed night"
@@ -283,9 +270,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed vacation"
@@ -295,9 +279,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm disarmed"
@@ -307,9 +288,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm triggered"

View File

@@ -13,11 +13,6 @@
- last
- any
translation_key: trigger_behavior
for:
required: true
default: 00:00:00
selector:
duration:
armed: *trigger_common

View File

@@ -54,16 +54,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
entry.data[CONF_PASSWORD],
entry.data[CONF_LOGIN_DATA],
)
device_registry = dr.async_get(hass)
self.previous_devices: set[str] = {
identifier
for device in device_registry.devices.get_devices_for_config_entry_id(
entry.entry_id
)
if device.entry_type != dr.DeviceEntryType.SERVICE
for identifier_domain, identifier in device.identifiers
if identifier_domain == DOMAIN
}
self.previous_devices: set[str] = set()
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.4.1"]
"requirements": ["aioamazondevices==13.3.2"]
}

View File

@@ -11,12 +11,12 @@
"user": {
"data": {
"tracked_apps": "Apps",
"tracked_custom_integrations": "Custom integrations",
"tracked_custom_integrations": "Community integrations",
"tracked_integrations": "Integrations"
},
"data_description": {
"tracked_apps": "Select the apps you want to track",
"tracked_custom_integrations": "Select the custom integrations you want to track",
"tracked_custom_integrations": "Select the community integrations you want to track",
"tracked_integrations": "Select the integrations you want to track"
}
}
@@ -31,7 +31,7 @@
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"custom_integrations": {
"name": "{custom_integration_domain} (custom)",
"name": "{custom_integration_domain} (community)",
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"total_active_installations": {

View File

@@ -37,7 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
coordinator = AnthropicCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
LOGGER.debug("Available models: %s", coordinator.data)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Mapping
import json
import logging
import re
from typing import TYPE_CHECKING, Any, cast
import anthropic
@@ -70,7 +71,6 @@ from .const import (
WEB_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
)
from .coordinator import model_alias
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -105,6 +105,34 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
await client.models.list(timeout=10.0)
async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
models = (await client.models.list()).data
except anthropic.AnthropicError:
models = []
_LOGGER.debug("Available models: %s", models)
model_options: list[SelectOptionDict] = []
short_form = re.compile(r"[^\d]-\d$")
for model_info in models:
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id != "claude-3-haiku-20240307"
and model_info.id[-2:-1] != "-"
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
value=model_alias,
)
)
return model_options
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthropic."""
@@ -201,7 +229,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
options: dict[str, Any]
model_info: anthropic.types.ModelInfo
@property
def _is_new(self) -> bool:
@@ -315,14 +342,15 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
) -> SubentryFlowResult:
"""Manage advanced options."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
step_schema: VolDictType = {
vol.Optional(
CONF_CHAT_MODEL,
default=DEFAULT[CONF_CHAT_MODEL],
): SelectSelector(
SelectSelectorConfig(options=self._get_model_list(), custom_value=True)
SelectSelectorConfig(
options=await self._get_model_list(), custom_value=True
)
),
vol.Optional(
CONF_MAX_TOKENS,
@@ -347,25 +375,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if user_input is not None:
self.options.update(user_input)
coordinator = self._get_entry().runtime_data
self.model_info, status = coordinator.get_model_info(
self.options[CONF_CHAT_MODEL]
)
if not status:
# Couldn't find the model in the cached list, try to fetch it directly
client = coordinator.client
try:
self.model_info = await client.models.retrieve(
self.options[CONF_CHAT_MODEL], timeout=10.0
)
except anthropic.NotFoundError:
errors[CONF_CHAT_MODEL] = "model_not_found"
except anthropic.AnthropicError as err:
errors[CONF_CHAT_MODEL] = "api_error"
description_placeholders["message"] = (
err.message if isinstance(err, anthropic.APIError) else str(err)
)
if not errors:
return await self.async_step_model()
@@ -375,7 +384,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
vol.Schema(step_schema), self.options
),
errors=errors,
description_placeholders=description_placeholders,
)
async def async_step_model(
@@ -505,16 +513,13 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
last_step=True,
)
def _get_model_list(self) -> list[SelectOptionDict]:
async def _get_model_list(self) -> list[SelectOptionDict]:
"""Get list of available models."""
coordinator = self._get_entry().runtime_data
return [
SelectOptionDict(
label=model_info.display_name,
value=model_alias(model_info.id),
)
for model_info in coordinator.data or []
]
client = anthropic.AsyncAnthropic(
api_key=self._get_entry().data[CONF_API_KEY],
http_client=get_async_client(self.hass),
)
return await get_model_list(client)
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""

View File

@@ -2,8 +2,7 @@
from __future__ import annotations
import datetime
import re
from datetime import timedelta
import anthropic
@@ -16,28 +15,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER
UPDATE_INTERVAL_CONNECTED = datetime.timedelta(hours=12)
UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
UPDATE_INTERVAL_CONNECTED = timedelta(hours=12)
UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
_model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id == "claude-3-haiku-20240307" or model_id.endswith("-preview"):
return model_id
if model_id[-2:-1] != "-":
model_id = model_id[:-9]
if _model_short_form.search(model_id):
return model_id + "-0"
return model_id
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
class AnthropicCoordinator(DataUpdateCoordinator[None]):
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
client: anthropic.AsyncAnthropic
@@ -58,16 +42,16 @@ class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]
)
@callback
def async_set_updated_data(self, data: list[anthropic.types.ModelInfo]) -> None:
def async_set_updated_data(self, data: None) -> None:
"""Manually update data, notify listeners and update refresh interval."""
self.update_interval = UPDATE_INTERVAL_CONNECTED
super().async_set_updated_data(data)
async def async_update_data(self) -> list[anthropic.types.ModelInfo]:
async def async_update_data(self) -> None:
"""Fetch data from the API."""
try:
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
result = await self.client.models.list(timeout=10.0)
await self.client.models.list(timeout=10.0)
self.update_interval = UPDATE_INTERVAL_CONNECTED
except anthropic.APITimeoutError as err:
raise TimeoutError(err.message or str(err)) from err
@@ -83,7 +67,6 @@ class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]
translation_key="api_error",
translation_placeholders={"message": err.message},
) from err
return result.data
def mark_connection_error(self) -> None:
"""Mark the connection as having an error and reschedule background check."""
@@ -93,23 +76,3 @@ class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]
self.async_update_listeners()
if self._listeners and not self.hass.is_stopping:
self._schedule_refresh()
@callback
def get_model_info(self, model_id: str) -> tuple[anthropic.types.ModelInfo, bool]:
"""Get model info for a given model ID."""
# First try: exact name match
for model in self.data or []:
if model.id == model_id:
return model, True
# Second try: match by alias
alias = model_alias(model_id)
for model in self.data or []:
if model_alias(model.id) == alias:
return model, True
# Model not found, return safe defaults
return anthropic.types.ModelInfo(
type="model",
id=model_id,
created_at=datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC),
display_name=alias,
), False

View File

@@ -689,17 +689,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
super().__init__(entry.runtime_data)
self.entry = entry
self.subentry = subentry
coordinator = entry.runtime_data
self.model_info, _ = coordinator.get_model_info(
subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
)
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Anthropic",
model=self.model_info.display_name,
model_id=self.model_info.id,
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]),
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -974,7 +969,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
) from err
except anthropic.AnthropicError as err:
# Non-connection error, mark connection as healthy
coordinator.async_set_updated_data(coordinator.data)
coordinator.async_set_updated_data(None)
LOGGER.error("Error while talking to Anthropic: %s", err)
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -987,7 +982,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
) from err
if not chat_log.unresponded_tool_results:
coordinator.async_set_updated_data(coordinator.data)
coordinator.async_set_updated_data(None)
break

View File

@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.92.0"]
"requirements": ["anthropic==0.83.0"]
}

View File

@@ -81,10 +81,7 @@ rules:
status: exempt
comment: |
No entities disabled by default.
entity-translations:
status: exempt
comment: |
Entities explicitly set `_attr_name` to `None`, so entity name translations are not used.
entity-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Iterator
from typing import TYPE_CHECKING
import anthropic
import voluptuous as vol
from homeassistant import data_entry_flow
@@ -19,8 +18,8 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
)
from .config_flow import get_model_list
from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN
from .coordinator import model_alias
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -62,7 +61,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
client = entry.runtime_data.client
model_list = [
model_option
for model_option in await self.get_model_list(client)
for model_option in await get_model_list(client)
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
]
self._model_list_cache[entry.entry_id] = model_list
@@ -108,22 +107,6 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
},
)
async def get_model_list(
self, client: anthropic.AsyncAnthropic
) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
models = (await client.models.list(timeout=10.0)).data
except anthropic.AnthropicError:
models = []
return [
SelectOptionDict(
label=model_info.display_name,
value=model_alias(model_info.id),
)
for model_info in models
]
def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]:
"""Yield entry/subentry pairs that use deprecated models."""
for entry in self.hass.config_entries.async_entries(DOMAIN):

View File

@@ -38,10 +38,6 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "AI task",
"error": {
"api_error": "[%key:component::anthropic::config_subentries::conversation::error::api_error%]",
"model_not_found": "[%key:component::anthropic::config_subentries::conversation::error::model_not_found%]"
},
"initiate_flow": {
"reconfigure": "Reconfigure AI task",
"user": "Add AI task"
@@ -102,10 +98,6 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "Conversation agent",
"error": {
"api_error": "Unable to get model info: {message}",
"model_not_found": "Model not found"
},
"initiate_flow": {
"reconfigure": "Reconfigure conversation agent",
"user": "Add conversation agent"

View File

@@ -14,8 +14,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SIGNAL_CONNECTED, AppleTvConfigEntry
from .entity import AppleTVEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,

View File

@@ -1,8 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_idle": {
@@ -161,9 +160,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::trigger_for_name%]"
}
},
"name": "Satellite became idle"
@@ -173,9 +169,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::trigger_for_name%]"
}
},
"name": "Satellite started listening"
@@ -185,9 +178,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::trigger_for_name%]"
}
},
"name": "Satellite started processing"
@@ -197,9 +187,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::trigger_for_name%]"
}
},
"name": "Satellite started responding"

View File

@@ -13,11 +13,6 @@
- last
- any
translation_key: trigger_behavior
for:
required: true
default: 00:00:00
selector:
duration:
idle: *trigger_common
listening: *trigger_common

View File

@@ -7,9 +7,9 @@ import logging
from typing import TYPE_CHECKING, Any
from aurorapy.client import AuroraError, AuroraSerialClient
import serial.tools.list_ports
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
@@ -57,11 +57,9 @@ def validate_and_connect(
return ret
async def async_scan_comports(
hass: HomeAssistant,
) -> tuple[list[str] | None, str | None]:
def scan_comports() -> tuple[list[str] | None, str | None]:
"""Find and store available com ports for the GUI dropdown."""
com_ports = await usb.async_scan_serial_ports(hass)
com_ports = serial.tools.list_ports.comports(include_links=True)
com_ports_list = []
for port in com_ports:
com_ports_list.append(port.device)
@@ -89,7 +87,7 @@ class AuroraABBConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if self._com_ports_list is None:
result = await async_scan_comports(self.hass)
result = await self.hass.async_add_executor_job(scan_comports)
self._com_ports_list, self._default_com_port = result
if self._default_com_port is None:
return self.async_abort(reason="no_serial_ports")

View File

@@ -3,7 +3,6 @@
"name": "Aurora ABB PowerOne Solar PV",
"codeowners": ["@davet2001"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone",
"integration_type": "device",
"iot_class": "local_polling",

View File

@@ -143,7 +143,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"occupancy",
"person",
"power",
"remote",
"schedule",
"select",
"siren",
@@ -151,8 +150,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"temperature",
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"water_heater",

View File

@@ -8,7 +8,7 @@ from autoskope_client.models import CannotConnect, InvalidAuth
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DEFAULT_HOST
@@ -31,7 +31,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) ->
try:
await api.connect()
except InvalidAuth as err:
raise ConfigEntryAuthFailed(
# Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed)
raise ConfigEntryError(
"Authentication failed, please check credentials"
) from err
except CannotConnect as err:

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from autoskope_client.api import AutoskopeApi
@@ -40,39 +39,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Autoskope."""
VERSION = 1
async def _async_validate_credentials(
self, host: str, username: str, password: str, errors: dict[str, str]
) -> bool:
"""Validate credentials against the Autoskope API."""
try:
async with AutoskopeApi(
host=host,
username=username,
password=password,
):
pass
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
else:
return True
return False
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -91,9 +63,18 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(f"{username}@{host}")
self._abort_if_unique_id_configured()
if await self._async_validate_credentials(
host, username, user_input[CONF_PASSWORD], errors
):
try:
async with AutoskopeApi(
host=host,
username=username,
password=user_input[CONF_PASSWORD],
):
pass
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
else:
return self.async_create_entry(
title=f"Autoskope ({username})",
data={
@@ -106,35 +87,3 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle initiation of re-authentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication with new credentials."""
errors: dict[str, str] = {}
if user_input is not None:
reauth_entry = self._get_reauth_entry()
if await self._async_validate_credentials(
reauth_entry.data[CONF_HOST],
reauth_entry.data[CONF_USERNAME],
user_input[CONF_PASSWORD],
errors,
):
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)

View File

@@ -39,7 +39,10 @@ rules:
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
reauthentication-flow:
status: todo
comment: |
Reauthentication flow removed for initial PR, will be added in follow-up.
test-coverage: done
# Gold
devices: done

View File

@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -11,15 +10,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "The new password for your Autoskope account."
},
"description": "Please re-enter your password for your Autoskope account."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -101,7 +101,8 @@ def handle_b2_errors[T](
try:
return await func(*args, **kwargs)
except B2Error as err:
raise BackupAgentError(f"Failed during {func.__name__}: {err}") from err
error_msg = f"Failed during {func.__name__}"
raise BackupAgentError(error_msg) from err
return wrapper
@@ -169,7 +170,8 @@ class BackblazeBackupAgent(BackupAgent):
async def _cleanup_failed_upload(self, filename: str) -> None:
"""Clean up a partially uploaded file after upload failure."""
_LOGGER.warning(
"Attempting to delete partially uploaded backup file %s",
"Attempting to delete partially uploaded main backup file %s "
"due to metadata upload failure",
filename,
)
try:
@@ -178,10 +180,11 @@ class BackblazeBackupAgent(BackupAgent):
)
await self._hass.async_add_executor_job(uploaded_main_file_info.delete)
except B2Error:
_LOGGER.warning(
"Failed to clean up partially uploaded backup file %s;"
" manual deletion from Backblaze B2 may be required",
_LOGGER.debug(
"Failed to clean up partially uploaded main backup file %s. "
"Manual intervention may be required to delete it from Backblaze B2",
filename,
exc_info=True,
)
else:
_LOGGER.debug(
@@ -253,10 +256,9 @@ class BackblazeBackupAgent(BackupAgent):
prefixed_metadata_filename,
)
tar_uploaded = False
upload_successful = False
try:
await self._upload_backup_file(prefixed_tar_filename, open_stream, {})
tar_uploaded = True
_LOGGER.debug(
"Main backup file upload finished for %s", prefixed_tar_filename
)
@@ -268,14 +270,15 @@ class BackblazeBackupAgent(BackupAgent):
_LOGGER.debug(
"Metadata file upload finished for %s", prefixed_metadata_filename
)
_LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename)
self._invalidate_caches(
backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename
)
except B2Error:
if tar_uploaded:
upload_successful = True
finally:
if upload_successful:
_LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename)
self._invalidate_caches(
backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename
)
else:
await self._cleanup_failed_upload(prefixed_tar_filename)
raise
def _upload_metadata_file_sync(
self, metadata_content: bytes, filename: str

View File

@@ -3,7 +3,6 @@
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold type"
},
"conditions": {
@@ -88,9 +87,6 @@
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
@@ -102,9 +98,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery low"
@@ -114,9 +107,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery not low"
@@ -126,9 +116,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery started charging"
@@ -138,9 +125,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery stopped charging"

View File

@@ -9,11 +9,6 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
@@ -47,25 +42,21 @@
low:
fields:
behavior: *trigger_behavior
for: *trigger_for
target: *trigger_target_battery
not_low:
fields:
behavior: *trigger_behavior
for: *trigger_for
target: *trigger_target_battery
started_charging:
fields:
behavior: *trigger_behavior
for: *trigger_for
target: *trigger_target_charging
stopped_charging:
fields:
behavior: *trigger_behavior
for: *trigger_for
target: *trigger_target_charging
level_changed:
@@ -83,7 +74,6 @@ level_crossed_threshold:
target: *trigger_target_percentage
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -1,7 +1,7 @@
{
"issues": {
"integration_removed": {
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a [community integration]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"title": "The BMW Connected Drive integration has been removed"
}
}

View File

@@ -260,14 +260,6 @@ BUTTONS: tuple[BondButtonEntityDescription, ...] = (
),
)
PRESET_BUTTON = BondButtonEntityDescription(
key=Action.PRESET,
name="Preset",
translation_key="preset",
mutually_exclusive=None,
argument=None,
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -293,8 +285,6 @@ async def async_setup_entry(
# we only add the stop action button if we add actions
# since its not so useful if there are no actions to stop
device_entities.append(BondButtonEntity(data, device, STOP_BUTTON))
if device.has_action(PRESET_BUTTON.key):
device_entities.append(BondButtonEntity(data, device, PRESET_BUTTON))
entities.extend(device_entities)
async_add_entities(entities)

View File

@@ -9,7 +9,7 @@
"iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"quality_scale": "platinum",
"requirements": ["brother==6.1.0"],
"requirements": ["brother==6.0.0"],
"zeroconf": [
{
"name": "brother*",

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.1.4"],
"requirements": ["python-bsblan==5.1.3"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/camera",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["PyTurboJPEG==2.2.0"]
"requirements": ["PyTurboJPEG==1.8.0"]
}

View File

@@ -39,9 +39,7 @@ class ChessConfigFlow(ConfigFlow, domain=DOMAIN):
else:
await self.async_set_unique_id(str(user.player_id))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user.name or user.username, data=user_input
)
return self.async_create_entry(title=user.name, data=user_input)
return self.async_show_form(
step_id="user",

View File

@@ -3,7 +3,6 @@
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold type"
},
"conditions": {
@@ -386,9 +385,6 @@
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::trigger_for_name%]"
},
"hvac_mode": {
"description": "The HVAC modes to trigger on.",
"name": "Modes"
@@ -401,9 +397,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device started cooling"
@@ -413,9 +406,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device started drying"
@@ -425,9 +415,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device started heating"
@@ -447,9 +434,6 @@
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
@@ -471,9 +455,6 @@
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
@@ -485,9 +466,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device turned off"
@@ -497,9 +475,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device turned on"

View File

@@ -13,11 +13,6 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.humidity_threshold_entity: &humidity_threshold_entity
- domain: input_number
@@ -55,7 +50,6 @@ hvac_mode_changed:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
for: *trigger_for
hvac_mode:
context:
filter_target: target
@@ -82,7 +76,6 @@ target_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -108,7 +101,6 @@ target_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -112,7 +112,7 @@ class ComelitAlarmEntity(
@property
def available(self) -> bool:
"""Return True if alarm is available."""
if self._area.human_status == AlarmAreaState.UNKNOWN:
if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]:
return False
return super().available
@@ -151,7 +151,7 @@ class ComelitAlarmEntity(
if code != str(self.coordinator.api.device_pin):
return
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[DISABLE], self._area.anomaly
self._area.index, ALARM_ACTIONS[DISABLE]
)
await self._async_update_state(
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
@@ -160,7 +160,7 @@ class ComelitAlarmEntity(
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[AWAY], self._area.anomaly
self._area.index, ALARM_ACTIONS[AWAY]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
@@ -169,7 +169,7 @@ class ComelitAlarmEntity(
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[HOME], self._area.anomaly
self._area.index, ALARM_ACTIONS[HOME]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
@@ -178,7 +178,7 @@ class ComelitAlarmEntity(
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[NIGHT], self._area.anomaly
self._area.index, ALARM_ACTIONS[NIGHT]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]

View File

@@ -1,18 +1,15 @@
"""Support for binary sensors."""
"""Support for sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, cast
from typing import TYPE_CHECKING, cast
from aiocomelit.api import ComelitVedoAreaObject, ComelitVedoZoneObject
from aiocomelit.const import ALARM_AREA, ALARM_ZONE, AlarmAreaState, AlarmZoneState
from aiocomelit.api import ComelitVedoZoneObject
from aiocomelit.const import ALARM_ZONE, AlarmZoneState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -26,68 +23,12 @@ from .utils import new_device_listener
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ComelitBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Comelit binary sensor entity description."""
object_type: str
is_on_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool]
available_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool] = (
lambda obj: True
)
BINARY_SENSOR_TYPES: Final[tuple[ComelitBinarySensorEntityDescription, ...]] = (
ComelitBinarySensorEntityDescription(
key="anomaly",
translation_key="anomaly",
object_type=ALARM_AREA,
device_class=BinarySensorDeviceClass.PROBLEM,
is_on_fn=lambda obj: cast(ComelitVedoAreaObject, obj).anomaly,
available_fn=lambda obj: (
cast(ComelitVedoAreaObject, obj).human_status != AlarmAreaState.UNKNOWN
),
),
ComelitBinarySensorEntityDescription(
key="presence",
translation_key="motion",
object_type=ALARM_ZONE,
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda obj: cast(ComelitVedoZoneObject, obj).status_api == "0001",
available_fn=lambda obj: (
cast(ComelitVedoZoneObject, obj).human_status
not in {
AlarmZoneState.FAULTY,
AlarmZoneState.UNAVAILABLE,
AlarmZoneState.UNKNOWN,
}
),
),
ComelitBinarySensorEntityDescription(
key="faulty",
translation_key="faulty",
object_type=ALARM_ZONE,
device_class=BinarySensorDeviceClass.PROBLEM,
is_on_fn=lambda obj: (
cast(ComelitVedoZoneObject, obj).human_status == AlarmZoneState.FAULTY
),
available_fn=lambda obj: (
cast(ComelitVedoZoneObject, obj).human_status
not in {
AlarmZoneState.UNAVAILABLE,
AlarmZoneState.UNKNOWN,
}
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit VEDO binary sensors."""
"""Set up Comelit VEDO presence sensors."""
coordinator = config_entry.runtime_data
is_bridge = isinstance(coordinator, ComelitSerialBridge)
@@ -101,23 +42,13 @@ async def async_setup_entry(
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBinarySensorEntity(
coordinator,
device,
config_entry.entry_id,
description,
)
for description in BINARY_SENSOR_TYPES
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if description.object_type == dev_type
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, ALARM_AREA)
)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, ALARM_ZONE)
)
@@ -128,47 +59,42 @@ class ComelitVedoBinarySensorEntity(
):
"""Sensor device."""
entity_description: ComelitBinarySensorEntityDescription
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.MOTION
def __init__(
self,
coordinator: ComelitVedoSystem | ComelitSerialBridge,
object_data: ComelitVedoAreaObject | ComelitVedoZoneObject,
zone: ComelitVedoZoneObject,
config_entry_entry_id: str,
description: ComelitBinarySensorEntityDescription,
) -> None:
"""Init sensor entity."""
self.entity_description = description
self._object_index = object_data.index
self._object_type = description.object_type
self._zone_index = zone.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = (
f"{config_entry_entry_id}-{description.key}-{self._object_index}"
)
self._attr_device_info = coordinator.platform_device_info(
object_data, "area" if self._object_type == ALARM_AREA else "zone"
)
self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
@property
def _object(self) -> ComelitVedoAreaObject | ComelitVedoZoneObject:
"""Return alarm object."""
def _zone(self) -> ComelitVedoZoneObject:
"""Return zone object."""
return cast(
ComelitVedoAreaObject | ComelitVedoZoneObject,
self.coordinator.data[self._object_type][self._object_index],
ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index]
)
@property
def available(self) -> bool:
"""Return True if object is available."""
if not self.entity_description.available_fn(self._object):
"""Return True if alarm is available."""
if self._zone.human_status in [
AlarmZoneState.FAULTY,
AlarmZoneState.UNAVAILABLE,
AlarmZoneState.UNKNOWN,
]:
return False
return super().available
@property
def is_on(self) -> bool:
"""Return object binary sensor state."""
return self.entity_description.is_on_fn(self._object)
"""Presence detected."""
return self._zone.status_api == "0001"

View File

@@ -65,7 +65,6 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
)
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
configuration_url=self.api.base_url,
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.entry_id)},
model=device,

View File

@@ -64,17 +64,6 @@
}
},
"entity": {
"binary_sensor": {
"anomaly": {
"name": "Anomaly"
},
"faulty": {
"name": "Faulty"
},
"motion": {
"name": "Motion"
}
},
"climate": {
"thermostat": {
"state_attributes": {

View File

@@ -1,7 +1,6 @@
{
"common": {
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_value": {
@@ -97,9 +96,6 @@
"fields": {
"behavior": {
"name": "[%key:component::counter::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::counter::common::trigger_for_name%]"
}
},
"name": "Counter reached maximum"
@@ -109,9 +105,6 @@
"fields": {
"behavior": {
"name": "[%key:component::counter::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::counter::common::trigger_for_name%]"
}
},
"name": "Counter reached minimum"
@@ -121,9 +114,6 @@
"fields": {
"behavior": {
"name": "[%key:component::counter::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::counter::common::trigger_for_name%]"
}
},
"name": "Counter reset"

View File

@@ -13,11 +13,6 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
incremented:
target:

View File

@@ -1,8 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"awning_is_closed": {
@@ -255,9 +254,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Awning closed"
@@ -267,9 +263,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Awning opened"
@@ -279,9 +272,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Blind closed"
@@ -291,9 +281,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Blind opened"
@@ -303,9 +290,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Curtain closed"
@@ -315,9 +299,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Curtain opened"
@@ -327,9 +308,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Shade closed"
@@ -339,9 +317,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Shade opened"
@@ -351,9 +326,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Shutter closed"
@@ -363,9 +335,6 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Shutter opened"

View File

@@ -9,11 +9,6 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
awning_closed:
fields: *trigger_common_fields

View File

@@ -10,6 +10,8 @@ from crownstone_cloud.exceptions import (
CrownstoneAuthenticationError,
CrownstoneUnknownError,
)
import serial.tools.list_ports
from serial.tools.list_ports_common import ListPortInfo
import voluptuous as vol
from homeassistant.components import usb
@@ -59,11 +61,9 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set up a Crownstone USB dongle."""
list_of_ports = [
p
for p in await usb.async_scan_serial_ports(self.hass)
if isinstance(p, usb.USBDevice)
]
list_of_ports = await self.hass.async_add_executor_job(
serial.tools.list_ports.comports
)
if self.flow_type == CONFIG_FLOW:
ports_as_string = list_ports_as_str(list_of_ports)
else:
@@ -82,8 +82,10 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
else:
index = ports_as_string.index(selection) - 1
selected_port = list_of_ports[index]
self.usb_path = selected_port.device
selected_port: ListPortInfo = list_of_ports[index]
self.usb_path = await self.hass.async_add_executor_job(
usb.get_serial_by_id, selected_port.device
)
return await self.async_step_usb_sphere_config()
return self.async_show_form(

View File

@@ -5,14 +5,15 @@ from __future__ import annotations
from collections.abc import Sequence
import os
from serial.tools.list_ports_common import ListPortInfo
from homeassistant.components import usb
from homeassistant.components.usb import USBDevice
from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST
def list_ports_as_str(
serial_ports: Sequence[USBDevice], no_usb_option: bool = True
serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True
) -> list[str]:
"""Represent currently available serial ports as string.
@@ -30,8 +31,8 @@ def list_ports_as_str(
port.serial_number,
port.manufacturer,
port.description,
port.vid,
port.pid,
f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None,
f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None,
)
for port in serial_ports
)

View File

@@ -1,9 +1,9 @@
{
"domain": "crownstone",
"name": "Crownstone",
"after_dependencies": ["usb"],
"codeowners": ["@Crownstone", "@RicArch97"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/crownstone",
"iot_class": "cloud_push",
"loggers": [
@@ -15,6 +15,7 @@
"requirements": [
"crownstone-cloud==1.4.11",
"crownstone-sse==2.0.5",
"crownstone-uart==2.1.0"
"crownstone-uart==2.1.0",
"pyserial==3.5"
]
}

View File

@@ -45,7 +45,7 @@ class DemoImageProcessingFace(ImageProcessingFaceEntity):
"""Return minimum confidence for send events."""
return 80
async def async_process_image(self, image: bytes) -> None:
def process_image(self, image: bytes) -> None:
"""Process image."""
demo_data = [
FaceInformation(
@@ -58,4 +58,4 @@ class DemoImageProcessingFace(ImageProcessingFaceEntity):
FaceInformation(confidence=62.53, name="Luna"),
]
self.async_process_faces(demo_data, 4)
self.process_faces(demo_data, 4)

View File

@@ -1,8 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_home": {
@@ -127,9 +126,6 @@
"fields": {
"behavior": {
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::device_tracker::common::trigger_for_name%]"
}
},
"name": "Entered home"
@@ -139,9 +135,6 @@
"fields": {
"behavior": {
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::device_tracker::common::trigger_for_name%]"
}
},
"name": "Left home"

View File

@@ -13,11 +13,6 @@
- last
- any
translation_key: trigger_behavior
for:
required: true
default: 00:00:00
selector:
duration:
entered_home: *trigger_common
left_home: *trigger_common

View File

@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pydoods"],
"quality_scale": "legacy",
"requirements": ["pydoods==1.0.2", "Pillow==12.2.0"]
"requirements": ["pydoods==1.0.2", "Pillow==12.1.1"]
}

View File

@@ -1,8 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_closed": {
@@ -46,9 +45,6 @@
"fields": {
"behavior": {
"name": "[%key:component::door::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::door::common::trigger_for_name%]"
}
},
"name": "Door closed"
@@ -58,9 +54,6 @@
"fields": {
"behavior": {
"name": "[%key:component::door::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::door::common::trigger_for_name%]"
}
},
"name": "Door opened"

View File

@@ -9,11 +9,6 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
closed:
fields: *trigger_common_fields

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from functools import partial
import os
from typing import Any
from dsmr_parser import obis_references as obis_ref
@@ -14,9 +15,9 @@ from dsmr_parser.clients.rfxtrx_protocol import (
)
from dsmr_parser.objects import DSMRObject
import serial
import serial.tools.list_ports
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -228,7 +229,9 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
self._dsmr_version = user_input[CONF_DSMR_VERSION]
return await self.async_step_setup_serial_manual_path()
dev_path = user_selection
dev_path = await self.hass.async_add_executor_job(
get_serial_by_id, user_selection
)
validate_data = {
CONF_PORT: dev_path,
@@ -239,10 +242,9 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
ports = await usb.async_scan_serial_ports(self.hass)
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
list_of_ports = {
port.device: f"{port.device} - {port.description or 'n/a'}"
f", s/n: {port.serial_number or 'n/a'}"
port.device: f"{port}, s/n: {port.serial_number or 'n/a'}"
+ (f" - {port.manufacturer}" if port.manufacturer else "")
for port in ports
}
@@ -333,6 +335,18 @@ class DSMROptionFlowHandler(OptionsFlow):
)
def get_serial_by_id(dev_path: str) -> str:
"""Return a /dev/serial/by-id match for given device if available."""
by_id = "/dev/serial/by-id"
if not os.path.isdir(by_id):
return dev_path
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
if os.path.realpath(path) == dev_path:
return path
return dev_path
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -3,7 +3,6 @@
"name": "DSMR Smart Meter",
"codeowners": ["@Robbie1221"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/dsmr",
"integration_type": "hub",
"iot_class": "local_push",

View File

@@ -1,34 +0,0 @@
"""The Duco integration."""
from __future__ import annotations
from duco import DucoClient
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
from .coordinator import DucoConfigEntry, DucoCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
client = DucoClient(
session=async_get_clientsession(hass),
host=entry.data[CONF_HOST],
)
coordinator = DucoCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Unload a Duco config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,74 +0,0 @@
"""Config flow for the Duco integration."""
from __future__ import annotations
import logging
from typing import Any
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
}
)
class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Duco."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
_LOGGER.exception("Unexpected error connecting to Duco box")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=box_name,
data={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_SCHEMA,
errors=errors,
)
async def _validate_input(self, host: str) -> tuple[str, str]:
"""Validate the user input by connecting to the Duco box.
Returns a tuple of (box_name, mac_address).
"""
client = DucoClient(
session=async_get_clientsession(self.hass),
host=host,
)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
return board_info.box_name, lan_info.mac

View File

@@ -1,9 +0,0 @@
"""Constants for the Duco integration."""
from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "duco"
PLATFORMS = [Platform.FAN, Platform.SENSOR]
SCAN_INTERVAL = timedelta(seconds=30)

View File

@@ -1,75 +0,0 @@
"""Data update coordinator for the Duco integration."""
from __future__ import annotations
import logging
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
from duco.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type DucoConfigEntry = ConfigEntry[DucoCoordinator]
type DucoData = dict[int, Node]
class DucoCoordinator(DataUpdateCoordinator[DucoData]):
"""Coordinator for the Duco integration."""
config_entry: DucoConfigEntry
board_info: BoardInfo
def __init__(
self,
hass: HomeAssistant,
config_entry: DucoConfigEntry,
client: DucoClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.client = client
async def _async_setup(self) -> None:
"""Fetch board info once during initial setup."""
try:
self.board_info = await self.client.async_get_board_info()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise ConfigEntryError(f"Duco API error: {err}") from err
async def _async_update_data(self) -> DucoData:
"""Fetch node data from the Duco box."""
try:
nodes = await self.client.async_get_nodes()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
return {node.node_id: node for node in nodes}

View File

@@ -1,53 +0,0 @@
"""Diagnostics support for Duco."""
from __future__ import annotations
import asyncio
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from .coordinator import DucoConfigEntry
TO_REDACT = {
CONF_HOST,
"mac",
"host_name",
"serial_board_box",
"serial_board_comm",
"serial_duco_box",
"serial_duco_comm",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: DucoConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
board.pop("time")
lan_info, duco_diags, write_remaining = await asyncio.gather(
coordinator.client.async_get_lan_info(),
coordinator.client.async_get_diagnostics(),
coordinator.client.async_get_write_req_remaining(),
)
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node) for node_id, node in coordinator.data.items()
},
"duco_diagnostics": [asdict(d) for d in duco_diags],
"write_requests_remaining": write_remaining,
},
TO_REDACT,
)

View File

@@ -1,52 +0,0 @@
"""Base entity for the Duco integration."""
from __future__ import annotations
from duco.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import DucoCoordinator
class DucoEntity(CoordinatorEntity[DucoCoordinator]):
"""Base class for Duco entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._node_id = node.node_id
mac = coordinator.config_entry.unique_id
assert mac is not None
device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
model=coordinator.board_info.box_name
if node.general.node_type == "BOX"
else node.general.node_type,
name=node.general.name or f"Node {node.node_id}",
)
device_info.update(
{
"connections": {(CONNECTION_NETWORK_MAC, mac)},
"serial_number": coordinator.board_info.serial_board_box,
}
if node.general.node_type == "BOX"
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
)
self._attr_device_info = device_info
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._node_id in self.coordinator.data
@property
def _node(self) -> Node:
"""Return the current node data from the coordinator."""
return self.coordinator.data[self._node_id]

View File

@@ -1,127 +0,0 @@
"""Fan platform for the Duco integration."""
from __future__ import annotations
from duco.exceptions import DucoError
from duco.models import Node, VentilationState
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import percentage_to_ordered_list_item
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
PARALLEL_UPDATES = 1
# Permanent speed states ordered low → high.
ORDERED_NAMED_FAN_SPEEDS: list[VentilationState] = [
VentilationState.CNT1,
VentilationState.CNT2,
VentilationState.CNT3,
]
PRESET_AUTO = "auto"
# Upper-bound percentages for 3 speed levels: 33 / 66 / 100.
# Using upper bounds guarantees that reading a percentage back and writing it
# again always round-trips to the same Duco state.
_SPEED_LEVEL_PERCENTAGES: list[int] = [
(i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS)
for i in range(len(ORDERED_NAMED_FAN_SPEEDS))
]
# Maps every active Duco state (including timed MAN variants) to its
# display percentage so externally-set timed modes show the correct level.
_STATE_TO_PERCENTAGE: dict[VentilationState, int] = {
VentilationState.CNT1: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.MAN1: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.MAN1x2: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.MAN1x3: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.CNT2: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.MAN2: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.MAN2x2: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.MAN2x3: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.CNT3: _SPEED_LEVEL_PERCENTAGES[2],
VentilationState.MAN3: _SPEED_LEVEL_PERCENTAGES[2],
VentilationState.MAN3x2: _SPEED_LEVEL_PERCENTAGES[2],
VentilationState.MAN3x3: _SPEED_LEVEL_PERCENTAGES[2],
}
async def async_setup_entry(
hass: HomeAssistant,
entry: DucoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Duco fan entities."""
coordinator = entry.runtime_data
async_add_entities(
DucoVentilationFanEntity(coordinator, node)
for node in coordinator.data.values()
if node.general.node_type == "BOX"
)
class DucoVentilationFanEntity(DucoEntity, FanEntity):
"""Fan entity for the ventilation control of a Duco node."""
_attr_translation_key = "ventilation"
_attr_name = None
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
_attr_preset_modes = [PRESET_AUTO]
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
"""Initialize the fan entity."""
super().__init__(coordinator, node)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{node.node_id}"
@property
def percentage(self) -> int | None:
"""Return the current speed as a percentage, or None when in AUTO mode."""
node = self._node
if node.ventilation is None:
return None
return _STATE_TO_PERCENTAGE.get(node.ventilation.state)
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode (auto when Duco controls, else None)."""
node = self._node
if node.ventilation is None:
return None
if node.ventilation.state not in _STATE_TO_PERCENTAGE:
return PRESET_AUTO
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode: 'auto' hands control back to Duco."""
self._valid_preset_mode_or_raise(preset_mode)
await self._async_set_state(VentilationState.AUTO)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the fan speed as a percentage (maps to low/medium/high)."""
if percentage == 0:
await self._async_set_state(VentilationState.AUTO)
return
state = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
await self._async_set_state(state)
async def _async_set_state(self, state: VentilationState) -> None:
"""Send the ventilation state to the device and refresh coordinator."""
try:
await self.coordinator.client.async_set_ventilation_state(
self._node_id, state
)
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_state",
translation_placeholders={"error": repr(err)},
) from err
await self.coordinator.async_refresh()

View File

@@ -1,15 +0,0 @@
{
"entity": {
"sensor": {
"iaq_co2": {
"default": "mdi:molecule-co2"
},
"iaq_rh": {
"default": "mdi:water-percent"
},
"ventilation_state": {
"default": "mdi:tune-variant"
}
}
}
}

View File

@@ -1,12 +0,0 @@
{
"domain": "duco",
"name": "Duco",
"codeowners": ["@ronaldvdmeer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/duco",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "bronze",
"requirements": ["python-duco-client==0.3.1"]
}

View File

@@ -1,89 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not provide service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not provide service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration uses a coordinator; entities do not subscribe to events directly.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not provide an option flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
status: done
comment: Handled by the DataUpdateCoordinator.
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Integration uses a local API that requires no credentials.
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: todo
comment: >-
DHCP host updating to be implemented in a follow-up PR.
The device hostname follows the pattern duco_<last 6 chars of MAC>
(e.g. duco_061293), which can be used for DHCP hostname matching.
discovery:
status: todo
comment: >-
Device can be discovered via DHCP. The hostname follows the pattern
duco_<last 6 chars of MAC> (e.g. duco_061293). To be implemented
in a follow-up PR together with discovery-update-info.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: todo
comment: >-
Users can pair new modules (CO2 sensors, humidity sensors, zone valves)
to their Duco box. Dynamic device support to be added in a follow-up PR.
entity-category: todo
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: todo
comment: >-
To be implemented together with dynamic device support in a follow-up PR.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -1,119 +0,0 @@
"""Sensor platform for the Duco integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from duco.models import Node, NodeType, VentilationState
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class DucoSensorEntityDescription(SensorEntityDescription):
"""Duco sensor entity description."""
value_fn: Callable[[Node], int | float | str | None]
node_types: tuple[NodeType, ...]
SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
DucoSensorEntityDescription(
key="ventilation_state",
translation_key="ventilation_state",
device_class=SensorDeviceClass.ENUM,
options=[s.lower() for s in VentilationState],
value_fn=lambda node: (
node.ventilation.state.lower() if node.ventilation else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
value_fn=lambda node: node.sensor.co2 if node.sensor else None,
node_types=(NodeType.UCCO2,),
),
DucoSensorEntityDescription(
key="iaq_co2",
translation_key="iaq_co2",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_co2 if node.sensor else None,
node_types=(NodeType.UCCO2,),
),
DucoSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda node: node.sensor.rh if node.sensor else None,
node_types=(NodeType.BSRH,),
),
DucoSensorEntityDescription(
key="iaq_rh",
translation_key="iaq_rh",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_rh if node.sensor else None,
node_types=(NodeType.BSRH,),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: DucoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Duco sensor entities."""
coordinator = entry.runtime_data
async_add_entities(
DucoSensorEntity(coordinator, node, description)
for node in coordinator.data.values()
for description in SENSOR_DESCRIPTIONS
if node.general.node_type in description.node_types
)
class DucoSensorEntity(DucoEntity, SensorEntity):
"""Sensor entity for a Duco node."""
entity_description: DucoSensorEntityDescription
def __init__(
self,
coordinator: DucoCoordinator,
node: Node,
description: DucoSensorEntityDescription,
) -> None:
"""Initialize the sensor entity."""
super().__init__(coordinator, node)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_{node.node_id}_{description.key}"
)
@property
def native_value(self) -> int | float | str | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self._node)

View File

@@ -1,75 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "IP address or hostname of your Duco ventilation box."
}
}
}
},
"entity": {
"fan": {
"ventilation": {
"state_attributes": {
"preset_mode": {
"state": {
"auto": "[%key:common::state::auto%]"
}
}
}
}
},
"sensor": {
"iaq_co2": {
"name": "CO2 air quality index"
},
"iaq_rh": {
"name": "Humidity air quality index"
},
"ventilation_state": {
"name": "Ventilation state",
"state": {
"aut1": "Automatic boost (15 min)",
"aut2": "Automatic boost (30 min)",
"aut3": "Automatic boost (45 min)",
"auto": "Automatic",
"cnt1": "Continuous low speed",
"cnt2": "Continuous medium speed",
"cnt3": "Continuous high speed",
"empt": "Empty house",
"man1": "Manual low speed (15 min)",
"man1x2": "Manual low speed (30 min)",
"man1x3": "Manual low speed (45 min)",
"man2": "Manual medium speed (15 min)",
"man2x2": "Manual medium speed (30 min)",
"man2x3": "Manual medium speed (45 min)",
"man3": "Manual high speed (15 min)",
"man3x2": "Manual high speed (30 min)",
"man3x3": "Manual high speed (45 min)"
}
}
}
},
"exceptions": {
"api_error": {
"message": "Unexpected error from the Duco API: {error}"
},
"cannot_connect": {
"message": "An error occurred while trying to connect to the Duco instance: {error}"
},
"failed_to_set_state": {
"message": "Failed to set ventilation state: {error}"
}
}
}

View File

@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from elgato import Elgato
from elgato import Elgato, ElgatoError
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -15,11 +15,11 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
from .helpers import elgato_exception_handler
PARALLEL_UPDATES = 1
@@ -80,7 +80,11 @@ class ElgatoButtonEntity(ElgatoEntity, ButtonEntity):
f"{coordinator.data.info.serial_number}_{description.key}"
)
@elgato_exception_handler
async def async_press(self) -> None:
"""Trigger button press on the Elgato device."""
await self.entity_description.press_fn(self.coordinator.client)
try:
await self.entity_description.press_fn(self.coordinator.client)
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while communicating with the Elgato Light"
) from error

View File

@@ -12,8 +12,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -25,6 +23,7 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
host: str
port: int
serial_number: str
mac: str | None = None
@@ -71,69 +70,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initiated by zeroconf."""
return self._async_create_entry()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of an existing Elgato device."""
errors: dict[str, str] = {}
if user_input is not None:
elgato = Elgato(
host=user_input[CONF_HOST],
session=async_get_clientsession(self.hass),
)
try:
info = await elgato.info()
except ElgatoError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(info.serial_number)
self._abort_if_unique_id_mismatch(reason="different_device")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{
vol.Required(
CONF_HOST,
default=self._get_reconfigure_entry().data[CONF_HOST],
): str,
}
),
errors=errors,
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery of a known Elgato device.
Only devices already configured (matched via ``registered_devices``)
reach this step. It is used to keep the stored host in sync with the
current IP address of the device.
"""
mac = format_mac(discovery_info.macaddress)
for entry in self._async_current_entries():
if (entry_mac := entry.data.get(CONF_MAC)) is None or format_mac(
entry_mac
) != mac:
continue
if entry.data[CONF_HOST] != discovery_info.ip:
self.hass.config_entries.async_update_entry(
entry,
data=entry.data | {CONF_HOST: discovery_info.ip},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
return self.async_abort(reason="no_devices_found")
@callback
def _async_show_setup_form(
self, errors: dict[str, str] | None = None

View File

@@ -2,15 +2,7 @@
from dataclasses import dataclass
from elgato import (
BatteryInfo,
Elgato,
ElgatoConnectionError,
ElgatoError,
Info,
Settings,
State,
)
from elgato import BatteryInfo, Elgato, ElgatoConnectionError, Info, Settings, State
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
@@ -67,12 +59,4 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]):
state=await self.client.state(),
)
except ElgatoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
) from err
except ElgatoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unknown_error",
) from err
raise UpdateFailed(err) from err

View File

@@ -1,43 +0,0 @@
"""Helpers for Elgato."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from elgato import ElgatoConnectionError, ElgatoError
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .entity import ElgatoEntity
def elgato_exception_handler[_ElgatoEntityT: ElgatoEntity, **_P](
func: Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Elgato calls to handle Elgato exceptions.
A decorator that wraps the passed in function, catches Elgato errors,
and raises a translated ``HomeAssistantError``.
"""
async def handler(
self: _ElgatoEntityT, *args: _P.args, **kwargs: _P.kwargs
) -> None:
try:
await func(self, *args, **kwargs)
except ElgatoConnectionError as error:
self.coordinator.last_update_success = False
self.coordinator.async_update_listeners()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
except ElgatoError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
) from error
return handler

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
from elgato import ElgatoError
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
@@ -12,12 +14,12 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
from .helpers import elgato_exception_handler
PARALLEL_UPDATES = 1
@@ -92,13 +94,17 @@ class ElgatoLight(ElgatoEntity, LightEntity):
"""Return the state of the light."""
return self.coordinator.data.state.on
@elgato_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self.coordinator.client.light(on=False)
await self.coordinator.async_request_refresh()
try:
await self.coordinator.client.light(on=False)
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while updating the Elgato Light"
) from error
finally:
await self.coordinator.async_refresh()
@elgato_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
@@ -131,16 +137,26 @@ class ElgatoLight(ElgatoEntity, LightEntity):
else color_util.color_temperature_kelvin_to_mired(temperature_kelvin)
)
await self.coordinator.client.light(
on=True,
brightness=brightness,
hue=hue,
saturation=saturation,
temperature=temperature,
)
await self.coordinator.async_request_refresh()
try:
await self.coordinator.client.light(
on=True,
brightness=brightness,
hue=hue,
saturation=saturation,
temperature=temperature,
)
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while updating the Elgato Light"
) from error
finally:
await self.coordinator.async_refresh()
@elgato_exception_handler
async def async_identify(self) -> None:
"""Identify the light, will make it blink."""
await self.coordinator.client.identify()
try:
await self.coordinator.client.identify()
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while identifying the Elgato Light"
) from error

View File

@@ -3,11 +3,6 @@
"name": "Elgato Light",
"codeowners": ["@frenck"],
"config_flow": true,
"dhcp": [
{
"registered_devices": true
}
],
"documentation": "https://www.home-assistant.io/integrations/elgato",
"integration_type": "device",
"iot_class": "local_polling",

View File

@@ -39,7 +39,11 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery-update-info:
status: todo
comment: |
The integration doesn't update the device info based on DHCP discovery
of known existing devices.
discovery: done
docs-data-update: todo
docs-examples: todo
@@ -60,9 +64,9 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |

View File

@@ -2,24 +2,13 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"different_device": "The configured Elgato device is not the same as the one at this address.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"flow_title": "{serial_number}",
"step": {
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::elgato::config::step::user::data_description::host%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -59,14 +48,6 @@
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the Elgato device."
},
"unknown_error": {
"message": "An unknown error occurred while communicating with the Elgato device."
}
},
"services": {
"identify": {
"description": "Identifies an Elgato Light. Blinks the light, which can be useful for, e.g., a visual notification.",

Some files were not shown because too many files have changed in this diff Show More