Compare commits

..

2 Commits

Author SHA1 Message Date
Robert Resch
dd8a6b4934 Update script/hassfest/docker.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 00:17:57 +02:00
Robert Resch
bc64666b96 Use COPY instead pip to install uv in the docker image 2026-04-14 15:08:27 +00:00
374 changed files with 3326 additions and 10958 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

@@ -530,7 +530,7 @@ jobs:
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

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

13
Dockerfile generated
View File

@@ -19,21 +19,22 @@ ENV \
UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
WORKDIR /usr/src
# Home Assistant S6-Overlay
COPY rootfs /
WORKDIR /usr/src
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
# Add uv binary
COPY --from=ghcr.io/astral-sh/uv:0.11.1 /uv /bin/uv
## Setup Home Assistant Core dependencies
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
COPY requirements.txt homeassistant/
COPY homeassistant/package_constraints.txt homeassistant/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 \
--no-build \
-r homeassistant/requirements.txt
@@ -48,7 +49,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

@@ -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

@@ -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

@@ -57,9 +57,9 @@ rules:
entity-category: done
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
entity-translations: todo
exception-translations: done
icon-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -5,5 +5,5 @@ from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "duco"
PLATFORMS = [Platform.FAN, Platform.SENSOR]
PLATFORMS = [Platform.FAN]
SCAN_INTERVAL = timedelta(seconds=30)

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,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

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "bronze",
"requirements": ["python-duco-client==0.3.1"]
"requirements": ["python-duco-client==0.3.0"]
}

View File

@@ -45,7 +45,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: todo
comment: >-
@@ -71,11 +71,11 @@ rules:
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-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:

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

@@ -29,36 +29,6 @@
}
}
}
},
"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": {

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.8"],
"requirements": ["pyenphase==2.4.6"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -22,8 +22,9 @@ CONF_LOCATION_IDX: Final = "location_idx"
SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60)
ATTR_DURATION: Final = "duration" # number of minutes, <24h
ATTR_PERIOD: Final = "period" # number of days
ATTR_DURATION: Final = "duration" # number of minutes, <24h
ATTR_SETPOINT: Final = "setpoint"
@@ -36,4 +37,3 @@ class EvoService(StrEnum):
RESET_SYSTEM = "reset_system"
SET_ZONE_OVERRIDE = "set_zone_override"
CLEAR_ZONE_OVERRIDE = "clear_zone_override"
SET_DHW_OVERRIDE = "set_dhw_override"

View File

@@ -22,9 +22,6 @@
"reset_system": {
"service": "mdi:refresh"
},
"set_dhw_override": {
"service": "mdi:water-heater"
},
"set_system_mode": {
"service": "mdi:pencil"
},

View File

@@ -14,8 +14,7 @@ from evohomeasync2.schemas.const import (
import voluptuous as vol
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.const import ATTR_MODE, ATTR_STATE
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, service
@@ -50,15 +49,6 @@ SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
),
}
# DHW service schemas (registered as entity services)
SET_DHW_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
vol.Required(ATTR_STATE): cv.boolean,
vol.Optional(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
),
}
def _register_zone_entity_services(hass: HomeAssistant) -> None:
"""Register entity-level services for zones."""
@@ -81,19 +71,6 @@ def _register_zone_entity_services(hass: HomeAssistant) -> None:
)
def _register_dhw_entity_services(hass: HomeAssistant) -> None:
"""Register entity-level services for DHW zones."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
EvoService.SET_DHW_OVERRIDE,
entity_domain=WATER_HEATER_DOMAIN,
schema=SET_DHW_OVERRIDE_SCHEMA,
func="async_set_dhw_override",
)
def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None:
"""Validate that a set_system_mode service call is properly formed."""
@@ -179,4 +156,3 @@ def setup_service_functions(
)
_register_zone_entity_services(hass)
_register_dhw_entity_services(hass)

View File

@@ -58,19 +58,3 @@ clear_zone_override:
domain: climate
supported_features:
- climate.ClimateEntityFeature.TARGET_TEMPERATURE
set_dhw_override:
target:
entity:
integration: evohome
domain: water_heater
fields:
state:
required: true
selector:
boolean:
duration:
example: "02:15"
selector:
duration:
enable_second: false

View File

@@ -21,7 +21,7 @@
},
"services": {
"clear_zone_override": {
"description": "Sets the zone to follow its schedule.",
"description": "Sets a zone to follow its schedule.",
"name": "Clear zone override"
},
"refresh_system": {
@@ -29,25 +29,11 @@
"name": "Refresh system"
},
"reset_system": {
"description": "Sets the system mode to `Auto` mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. `AutoWithReset` mode).",
"description": "Sets the system to `Auto` mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. `AutoWithReset` mode).",
"name": "Reset system"
},
"set_dhw_override": {
"description": "Overrides the DHW state, either indefinitely or for a specified duration, after which it will revert to following its schedule.",
"fields": {
"duration": {
"description": "The DHW will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.",
"name": "Duration"
},
"state": {
"description": "The DHW state: True (on: heat the water up to the setpoint) or False (off).",
"name": "State"
}
},
"name": "Set DHW override"
},
"set_system_mode": {
"description": "Sets the system mode, either indefinitely or until a specified end time, after which it will revert to `Auto`. Not all systems support all modes.",
"description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to `Auto`. Not all systems support all modes.",
"fields": {
"duration": {
"description": "The duration in hours; used only with `AutoWithEco` mode (up to 24 hours).",
@@ -65,7 +51,7 @@
"name": "Set system mode"
},
"set_zone_override": {
"description": "Overrides the zone setpoint, either indefinitely or for a specified duration, after which it will revert to following its schedule.",
"description": "Overrides the zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.",
"fields": {
"duration": {
"description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.",

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
@@ -98,28 +97,6 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
PRECISION_TENTHS if coordinator.client_v1 else PRECISION_WHOLE
)
async def async_set_dhw_override(
self, state: bool, duration: timedelta | None = None
) -> None:
"""Override the DHW zone's on/off state, either permanently or for a duration."""
if duration is None:
until = None # indefinitely, aka permanent override
elif duration.total_seconds() == 0:
await self._update_schedule()
until = self.setpoints.get("next_sp_from")
else:
until = dt_util.now() + duration
until = dt_util.as_utc(until) if until else None
if state:
await self.coordinator.call_client_api(self._evo_device.set_on(until=until))
else:
await self.coordinator.call_client_api(
self._evo_device.set_off(until=until)
)
@property
def current_operation(self) -> str | None:
"""Return the current operating mode (Auto, On, or Off)."""

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_off": {
@@ -197,9 +196,6 @@
"fields": {
"behavior": {
"name": "[%key:component::fan::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::fan::common::trigger_for_name%]"
}
},
"name": "Fan turned off"
@@ -209,9 +205,6 @@
"fields": {
"behavior": {
"name": "[%key:component::fan::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::fan::common::trigger_for_name%]"
}
},
"name": "Fan turned on"

View File

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

View File

@@ -6,7 +6,7 @@ from datetime import timedelta
from aiohttp import ClientError
from pyfreshr import FreshrClient
from pyfreshr.exceptions import ApiResponseError, LoginError
from pyfreshr.models import DeviceReadings, DeviceSummary, DeviceType
from pyfreshr.models import DeviceReadings, DeviceSummary
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
@@ -18,12 +18,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER
_DEVICE_TYPE_NAMES: dict[DeviceType, str] = {
DeviceType.FRESH_R: "Fresh-r",
DeviceType.FORWARD: "Fresh-r Forward",
DeviceType.MONITOR: "Fresh-r Monitor",
}
DEVICES_SCAN_INTERVAL = timedelta(hours=1)
READINGS_SCAN_INTERVAL = timedelta(minutes=10)
@@ -116,12 +110,6 @@ class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):
)
self._device = device
self._client = client
self.device_info = dr.DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
serial_number=device.id,
manufacturer="Fresh-r",
)
@property
def device_id(self) -> str:

View File

@@ -1,18 +0,0 @@
"""Base entity for the Fresh-r integration."""
from __future__ import annotations
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import FreshrReadingsCoordinator
class FreshrEntity(CoordinatorEntity[FreshrReadingsCoordinator]):
"""Base class for Fresh-r entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: FreshrReadingsCoordinator) -> None:
"""Initialize the Fresh-r entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/freshr",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"quality_scale": "silver",
"requirements": ["pyfreshr==1.2.0"]
}

