Compare commits

...

40 Commits

Author SHA1 Message Date
Erik
68ff9ad29d Avoid checking duration if it's 0 2026-04-16 11:25:34 +02:00
Erik
dcae538943 Add duration to state based entity conditions 2026-04-16 11:22:51 +02:00
Erik Montnemery
4f8e7125d4 Add state based condition tests (#168349) 2026-04-16 11:22:14 +02:00
renovate[bot]
baf5e32c59 Update xmltodict to 1.0.4 (#168330)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 10:49:35 +02:00
renovate[bot]
0f0ceaace2 Update PyJWT to 2.12.1 (#168239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robert Resch <robert@resch.dev>
2026-04-16 10:44:41 +02:00
Andres Ruiz
5ecae7066b Bump waterfurance to 1.6.5 (#168328) 2026-04-16 10:09:25 +02:00
Ronald van der Meer
ac9bf9b7cb Bump python-duco-client to 0.3.1 (#168341) 2026-04-16 10:08:41 +02:00
renovate[bot]
d4a98c3336 Update audioop-lts to 0.2.2 (#168326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 10:07:45 +02:00
dependabot[bot]
f0aae350b5 Bump docker/build-push-action from 7.0.0 to 7.1.0 (#168338)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 10:06:09 +02:00
Paulus Schoutsen
69332ed822 Add SerialSelector (#168263) 2026-04-16 10:45:37 +03:00
Erik Montnemery
32db17fab9 Add duration to more triggers (#168337) 2026-04-16 08:46:58 +02:00
renovate[bot]
84e8cff2ea Update infrared-protocols to 1.2.0 (#168335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 08:31:56 +02:00
Ariel Ebersberger
cfe390e4f6 Migrate demo image_processing to async (#168315) 2026-04-16 08:17:00 +02:00
Erik Montnemery
a9becca321 Add duration to state based entity triggers (#167740) 2026-04-16 07:38:50 +02:00
renovate[bot]
0043a307f0 Update PyTurboJPEG to 1.8.3 (#168329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 05:49:04 +02:00
renovate[bot]
dfb1819800 Update fnv-hash-fast to 2.0.2 (#168327)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 17:04:50 -10:00
puddly
12018cf9f4 Migrate remaining Core integrations from pyserial to serialx (#168325) 2026-04-15 22:39:32 -04:00
Franck Nijhof
70368c622e Extend Renovate allowlist with common packages (#168295) 2026-04-15 23:42:32 +02:00
Franck Nijhof
743aef05be Update twentemilieu to 3.0.0 (#168313) 2026-04-15 22:39:42 +02:00
Ariel Ebersberger
49e5b03c08 Migrate hdmi_cec to async (#168306) 2026-04-15 21:51:07 +02:00
Jan Bouwhuis
6bc3fcef36 Fix minor issues in MQTT tests (#168303) 2026-04-15 21:34:44 +02:00
puddly
e3e87185c5 Switch USB integration to list serial ports with serialx (#167615) 2026-04-15 19:22:45 +02:00
epenet
6d83b73cbb Simplify raise-pull-request agent push step (#167739)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:10:31 +01:00
Ariel Ebersberger
533871babb Optimize add_job to skip double-deferral for @callback targets (#168198) 2026-04-15 18:50:33 +02:00
Erik Montnemery
1dc93a80c4 Improve type annotations and remove unused code in mobile_app (#168298) 2026-04-15 18:09:10 +02:00
Erik Montnemery
f8a94c6f22 Fix climate trigger labs flag test (#168299) 2026-04-15 17:53:26 +02:00
Erik Montnemery
b127d13587 Add additional media_player triggers (#156927)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-04-15 17:34:36 +02:00
renovate[bot]
1895f8ebce Update attrs to 26.1.0 (#168276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-04-15 17:22:33 +02:00
renovate[bot]
b6916954dc Update respx to 0.23.1 (#168272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 17:10:28 +02:00
renovate[bot]
23181f5275 Update pytest-github-actions-annotate-failures to 0.4.0 (#168269)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 16:59:51 +02:00
Robert Resch
607a10d1e1 Use pip to install dynamically extracted version from requirements.txt (#168246) 2026-04-15 16:34:01 +02:00
Ariel Ebersberger
ecb814adb0 Add test coverage for add_job and fix docstring (#168291) 2026-04-15 16:17:01 +02:00
G Johansson
67df556e84 Add async_on_create_entry method to create config entries (#155016)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 15:57:32 +02:00
AlCalzone
4d472418c5 Ensure extra_fields in Z-Wave automation config are strings (#168281) 2026-04-15 15:12:18 +02:00
renovate[bot]
cf6441561c Update voluptuous-openapi to 0.3.0 (#168275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 15:06:24 +02:00
Erik Montnemery
6d8d447355 Revert "Add last_non_buffering_state media_player state attribute (#166941)" (#168285) 2026-04-15 14:41:02 +02:00
Erik Montnemery
ab5ae33290 Exclude unavailable and unknown in trigger first and last checks (#168224)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 14:20:49 +02:00
renovate[bot]
c0bf9a2bd2 Update pytest-sugar to 1.1.1 (#168270)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 13:07:21 +02:00
Norbert Rittel
d862b999ae Capitalize "REST" abbreviation in scrape error messages (#168280) 2026-04-15 11:36:39 +02:00
Erik Montnemery
d6be6e8810 Improve timer tests (#168277) 2026-04-15 11:21:59 +02:00
173 changed files with 3772 additions and 961 deletions

View File

@@ -186,15 +186,11 @@ If `CHANGE_TYPE` IS "Breaking change" or "Deprecation", keep the `## Breaking ch
## Step 10: Push Branch and Create PR
```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#')
Push the branch with upstream tracking, and create a PR against `home-assistant/core` with the generated title and body:
```bash
# 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'

44
.github/renovate.json vendored
View File

@@ -78,6 +78,50 @@
"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": [

View File

@@ -530,7 +530,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile

16
Dockerfile generated
View File

@@ -19,25 +19,23 @@ ENV \
UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
WORKDIR /usr/src
# Home Assistant S6-Overlay
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv==0.11.1
WORKDIR /usr/src
## Setup Home Assistant Core dependencies
COPY requirements.txt homeassistant/
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
RUN \
uv pip install \
# 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

View File

@@ -7,23 +7,31 @@ 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, partial
from typing import Any
from functools import lru_cache
from typing import Any, override
from jwt import DecodeError, PyJWS, PyJWT
from jwt import DecodeError, PyJWK, PyJWS, PyJWT
from jwt.algorithms import AllowedPublicKeys
from jwt.types import Options
from homeassistant.util.json import json_loads
JWT_TOKEN_CACHE_SIZE = 16
MAX_TOKEN_SIZE = 8192
_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}
_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=[],
)
class _PyJWSWithLoadCache(PyJWS):
@@ -38,9 +46,6 @@ 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."""
@@ -56,21 +61,12 @@ def _decode_payload(json_payload: str) -> dict[str, Any]:
class _PyJWTWithVerify(PyJWT):
"""PyJWT with a fast decode implementation."""
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 __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 verify_and_decode(
self,
@@ -79,37 +75,70 @@ class _PyJWTWithVerify(PyJWT):
algorithms: list[str],
issuer: str | None = None,
leeway: float | timedelta = 0,
options: dict[str, Any] | None = None,
options: Options | None = None,
) -> dict[str, Any]:
"""Verify a JWT's signature and claims."""
merged_options = {**_VERIFY_OPTIONS, **(options or {})}
payload = self.decode_payload(
return self.decode(
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,
)
return payload
@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"])
_jwt = _PyJWTWithVerify()
verify_and_decode = _jwt.verify_and_decode
unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)(
partial(
_jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS
@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,
)
)
__all__ = [
"unverified_hs256_token_decode",

View File

@@ -6,10 +6,11 @@ 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_TIMEOUT: Final = 1
DEFAULT_READ_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": ["pyserial==3.5"]
"requirements": ["serialx==1.2.2"]
}

View File

@@ -6,7 +6,7 @@ import logging
import re
from typing import Any
import serial
from serialx import Serial, SerialException
import voluptuous as vol
from homeassistant.components.switch import (
@@ -16,21 +16,22 @@ 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_TIMEOUT,
DEFAULT_READ_TIMEOUT,
DEFAULT_WRITE_TIMEOUT,
ECO_MODE,
ICON,
@@ -45,7 +46,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_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
vol.Optional(CONF_READ_TIMEOUT, default=DEFAULT_READ_TIMEOUT): cv.positive_int,
vol.Optional(
CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT
): cv.positive_int,
@@ -62,10 +63,10 @@ def setup_platform(
"""Connect with serial port and return Acer Projector."""
serial_port = config[CONF_FILENAME]
name = config[CONF_NAME]
timeout = config[CONF_TIMEOUT]
read_timeout = config[CONF_READ_TIMEOUT]
write_timeout = config[CONF_WRITE_TIMEOUT]
add_entities([AcerSwitch(serial_port, name, timeout, write_timeout)], True)
add_entities([AcerSwitch(serial_port, name, read_timeout, write_timeout)], True)
class AcerSwitch(SwitchEntity):
@@ -77,14 +78,14 @@ class AcerSwitch(SwitchEntity):
self,
serial_port: str,
name: str,
timeout: int,
read_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,
@@ -94,22 +95,26 @@ 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:
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
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
def _write_read_format(self, msg: str) -> str:
"""Write msg, obtain answer and format output."""

View File

@@ -3,6 +3,7 @@
"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": {
@@ -249,6 +250,9 @@
"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%]"
}
@@ -269,6 +273,9 @@
"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"
@@ -279,6 +286,9 @@
"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%]"
}
@@ -290,6 +300,9 @@
"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"
@@ -299,6 +312,9 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Gas cleared"
@@ -308,6 +324,9 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Gas detected"
@@ -327,6 +346,9 @@
"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%]"
}
@@ -348,6 +370,9 @@
"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%]"
}
@@ -369,6 +394,9 @@
"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%]"
}
@@ -390,6 +418,9 @@
"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%]"
}
@@ -411,6 +442,9 @@
"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%]"
}
@@ -432,6 +466,9 @@
"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%]"
}
@@ -453,6 +490,9 @@
"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%]"
}
@@ -474,6 +514,9 @@
"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%]"
}
@@ -485,6 +528,9 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Smoke cleared"
@@ -494,6 +540,9 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Smoke detected"
@@ -513,6 +562,9 @@
"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%]"
}
@@ -534,6 +586,9 @@
"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%]"
}
@@ -555,6 +610,9 @@
"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,6 +9,11 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
# --- Unit lists for multi-unit pollutants ---
@@ -163,6 +168,7 @@
# Binary sensor detected/cleared trigger fields
.trigger_binary_fields: &trigger_binary_fields
behavior: *trigger_behavior
for: *trigger_for
# --- Binary sensor targets ---
@@ -294,6 +300,7 @@ co_crossed_threshold:
target: *target_co_sensor
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -320,6 +327,7 @@ co2_crossed_threshold:
target: *target_co2
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -344,6 +352,7 @@ pm1_crossed_threshold:
target: *target_pm1
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -368,6 +377,7 @@ pm25_crossed_threshold:
target: *target_pm25
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -392,6 +402,7 @@ pm4_crossed_threshold:
target: *target_pm4
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -416,6 +427,7 @@ pm10_crossed_threshold:
target: *target_pm10
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -442,6 +454,7 @@ ozone_crossed_threshold:
target: *target_ozone
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -470,6 +483,7 @@ voc_crossed_threshold:
target: *target_voc
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -498,6 +512,7 @@ voc_ratio_crossed_threshold:
target: *target_voc_ratio
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -526,6 +541,7 @@ no_crossed_threshold:
target: *target_no
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -554,6 +570,7 @@ no2_crossed_threshold:
target: *target_no2
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -580,6 +597,7 @@ n2o_crossed_threshold:
target: *target_n2o
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -606,6 +624,7 @@ so2_crossed_threshold:
target: *target_so2
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_armed": {
@@ -234,6 +235,9 @@
"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"
@@ -243,6 +247,9 @@
"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"
@@ -252,6 +259,9 @@
"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"
@@ -261,6 +271,9 @@
"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"
@@ -270,6 +283,9 @@
"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"
@@ -279,6 +295,9 @@
"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"
@@ -288,6 +307,9 @@
"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,6 +13,11 @@
- last
- any
translation_key: trigger_behavior
for:
required: true
default: 00:00:00
selector:
duration:
armed: *trigger_common

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_idle": {
@@ -160,6 +161,9 @@
"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"
@@ -169,6 +173,9 @@
"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"
@@ -178,6 +185,9 @@
"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"
@@ -187,6 +197,9 @@
"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,6 +13,11 @@
- last
- any
translation_key: trigger_behavior
for:
required: true
default: 00:00:00
selector:
duration:
idle: *trigger_common
listening: *trigger_common

View File

@@ -3,6 +3,7 @@
"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": {
@@ -87,6 +88,9 @@
"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%]"
}
@@ -98,6 +102,9 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery low"
@@ -107,6 +114,9 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery not low"
@@ -116,6 +126,9 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery started charging"
@@ -125,6 +138,9 @@
"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,6 +9,11 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
@@ -42,21 +47,25 @@
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:
@@ -74,6 +83,7 @@ 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==1.8.0"]
"requirements": ["PyTurboJPEG==1.8.3"]
}

View File

@@ -3,6 +3,7 @@
"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": {
@@ -385,6 +386,9 @@
"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"
@@ -397,6 +401,9 @@
"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"
@@ -406,6 +413,9 @@
"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"
@@ -415,6 +425,9 @@
"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"
@@ -434,6 +447,9 @@
"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%]"
}
@@ -455,6 +471,9 @@
"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%]"
}
@@ -466,6 +485,9 @@
"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"
@@ -475,6 +497,9 @@
"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,6 +13,11 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.humidity_threshold_entity: &humidity_threshold_entity
- domain: input_number
@@ -50,6 +55,7 @@ hvac_mode_changed:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
for: *trigger_for
hvac_mode:
context:
filter_target: target
@@ -76,6 +82,7 @@ target_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -101,6 +108,7 @@ target_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -1,6 +1,7 @@
{
"common": {
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_value": {
@@ -96,6 +97,9 @@
"fields": {
"behavior": {
"name": "[%key:component::counter::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::counter::common::trigger_for_name%]"
}
},
"name": "Counter reached maximum"
@@ -105,6 +109,9 @@
"fields": {
"behavior": {
"name": "[%key:component::counter::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::counter::common::trigger_for_name%]"
}
},
"name": "Counter reached minimum"
@@ -114,6 +121,9 @@
"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,6 +13,11 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
incremented:
target:

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"awning_is_closed": {
@@ -254,6 +255,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Awning closed"
@@ -263,6 +267,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Awning opened"
@@ -272,6 +279,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Blind closed"
@@ -281,6 +291,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Blind opened"
@@ -290,6 +303,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Curtain closed"
@@ -299,6 +315,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Curtain opened"
@@ -308,6 +327,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Shade closed"
@@ -317,6 +339,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Shade opened"
@@ -326,6 +351,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::trigger_for_name%]"
}
},
"name": "Shutter closed"
@@ -335,6 +363,9 @@
"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,6 +9,11 @@
- 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
def process_image(self, image: bytes) -> None:
async def async_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.process_faces(demo_data, 4)
self.async_process_faces(demo_data, 4)

View File

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

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_closed": {
@@ -45,6 +46,9 @@
"fields": {
"behavior": {
"name": "[%key:component::door::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::door::common::trigger_for_name%]"
}
},
"name": "Door closed"
@@ -54,6 +58,9 @@
"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,6 +9,11 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
closed:
fields: *trigger_common_fields

View File

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

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_off": {
@@ -196,6 +197,9 @@
"fields": {
"behavior": {
"name": "[%key:component::fan::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::fan::common::trigger_for_name%]"
}
},
"name": "Fan turned off"
@@ -205,6 +209,9 @@
"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,6 +13,11 @@
- 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

@@ -9,7 +9,7 @@
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
"quality_scale": "gold",
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.4"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_closed": {
@@ -45,6 +46,9 @@
"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"
@@ -54,6 +58,9 @@
"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,6 +9,11 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
closed:
fields: *trigger_common_fields

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_closed": {
@@ -45,6 +46,9 @@
"fields": {
"behavior": {
"name": "[%key:component::gate::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::gate::common::trigger_for_name%]"
}
},
"name": "Gate closed"
@@ -54,6 +58,9 @@
"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,6 +9,11 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
closed:
fields: *trigger_common_fields

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE
@@ -55,9 +56,10 @@ 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.schedule_update_ha_state(False)
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register HDMI callbacks after initialization."""

View File

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

View File

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

View File

@@ -2,7 +2,8 @@
"common": {
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_drying": {
@@ -211,6 +212,9 @@
"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"
@@ -223,6 +227,9 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::trigger_for_name%]"
}
},
"name": "Humidifier started drying"
@@ -232,6 +239,9 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::trigger_for_name%]"
}
},
"name": "Humidifier started humidifying"
@@ -241,6 +251,9 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::trigger_for_name%]"
}
},
"name": "Humidifier turned off"
@@ -250,6 +263,9 @@
"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,6 +13,11 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
started_drying: *trigger_common
started_humidifying: *trigger_common
@@ -23,6 +28,7 @@ mode_changed:
target: *trigger_humidifier_target
fields:
behavior: *trigger_behavior
for: *trigger_for
mode:
context:
filter_target: target

View File

@@ -3,6 +3,7 @@
"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": {
@@ -51,6 +52,9 @@
"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,6 +9,11 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.humidity_threshold_entity: &humidity_threshold_entity
- domain: input_number
@@ -47,6 +52,7 @@ crossed_threshold:
target: *trigger_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -3,6 +3,7 @@
"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": {
@@ -68,6 +69,9 @@
"fields": {
"behavior": {
"name": "[%key:component::illuminance::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::illuminance::common::trigger_for_name%]"
}
},
"name": "Light cleared"
@@ -78,6 +82,9 @@
"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%]"
}
@@ -89,6 +96,9 @@
"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,6 +9,11 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.illuminance_threshold_entity: &illuminance_threshold_entity
- domain: input_number
@@ -55,6 +60,7 @@ crossed_threshold:
target: *trigger_numerical_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

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

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_docked": {
@@ -98,6 +99,9 @@
"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"
@@ -107,6 +111,9 @@
"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"
@@ -116,6 +123,9 @@
"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"
@@ -125,6 +135,9 @@
"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"
@@ -134,6 +147,9 @@
"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,6 +13,11 @@
- last
- any
translation_key: trigger_behavior
for:
required: true
default: 00:00:00
selector:
duration:
docked: *trigger_common
errored: *trigger_common

View File

@@ -36,6 +36,7 @@
"field_xy_color_name": "XY-color",
"section_advanced_fields_name": "Advanced options",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold type"
},
"conditions": {
@@ -515,6 +516,9 @@
"behavior": {
"name": "[%key:component::light::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::light::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::light::common::trigger_threshold_name%]"
}
@@ -526,6 +530,9 @@
"fields": {
"behavior": {
"name": "[%key:component::light::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::light::common::trigger_for_name%]"
}
},
"name": "Light turned off"
@@ -535,6 +542,9 @@
"fields": {
"behavior": {
"name": "[%key:component::light::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::light::common::trigger_for_name%]"
}
},
"name": "Light turned on"

View File

@@ -13,6 +13,11 @@
- last
- any
translation_key: trigger_behavior
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.brightness_threshold_entity: &brightness_threshold_entity
- domain: input_number
@@ -46,6 +51,7 @@ brightness_crossed_threshold:
target: *trigger_light_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_jammed": {
@@ -146,6 +147,9 @@
"fields": {
"behavior": {
"name": "[%key:component::lock::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::lock::common::trigger_for_name%]"
}
},
"name": "Lock jammed"
@@ -155,6 +159,9 @@
"fields": {
"behavior": {
"name": "[%key:component::lock::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::lock::common::trigger_for_name%]"
}
},
"name": "Lock locked"
@@ -164,6 +171,9 @@
"fields": {
"behavior": {
"name": "[%key:component::lock::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::lock::common::trigger_for_name%]"
}
},
"name": "Lock opened"
@@ -173,6 +183,9 @@
"fields": {
"behavior": {
"name": "[%key:component::lock::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::lock::common::trigger_for_name%]"
}
},
"name": "Lock unlocked"

View File

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

View File

@@ -75,7 +75,6 @@ from .const import ( # noqa: F401
ATTR_GROUP_MEMBERS,
ATTR_INPUT_SOURCE,
ATTR_INPUT_SOURCE_LIST,
ATTR_LAST_NON_BUFFERING_STATE,
ATTR_MEDIA_ALBUM_ARTIST,
ATTR_MEDIA_ALBUM_NAME,
ATTR_MEDIA_ANNOUNCE,
@@ -588,8 +587,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_volume_level: float | None = None
_attr_volume_step: float
__last_non_buffering_state: MediaPlayerState | None = None
# Implement these for your media player
@cached_property
def device_class(self) -> MediaPlayerDeviceClass | None:
@@ -1127,12 +1124,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
if (state := self.state) != MediaPlayerState.BUFFERING:
self.__last_non_buffering_state = state
state_attr: dict[str, Any] = {
ATTR_LAST_NON_BUFFERING_STATE: self.__last_non_buffering_state
}
state_attr: dict[str, Any] = {}
if self.support_grouping:
state_attr[ATTR_GROUP_MEMBERS] = self.group_members

View File

@@ -13,7 +13,6 @@ ATTR_ENTITY_PICTURE_LOCAL = "entity_picture_local"
ATTR_GROUP_MEMBERS = "group_members"
ATTR_INPUT_SOURCE = "source"
ATTR_INPUT_SOURCE_LIST = "source_list"
ATTR_LAST_NON_BUFFERING_STATE = "last_non_buffering_state"
ATTR_MEDIA_ANNOUNCE = "announce"
ATTR_MEDIA_ALBUM_ARTIST = "media_album_artist"
ATTR_MEDIA_ALBUM_NAME = "media_album_name"

View File

@@ -123,8 +123,20 @@
}
},
"triggers": {
"paused_playing": {
"trigger": "mdi:pause"
},
"started_playing": {
"trigger": "mdi:play"
},
"stopped_playing": {
"trigger": "mdi:stop"
},
"turned_off": {
"trigger": "mdi:power"
},
"turned_on": {
"trigger": "mdi:power"
}
}
}

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_not_playing": {
@@ -433,14 +434,65 @@
},
"title": "Media player",
"triggers": {
"stopped_playing": {
"description": "Triggers after one or more media players stop playing media.",
"paused_playing": {
"description": "Triggers after one or more media players pause playing.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player paused playing"
},
"started_playing": {
"description": "Triggers after one or more media players start playing.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player started playing"
},
"stopped_playing": {
"description": "Triggers after one or more media players stop playing.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player stopped playing"
},
"turned_off": {
"description": "Triggers after one or more media players turn off.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player turned off"
},
"turned_on": {
"description": "Triggers after one or more media players turn on.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player turned on"
}
}
}

View File

@@ -7,6 +7,29 @@ from . import MediaPlayerState
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"paused_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.PLAYING,
},
to_states={
MediaPlayerState.PAUSED,
},
),
"started_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
to_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.PLAYING,
},
),
"stopped_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
@@ -20,6 +43,32 @@ TRIGGERS: dict[str, type[Trigger]] = {
MediaPlayerState.ON,
},
),
"turned_off": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
},
to_states={
MediaPlayerState.OFF,
},
),
"turned_on": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.OFF,
},
to_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
},
),
}

View File

@@ -1,4 +1,4 @@
stopped_playing:
.trigger_common: &trigger_common
target:
entity:
domain: media_player
@@ -13,3 +13,14 @@ stopped_playing:
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
paused_playing: *trigger_common
started_playing: *trigger_common
stopped_playing: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -1,5 +1,6 @@
"""Device tracker for Mobile app."""
from collections.abc import Callable
from typing import Any
from homeassistant.components.device_tracker import (
@@ -53,11 +54,11 @@ async def async_setup_entry(
class MobileAppEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device."""
def __init__(self, entry, data=None):
def __init__(self, entry: ConfigEntry) -> None:
"""Set up Mobile app entity."""
self._entry = entry
self._data = data
self._dispatch_unsub = None
self._data: dict[str, Any] = {}
self._dispatch_unsub: Callable[[], None] | None = None
@property
def unique_id(self) -> str:
@@ -132,12 +133,7 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
self.update_data,
)
# Don't restore if we got set up with data.
if self._data is not None:
return
if (state := await self.async_get_last_state()) is None:
self._data = {}
return
attr = state.attributes
@@ -158,7 +154,7 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
self._dispatch_unsub = None
@callback
def update_data(self, data):
def update_data(self, data: dict[str, Any]) -> None:
"""Mark the device as seen."""
self._data = data
self.async_write_ha_state()

View File

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

View File

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

View File

@@ -15,8 +15,12 @@ _MOTION_DOMAIN_SPECS = {
CONDITIONS: dict[str, type[Condition]] = {
"is_detected": make_entity_state_condition(_MOTION_DOMAIN_SPECS, STATE_ON),
"is_not_detected": make_entity_state_condition(_MOTION_DOMAIN_SPECS, STATE_OFF),
"is_detected": make_entity_state_condition(
_MOTION_DOMAIN_SPECS, STATE_ON, support_duration=True
),
"is_not_detected": make_entity_state_condition(
_MOTION_DOMAIN_SPECS, STATE_OFF, support_duration=True
),
}

View File

@@ -8,6 +8,11 @@
options:
- all
- any
for:
required: true
default: 00:00:00
selector:
duration:
is_detected:
fields: *condition_common_fields

View File

@@ -1,7 +1,9 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_for_name": "For at least",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_detected": {
@@ -9,6 +11,9 @@
"fields": {
"behavior": {
"name": "[%key:component::motion::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::motion::common::condition_for_name%]"
}
},
"name": "Motion is detected"
@@ -18,6 +23,9 @@
"fields": {
"behavior": {
"name": "[%key:component::motion::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::motion::common::condition_for_name%]"
}
},
"name": "Motion is not detected"
@@ -45,6 +53,9 @@
"fields": {
"behavior": {
"name": "[%key:component::motion::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::motion::common::trigger_for_name%]"
}
},
"name": "Motion cleared"
@@ -54,6 +65,9 @@
"fields": {
"behavior": {
"name": "[%key:component::motion::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::motion::common::trigger_for_name%]"
}
},
"name": "Motion detected"

View File

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

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_detected": {
@@ -45,6 +46,9 @@
"fields": {
"behavior": {
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::occupancy::common::trigger_for_name%]"
}
},
"name": "Occupancy cleared"
@@ -54,6 +58,9 @@
"fields": {
"behavior": {
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::occupancy::common::trigger_for_name%]"
}
},
"name": "Occupancy detected"

View File

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

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_home": {
@@ -77,6 +78,9 @@
"fields": {
"behavior": {
"name": "[%key:component::person::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::person::common::trigger_for_name%]"
}
},
"name": "Entered home"
@@ -86,6 +90,9 @@
"fields": {
"behavior": {
"name": "[%key:component::person::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::person::common::trigger_for_name%]"
}
},
"name": "Left home"

View File

@@ -13,6 +13,11 @@
- 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

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

View File

@@ -9,6 +9,11 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.power_units: &power_units
- "mW"
@@ -49,6 +54,7 @@ crossed_threshold:
target: *trigger_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -8,7 +8,7 @@
"quality_scale": "internal",
"requirements": [
"SQLAlchemy==2.0.49",
"fnv-hash-fast==2.0.0",
"fnv-hash-fast==2.0.2",
"psutil-home-assistant==0.0.1"
]
}

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_off": {
@@ -159,6 +160,9 @@
"fields": {
"behavior": {
"name": "[%key:component::remote::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::remote::common::trigger_for_name%]"
}
},
"name": "Remote turned off"
@@ -168,6 +172,9 @@
"fields": {
"behavior": {
"name": "[%key:component::remote::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::remote::common::trigger_for_name%]"
}
},
"name": "Remote turned on"

View File

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

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/rest",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["jsonpath==0.82.2", "xmltodict==1.0.2"]
"requirements": ["jsonpath==0.82.2", "xmltodict==1.0.4"]
}

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_off": {
@@ -76,6 +77,9 @@
"fields": {
"behavior": {
"name": "[%key:component::schedule::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::schedule::common::trigger_for_name%]"
}
},
"name": "Schedule block ended"
@@ -85,6 +89,9 @@
"fields": {
"behavior": {
"name": "[%key:component::schedule::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::schedule::common::trigger_for_name%]"
}
},
"name": "Schedule block started"

View File

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

View File

@@ -4,8 +4,8 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"no_data": "Rest data is empty. Verify your configuration",
"resource_error": "Could not update rest data. Verify your configuration"
"no_data": "REST data is empty. Verify your configuration",
"resource_error": "Could not update REST data. Verify your configuration"
},
"step": {
"user": {

View File

@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
"requirements": ["pyserial-asyncio-fast==0.16"]
"requirements": ["serialx==1.2.2"]
}

View File

@@ -3,11 +3,11 @@
from __future__ import annotations
import asyncio
from asyncio import Task
import json
import logging
from serial import SerialException
import serial_asyncio_fast as serial_asyncio
from serialx import Parity, SerialException, StopBits, open_serial_connection
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -18,6 +18,7 @@ from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSIST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
@@ -33,9 +34,9 @@ CONF_DSRDTR = "dsrdtr"
DEFAULT_NAME = "Serial Sensor"
DEFAULT_BAUDRATE = 9600
DEFAULT_BYTESIZE = serial_asyncio.serial.EIGHTBITS
DEFAULT_PARITY = serial_asyncio.serial.PARITY_NONE
DEFAULT_STOPBITS = serial_asyncio.serial.STOPBITS_ONE
DEFAULT_BYTESIZE = 8
DEFAULT_PARITY = Parity.NONE
DEFAULT_STOPBITS = StopBits.ONE
DEFAULT_XONXOFF = False
DEFAULT_RTSCTS = False
DEFAULT_DSRDTR = False
@@ -46,28 +47,21 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_BYTESIZE, default=DEFAULT_BYTESIZE): vol.In(
[
serial_asyncio.serial.FIVEBITS,
serial_asyncio.serial.SIXBITS,
serial_asyncio.serial.SEVENBITS,
serial_asyncio.serial.EIGHTBITS,
]
),
vol.Optional(CONF_BYTESIZE, default=DEFAULT_BYTESIZE): vol.In([5, 6, 7, 8]),
vol.Optional(CONF_PARITY, default=DEFAULT_PARITY): vol.In(
[
serial_asyncio.serial.PARITY_NONE,
serial_asyncio.serial.PARITY_EVEN,
serial_asyncio.serial.PARITY_ODD,
serial_asyncio.serial.PARITY_MARK,
serial_asyncio.serial.PARITY_SPACE,
Parity.NONE,
Parity.EVEN,
Parity.ODD,
Parity.MARK,
Parity.SPACE,
]
),
vol.Optional(CONF_STOPBITS, default=DEFAULT_STOPBITS): vol.In(
[
serial_asyncio.serial.STOPBITS_ONE,
serial_asyncio.serial.STOPBITS_ONE_POINT_FIVE,
serial_asyncio.serial.STOPBITS_TWO,
StopBits.ONE,
StopBits.ONE_POINT_FIVE,
StopBits.TWO,
]
),
vol.Optional(CONF_XONXOFF, default=DEFAULT_XONXOFF): cv.boolean,
@@ -84,28 +78,17 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Serial sensor platform."""
name = config.get(CONF_NAME)
port = config.get(CONF_SERIAL_PORT)
baudrate = config.get(CONF_BAUDRATE)
bytesize = config.get(CONF_BYTESIZE)
parity = config.get(CONF_PARITY)
stopbits = config.get(CONF_STOPBITS)
xonxoff = config.get(CONF_XONXOFF)
rtscts = config.get(CONF_RTSCTS)
dsrdtr = config.get(CONF_DSRDTR)
value_template = config.get(CONF_VALUE_TEMPLATE)
sensor = SerialSensor(
name,
port,
baudrate,
bytesize,
parity,
stopbits,
xonxoff,
rtscts,
dsrdtr,
value_template,
name=config[CONF_NAME],
port=config[CONF_SERIAL_PORT],
baudrate=config[CONF_BAUDRATE],
bytesize=config[CONF_BYTESIZE],
parity=config[CONF_PARITY],
stopbits=config[CONF_STOPBITS],
xonxoff=config[CONF_XONXOFF],
rtscts=config[CONF_RTSCTS],
dsrdtr=config[CONF_DSRDTR],
value_template=config.get(CONF_VALUE_TEMPLATE),
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, sensor.stop_serial_read)
@@ -119,17 +102,17 @@ class SerialSensor(SensorEntity):
def __init__(
self,
name,
port,
baudrate,
bytesize,
parity,
stopbits,
xonxoff,
rtscts,
dsrdtr,
value_template,
):
name: str,
port: str,
baudrate: int,
bytesize: int,
parity: Parity,
stopbits: StopBits,
xonxoff: bool,
rtscts: bool,
dsrdtr: bool,
value_template: Template | None,
) -> None:
"""Initialize the Serial sensor."""
self._attr_name = name
self._port = port
@@ -140,12 +123,12 @@ class SerialSensor(SensorEntity):
self._xonxoff = xonxoff
self._rtscts = rtscts
self._dsrdtr = dsrdtr
self._serial_loop_task = None
self._serial_loop_task: Task[None] | None = None
self._template = value_template
async def async_added_to_hass(self) -> None:
"""Handle when an entity is about to be added to Home Assistant."""
self._serial_loop_task = self.hass.loop.create_task(
self._serial_loop_task = self.hass.async_create_background_task(
self.serial_read(
self._port,
self._baudrate,
@@ -155,26 +138,31 @@ class SerialSensor(SensorEntity):
self._xonxoff,
self._rtscts,
self._dsrdtr,
)
),
"Serial reader",
)
async def serial_read(
self,
device,
baudrate,
bytesize,
parity,
stopbits,
xonxoff,
rtscts,
dsrdtr,
device: str,
baudrate: int,
bytesize: int,
parity: Parity,
stopbits: StopBits,
xonxoff: bool,
rtscts: bool,
dsrdtr: bool,
**kwargs,
):
"""Read the data from the port."""
logged_error = False
while True:
reader = None
writer = None
try:
reader, _ = await serial_asyncio.open_serial_connection(
reader, writer = await open_serial_connection(
url=device,
baudrate=baudrate,
bytesize=bytesize,
@@ -185,8 +173,7 @@ class SerialSensor(SensorEntity):
dsrdtr=dsrdtr,
**kwargs,
)
except SerialException:
except OSError, SerialException, TimeoutError:
if not logged_error:
_LOGGER.exception(
"Unable to connect to the serial device %s. Will retry", device
@@ -197,15 +184,15 @@ class SerialSensor(SensorEntity):
_LOGGER.debug("Serial device %s connected", device)
while True:
try:
line = await reader.readline()
except SerialException:
line_bytes = await reader.readline()
except OSError, SerialException:
_LOGGER.exception(
"Error while reading serial device %s", device
)
await self._handle_error()
break
else:
line = line.decode("utf-8").strip()
line = line_bytes.decode("utf-8").strip()
try:
data = json.loads(line)
@@ -223,6 +210,10 @@ class SerialSensor(SensorEntity):
_LOGGER.debug("Received: %s", line)
self._attr_native_value = line
self.async_write_ha_state()
finally:
if writer is not None:
writer.close()
await writer.wait_closed()
async def _handle_error(self):
"""Handle error for serial connection."""

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_off": {
@@ -87,6 +88,9 @@
"fields": {
"behavior": {
"name": "[%key:component::siren::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::siren::common::trigger_for_name%]"
}
},
"name": "Siren turned off"
@@ -96,6 +100,9 @@
"fields": {
"behavior": {
"name": "[%key:component::siren::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::siren::common::trigger_for_name%]"
}
},
"name": "Siren turned on"

View File

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

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/startca",
"iot_class": "cloud_polling",
"quality_scale": "legacy",
"requirements": ["xmltodict==1.0.2"]
"requirements": ["xmltodict==1.0.4"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["PyTurboJPEG==1.8.0", "av==16.0.1", "numpy==2.3.2"]
"requirements": ["PyTurboJPEG==1.8.3", "av==16.0.1", "numpy==2.3.2"]
}

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_off": {
@@ -101,6 +102,9 @@
"fields": {
"behavior": {
"name": "[%key:component::switch::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::switch::common::trigger_for_name%]"
}
},
"name": "Switch turned off"
@@ -110,6 +114,9 @@
"fields": {
"behavior": {
"name": "[%key:component::switch::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::switch::common::trigger_for_name%]"
}
},
"name": "Switch turned on"

View File

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

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/ted5000",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["xmltodict==1.0.2"]
"requirements": ["xmltodict==1.0.4"]
}

View File

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

View File

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

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["twentemilieu"],
"quality_scale": "silver",
"requirements": ["twentemilieu==2.2.1"]
"requirements": ["twentemilieu==3.0.0"]
}

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_available": {
@@ -125,6 +126,9 @@
"fields": {
"behavior": {
"name": "[%key:component::update::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::update::common::trigger_for_name%]"
}
},
"name": "Update became available"

View File

@@ -13,5 +13,10 @@
- last
- any
translation_key: trigger_behavior
for:
required: true
default: 00:00:00
selector:
duration:
update_became_available: *trigger_common

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine, Sequence
import dataclasses
from datetime import datetime, timedelta
import logging
import os
@@ -26,24 +27,20 @@ from homeassistant.core import (
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import USBMatcher, async_get_usb
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .models import (
SerialDevice, # noqa: F401
USBDevice,
)
from .models import SerialDevice, USBDevice
from .utils import (
async_scan_serial_ports,
scan_serial_ports, # noqa: F401
usb_device_from_path, # noqa: F401
usb_device_from_port, # noqa: F401
scan_serial_ports,
usb_device_from_path,
usb_device_matches_matcher,
usb_service_info_from_device,
usb_unique_id_from_service_info, # noqa: F401
usb_unique_id_from_service_info,
)
_LOGGER = logging.getLogger(__name__)
@@ -56,9 +53,17 @@ REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown
ADD_REMOVE_SCAN_COOLDOWN = 5 # 5 second cooldown to give devices a chance to register
__all__ = [
"SerialDevice",
"USBCallbackMatcher",
"USBDevice",
"async_register_port_event_callback",
"async_register_scan_request_callback",
"async_scan_serial_ports",
"scan_serial_ports",
"usb_device_from_path",
"usb_device_matches_matcher",
"usb_service_info_from_device",
"usb_unique_id_from_service_info",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -163,6 +168,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await usb_discovery.async_setup()
hass.data[_USB_DATA] = usb_discovery
websocket_api.async_register_command(hass, websocket_usb_scan)
websocket_api.async_register_command(hass, websocket_usb_list_serial_ports)
return True
@@ -358,7 +364,7 @@ class USBDiscovery:
for matcher in matched:
for flow in self.hass.config_entries.flow.async_progress_by_init_data_type(
_UsbServiceInfo,
UsbServiceInfo,
lambda flow_service_info: flow_service_info == service_info,
):
if matcher["domain"] != flow["handler"]:
@@ -477,3 +483,23 @@ async def websocket_usb_scan(
"""Scan for new usb devices."""
await async_request_scan(hass)
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "usb/list_serial_ports"})
@websocket_api.async_response
async def websocket_usb_list_serial_ports(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List available serial ports."""
try:
ports = await async_scan_serial_ports(hass)
except OSError as err:
connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
return
connection.send_result(
msg["id"],
[dataclasses.asdict(port) for port in ports],
)

View File

@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["aiousbwatcher==1.1.1", "pyserial==3.5"]
"requirements": ["aiousbwatcher==1.1.1", "serialx==1.2.2"]
}

View File

@@ -3,12 +3,10 @@
from __future__ import annotations
from collections.abc import Sequence
import dataclasses
import fnmatch
import os
from serial.tools.list_ports import comports
from serial.tools.list_ports_common import ListPortInfo
from serialx import SerialPortInfo, list_serial_ports
from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.usb import UsbServiceInfo
@@ -17,8 +15,8 @@ from homeassistant.loader import USBMatcher
from .models import SerialDevice, USBDevice
def usb_device_from_port(port: ListPortInfo) -> USBDevice:
"""Convert serial ListPortInfo to USBDevice."""
def usb_device_from_port(port: SerialPortInfo) -> USBDevice:
"""Convert serialx SerialPortInfo to USBDevice."""
assert port.vid is not None
assert port.pid is not None
@@ -28,53 +26,30 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice:
pid=f"{hex(port.pid)[2:]:0>4}".upper(),
serial_number=port.serial_number,
manufacturer=port.manufacturer,
description=port.description,
description=port.product,
)
def serial_device_from_port(port: ListPortInfo) -> SerialDevice:
"""Convert serial ListPortInfo to SerialDevice."""
def serial_device_from_port(port: SerialPortInfo) -> SerialDevice:
"""Convert serialx SerialPortInfo to SerialDevice."""
return SerialDevice(
device=port.device,
serial_number=port.serial_number,
manufacturer=port.manufacturer,
description=port.description,
description=port.product,
)
def usb_serial_device_from_port(port: ListPortInfo) -> USBDevice | SerialDevice:
"""Convert serial ListPortInfo to USBDevice or SerialDevice."""
if port.vid is not None or port.pid is not None:
assert port.vid is not None
assert port.pid is not None
def usb_serial_device_from_port(port: SerialPortInfo) -> USBDevice | SerialDevice:
"""Convert serialx SerialPortInfo to USBDevice or SerialDevice."""
if port.vid is not None and port.pid is not None:
return usb_device_from_port(port)
return serial_device_from_port(port)
def scan_serial_ports() -> Sequence[USBDevice | SerialDevice]:
"""Scan serial ports and return USB and other serial devices."""
# Scan all symlinks first
by_id = "/dev/serial/by-id"
realpath_to_by_id: dict[str, str] = {}
if os.path.isdir(by_id):
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
realpath_to_by_id[os.path.realpath(path)] = path
serial_ports = []
for port in comports():
device = usb_serial_device_from_port(port)
device_path = realpath_to_by_id.get(port.device, port.device)
if device_path != port.device:
# Prefer the unique /dev/serial/by-id/ path if it exists
device = dataclasses.replace(device, device=device_path)
serial_ports.append(device)
return serial_ports
return [usb_serial_device_from_port(port) for port in list_serial_ports()]
async def async_scan_serial_ports(

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_cleaning": {
@@ -190,6 +191,9 @@
"fields": {
"behavior": {
"name": "[%key:component::vacuum::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::vacuum::common::trigger_for_name%]"
}
},
"name": "Vacuum returned to dock"
@@ -199,6 +203,9 @@
"fields": {
"behavior": {
"name": "[%key:component::vacuum::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::vacuum::common::trigger_for_name%]"
}
},
"name": "Vacuum encountered an error"
@@ -208,6 +215,9 @@
"fields": {
"behavior": {
"name": "[%key:component::vacuum::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::vacuum::common::trigger_for_name%]"
}
},
"name": "Vacuum cleaner paused cleaning"
@@ -217,6 +227,9 @@
"fields": {
"behavior": {
"name": "[%key:component::vacuum::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::vacuum::common::trigger_for_name%]"
}
},
"name": "Vacuum cleaner started cleaning"
@@ -226,6 +239,9 @@
"fields": {
"behavior": {
"name": "[%key:component::vacuum::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::vacuum::common::trigger_for_name%]"
}
},
"name": "Vacuum cleaner started returning to dock"

View File

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

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_closed": {
@@ -96,6 +97,9 @@
"fields": {
"behavior": {
"name": "[%key:component::valve::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::valve::common::trigger_for_name%]"
}
},
"name": "Valve closed"
@@ -105,6 +109,9 @@
"fields": {
"behavior": {
"name": "[%key:component::valve::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::valve::common::trigger_for_name%]"
}
},
"name": "Valve opened"

View File

@@ -13,6 +13,11 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
closed: *trigger_common
opened: *trigger_common

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