View File

@@ -21,10 +21,12 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import FreshrConfigEntry, FreshrReadingsCoordinator
from .entity import FreshrEntity
PARALLEL_UPDATES = 0
@@ -91,6 +93,12 @@ _TEMP = FreshrSensorEntityDescription(
value_fn=lambda r: r.temp,
)
_DEVICE_TYPE_NAMES: dict[DeviceType, str] = {
DeviceType.FRESH_R: "Fresh-r",
DeviceType.FORWARD: "Fresh-r Forward",
DeviceType.MONITOR: "Fresh-r Monitor",
}
SENSOR_TYPES: dict[DeviceType, tuple[FreshrSensorEntityDescription, ...]] = {
DeviceType.FRESH_R: (_T1, _T2, _CO2, _HUM, _FLOW, _DP),
DeviceType.FORWARD: (_T1, _T2, _CO2, _HUM, _FLOW, _DP, _TEMP),
@@ -123,10 +131,17 @@ async def async_setup_entry(
descriptions = SENSOR_TYPES.get(
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
)
device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
serial_number=device_id,
manufacturer="Fresh-r",
)
entities.extend(
FreshrSensor(
config_entry.runtime_data.readings[device_id],
description,
device_info,
)
for description in descriptions
)
@@ -136,19 +151,22 @@ async def async_setup_entry(
config_entry.async_on_unload(coordinator.async_add_listener(_check_devices))
class FreshrSensor(FreshrEntity, SensorEntity):
class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):
"""Representation of a Fresh-r sensor."""
_attr_has_entity_name = True
entity_description: FreshrSensorEntityDescription
def __init__(
self,
coordinator: FreshrReadingsCoordinator,
description: FreshrSensorEntityDescription,
device_info: DeviceInfo,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_device_info = device_info
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
@property

View File

@@ -9,7 +9,7 @@
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
"quality_scale": "gold",
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.4"],
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox: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::garage_door::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::garage_door::common::trigger_for_name%]"
}
},
"name": "Garage door closed"
@@ -58,9 +54,6 @@
"fields": {
"behavior": {
"name": "[%key:component::garage_door::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::garage_door::common::trigger_for_name%]"
}
},
"name": "Garage 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

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
from bleak.backends.device import BLEDevice
@@ -12,8 +13,7 @@ from gardena_bluetooth.exceptions import (
CharacteristicNotFound,
CommunicationFailure,
)
from gardena_bluetooth.parse import CharacteristicTime, ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
from gardena_bluetooth.parse import CharacteristicTime
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
@@ -29,6 +29,7 @@ from .coordinator import (
GardenaBluetoothConfigEntry,
GardenaBluetoothCoordinator,
)
from .util import async_get_product_type
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -75,10 +76,11 @@ async def async_setup_entry(
address = entry.data[CONF_ADDRESS]
mfg_data = await async_get_manufacturer_data({address})
product_type = mfg_data[address].product_type
if product_type == ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
try:
async with asyncio.timeout(TIMEOUT):
product_type = await async_get_product_type(hass, address)
except TimeoutError as exception:
raise ConfigEntryNotReady("Unable to find product type") from exception
client = Client(get_connection(hass, address), product_type)
try:

View File

@@ -9,7 +9,6 @@ from gardena_bluetooth.client import Client
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
from gardena_bluetooth.parse import ManufacturerData, ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
import voluptuous as vol
from homeassistant.components.bluetooth import (
@@ -25,27 +24,41 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_SUPPORTED_PRODUCT_TYPES = {
ProductType.PUMP,
ProductType.VALVE,
ProductType.WATER_COMPUTER,
ProductType.AUTOMATS,
ProductType.PRESSURE_TANKS,
ProductType.AQUA_CONTOURS,
}
def _is_supported(discovery_info: BluetoothServiceInfo):
"""Check if device is supported."""
if ScanService not in discovery_info.service_uuids:
return False
if discovery_info.manufacturer_data.get(ManufacturerData.company) is None:
if not (data := discovery_info.manufacturer_data.get(ManufacturerData.company)):
_LOGGER.debug("Missing manufacturer data: %s", discovery_info)
return False
manufacturer_data = ManufacturerData.decode(data)
product_type = ProductType.from_manufacturer_data(manufacturer_data)
if product_type not in (
ProductType.PUMP,
ProductType.VALVE,
ProductType.WATER_COMPUTER,
ProductType.AUTOMATS,
ProductType.PRESSURE_TANKS,
ProductType.AQUA_CONTOURS,
):
_LOGGER.debug("Unsupported device: %s", manufacturer_data)
return False
return True
def _get_name(discovery_info: BluetoothServiceInfo):
data = discovery_info.manufacturer_data[ManufacturerData.company]
manufacturer_data = ManufacturerData.decode(data)
product_type = ProductType.from_manufacturer_data(manufacturer_data)
return PRODUCT_NAMES.get(product_type, "Gardena Device")
class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gardena Bluetooth."""
@@ -77,13 +90,11 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
data = await async_get_manufacturer_data({discovery_info.address})
product_type = data[discovery_info.address].product_type
if product_type not in _SUPPORTED_PRODUCT_TYPES:
if not _is_supported(discovery_info):
return self.async_abort(reason="no_devices_found")
self.address = discovery_info.address
self.devices = {discovery_info.address: PRODUCT_NAMES[product_type]}
self.devices = {discovery_info.address: _get_name(discovery_info)}
await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
@@ -120,21 +131,12 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
current_addresses = self._async_current_ids(include_ignore=False)
candidates = set()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or not _is_supported(discovery_info):
continue
candidates.add(address)
data = await async_get_manufacturer_data(candidates)
for address, mfg_data in data.items():
if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES:
continue
self.devices[address] = PRODUCT_NAMES[mfg_data.product_type]
# Keep selection sorted by address to ensure stable tests
self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0]))
self.devices[address] = _get_name(discovery_info)
if not self.devices:
return self.async_abort(reason="no_devices_found")

View File

@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.4.0"]
"requirements": ["gardena-bluetooth==2.3.0"]
}

View File

@@ -0,0 +1,51 @@
"""Utility functions for Gardena Bluetooth integration."""
import asyncio
from collections.abc import AsyncIterator
from gardena_bluetooth.parse import ManufacturerData, ProductType
from homeassistant.components import bluetooth
async def _async_service_info(
hass, address
) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]:
queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]()
def _callback(
service_info: bluetooth.BluetoothServiceInfoBleak,
change: bluetooth.BluetoothChange,
) -> None:
if change != bluetooth.BluetoothChange.ADVERTISEMENT:
return
queue.put_nowait(service_info)
service_info = bluetooth.async_last_service_info(hass, address, True)
if service_info:
yield service_info
cancel = bluetooth.async_register_callback(
hass,
_callback,
{bluetooth.match.ADDRESS: address},
bluetooth.BluetoothScanningMode.ACTIVE,
)
try:
while True:
yield await queue.get()
finally:
cancel()
async def async_get_product_type(hass, address: str) -> ProductType:
"""Wait for enough packets of manufacturer data to get the product type."""
data = ManufacturerData()
async for service_info in _async_service_info(hass, address):
data.update(service_info.manufacturer_data.get(ManufacturerData.company, b""))
product_type = ProductType.from_manufacturer_data(data)
if product_type is not ProductType.UNKNOWN:
return product_type
raise AssertionError("Iterator should have been infinite")

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::gate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::gate::common::trigger_for_name%]"
}
},
"name": "Gate closed"
@@ -58,9 +54,6 @@
"fields": {
"behavior": {
"name": "[%key:component::gate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::gate::common::trigger_for_name%]"
}
},
"name": "Gate 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

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["av==16.0.1", "Pillow==12.2.0"]
"requirements": ["av==16.0.1", "Pillow==12.1.1"]
}

View File

@@ -175,7 +175,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await server.start()
except Exception: # noqa: BLE001
_LOGGER.warning("Could not start go2rtc server", exc_info=True)
await session.close()
return False
async def on_stop(event: Event) -> None:

View File

@@ -72,7 +72,7 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
key="mix_export_to_grid",
translation_key="mix_export_to_grid",
api_key="pacToGridTotal",
native_unit_of_measurement=UnitOfPower.WATT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
@@ -80,7 +80,7 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
key="mix_import_from_grid",
translation_key="mix_import_from_grid",
api_key="pacToUserR",
native_unit_of_measurement=UnitOfPower.WATT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),

View File

@@ -91,14 +91,10 @@ from .const import (
DATA_STORE,
DATA_SUPERVISOR_INFO,
DOMAIN,
HASSIO_MAIN_UPDATE_INTERVAL,
MAIN_COORDINATOR,
STATS_COORDINATOR,
HASSIO_UPDATE_INTERVAL,
)
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
HassioStatsDataUpdateCoordinator,
HassioDataUpdateCoordinator,
get_addons_info,
get_addons_list,
get_addons_stats,
@@ -388,6 +384,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
]
hass.data[DATA_SUPERVISOR_INFO]["addons"] = hass.data[DATA_ADDONS_LIST]
async_call_later(
hass,
HASSIO_UPDATE_INTERVAL,
HassJob(update_info_data, cancel_on_shutdown=True),
)
# Fetch data
update_info_task = hass.async_create_task(update_info_data(), eager_start=True)
@@ -434,7 +436,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
# os info not yet fetched from supervisor, retry later
async_call_later(
hass,
HASSIO_MAIN_UPDATE_INTERVAL,
HASSIO_UPDATE_INTERVAL,
async_setup_hardware_integration_job,
)
return
@@ -460,20 +462,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
dev_reg = dr.async_get(hass)
coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg)
coordinator = HassioDataUpdateCoordinator(hass, entry, dev_reg)
await coordinator.async_config_entry_first_refresh()
hass.data[MAIN_COORDINATOR] = coordinator
addon_coordinator = HassioAddOnDataUpdateCoordinator(
hass, entry, dev_reg, coordinator.jobs
)
await addon_coordinator.async_config_entry_first_refresh()
hass.data[ADDONS_COORDINATOR] = addon_coordinator
stats_coordinator = HassioStatsDataUpdateCoordinator(hass, entry)
await stats_coordinator.async_config_entry_first_refresh()
hass.data[STATS_COORDINATOR] = stats_coordinator
hass.data[ADDONS_COORDINATOR] = coordinator
def deprecated_setup_issue() -> None:
os_info = get_os_info(hass)
@@ -540,12 +531,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
# Unload coordinator
coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR]
coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
coordinator.unload()
# Pop coordinators
hass.data.pop(MAIN_COORDINATOR, None)
# Pop coordinator
hass.data.pop(ADDONS_COORDINATOR, None)
hass.data.pop(STATS_COORDINATOR, None)
return unload_ok

View File

@@ -22,7 +22,6 @@ from .const import (
ATTR_STATE,
DATA_KEY_ADDONS,
DATA_KEY_MOUNTS,
MAIN_COORDINATOR,
)
from .entity import HassioAddonEntity, HassioMountEntity
@@ -61,18 +60,17 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Binary sensor set up for Hass.io config entry."""
addons_coordinator = hass.data[ADDONS_COORDINATOR]
coordinator = hass.data[MAIN_COORDINATOR]
coordinator = hass.data[ADDONS_COORDINATOR]
async_add_entities(
itertools.chain(
[
HassioAddonBinarySensor(
addon=addon,
coordinator=addons_coordinator,
coordinator=coordinator,
entity_description=entity_description,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
],
[

View File

@@ -77,9 +77,7 @@ EVENT_JOB = "job"
UPDATE_KEY_SUPERVISOR = "supervisor"
STARTUP_COMPLETE = "complete"
MAIN_COORDINATOR = "hassio_main_coordinator"
ADDONS_COORDINATOR = "hassio_addons_coordinator"
STATS_COORDINATOR = "hassio_stats_coordinator"
DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN)
@@ -96,9 +94,7 @@ DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats"
DATA_ADDONS_LIST = "hassio_addons_list"
HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5)
HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15)
HASSIO_STATS_UPDATE_INTERVAL = timedelta(seconds=60)
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
ATTR_AUTO_UPDATE = "auto_update"
ATTR_VERSION = "version"

View File

@@ -7,7 +7,7 @@ from collections import defaultdict
from collections.abc import Awaitable
from copy import deepcopy
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
from aiohasupervisor.models import (
@@ -15,9 +15,9 @@ from aiohasupervisor.models import (
CIFSMountResponse,
InstalledAddon,
NFSMountResponse,
ResponseData,
StoreInfo,
)
from aiohasupervisor.models.base import ResponseData
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
@@ -35,11 +35,13 @@ from .const import (
ATTR_SLUG,
ATTR_URL,
ATTR_VERSION,
CONTAINER_INFO,
CONTAINER_STATS,
CORE_CONTAINER,
DATA_ADDONS_INFO,
DATA_ADDONS_LIST,
DATA_ADDONS_STATS,
DATA_COMPONENT,
DATA_CORE_INFO,
DATA_CORE_STATS,
DATA_HOST_INFO,
@@ -57,9 +59,7 @@ from .const import (
DATA_SUPERVISOR_INFO,
DATA_SUPERVISOR_STATS,
DOMAIN,
HASSIO_ADDON_UPDATE_INTERVAL,
HASSIO_MAIN_UPDATE_INTERVAL,
HASSIO_STATS_UPDATE_INTERVAL,
HASSIO_UPDATE_INTERVAL,
REQUEST_REFRESH_DELAY,
SUPERVISOR_CONTAINER,
SupervisorEntityModel,
@@ -318,314 +318,7 @@ def async_remove_devices_from_dev_reg(
dev_reg.async_remove_device(dev.id)
class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to retrieve Hass.io container stats."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=HASSIO_STATS_UPDATE_INTERVAL,
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
self.supervisor_client = get_supervisor_client(hass)
self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict(
lambda: defaultdict(set)
)
async def _async_update_data(self) -> dict[str, Any]:
"""Update stats data via library."""
try:
await self._fetch_stats()
except SupervisorError as err:
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
new_data: dict[str, Any] = {}
new_data[DATA_KEY_CORE] = get_core_stats(self.hass)
new_data[DATA_KEY_SUPERVISOR] = get_supervisor_stats(self.hass)
new_data[DATA_KEY_ADDONS] = get_addons_stats(self.hass)
return new_data
async def _fetch_stats(self) -> None:
"""Fetch container stats for subscribed entities."""
container_updates = self._container_updates
data = self.hass.data
client = self.supervisor_client
# Fetch core and supervisor stats
updates: dict[str, Awaitable] = {}
if container_updates.get(CORE_CONTAINER, {}).get(CONTAINER_STATS):
updates[DATA_CORE_STATS] = client.homeassistant.stats()
if container_updates.get(SUPERVISOR_CONTAINER, {}).get(CONTAINER_STATS):
updates[DATA_SUPERVISOR_STATS] = client.supervisor.stats()
if updates:
api_results: list[ResponseData] = await asyncio.gather(*updates.values())
for key, result in zip(updates, api_results, strict=True):
data[key] = result.to_dict()
# Fetch addon stats
addons_list = get_addons_list(self.hass) or []
started_addons = {
addon[ATTR_SLUG]
for addon in addons_list
if addon.get("state") in {AddonState.STARTED, AddonState.STARTUP}
}
addons_stats: dict[str, Any] = data.setdefault(DATA_ADDONS_STATS, {})
# Clean up cache for stopped/removed addons
for slug in addons_stats.keys() - started_addons:
del addons_stats[slug]
# Fetch stats for addons with subscribed entities
addon_stats_results = dict(
await asyncio.gather(
*[
self._update_addon_stats(slug)
for slug in started_addons
if container_updates.get(slug, {}).get(CONTAINER_STATS)
]
)
)
addons_stats.update(addon_stats_results)
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
"""Update single addon stats."""
try:
stats = await self.supervisor_client.addons.addon_stats(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch stats for %s: %s", slug, err)
return (slug, None)
return (slug, stats.to_dict())
@callback
def async_enable_container_updates(
self, slug: str, entity_id: str, types: set[str]
) -> CALLBACK_TYPE:
"""Enable stats updates for a container."""
enabled_updates = self._container_updates[slug]
for key in types:
enabled_updates[key].add(entity_id)
@callback
def _remove() -> None:
for key in types:
enabled_updates[key].discard(entity_id)
if not enabled_updates[key]:
del enabled_updates[key]
if not enabled_updates:
self._container_updates.pop(slug, None)
return _remove
class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to retrieve Hass.io Add-on status."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
dev_reg: dr.DeviceRegistry,
jobs: SupervisorJobs,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=HASSIO_ADDON_UPDATE_INTERVAL,
# We don't want an immediate refresh since we want to avoid
# hammering the Supervisor API on startup
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
self.entry_id = config_entry.entry_id
self.dev_reg = dev_reg
self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set)
self.supervisor_client = get_supervisor_client(hass)
self.jobs = jobs
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
is_first_update = not self.data
client = self.supervisor_client
try:
installed_addons: list[InstalledAddon] = await client.addons.list()
all_addons = {addon.slug for addon in installed_addons}
# Fetch addon info for all addons on first update, or only
# for addons with subscribed entities on subsequent updates.
addon_info_results = dict(
await asyncio.gather(
*[
self._update_addon_info(slug)
for slug in all_addons
if is_first_update or self._addon_info_subscriptions.get(slug)
]
)
)
except SupervisorError as err:
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
# Update hass.data for legacy accessor functions
data = self.hass.data
addons_list_dicts = [addon.to_dict() for addon in installed_addons]
data[DATA_ADDONS_LIST] = addons_list_dicts
# Update addon info cache in hass.data
addon_info_cache: dict[str, Any] = data.setdefault(DATA_ADDONS_INFO, {})
for slug in addon_info_cache.keys() - all_addons:
del addon_info_cache[slug]
addon_info_cache.update(addon_info_results)
# Deprecated 2026.4.0: Folding addons.list results into supervisor_info
# for compatibility. Written to hass.data only, not coordinator data.
if DATA_SUPERVISOR_INFO in data:
data[DATA_SUPERVISOR_INFO]["addons"] = addons_list_dicts
# Build clean coordinator data
store_data = get_store(self.hass)
if store_data:
repositories = {
repo.slug: repo.name
for repo in StoreInfo.from_dict(store_data).repositories
}
else:
repositories = {}
new_data: dict[str, Any] = {}
new_data[DATA_KEY_ADDONS] = {
(slug := addon[ATTR_SLUG]): {
**addon,
ATTR_AUTO_UPDATE: (addon_info_cache.get(slug) or {}).get(
ATTR_AUTO_UPDATE, False
),
ATTR_REPOSITORY: repositories.get(
repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug
),
}
for addon in addons_list_dicts
}
# If this is the initial refresh, register all addons
if is_first_update:
async_register_addons_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values()
)
# Remove add-ons that are no longer installed from device registry
supervisor_addon_devices = {
list(device.identifiers)[0][1]
for device in self.dev_reg.devices.get_devices_for_config_entry_id(
self.entry_id
)
if device.model == SupervisorEntityModel.ADDON
}
if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
async_remove_devices_from_dev_reg(self.dev_reg, stale_addons)
# If there are new add-ons, we should reload the config entry so we can
# create new devices and entities. We can return an empty dict because
# coordinator will be recreated.
if self.data and (
set(new_data[DATA_KEY_ADDONS]) - set(self.data[DATA_KEY_ADDONS])
):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
)
return {}
return new_data
async def get_changelog(self, addon_slug: str) -> str | None:
"""Get the changelog for an add-on."""
try:
return await self.supervisor_client.store.addon_changelog(addon_slug)
except SupervisorNotFoundError:
return None
async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]:
"""Return the info for an addon."""
try:
info = await self.supervisor_client.addons.addon_info(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch info for %s: %s", slug, err)
return (slug, None)
# Translate to legacy hassio names for compatibility
info_dict = info.to_dict()
info_dict["hassio_api"] = info_dict.pop("supervisor_api")
info_dict["hassio_role"] = info_dict.pop("supervisor_role")
return (slug, info_dict)
@callback
def async_enable_addon_info_updates(
self, slug: str, entity_id: str
) -> CALLBACK_TYPE:
"""Enable info updates for an add-on."""
self._addon_info_subscriptions[slug].add(entity_id)
@callback
def _remove() -> None:
self._addon_info_subscriptions[slug].discard(entity_id)
if not self._addon_info_subscriptions[slug]:
del self._addon_info_subscriptions[slug]
return _remove
async def _async_refresh(
self,
log_failures: bool = True,
raise_on_auth_failed: bool = False,
scheduled: bool = False,
raise_on_entry_error: bool = False,
) -> None:
"""Refresh data."""
if not scheduled and not raise_on_auth_failed:
# Force reloading add-on updates for non-scheduled
# updates.
#
# If `raise_on_auth_failed` is set, it means this is
# the first refresh and we do not want to delay
# startup or cause a timeout so we only refresh the
# updates if this is not a scheduled refresh and
# we are not doing the first refresh.
try:
await self.supervisor_client.store.reload()
except SupervisorError as err:
_LOGGER.warning("Error on Supervisor API: %s", err)
await super()._async_refresh(
log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
)
async def force_addon_info_data_refresh(self, addon_slug: str) -> None:
"""Force refresh of addon info data for a specific addon."""
try:
slug, info = await self._update_addon_info(addon_slug)
if info is not None and DATA_KEY_ADDONS in self.data:
if slug in self.data[DATA_KEY_ADDONS]:
data = deepcopy(self.data)
data[DATA_KEY_ADDONS][slug].update(info)
self.async_set_updated_data(data)
except SupervisorError as err:
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
class HassioDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to retrieve Hass.io status."""
config_entry: ConfigEntry
@@ -639,77 +332,82 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=HASSIO_MAIN_UPDATE_INTERVAL,
update_interval=HASSIO_UPDATE_INTERVAL,
# We don't want an immediate refresh since we want to avoid
# hammering the Supervisor API on startup
# fetching the container stats right away and avoid hammering
# the Supervisor API on startup
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
self.hassio = hass.data[DATA_COMPONENT]
self.data = {}
self.entry_id = config_entry.entry_id
self.dev_reg = dev_reg
self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None
self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict(
lambda: defaultdict(set)
)
self.supervisor_client = get_supervisor_client(hass)
self.jobs = SupervisorJobs(hass)
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
is_first_update = not self.data
client = self.supervisor_client
try:
(
info,
core_info,
supervisor_info,
os_info,
host_info,
store_info,
network_info,
) = await asyncio.gather(
client.info(),
client.homeassistant.info(),
client.supervisor.info(),
client.os.info(),
client.host.info(),
client.store.info(),
client.network.info(),
)
mounts_info = await client.mounts.info()
await self.jobs.refresh_data(is_first_update)
await self.force_data_refresh(is_first_update)
except SupervisorError as err:
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
# Build clean coordinator data
new_data: dict[str, Any] = {}
new_data[DATA_KEY_CORE] = core_info.to_dict()
new_data[DATA_KEY_SUPERVISOR] = supervisor_info.to_dict()
new_data[DATA_KEY_HOST] = host_info.to_dict()
new_data[DATA_KEY_MOUNTS] = {mount.name: mount for mount in mounts_info.mounts}
supervisor_info = get_supervisor_info(self.hass) or {}
addons_info = get_addons_info(self.hass) or {}
addons_stats = get_addons_stats(self.hass)
store_data = get_store(self.hass)
mounts_info = await self.supervisor_client.mounts.info()
addons_list = get_addons_list(self.hass) or []
if store_data:
repositories = {
repo.slug: repo.name
for repo in StoreInfo.from_dict(store_data).repositories
}
else:
repositories = {}
new_data[DATA_KEY_ADDONS] = {
(slug := addon[ATTR_SLUG]): {
**addon,
**(addons_stats.get(slug) or {}),
ATTR_AUTO_UPDATE: (addons_info.get(slug) or {}).get(
ATTR_AUTO_UPDATE, False
),
ATTR_REPOSITORY: repositories.get(
repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug
),
}
for addon in addons_list
}
if self.is_hass_os:
new_data[DATA_KEY_OS] = os_info.to_dict()
new_data[DATA_KEY_OS] = get_os_info(self.hass)
# Update hass.data for legacy accessor functions
data = self.hass.data
data[DATA_INFO] = info.to_dict()
data[DATA_CORE_INFO] = new_data[DATA_KEY_CORE]
data[DATA_OS_INFO] = new_data.get(DATA_KEY_OS, os_info.to_dict())
data[DATA_HOST_INFO] = new_data[DATA_KEY_HOST]
data[DATA_STORE] = store_info.to_dict()
data[DATA_NETWORK_INFO] = network_info.to_dict()
# Separate dict for hass.data supervisor info since we add deprecated
# compat keys that should not be in coordinator data
supervisor_info_dict = supervisor_info.to_dict()
# Deprecated 2026.4.0: Folding repositories and addons into
# supervisor_info for compatibility. Written to hass.data only, not
# coordinator data. Preserve the addons key from the addon coordinator.
supervisor_info_dict["repositories"] = data[DATA_STORE][ATTR_REPOSITORIES]
if (prev := data.get(DATA_SUPERVISOR_INFO)) and "addons" in prev:
supervisor_info_dict["addons"] = prev["addons"]
data[DATA_SUPERVISOR_INFO] = supervisor_info_dict
new_data[DATA_KEY_CORE] = {
**(get_core_info(self.hass) or {}),
**get_core_stats(self.hass),
}
new_data[DATA_KEY_SUPERVISOR] = {
**supervisor_info,
**get_supervisor_stats(self.hass),
}
new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {}
new_data[DATA_KEY_MOUNTS] = {mount.name: mount for mount in mounts_info.mounts}
# If this is the initial refresh, register all main components
# If this is the initial refresh, register all addons and return the dict
if is_first_update:
async_register_addons_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values()
)
async_register_mounts_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_MOUNTS].values()
)
@@ -725,6 +423,17 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.entry_id, self.dev_reg, new_data[DATA_KEY_OS]
)
# Remove add-ons that are no longer installed from device registry
supervisor_addon_devices = {
list(device.identifiers)[0][1]
for device in self.dev_reg.devices.get_devices_for_config_entry_id(
self.entry_id
)
if device.model == SupervisorEntityModel.ADDON
}
if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
async_remove_devices_from_dev_reg(self.dev_reg, stale_addons)
# Remove mounts that no longer exists from device registry
supervisor_mount_devices = {
device.name
@@ -744,11 +453,12 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Remove the OS device if it exists and the installation is not hassos
self.dev_reg.async_remove_device(dev.id)
# If there are new mounts, we should reload the config entry so we can
# If there are new add-ons or mounts, we should reload the config entry so we can
# create new devices and entities. We can return an empty dict because
# coordinator will be recreated.
if self.data and (
set(new_data[DATA_KEY_MOUNTS]) - set(self.data.get(DATA_KEY_MOUNTS, {}))
set(new_data[DATA_KEY_ADDONS]) - set(self.data[DATA_KEY_ADDONS])
or set(new_data[DATA_KEY_MOUNTS]) - set(self.data[DATA_KEY_MOUNTS])
):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
@@ -757,6 +467,146 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return new_data
async def get_changelog(self, addon_slug: str) -> str | None:
"""Get the changelog for an add-on."""
try:
return await self.supervisor_client.store.addon_changelog(addon_slug)
except SupervisorNotFoundError:
return None
async def force_data_refresh(self, first_update: bool) -> None:
"""Force update of the addon info."""
container_updates = self._container_updates
data = self.hass.data
client = self.supervisor_client
updates: dict[str, Awaitable[ResponseData]] = {
DATA_INFO: client.info(),
DATA_CORE_INFO: client.homeassistant.info(),
DATA_SUPERVISOR_INFO: client.supervisor.info(),
DATA_OS_INFO: client.os.info(),
DATA_STORE: client.store.info(),
}
if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
updates[DATA_CORE_STATS] = client.homeassistant.stats()
if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]:
updates[DATA_SUPERVISOR_STATS] = client.supervisor.stats()
# Pull off addons.list results for further processing before caching
addons_list, *results = await asyncio.gather(
client.addons.list(), *updates.values()
)
for key, result in zip(updates, cast(list[ResponseData], results), strict=True):
data[key] = result.to_dict()
installed_addons = cast(list[InstalledAddon], addons_list)
data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in installed_addons]
# Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility
# Can drop this after removal period
data[DATA_SUPERVISOR_INFO].update(
{
"repositories": data[DATA_STORE][ATTR_REPOSITORIES],
"addons": [addon.to_dict() for addon in installed_addons],
}
)
all_addons = {addon.slug for addon in installed_addons}
started_addons = {
addon.slug
for addon in installed_addons
if addon.state in {AddonState.STARTED, AddonState.STARTUP}
}
#
# Update addon info if its the first update or
# there is at least one entity that needs the data.
#
# When entities are added they call async_enable_container_updates
# to enable updates for the endpoints they need via
# async_added_to_hass. This ensures that we only update
# the data for the endpoints that are needed to avoid unnecessary
# API calls since otherwise we would fetch stats for all containers
# and throw them away.
#
for data_key, update_func, enabled_key, wanted_addons, needs_first_update in (
(
DATA_ADDONS_STATS,
self._update_addon_stats,
CONTAINER_STATS,
started_addons,
False,
),
(
DATA_ADDONS_INFO,
self._update_addon_info,
CONTAINER_INFO,
all_addons,
True,
),
):
container_data: dict[str, Any] = data.setdefault(data_key, {})
# Clean up cache
for slug in container_data.keys() - wanted_addons:
del container_data[slug]
# Update cache from API
container_data.update(
dict(
await asyncio.gather(
*[
update_func(slug)
for slug in wanted_addons
if (first_update and needs_first_update)
or enabled_key in container_updates[slug]
]
)
)
)
# Refresh jobs data
await self.jobs.refresh_data(first_update)
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
"""Update single addon stats."""
try:
stats = await self.supervisor_client.addons.addon_stats(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch stats for %s: %s", slug, err)
return (slug, None)
return (slug, stats.to_dict())
async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]:
"""Return the info for an addon."""
try:
info = await self.supervisor_client.addons.addon_info(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch info for %s: %s", slug, err)
return (slug, None)
# Translate to legacy hassio names for compatibility
info_dict = info.to_dict()
info_dict["hassio_api"] = info_dict.pop("supervisor_api")
info_dict["hassio_role"] = info_dict.pop("supervisor_role")
return (slug, info_dict)
@callback
def async_enable_container_updates(
self, slug: str, entity_id: str, types: set[str]
) -> CALLBACK_TYPE:
"""Enable updates for an add-on."""
enabled_updates = self._container_updates[slug]
for key in types:
enabled_updates[key].add(entity_id)
@callback
def _remove() -> None:
for key in types:
enabled_updates[key].remove(entity_id)
return _remove
async def _async_refresh(
self,
log_failures: bool = True,
@@ -766,16 +616,14 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) -> None:
"""Refresh data."""
if not scheduled and not raise_on_auth_failed:
# Force reloading updates of main components for
# non-scheduled updates.
#
# Force refreshing updates for non-scheduled updates
# If `raise_on_auth_failed` is set, it means this is
# the first refresh and we do not want to delay
# startup or cause a timeout so we only refresh the
# updates if this is not a scheduled refresh and
# we are not doing the first refresh.
try:
await self.supervisor_client.reload_updates()
await self.supervisor_client.refresh_updates()
except SupervisorError as err:
_LOGGER.warning("Error on Supervisor API: %s", err)
@@ -783,6 +631,18 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
)
async def force_addon_info_data_refresh(self, addon_slug: str) -> None:
"""Force refresh of addon info data for a specific addon."""
try:
slug, info = await self._update_addon_info(addon_slug)
if info is not None and DATA_KEY_ADDONS in self.data:
if slug in self.data[DATA_KEY_ADDONS]:
data = deepcopy(self.data)
data[DATA_KEY_ADDONS][slug].update(info)
self.async_set_updated_data(data)
except SupervisorError as err:
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
@callback
def unload(self) -> None:
"""Clean up when config entry unloaded."""

View File

@@ -11,12 +11,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import ADDONS_COORDINATOR, MAIN_COORDINATOR, STATS_COORDINATOR
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
HassioStatsDataUpdateCoordinator,
)
from .const import ADDONS_COORDINATOR
from .coordinator import HassioDataUpdateCoordinator
async def async_get_config_entry_diagnostics(
@@ -24,9 +20,7 @@ async def async_get_config_entry_diagnostics(
config_entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR]
addons_coordinator: HassioAddOnDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
stats_coordinator: HassioStatsDataUpdateCoordinator = hass.data[STATS_COORDINATOR]
coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
@@ -59,7 +53,5 @@ async def async_get_config_entry_diagnostics(
return {
"coordinator_data": coordinator.data,
"addons_coordinator_data": addons_coordinator.data,
"stats_coordinator_data": stats_coordinator.data,
"devices": devices,
}

View File

@@ -13,6 +13,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_SLUG,
CONTAINER_STATS,
CORE_CONTAINER,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
@@ -20,79 +21,20 @@ from .const import (
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DOMAIN,
KEY_TO_UPDATE_TYPES,
SUPERVISOR_CONTAINER,
)
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
HassioStatsDataUpdateCoordinator,
)
from .coordinator import HassioDataUpdateCoordinator
class HassioStatsEntity(CoordinatorEntity[HassioStatsDataUpdateCoordinator]):
"""Base entity for container stats (CPU, memory)."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioStatsDataUpdateCoordinator,
entity_description: EntityDescription,
*,
container_id: str,
data_key: str,
device_id: str,
unique_id_prefix: str,
) -> None:
"""Initialize base entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._container_id = container_id
self._data_key = data_key
self._attr_unique_id = f"{unique_id_prefix}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
@property
def available(self) -> bool:
"""Return True if entity is available."""
if self._data_key == DATA_KEY_ADDONS:
return (
super().available
and DATA_KEY_ADDONS in self.coordinator.data
and self.entity_description.key
in (
self.coordinator.data[DATA_KEY_ADDONS].get(self._container_id) or {}
)
)
return (
super().available
and self._data_key in self.coordinator.data
and self.entity_description.key in self.coordinator.data[self._data_key]
)
async def async_added_to_hass(self) -> None:
"""Subscribe to stats updates."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_enable_container_updates(
self._container_id, self.entity_id, {CONTAINER_STATS}
)
)
# Stats are only fetched for containers with subscribed entities.
# The first coordinator refresh (before entities exist) has no
# subscribers, so no stats are fetched. Schedule a debounced
# refresh so that all stats entities registering during platform
# setup are batched into a single API call.
await self.coordinator.async_request_refresh()
class HassioAddonEntity(CoordinatorEntity[HassioAddOnDataUpdateCoordinator]):
class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
"""Base entity for a Hass.io add-on."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioAddOnDataUpdateCoordinator,
coordinator: HassioDataUpdateCoordinator,
entity_description: EntityDescription,
addon: dict[str, Any],
) -> None:
@@ -114,23 +56,26 @@ class HassioAddonEntity(CoordinatorEntity[HassioAddOnDataUpdateCoordinator]):
)
async def async_added_to_hass(self) -> None:
"""Subscribe to addon info updates."""
"""Subscribe to updates."""
await super().async_added_to_hass()
update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key]
self.async_on_remove(
self.coordinator.async_enable_addon_info_updates(
self._addon_slug, self.entity_id
self.coordinator.async_enable_container_updates(
self._addon_slug, self.entity_id, update_types
)
)
if CONTAINER_STATS in update_types:
await self.coordinator.async_request_refresh()
class HassioOSEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
"""Base Entity for Hass.io OS."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioMainDataUpdateCoordinator,
coordinator: HassioDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize base entity."""
@@ -149,14 +94,14 @@ class HassioOSEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
)
class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
class HassioHostEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
"""Base Entity for Hass.io host."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioMainDataUpdateCoordinator,
coordinator: HassioDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize base entity."""
@@ -175,14 +120,14 @@ class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
)
class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
"""Base Entity for Supervisor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioMainDataUpdateCoordinator,
coordinator: HassioDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize base entity."""
@@ -201,15 +146,27 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator])
in self.coordinator.data[DATA_KEY_SUPERVISOR]
)
async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
await super().async_added_to_hass()
update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key]
self.async_on_remove(
self.coordinator.async_enable_container_updates(
SUPERVISOR_CONTAINER, self.entity_id, update_types
)
)
if CONTAINER_STATS in update_types:
await self.coordinator.async_request_refresh()
class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
"""Base Entity for Core."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioMainDataUpdateCoordinator,
coordinator: HassioDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize base entity."""
@@ -227,15 +184,27 @@ class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
and self.entity_description.key in self.coordinator.data[DATA_KEY_CORE]
)
async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
await super().async_added_to_hass()
update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key]
self.async_on_remove(
self.coordinator.async_enable_container_updates(
CORE_CONTAINER, self.entity_id, update_types
)
)
if CONTAINER_STATS in update_types:
await self.coordinator.async_request_refresh()
class HassioMountEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
class HassioMountEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
"""Base Entity for Mount."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioMainDataUpdateCoordinator,
coordinator: HassioDataUpdateCoordinator,
entity_description: EntityDescription,
mount: CIFSMountResponse | NFSMountResponse,
) -> None:

View File

@@ -28,6 +28,7 @@ from homeassistant.helpers.issue_registry import (
)
from .const import (
ADDONS_COORDINATOR,
ATTR_DATA,
ATTR_HEALTHY,
ATTR_SLUG,
@@ -53,7 +54,6 @@ from .const import (
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_MOUNT_MOUNT_FAILED,
MAIN_COORDINATOR,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_ADDON_URL,
PLACEHOLDER_KEY_FREE_SPACE,
@@ -62,7 +62,7 @@ from .const import (
STARTUP_COMPLETE,
UPDATE_KEY_SUPERVISOR,
)
from .coordinator import HassioMainDataUpdateCoordinator, get_addons_list, get_host_info
from .coordinator import HassioDataUpdateCoordinator, get_addons_list, get_host_info
from .handler import get_supervisor_client
ISSUE_KEY_UNHEALTHY = "unhealthy"
@@ -417,8 +417,8 @@ class SupervisorIssues:
def _async_coordinator_refresh(self) -> None:
"""Refresh coordinator to update latest data in entities."""
coordinator: HassioMainDataUpdateCoordinator | None
if coordinator := self._hass.data.get(MAIN_COORDINATOR):
coordinator: HassioDataUpdateCoordinator | None
if coordinator := self._hass.data.get(ADDONS_COORDINATOR):
coordinator.config_entry.async_create_task(
self._hass, coordinator.async_refresh()
)

View File

@@ -17,24 +17,20 @@ from .const import (
ADDONS_COORDINATOR,
ATTR_CPU_PERCENT,
ATTR_MEMORY_PERCENT,
ATTR_SLUG,
ATTR_VERSION,
ATTR_VERSION_LATEST,
CORE_CONTAINER,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
MAIN_COORDINATOR,
STATS_COORDINATOR,
SUPERVISOR_CONTAINER,
)
from .entity import (
HassioAddonEntity,
HassioCoreEntity,
HassioHostEntity,
HassioOSEntity,
HassioStatsEntity,
HassioSupervisorEntity,
)
COMMON_ENTITY_DESCRIPTIONS = (
@@ -67,7 +63,10 @@ STATS_ENTITY_DESCRIPTIONS = (
),
)
ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + STATS_ENTITY_DESCRIPTIONS
CORE_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS
OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS
SUPERVISOR_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS
HOST_ENTITY_DESCRIPTIONS = (
SensorEntityDescription(
@@ -115,64 +114,36 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Sensor set up for Hass.io config entry."""
addons_coordinator = hass.data[ADDONS_COORDINATOR]
coordinator = hass.data[MAIN_COORDINATOR]
stats_coordinator = hass.data[STATS_COORDINATOR]
coordinator = hass.data[ADDONS_COORDINATOR]
entities: list[SensorEntity] = []
# Add-on non-stats sensors (version, version_latest)
entities.extend(
entities: list[
HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor | HostSensor
] = [
HassioAddonSensor(
addon=addon,
coordinator=addons_coordinator,
coordinator=coordinator,
entity_description=entity_description,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in COMMON_ENTITY_DESCRIPTIONS
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
]
# Add-on stats sensors (cpu_percent, memory_percent)
entities.extend(
HassioStatsSensor(
coordinator=stats_coordinator,
CoreSensor(
coordinator=coordinator,
entity_description=entity_description,
container_id=addon[ATTR_SLUG],
data_key=DATA_KEY_ADDONS,
device_id=addon[ATTR_SLUG],
unique_id_prefix=addon[ATTR_SLUG],
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in STATS_ENTITY_DESCRIPTIONS
for entity_description in CORE_ENTITY_DESCRIPTIONS
)
# Core stats sensors
entities.extend(
HassioStatsSensor(
coordinator=stats_coordinator,
SupervisorSensor(
coordinator=coordinator,
entity_description=entity_description,
container_id=CORE_CONTAINER,
data_key=DATA_KEY_CORE,
device_id="core",
unique_id_prefix="home_assistant_core",
)
for entity_description in STATS_ENTITY_DESCRIPTIONS
for entity_description in SUPERVISOR_ENTITY_DESCRIPTIONS
)
# Supervisor stats sensors
entities.extend(
HassioStatsSensor(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=SUPERVISOR_CONTAINER,
data_key=DATA_KEY_SUPERVISOR,
device_id="supervisor",
unique_id_prefix="home_assistant_supervisor",
)
for entity_description in STATS_ENTITY_DESCRIPTIONS
)
# Host sensors
entities.extend(
HostSensor(
coordinator=coordinator,
@@ -181,7 +152,6 @@ async def async_setup_entry(
for entity_description in HOST_ENTITY_DESCRIPTIONS
)
# OS sensors
if coordinator.is_hass_os:
entities.extend(
HassioOSSensor(
@@ -205,21 +175,8 @@ class HassioAddonSensor(HassioAddonEntity, SensorEntity):
]
class HassioStatsSensor(HassioStatsEntity, SensorEntity):
"""Sensor to track container stats."""
@property
def native_value(self) -> str:
"""Return native value of entity."""
if self._data_key == DATA_KEY_ADDONS:
return self.coordinator.data[DATA_KEY_ADDONS][self._container_id][
self.entity_description.key
]
return self.coordinator.data[self._data_key][self.entity_description.key]
class HassioOSSensor(HassioOSEntity, SensorEntity):
"""Sensor to track a Hass.io OS attribute."""
"""Sensor to track a Hass.io add-on attribute."""
@property
def native_value(self) -> str:
@@ -227,6 +184,24 @@ class HassioOSSensor(HassioOSEntity, SensorEntity):
return self.coordinator.data[DATA_KEY_OS][self.entity_description.key]
class CoreSensor(HassioCoreEntity, SensorEntity):
"""Sensor to track a core attribute."""
@property
def native_value(self) -> str:
"""Return native value of entity."""
return self.coordinator.data[DATA_KEY_CORE][self.entity_description.key]
class SupervisorSensor(HassioSupervisorEntity, SensorEntity):
"""Sensor to track a supervisor attribute."""
@property
def native_value(self) -> str:
"""Return native value of entity."""
return self.coordinator.data[DATA_KEY_SUPERVISOR][self.entity_description.key]
class HostSensor(HassioHostEntity, SensorEntity):
"""Sensor to track a host attribute."""

View File

@@ -32,6 +32,7 @@ from homeassistant.helpers import (
from homeassistant.util.dt import now
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
@@ -45,10 +46,9 @@ from .const import (
ATTR_PASSWORD,
ATTR_SLUG,
DOMAIN,
MAIN_COORDINATOR,
SupervisorEntityModel,
)
from .coordinator import HassioMainDataUpdateCoordinator, get_addons_info
from .coordinator import HassioDataUpdateCoordinator, get_addons_info
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
@@ -406,7 +406,7 @@ def async_register_network_storage_services(
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioMainDataUpdateCoordinator | None = None
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
@@ -417,7 +417,7 @@ def async_register_network_storage_services(
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(MAIN_COORDINATOR)) is None
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(

View File

@@ -29,7 +29,6 @@ from .const import (
DATA_KEY_CORE,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
MAIN_COORDINATOR,
)
from .entity import (
HassioAddonEntity,
@@ -52,9 +51,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Supervisor update based on a config entry."""
coordinator = hass.data[MAIN_COORDINATOR]
coordinator = hass.data[ADDONS_COORDINATOR]
entities: list[UpdateEntity] = [
entities = [
SupervisorSupervisorUpdateEntity(
coordinator=coordinator,
entity_description=ENTITY_DESCRIPTION,
@@ -65,6 +64,15 @@ async def async_setup_entry(
),
]
entities.extend(
SupervisorAddonUpdateEntity(
addon=addon,
coordinator=coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
)
if coordinator.is_hass_os:
entities.append(
SupervisorOSUpdateEntity(
@@ -73,16 +81,6 @@ async def async_setup_entry(
)
)
addons_coordinator = hass.data[ADDONS_COORDINATOR]
entities.extend(
SupervisorAddonUpdateEntity(
addon=addon,
coordinator=addons_coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
)
async_add_entities(entities)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Any
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE
@@ -56,10 +55,9 @@ class CecEntity(Entity):
else:
self._attr_name = f"{self._device.type_name} {self._logical_address} ({self._device.osd_name})"
@callback
def _hdmi_cec_unavailable(self, callback_event):
self._attr_available = False
self.async_write_ha_state()
self.schedule_update_ha_state(False)
async def async_added_to_hass(self) -> None:
"""Register HDMI callbacks after initialization."""

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand
from pycec.const import (
@@ -30,6 +31,7 @@ from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -43,20 +45,20 @@ _LOGGER = logging.getLogger(__name__)
ENTITY_ID_FORMAT = MP_DOMAIN + ".{}"
async def async_setup_platform(
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Find and return HDMI devices as media players."""
"""Find and return HDMI devices as +switches."""
if discovery_info and ATTR_NEW in discovery_info:
_LOGGER.debug("Setting up HDMI devices %s", discovery_info[ATTR_NEW])
entities = []
for device in discovery_info[ATTR_NEW]:
hdmi_device = hass.data[DOMAIN][device]
entities.append(CecPlayerEntity(hdmi_device, hdmi_device.logical_address))
async_add_entities(entities, True)
add_entities(entities, True)
class CecPlayerEntity(CecEntity, MediaPlayerEntity):
@@ -77,61 +79,78 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity):
def send_playback(self, key):
"""Send playback status to CEC adapter."""
self._device.send_command(CecCommand(key, dst=self._logical_address))
self._device.async_send_command(CecCommand(key, dst=self._logical_address))
async def async_mute_volume(self, mute: bool) -> None:
def mute_volume(self, mute: bool) -> None:
"""Mute volume."""
self.send_keypress(KEY_MUTE_TOGGLE)
async def async_media_previous_track(self) -> None:
def media_previous_track(self) -> None:
"""Go to previous track."""
self.send_keypress(KEY_BACKWARD)
async def async_turn_on(self) -> None:
def turn_on(self) -> None:
"""Turn device on."""
self._device.turn_on()
self._attr_state = MediaPlayerState.ON
self.async_write_ha_state()
async def async_turn_off(self) -> None:
def clear_playlist(self) -> None:
"""Clear players playlist."""
raise NotImplementedError
def turn_off(self) -> None:
"""Turn device off."""
self._device.turn_off()
self._attr_state = MediaPlayerState.OFF
self.async_write_ha_state()
async def async_media_stop(self) -> None:
def media_stop(self) -> None:
"""Stop playback."""
self.send_keypress(KEY_STOP)
self._attr_state = MediaPlayerState.IDLE
self.async_write_ha_state()
async def async_media_next_track(self) -> None:
def play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Not supported."""
raise NotImplementedError
def media_next_track(self) -> None:
"""Skip to next track."""
self.send_keypress(KEY_FORWARD)
async def async_media_pause(self) -> None:
def media_seek(self, position: float) -> None:
"""Not supported."""
raise NotImplementedError
def set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
raise NotImplementedError
def media_pause(self) -> None:
"""Pause playback."""
self.send_keypress(KEY_PAUSE)
self._attr_state = MediaPlayerState.PAUSED
self.async_write_ha_state()
async def async_media_play(self) -> None:
def select_source(self, source: str) -> None:
"""Not supported."""
raise NotImplementedError
def media_play(self) -> None:
"""Start playback."""
self.send_keypress(KEY_PLAY)
self._attr_state = MediaPlayerState.PLAYING
self.async_write_ha_state()
async def async_volume_up(self) -> None:
def volume_up(self) -> None:
"""Increase volume."""
_LOGGER.debug("%s: volume up", self._logical_address)
self.send_keypress(KEY_VOLUME_UP)
async def async_volume_down(self) -> None:
def volume_down(self) -> None:
"""Decrease volume."""
_LOGGER.debug("%s: volume down", self._logical_address)
self.send_keypress(KEY_VOLUME_DOWN)
async def async_update(self) -> None:
def update(self) -> None:
"""Update device status."""
device = self._device
if device.power_status in [POWER_OFF, 3]:

View File

@@ -20,10 +20,10 @@ _LOGGER = logging.getLogger(__name__)
ENTITY_ID_FORMAT = SWITCH_DOMAIN + ".{}"
async def async_setup_platform(
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Find and return HDMI devices as switches."""
@@ -33,7 +33,7 @@ async def async_setup_platform(
for device in discovery_info[ATTR_NEW]:
hdmi_device = hass.data[DOMAIN][device]
entities.append(CecSwitchEntity(hdmi_device, hdmi_device.logical_address))
async_add_entities(entities, True)
add_entities(entities, True)
class CecSwitchEntity(CecEntity, SwitchEntity):
@@ -44,19 +44,19 @@ class CecSwitchEntity(CecEntity, SwitchEntity):
CecEntity.__init__(self, device, logical)
self.entity_id = f"{SWITCH_DOMAIN}.hdmi_{hex(self._logical_address)[2:]}"
async def async_turn_on(self, **kwargs: Any) -> None:
def turn_on(self, **kwargs: Any) -> None:
"""Turn device on."""
self._device.turn_on()
self._attr_is_on = True
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=False)
async def async_turn_off(self, **kwargs: Any) -> None:
def turn_off(self, **kwargs: Any) -> None:
"""Turn device off."""
self._device.turn_off()
self._attr_is_on = False
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=False)
async def async_update(self) -> None:
def update(self) -> None:
"""Update device status."""
device = self._device
if device.power_status in {POWER_OFF, 3}:

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"serialx==1.2.2",
"serialx==1.1.1",
"universal-silabs-flasher==1.0.3",
"ha-silabs-firmware-client==0.3.0"
]

View File

@@ -10,7 +10,7 @@
"loggers": ["pyhap"],
"requirements": [
"HAP-python==5.0.0",
"fnv-hash-fast==2.0.2",
"fnv-hash-fast==2.0.0",
"homekit-audio-proxy==1.2.1",
"PyQRCode==1.2.1",
"base36==0.1.1"

View File

@@ -625,13 +625,10 @@ def _get_test_socket() -> socket.socket:
@callback
def async_port_is_available(port: int) -> bool:
"""Check to see if a port is available."""
test_socket = _get_test_socket()
try:
test_socket.bind(("", port))
_get_test_socket().bind(("", port))
except OSError:
return False
finally:
test_socket.close()
return True

View File

@@ -2,8 +2,7 @@
"common": {
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_drying": {
@@ -212,9 +211,6 @@
"behavior": {
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::trigger_for_name%]"
},
"mode": {
"description": "The operation modes to trigger on.",
"name": "Mode"
@@ -227,9 +223,6 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::trigger_for_name%]"
}
},
"name": "Humidifier started drying"
@@ -239,9 +232,6 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::trigger_for_name%]"
}
},
"name": "Humidifier started humidifying"
@@ -251,9 +241,6 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::trigger_for_name%]"
}
},
"name": "Humidifier turned off"
@@ -263,9 +250,6 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::trigger_for_name%]"
}
},
"name": "Humidifier turned on"

View File

@@ -13,11 +13,6 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
started_drying: *trigger_common
started_humidifying: *trigger_common
@@ -28,7 +23,6 @@ mode_changed:
target: *trigger_humidifier_target
fields:
behavior: *trigger_behavior
for: *trigger_for
mode:
context:
filter_target: target

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": {
@@ -52,9 +51,6 @@
"behavior": {
"name": "[%key:component::humidity::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::humidity::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::humidity::common::trigger_threshold_name%]"
}

View File

@@ -9,11 +9,6 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.humidity_threshold_entity: &humidity_threshold_entity
- domain: input_number
@@ -52,7 +47,6 @@ crossed_threshold:
target: *trigger_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.3.0"]
}

View File

@@ -42,6 +42,7 @@ class HassAqualinkBinarySensor(
) -> None:
"""Initialize AquaLink binary sensor."""
super().__init__(coordinator, dev)
self._attr_name = dev.label
if dev.label == "Freeze Protection":
self._attr_device_class = BinarySensorDeviceClass.COLD

View File

@@ -57,6 +57,7 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity):
) -> None:
"""Initialize AquaLink thermostat."""
super().__init__(coordinator, dev)
self._attr_name = dev.label.split(" ")[0]
self._attr_temperature_unit = (
UnitOfTemperature.FAHRENHEIT
if dev.unit == "F"

View File

@@ -22,9 +22,6 @@ class AqualinkEntity[AqualinkDeviceT: AqualinkDevice](
entity update flow.
"""
_attr_has_entity_name = True
_attr_name = None
def __init__(
self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkDeviceT
) -> None:

View File

@@ -46,6 +46,7 @@ class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity):
) -> None:
"""Initialize AquaLink light."""
super().__init__(coordinator, dev)
self._attr_name = dev.label
if dev.supports_effect:
self._attr_effect_list = list(dev.supported_effects)
self._attr_supported_features = LightEntityFeature.EFFECT

View File

@@ -38,6 +38,7 @@ class HassAqualinkSensor(AqualinkEntity[AqualinkSensor], SensorEntity):
) -> None:
"""Initialize AquaLink sensor."""
super().__init__(coordinator, dev)
self._attr_name = dev.label
if not dev.name.endswith("_temp"):
return
self._attr_device_class = SensorDeviceClass.TEMPERATURE

View File

@@ -40,7 +40,7 @@ class HassAqualinkSwitch(AqualinkEntity[AqualinkSwitch], SwitchEntity):
) -> None:
"""Initialize AquaLink switch."""
super().__init__(coordinator, dev)
name = dev.label
name = self._attr_name = dev.label
if name == "Cleaner":
self._attr_icon = "mdi:robot-vacuum"
elif name == "Waterfall" or name.endswith("Dscnt"):

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": {
@@ -69,9 +68,6 @@
"fields": {
"behavior": {
"name": "[%key:component::illuminance::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::illuminance::common::trigger_for_name%]"
}
},
"name": "Light cleared"
@@ -82,9 +78,6 @@
"behavior": {
"name": "[%key:component::illuminance::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::illuminance::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::illuminance::common::trigger_threshold_name%]"
}
@@ -96,9 +89,6 @@
"fields": {
"behavior": {
"name": "[%key:component::illuminance::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::illuminance::common::trigger_for_name%]"
}
},
"name": "Light detected"

View File

@@ -9,11 +9,6 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.illuminance_threshold_entity: &illuminance_threshold_entity
- domain: input_number
@@ -60,7 +55,6 @@ crossed_threshold:
target: *trigger_numerical_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["Pillow==12.2.0"]
"requirements": ["Pillow==12.1.1"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==2.1.0"]
"requirements": ["imgw_pib==2.0.2"]
}

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==1.2.0"]
"requirements": ["infrared-protocols==1.1.0"]
}

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_docked": {
@@ -99,9 +98,6 @@
"fields": {
"behavior": {
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::lawn_mower::common::trigger_for_name%]"
}
},
"name": "Lawn mower returned to dock"
@@ -111,9 +107,6 @@
"fields": {
"behavior": {
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::lawn_mower::common::trigger_for_name%]"
}
},
"name": "Lawn mower encountered an error"
@@ -123,9 +116,6 @@
"fields": {
"behavior": {
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::lawn_mower::common::trigger_for_name%]"
}
},
"name": "Lawn mower paused mowing"
@@ -135,9 +125,6 @@
"fields": {
"behavior": {
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::lawn_mower::common::trigger_for_name%]"
}
},
"name": "Lawn mower started mowing"
@@ -147,9 +134,6 @@
"fields": {
"behavior": {
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::lawn_mower::common::trigger_for_name%]"
}
},
"name": "Lawn mower started returning to dock"

View File

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

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