mirror of
https://github.com/home-assistant/core.git
synced 2026-04-16 06:36:14 +02:00
Compare commits
25 Commits
timer_add_
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0043a307f0 | ||
|
|
dfb1819800 | ||
|
|
12018cf9f4 | ||
|
|
70368c622e | ||
|
|
743aef05be | ||
|
|
49e5b03c08 | ||
|
|
6bc3fcef36 | ||
|
|
e3e87185c5 | ||
|
|
6d83b73cbb | ||
|
|
533871babb | ||
|
|
1dc93a80c4 | ||
|
|
f8a94c6f22 | ||
|
|
b127d13587 | ||
|
|
1895f8ebce | ||
|
|
b6916954dc | ||
|
|
23181f5275 | ||
|
|
607a10d1e1 | ||
|
|
ecb814adb0 | ||
|
|
67df556e84 | ||
|
|
4d472418c5 | ||
|
|
cf6441561c | ||
|
|
6d8d447355 | ||
|
|
ab5ae33290 | ||
|
|
c0bf9a2bd2 | ||
|
|
d862b999ae |
@@ -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
44
.github/renovate.json
vendored
@@ -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": [
|
||||
|
||||
16
Dockerfile
generated
16
Dockerfile
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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}:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,14 +433,50 @@
|
||||
},
|
||||
"title": "Media player",
|
||||
"triggers": {
|
||||
"paused_playing": {
|
||||
"description": "Triggers after one or more media players pause playing.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::media_player::common::trigger_behavior_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%]"
|
||||
}
|
||||
},
|
||||
"name": "Media player started playing"
|
||||
},
|
||||
"stopped_playing": {
|
||||
"description": "Triggers after one or more media players stop playing media.",
|
||||
"description": "Triggers after one or more media players stop playing.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::media_player::common::trigger_behavior_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%]"
|
||||
}
|
||||
},
|
||||
"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%]"
|
||||
}
|
||||
},
|
||||
"name": "Media player turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
stopped_playing:
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: media_player
|
||||
@@ -13,3 +13,9 @@ stopped_playing:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
paused_playing: *trigger_common
|
||||
started_playing: *trigger_common
|
||||
stopped_playing: *trigger_common
|
||||
turned_off: *trigger_common
|
||||
turned_on: *trigger_common
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["twentemilieu"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["twentemilieu==2.2.1"]
|
||||
"requirements": ["twentemilieu==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -26,24 +26,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 +52,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)
|
||||
@@ -358,7 +362,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"]:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from zwave_js_server.const import CommandClass
|
||||
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
@@ -18,6 +19,10 @@ BITMASK_SCHEMA = vol.All(
|
||||
lambda value: int(value, 16),
|
||||
)
|
||||
|
||||
COMMAND_CLASS_SCHEMA = vol.All(
|
||||
vol.Coerce(int), vol.In([cc.value for cc in CommandClass])
|
||||
)
|
||||
|
||||
|
||||
def boolean(value: Any) -> bool:
|
||||
"""Validate and coerce a boolean value."""
|
||||
|
||||
@@ -30,7 +30,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
from .config_validation import VALUE_SCHEMA
|
||||
from .config_validation import COMMAND_CLASS_SCHEMA, VALUE_SCHEMA
|
||||
from .const import (
|
||||
ATTR_COMMAND_CLASS,
|
||||
ATTR_CONFIG_PARAMETER,
|
||||
@@ -122,7 +122,7 @@ SET_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
|
||||
SET_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): SERVICE_SET_VALUE,
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
|
||||
vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA,
|
||||
vol.Required(ATTR_PROPERTY): vol.Any(int, str),
|
||||
vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string),
|
||||
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
|
||||
@@ -334,7 +334,7 @@ async def async_get_action_capabilities(
|
||||
{
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.In(
|
||||
{
|
||||
CommandClass(cc.id).value: cc.name
|
||||
str(CommandClass(cc.id).value): cc.name
|
||||
for cc in sorted(
|
||||
node.command_classes, key=lambda cc: cc.name
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
from .config_validation import VALUE_SCHEMA
|
||||
from .config_validation import COMMAND_CLASS_SCHEMA, VALUE_SCHEMA
|
||||
from .const import (
|
||||
ATTR_COMMAND_CLASS,
|
||||
ATTR_ENDPOINT,
|
||||
@@ -65,7 +65,7 @@ CONFIG_PARAMETER_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend(
|
||||
VALUE_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): VALUE_TYPE,
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
|
||||
vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA,
|
||||
vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string),
|
||||
vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string),
|
||||
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
|
||||
@@ -221,7 +221,7 @@ async def async_get_condition_capabilities(
|
||||
{
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.In(
|
||||
{
|
||||
CommandClass(cc.id).value: cc.name
|
||||
str(CommandClass(cc.id).value): cc.name
|
||||
for cc in sorted(
|
||||
node.command_classes, key=lambda cc: cc.name
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ from homeassistant.helpers import (
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .config_validation import VALUE_SCHEMA
|
||||
from .config_validation import COMMAND_CLASS_SCHEMA, VALUE_SCHEMA
|
||||
from .const import (
|
||||
ATTR_COMMAND_CLASS,
|
||||
ATTR_DATA_TYPE,
|
||||
@@ -91,7 +91,7 @@ NOTIFICATION_EVENT_CC_MAPPINGS = (
|
||||
# Event based trigger schemas
|
||||
BASE_EVENT_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
|
||||
vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -162,7 +162,7 @@ NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend(
|
||||
# zwave_js.value_updated based trigger schemas
|
||||
BASE_VALUE_UPDATED_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
|
||||
vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA,
|
||||
vol.Required(ATTR_PROPERTY): vol.Any(int, str),
|
||||
vol.Optional(ATTR_PROPERTY_KEY): vol.Any(None, vol.Coerce(int), str),
|
||||
vol.Optional(ATTR_ENDPOINT, default=0): vol.Any(None, vol.Coerce(int)),
|
||||
@@ -558,7 +558,7 @@ async def async_get_trigger_capabilities(
|
||||
{
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.In(
|
||||
{
|
||||
CommandClass(cc.id).value: cc.name
|
||||
str(CommandClass(cc.id).value): cc.name
|
||||
for cc in sorted(
|
||||
node.command_classes, key=lambda cc: cc.name
|
||||
)
|
||||
|
||||
@@ -572,12 +572,12 @@ def get_value_state_schema(
|
||||
return vol.Coerce(bool)
|
||||
|
||||
if value.configuration_value_type == ConfigurationValueType.ENUMERATED:
|
||||
return vol.In({int(k): v for k, v in value.metadata.states.items()})
|
||||
return vol.In({str(int(k)): v for k, v in value.metadata.states.items()})
|
||||
|
||||
return None
|
||||
|
||||
if value.metadata.states:
|
||||
return vol.In({int(k): v for k, v in value.metadata.states.items()})
|
||||
return vol.In({str(int(k)): v for k, v in value.metadata.states.items()})
|
||||
|
||||
return vol.All(
|
||||
vol.Coerce(int),
|
||||
|
||||
@@ -51,8 +51,8 @@ ATTR_TO = "to"
|
||||
_OPTIONS_SCHEMA_DICT = {
|
||||
vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.In(
|
||||
{cc.value: cc.name for cc in CommandClass}
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.All(
|
||||
vol.Coerce(int), vol.In({cc.value: cc.name for cc in CommandClass})
|
||||
),
|
||||
vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string),
|
||||
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
|
||||
|
||||
@@ -316,11 +316,11 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
|
||||
|
||||
|
||||
class FlowType(StrEnum):
|
||||
"""Flow type."""
|
||||
"""Flow type supported in `next_flow` of ConfigFlowResult."""
|
||||
|
||||
CONFIG_FLOW = "config_flow"
|
||||
# Add other flow types here as needed in the future,
|
||||
# if we want to support them in the `next_flow` parameter.
|
||||
OPTIONS_FLOW = "options_flow"
|
||||
CONFIG_SUBENTRIES_FLOW = "config_subentries_flow"
|
||||
|
||||
|
||||
def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> None:
|
||||
@@ -1608,6 +1608,26 @@ class ConfigEntriesFlowManager(
|
||||
issue_id = f"config_entry_reauth_{flow.handler}_{entry_id}"
|
||||
ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
|
||||
|
||||
def _async_validate_next_flow(
|
||||
self,
|
||||
result: ConfigFlowResult,
|
||||
) -> None:
|
||||
"""Validate `next_flow` in result if provided."""
|
||||
if (next_flow := result.get("next_flow")) is None:
|
||||
return
|
||||
flow_type, flow_id = next_flow
|
||||
if flow_type not in FlowType:
|
||||
raise HomeAssistantError(f"Invalid flow type: {flow_type}")
|
||||
if flow_type == FlowType.CONFIG_FLOW:
|
||||
# Raises UnknownFlow if the flow does not exist.
|
||||
self.hass.config_entries.flow.async_get(flow_id)
|
||||
if flow_type == FlowType.OPTIONS_FLOW:
|
||||
# Raises UnknownFlow if the flow does not exist.
|
||||
self.hass.config_entries.options.async_get(flow_id)
|
||||
if flow_type == FlowType.CONFIG_SUBENTRIES_FLOW:
|
||||
# Raises UnknownFlow if the flow does not exist.
|
||||
self.hass.config_entries.subentries.async_get(flow_id)
|
||||
|
||||
async def async_finish_flow(
|
||||
self,
|
||||
flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult],
|
||||
@@ -1656,6 +1676,8 @@ class ConfigEntriesFlowManager(
|
||||
self.config_entries.async_update_entry(
|
||||
entry, discovery_keys=new_discovery_keys
|
||||
)
|
||||
|
||||
self._async_validate_next_flow(result)
|
||||
return result
|
||||
|
||||
# Mark the step as done.
|
||||
@@ -1770,6 +1792,10 @@ class ConfigEntriesFlowManager(
|
||||
self.config_entries._async_clean_up(existing_entry) # noqa: SLF001
|
||||
|
||||
result["result"] = entry
|
||||
if not existing_entry:
|
||||
result = await flow.async_on_create_entry(result)
|
||||
self._async_validate_next_flow(result)
|
||||
|
||||
return result
|
||||
|
||||
async def async_create_flow(
|
||||
@@ -3291,7 +3317,10 @@ class ConfigFlow(ConfigEntryBaseFlow):
|
||||
return
|
||||
flow_type, flow_id = next_flow
|
||||
if flow_type != FlowType.CONFIG_FLOW:
|
||||
raise HomeAssistantError("Invalid next_flow type")
|
||||
raise HomeAssistantError(
|
||||
"next_flow only supports FlowType.CONFIG_FLOW; "
|
||||
"use async_on_create_entry for options or subentry flows"
|
||||
)
|
||||
# Raises UnknownFlow if the flow does not exist.
|
||||
self.hass.config_entries.flow.async_get(flow_id)
|
||||
result["next_flow"] = next_flow
|
||||
@@ -3312,6 +3341,15 @@ class ConfigFlow(ConfigEntryBaseFlow):
|
||||
self._async_set_next_flow_if_valid(result, next_flow)
|
||||
return result
|
||||
|
||||
async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult:
|
||||
"""Runs after a config flow has created a config entry.
|
||||
|
||||
Can be overridden by integrations to add additional data to the result.
|
||||
Example: creating next flow entries to the result which needs a
|
||||
config entry created before it can start.
|
||||
"""
|
||||
return result
|
||||
|
||||
@callback
|
||||
def async_create_entry( # type: ignore[override]
|
||||
self,
|
||||
|
||||
@@ -544,8 +544,9 @@ class HomeAssistant:
|
||||
) -> None:
|
||||
"""Add a job to be executed by the event loop or by an executor.
|
||||
|
||||
If the job is either a coroutine or decorated with @callback, it will be
|
||||
run by the event loop, if not it will be run by an executor.
|
||||
If the job is a coroutine, coroutine function, or decorated with
|
||||
@callback, it will be run by the event loop, if not it will be run
|
||||
by an executor.
|
||||
|
||||
target: target to call.
|
||||
args: parameters for method to call.
|
||||
@@ -557,6 +558,14 @@ class HomeAssistant:
|
||||
functools.partial(self.async_create_task, target, eager_start=True)
|
||||
)
|
||||
return
|
||||
# For @callback targets, schedule directly via call_soon_threadsafe
|
||||
# to avoid the extra deferral through _async_add_hass_job + call_soon.
|
||||
# Check iscoroutinefunction to gracefully handle incorrectly labeled @callback functions.
|
||||
if is_callback_check_partial(target) and not inspect.iscoroutinefunction(
|
||||
target
|
||||
):
|
||||
self.loop.call_soon_threadsafe(target, *args)
|
||||
return
|
||||
self.loop.call_soon_threadsafe(
|
||||
functools.partial(self._async_add_hass_job, HassJob(target), *args)
|
||||
)
|
||||
@@ -598,8 +607,9 @@ class HomeAssistant:
|
||||
) -> asyncio.Future[_R] | None:
|
||||
"""Add a job to be executed by the event loop or by an executor.
|
||||
|
||||
If the job is either a coroutine or decorated with @callback, it will be
|
||||
run by the event loop, if not it will be run by an executor.
|
||||
If the job is a coroutine, coroutine function, or decorated with
|
||||
@callback, it will be run by the event loop, if not it will be run
|
||||
by an executor.
|
||||
|
||||
This method must be run in the event loop.
|
||||
|
||||
|
||||
@@ -349,6 +349,9 @@ class EntityTriggerBase(Trigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain_specs: Mapping[str, DomainSpec]
|
||||
_excluded_states: Final[frozenset[str]] = frozenset(
|
||||
{STATE_UNAVAILABLE, STATE_UNKNOWN}
|
||||
)
|
||||
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST
|
||||
|
||||
@override
|
||||
@@ -392,6 +395,7 @@ class EntityTriggerBase(Trigger):
|
||||
self.is_valid_state(state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self._hass.states.get(entity_id)) is not None
|
||||
and state.state not in self._excluded_states
|
||||
)
|
||||
|
||||
def check_one_match(self, entity_ids: set[str]) -> bool:
|
||||
@@ -401,6 +405,7 @@ class EntityTriggerBase(Trigger):
|
||||
self.is_valid_state(state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self._hass.states.get(entity_id)) is not None
|
||||
and state.state not in self._excluded_states
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ astral==2.2
|
||||
async-interrupt==1.2.2
|
||||
async-upnp-client==0.46.2
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==25.4.0
|
||||
attrs==26.1.0
|
||||
audioop-lts==0.2.1
|
||||
av==16.0.1
|
||||
awesomeversion==25.8.0
|
||||
@@ -32,7 +32,7 @@ cronsim==2.7
|
||||
cryptography==46.0.7
|
||||
dbus-fast==4.0.4
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.0
|
||||
fnv-hash-fast==2.0.2
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==6.0.0
|
||||
@@ -57,13 +57,13 @@ PyJWT==2.10.1
|
||||
pymicro-vad==1.0.1
|
||||
PyNaCl==1.6.2
|
||||
pyOpenSSL==26.0.0
|
||||
pyserial==3.5
|
||||
pyspeex-noise==1.0.2
|
||||
python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.0
|
||||
PyTurboJPEG==1.8.3
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
securetar==2026.4.1
|
||||
serialx==1.2.2
|
||||
SQLAlchemy==2.0.49
|
||||
standard-aifc==3.13.0
|
||||
standard-telnetlib==3.13.0
|
||||
@@ -71,7 +71,7 @@ typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==2.2.0
|
||||
urllib3>=2.0
|
||||
uv==0.11.1
|
||||
voluptuous-openapi==0.2.0
|
||||
voluptuous-openapi==0.3.0
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
webrtc-models==0.3.0
|
||||
|
||||
@@ -36,7 +36,7 @@ dependencies = [
|
||||
"annotatedyaml==1.0.2",
|
||||
"astral==2.2",
|
||||
"async-interrupt==1.2.2",
|
||||
"attrs==25.4.0",
|
||||
"attrs==26.1.0",
|
||||
"atomicwrites-homeassistant==1.4.1",
|
||||
"audioop-lts==0.2.1",
|
||||
"awesomeversion==25.8.0",
|
||||
@@ -44,7 +44,7 @@ dependencies = [
|
||||
"certifi>=2021.5.30",
|
||||
"ciso8601==2.3.3",
|
||||
"cronsim==2.7",
|
||||
"fnv-hash-fast==2.0.0",
|
||||
"fnv-hash-fast==2.0.2",
|
||||
# hass-nabucasa is imported by helpers which don't depend on the cloud
|
||||
# integration
|
||||
"hass-nabucasa==2.2.0",
|
||||
@@ -77,7 +77,7 @@ dependencies = [
|
||||
"uv==0.11.1",
|
||||
"voluptuous==0.15.2",
|
||||
"voluptuous-serialize==2.7.0",
|
||||
"voluptuous-openapi==0.2.0",
|
||||
"voluptuous-openapi==0.3.0",
|
||||
"yarl==1.23.0",
|
||||
"webrtc-models==0.3.0",
|
||||
"zeroconf==0.148.0",
|
||||
|
||||
8
requirements.txt
generated
8
requirements.txt
generated
@@ -14,7 +14,7 @@ annotatedyaml==1.0.2
|
||||
astral==2.2
|
||||
async-interrupt==1.2.2
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==25.4.0
|
||||
attrs==26.1.0
|
||||
audioop-lts==0.2.1
|
||||
awesomeversion==25.8.0
|
||||
bcrypt==5.0.0
|
||||
@@ -22,7 +22,7 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==46.0.7
|
||||
fnv-hash-fast==2.0.0
|
||||
fnv-hash-fast==2.0.2
|
||||
ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
@@ -44,7 +44,7 @@ pymicro-vad==1.0.1
|
||||
pyOpenSSL==26.0.0
|
||||
pyspeex-noise==1.0.2
|
||||
python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.0
|
||||
PyTurboJPEG==1.8.3
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
securetar==2026.4.1
|
||||
@@ -55,7 +55,7 @@ typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==2.2.0
|
||||
urllib3>=2.0
|
||||
uv==0.11.1
|
||||
voluptuous-openapi==0.2.0
|
||||
voluptuous-openapi==0.3.0
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
webrtc-models==0.3.0
|
||||
|
||||
16
requirements_all.txt
generated
16
requirements_all.txt
generated
@@ -96,7 +96,7 @@ PyTransportNSW==0.1.1
|
||||
|
||||
# homeassistant.components.camera
|
||||
# homeassistant.components.stream
|
||||
PyTurboJPEG==1.8.0
|
||||
PyTurboJPEG==1.8.3
|
||||
|
||||
# homeassistant.components.vicare
|
||||
PyViCare==2.59.0
|
||||
@@ -1006,7 +1006,7 @@ flux-led==1.2.0
|
||||
|
||||
# homeassistant.components.homekit
|
||||
# homeassistant.components.recorder
|
||||
fnv-hash-fast==2.0.0
|
||||
fnv-hash-fast==2.0.2
|
||||
|
||||
# homeassistant.components.foobot
|
||||
foobot_async==1.0.0
|
||||
@@ -2465,13 +2465,6 @@ pysensibo==1.2.1
|
||||
# homeassistant.components.senz
|
||||
pysenz==1.0.2
|
||||
|
||||
# homeassistant.components.serial
|
||||
pyserial-asyncio-fast==0.16
|
||||
|
||||
# homeassistant.components.acer_projector
|
||||
# homeassistant.components.usb
|
||||
pyserial==3.5
|
||||
|
||||
# homeassistant.components.sesame
|
||||
pysesame2==1.0.1
|
||||
|
||||
@@ -2928,7 +2921,10 @@ sentence-stream==1.2.0
|
||||
# homeassistant.components.sentry
|
||||
sentry-sdk==2.48.0
|
||||
|
||||
# homeassistant.components.acer_projector
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
# homeassistant.components.serial
|
||||
# homeassistant.components.usb
|
||||
# homeassistant.components.zha
|
||||
serialx==1.2.2
|
||||
|
||||
@@ -3163,7 +3159,7 @@ tuya-device-handlers==0.0.17
|
||||
tuya-device-sharing-sdk==0.2.8
|
||||
|
||||
# homeassistant.components.twentemilieu
|
||||
twentemilieu==2.2.1
|
||||
twentemilieu==3.0.0
|
||||
|
||||
# homeassistant.components.twilio
|
||||
twilio==6.32.0
|
||||
|
||||
@@ -24,16 +24,16 @@ pytest-asyncio==1.3.0
|
||||
pytest-aiohttp==1.1.0
|
||||
pytest-cov==7.1.0
|
||||
pytest-freezer==0.4.9
|
||||
pytest-github-actions-annotate-failures==0.3.0
|
||||
pytest-github-actions-annotate-failures==0.4.0
|
||||
pytest-socket==0.7.0
|
||||
pytest-sugar==1.0.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-timeout==2.4.0
|
||||
pytest-unordered==0.7.0
|
||||
pytest-picked==0.5.1
|
||||
pytest-xdist==3.8.0
|
||||
pytest==9.0.3
|
||||
requests-mock==1.12.1
|
||||
respx==0.22.0
|
||||
respx==0.23.1
|
||||
syrupy==5.1.0
|
||||
tqdm==4.67.1
|
||||
types-aiofiles==24.1.0.20250822
|
||||
|
||||
13
requirements_test_all.txt
generated
13
requirements_test_all.txt
generated
@@ -93,7 +93,7 @@ PyTransportNSW==0.1.1
|
||||
|
||||
# homeassistant.components.camera
|
||||
# homeassistant.components.stream
|
||||
PyTurboJPEG==1.8.0
|
||||
PyTurboJPEG==1.8.3
|
||||
|
||||
# homeassistant.components.vicare
|
||||
PyViCare==2.59.0
|
||||
@@ -894,7 +894,7 @@ flux-led==1.2.0
|
||||
|
||||
# homeassistant.components.homekit
|
||||
# homeassistant.components.recorder
|
||||
fnv-hash-fast==2.0.0
|
||||
fnv-hash-fast==2.0.2
|
||||
|
||||
# homeassistant.components.foobot
|
||||
foobot_async==1.0.0
|
||||
@@ -2109,10 +2109,6 @@ pysensibo==1.2.1
|
||||
# homeassistant.components.senz
|
||||
pysenz==1.0.2
|
||||
|
||||
# homeassistant.components.acer_projector
|
||||
# homeassistant.components.usb
|
||||
pyserial==3.5
|
||||
|
||||
# homeassistant.components.seventeentrack
|
||||
pyseventeentrack==1.1.3
|
||||
|
||||
@@ -2485,7 +2481,10 @@ sentence-stream==1.2.0
|
||||
# homeassistant.components.sentry
|
||||
sentry-sdk==2.48.0
|
||||
|
||||
# homeassistant.components.acer_projector
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
# homeassistant.components.serial
|
||||
# homeassistant.components.usb
|
||||
# homeassistant.components.zha
|
||||
serialx==1.2.2
|
||||
|
||||
@@ -2675,7 +2674,7 @@ tuya-device-handlers==0.0.17
|
||||
tuya-device-sharing-sdk==0.2.8
|
||||
|
||||
# homeassistant.components.twentemilieu
|
||||
twentemilieu==2.2.1
|
||||
twentemilieu==3.0.0
|
||||
|
||||
# homeassistant.components.twilio
|
||||
twilio==6.32.0
|
||||
|
||||
@@ -34,25 +34,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:{go2rtc} /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
# Install uv
|
||||
&& pip3 install uv=={uv}
|
||||
|
||||
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
|
||||
|
||||
@@ -143,12 +141,12 @@ WORKDIR "/github/workspace"
|
||||
|
||||
COPY . /usr/src/homeassistant
|
||||
|
||||
# Uv is only needed during build
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \
|
||||
# Uv creates a lock file in /tmp
|
||||
--mount=type=tmpfs,target=/tmp \
|
||||
# Uv creates a lock file in /tmp
|
||||
RUN --mount=type=tmpfs,target=/tmp \
|
||||
# Required for PyTurboJPEG
|
||||
apk add --no-cache libturbojpeg \
|
||||
# Install uv at the version pinned in the requirements file
|
||||
&& pip install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{{print $2}}' /usr/src/homeassistant/requirements.txt)" \
|
||||
&& uv pip install \
|
||||
--no-build \
|
||||
--no-cache \
|
||||
@@ -217,8 +215,7 @@ def _generate_files(config: Config) -> list[File]:
|
||||
+ 10
|
||||
) * 1000
|
||||
|
||||
package_versions = _get_package_versions(config.root / "requirements.txt", {"uv"})
|
||||
package_versions |= _get_package_versions(
|
||||
package_versions = _get_package_versions(
|
||||
config.root / "requirements_test.txt", {"pipdeptree", "tqdm"}
|
||||
)
|
||||
package_versions |= _get_package_versions(
|
||||
|
||||
8
script/hassfest/docker/Dockerfile
generated
8
script/hassfest/docker/Dockerfile
generated
@@ -13,12 +13,12 @@ WORKDIR "/github/workspace"
|
||||
|
||||
COPY . /usr/src/homeassistant
|
||||
|
||||
# Uv is only needed during build
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.11.1,source=/uv,target=/bin/uv \
|
||||
# Uv creates a lock file in /tmp
|
||||
--mount=type=tmpfs,target=/tmp \
|
||||
# Uv creates a lock file in /tmp
|
||||
RUN --mount=type=tmpfs,target=/tmp \
|
||||
# Required for PyTurboJPEG
|
||||
apk add --no-cache libturbojpeg \
|
||||
# Install uv at the version pinned in the requirements file
|
||||
&& pip install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{print $2}' /usr/src/homeassistant/requirements.txt)" \
|
||||
&& uv pip install \
|
||||
--no-build \
|
||||
--no-cache \
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Arcam FMJ (127.0.0.1)',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 200588>,
|
||||
'volume_level': 0.0,
|
||||
}),
|
||||
@@ -95,7 +94,6 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Arcam FMJ (127.0.0.1) Zone 2',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 135052>,
|
||||
'volume_level': 0.0,
|
||||
}),
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': 'playing',
|
||||
'media_content_type': 'music',
|
||||
'repeat': 'off',
|
||||
'shuffle': False,
|
||||
@@ -186,7 +185,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'media_player.beosound_a5_44444444',
|
||||
]),
|
||||
'last_non_buffering_state': 'playing',
|
||||
'media_content_type': 'music',
|
||||
'repeat': 'off',
|
||||
'shuffle': False,
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -72,7 +71,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -122,7 +120,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -172,7 +169,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -222,7 +218,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -272,7 +267,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -321,7 +315,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -370,7 +363,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -419,7 +411,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -468,7 +459,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -517,7 +507,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -566,7 +555,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -615,7 +603,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -665,7 +652,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -715,7 +701,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -765,7 +750,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -815,7 +799,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'media_position': 0,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
@@ -866,7 +849,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -916,7 +898,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -966,7 +947,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -1016,7 +996,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -1063,7 +1042,6 @@
|
||||
'media_player.beoconnect_core_22222222',
|
||||
'media_player.beosound_balance_11111111',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
@@ -1112,7 +1090,6 @@
|
||||
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
|
||||
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
|
||||
]),
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
'friendly_name': 'player-name1111',
|
||||
'group_members': None,
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'master': False,
|
||||
'media_album_name': 'album',
|
||||
'media_artist': 'artist',
|
||||
|
||||
@@ -54,6 +54,8 @@ async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"climate.target_temperature_crossed_threshold",
|
||||
"climate.turned_off",
|
||||
"climate.turned_on",
|
||||
"climate.started_cooling",
|
||||
"climate.started_drying",
|
||||
"climate.started_heating",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
'device_class': 'tv',
|
||||
'friendly_name': 'Living Room',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'source_list': list([
|
||||
'TV',
|
||||
]),
|
||||
|
||||
@@ -489,7 +489,7 @@ LIGHT_ATTRS = [
|
||||
]
|
||||
LOCK_ATTRS = [{"supported_features": 1}, {}]
|
||||
NOTIFY_ATTRS = [{"supported_features": 0}, {}]
|
||||
MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {"last_non_buffering_state": "on"}]
|
||||
MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}]
|
||||
SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}]
|
||||
VALVE_ATTRS = [{"supported_features": 0}, {"is_closed": False}]
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for the HDMI-CEC media player platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from pycec.const import (
|
||||
@@ -58,39 +57,6 @@ from homeassistant.core import HomeAssistant
|
||||
from . import MockHDMIDevice, assert_key_press_release
|
||||
from .conftest import CecEntityCreator, HDMINetworkCreator
|
||||
|
||||
type AssertState = Callable[[str, str], None]
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
name="assert_state",
|
||||
params=[
|
||||
False,
|
||||
pytest.param(
|
||||
True,
|
||||
marks=pytest.mark.xfail(
|
||||
reason="""State isn't updated because the function is missing the
|
||||
`schedule_update_ha_state` for a correct push entity. Would still
|
||||
update once the data comes back from the device."""
|
||||
),
|
||||
),
|
||||
],
|
||||
ids=["skip_assert_state", "run_assert_state"],
|
||||
)
|
||||
def assert_state_fixture(request: pytest.FixtureRequest) -> AssertState:
|
||||
"""Allow for skipping the assert state changes.
|
||||
|
||||
This is broken in this entity, but we still want to test that
|
||||
the rest of the code works as expected.
|
||||
"""
|
||||
|
||||
def _test_state(state: str, expected: str) -> None:
|
||||
if request.param:
|
||||
assert state == expected
|
||||
else:
|
||||
assert True
|
||||
|
||||
return _test_state
|
||||
|
||||
|
||||
async def test_load_platform(
|
||||
hass: HomeAssistant,
|
||||
@@ -142,7 +108,6 @@ async def test_service_on(
|
||||
hass: HomeAssistant,
|
||||
create_hdmi_network: HDMINetworkCreator,
|
||||
create_cec_entity: CecEntityCreator,
|
||||
assert_state: AssertState,
|
||||
) -> None:
|
||||
"""Test that media_player triggers on `on` service."""
|
||||
hdmi_network = await create_hdmi_network({"platform": "media_player"})
|
||||
@@ -157,19 +122,17 @@ async def test_service_on(
|
||||
{ATTR_ENTITY_ID: "media_player.hdmi_3"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_hdmi_device.turn_on.assert_called_once_with()
|
||||
|
||||
state = hass.states.get("media_player.hdmi_3")
|
||||
assert_state(state.state, STATE_ON)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_service_off(
|
||||
hass: HomeAssistant,
|
||||
create_hdmi_network: HDMINetworkCreator,
|
||||
create_cec_entity: CecEntityCreator,
|
||||
assert_state: AssertState,
|
||||
) -> None:
|
||||
"""Test that media_player triggers on `off` service."""
|
||||
hdmi_network = await create_hdmi_network({"platform": "media_player"})
|
||||
@@ -188,7 +151,7 @@ async def test_service_off(
|
||||
mock_hdmi_device.turn_off.assert_called_once_with()
|
||||
|
||||
state = hass.states.get("media_player.hdmi_3")
|
||||
assert_state(state.state, STATE_OFF)
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -317,7 +280,6 @@ async def test_volume_services(
|
||||
data,
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_hdmi_device.send_command.call_count == 2
|
||||
assert_key_press_release(mock_hdmi_device.send_command, dst=3, key=key)
|
||||
@@ -348,7 +310,6 @@ async def test_track_change_services(
|
||||
{ATTR_ENTITY_ID: "media_player.hdmi_3"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_hdmi_device.send_command.call_count == 2
|
||||
assert_key_press_release(mock_hdmi_device.send_command, dst=3, key=key)
|
||||
@@ -373,7 +334,6 @@ async def test_playback_services(
|
||||
hass: HomeAssistant,
|
||||
create_hdmi_network: HDMINetworkCreator,
|
||||
create_cec_entity: CecEntityCreator,
|
||||
assert_state: AssertState,
|
||||
service: str,
|
||||
key: int,
|
||||
expected_state: str,
|
||||
@@ -389,13 +349,12 @@ async def test_playback_services(
|
||||
{ATTR_ENTITY_ID: "media_player.hdmi_3"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_hdmi_device.send_command.call_count == 2
|
||||
assert_key_press_release(mock_hdmi_device.send_command, dst=3, key=key)
|
||||
|
||||
state = hass.states.get("media_player.hdmi_3")
|
||||
assert_state(state.state, expected_state)
|
||||
assert state.state == expected_state
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="PLAY feature isn't enabled")
|
||||
@@ -403,7 +362,6 @@ async def test_play_pause_service(
|
||||
hass: HomeAssistant,
|
||||
create_hdmi_network: HDMINetworkCreator,
|
||||
create_cec_entity: CecEntityCreator,
|
||||
assert_state: AssertState,
|
||||
) -> None:
|
||||
"""Test play pause service."""
|
||||
hdmi_network = await create_hdmi_network({"platform": "media_player"})
|
||||
@@ -418,13 +376,12 @@ async def test_play_pause_service(
|
||||
{ATTR_ENTITY_ID: "media_player.hdmi_3"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_hdmi_device.send_command.call_count == 2
|
||||
assert_key_press_release(mock_hdmi_device.send_command, dst=3, key=KEY_PAUSE)
|
||||
|
||||
state = hass.states.get("media_player.hdmi_3")
|
||||
assert_state(state.state, STATE_PAUSED)
|
||||
assert state.state == STATE_PAUSED
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
@@ -432,7 +389,6 @@ async def test_play_pause_service(
|
||||
{ATTR_ENTITY_ID: "media_player.hdmi_3"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_hdmi_device.send_command.call_count == 4
|
||||
assert_key_press_release(mock_hdmi_device.send_command, 1, dst=3, key=KEY_PLAY)
|
||||
@@ -527,9 +483,6 @@ async def test_starting_state(
|
||||
assert state.state == expected_state
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason="The code only sets the state to unavailable, doesn't set the `_attr_available` to false."
|
||||
)
|
||||
async def test_unavailable_status(
|
||||
hass: HomeAssistant,
|
||||
create_hdmi_network: HDMINetworkCreator,
|
||||
@@ -541,6 +494,7 @@ async def test_unavailable_status(
|
||||
await create_cec_entity(hdmi_network, mock_hdmi_device)
|
||||
|
||||
hass.bus.async_fire(EVENT_HDMI_CEC_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("media_player.hdmi_3")
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
@@ -314,7 +314,6 @@
|
||||
'media_player.test_player_2',
|
||||
]),
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': 'idle',
|
||||
'media_album_id': '1',
|
||||
'media_album_name': 'Album',
|
||||
'media_artist': 'Artist',
|
||||
|
||||
@@ -208,7 +208,6 @@
|
||||
'media_player.test_player_2',
|
||||
]),
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_album_id': '1',
|
||||
'media_album_name': 'Album',
|
||||
'media_artist': 'Artist',
|
||||
|
||||
@@ -18319,7 +18319,6 @@
|
||||
'attributes': dict({
|
||||
'device_class': 'tv',
|
||||
'friendly_name': 'LG webOS TV AF80',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'source': 'HDMI 4',
|
||||
'source_list': list([
|
||||
'AirPlay',
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
'assumed_state': True,
|
||||
'device_class': 'tv',
|
||||
'friendly_name': 'LG TV',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 21945>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -12,12 +12,10 @@ from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
ATTR_MEDIA_FILTER_CLASSES,
|
||||
ATTR_MEDIA_SEARCH_QUERY,
|
||||
DOMAIN,
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerEnqueue,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerState,
|
||||
SearchMedia,
|
||||
SearchMediaQuery,
|
||||
)
|
||||
@@ -26,11 +24,11 @@ from homeassistant.components.media_player.const import (
|
||||
SERVICE_SEARCH_MEDIA,
|
||||
)
|
||||
from homeassistant.components.websocket_api import TYPE_RESULT
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_OFF
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockEntityPlatform, setup_test_component_platform
|
||||
from tests.common import MockEntityPlatform
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
@@ -637,62 +635,3 @@ async def test_play_media_via_selector(hass: HomeAssistant) -> None:
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_media_player_state(hass: HomeAssistant) -> None:
|
||||
"""Test that media player state includes last_non_buffering_state."""
|
||||
entity1 = MediaPlayerEntity()
|
||||
entity1._attr_name = "test1"
|
||||
|
||||
setup_test_component_platform(hass, DOMAIN, [entity1])
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("media_player.test1")
|
||||
assert state.state == "unknown"
|
||||
assert state.attributes == {
|
||||
"friendly_name": "test1",
|
||||
"last_non_buffering_state": None,
|
||||
"supported_features": 0,
|
||||
}
|
||||
|
||||
entity1._attr_state = MediaPlayerState.PLAYING
|
||||
entity1.async_write_ha_state()
|
||||
state = hass.states.get("media_player.test1")
|
||||
assert state.state == "playing"
|
||||
assert state.attributes == {
|
||||
"friendly_name": "test1",
|
||||
"last_non_buffering_state": "playing",
|
||||
"supported_features": 0,
|
||||
}
|
||||
|
||||
# last_non_buffering_state not updated when state is buffering
|
||||
entity1._attr_state = MediaPlayerState.BUFFERING
|
||||
entity1.async_write_ha_state()
|
||||
state = hass.states.get("media_player.test1")
|
||||
assert state.state == "buffering"
|
||||
assert state.attributes == {
|
||||
"friendly_name": "test1",
|
||||
"last_non_buffering_state": "playing",
|
||||
"supported_features": 0,
|
||||
}
|
||||
|
||||
entity1._attr_state = MediaPlayerState.PAUSED
|
||||
entity1.async_write_ha_state()
|
||||
state = hass.states.get("media_player.test1")
|
||||
assert state.state == "paused"
|
||||
assert state.attributes == {
|
||||
"friendly_name": "test1",
|
||||
"last_non_buffering_state": "paused",
|
||||
"supported_features": 0,
|
||||
}
|
||||
|
||||
# last_non_buffering_state not present when unavailable
|
||||
entity1._attr_available = False
|
||||
entity1.async_write_ha_state()
|
||||
state = hass.states.get("media_player.test1")
|
||||
assert state.state == "unavailable"
|
||||
assert state.attributes == {
|
||||
"friendly_name": "test1",
|
||||
"supported_features": 0,
|
||||
}
|
||||
|
||||
@@ -28,7 +28,11 @@ async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"media_player.paused_playing",
|
||||
"media_player.started_playing",
|
||||
"media_player.stopped_playing",
|
||||
"media_player.turned_off",
|
||||
"media_player.turned_on",
|
||||
],
|
||||
)
|
||||
async def test_media_player_triggers_gated_by_labs_flag(
|
||||
@@ -46,6 +50,29 @@ async def test_media_player_triggers_gated_by_labs_flag(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="media_player.paused_playing",
|
||||
target_states=[
|
||||
MediaPlayerState.PAUSED,
|
||||
],
|
||||
other_states=[
|
||||
MediaPlayerState.BUFFERING,
|
||||
MediaPlayerState.PLAYING,
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="media_player.started_playing",
|
||||
target_states=[
|
||||
MediaPlayerState.BUFFERING,
|
||||
MediaPlayerState.PLAYING,
|
||||
],
|
||||
other_states=[
|
||||
MediaPlayerState.IDLE,
|
||||
MediaPlayerState.OFF,
|
||||
MediaPlayerState.ON,
|
||||
MediaPlayerState.PAUSED,
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="media_player.stopped_playing",
|
||||
target_states=[
|
||||
@@ -59,6 +86,32 @@ async def test_media_player_triggers_gated_by_labs_flag(
|
||||
MediaPlayerState.PLAYING,
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="media_player.turned_off",
|
||||
target_states=[
|
||||
MediaPlayerState.OFF,
|
||||
],
|
||||
other_states=[
|
||||
MediaPlayerState.BUFFERING,
|
||||
MediaPlayerState.IDLE,
|
||||
MediaPlayerState.ON,
|
||||
MediaPlayerState.PAUSED,
|
||||
MediaPlayerState.PLAYING,
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="media_player.turned_on",
|
||||
target_states=[
|
||||
MediaPlayerState.BUFFERING,
|
||||
MediaPlayerState.IDLE,
|
||||
MediaPlayerState.ON,
|
||||
MediaPlayerState.PAUSED,
|
||||
MediaPlayerState.PLAYING,
|
||||
],
|
||||
other_states=[
|
||||
MediaPlayerState.OFF,
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_media_player_state_trigger_behavior_any(
|
||||
|
||||
@@ -2634,7 +2634,7 @@ async def help_test_reload_with_config(
|
||||
"""Test reloading with supplied config."""
|
||||
new_yaml_config_file = tmp_path / "configuration.yaml"
|
||||
|
||||
def _write_yaml_config() -> None:
|
||||
def _write_yaml_config() -> str:
|
||||
new_yaml_config = yaml.dump(config)
|
||||
new_yaml_config_file.write_text(new_yaml_config)
|
||||
assert new_yaml_config_file.read_text() == new_yaml_config
|
||||
|
||||
@@ -536,7 +536,6 @@ async def test_loading_subentries(
|
||||
async def test_loading_subentry_with_bad_component_schema(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
mqtt_config_subentries_data: tuple[dict[str, Any]],
|
||||
device_registry: dr.DeviceRegistry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
@@ -565,10 +564,9 @@ async def test_loading_subentry_with_bad_component_schema(
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_qos_on_mqt_device_from_subentry(
|
||||
async def test_qos_on_mqtt_device_from_subentry(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
mqtt_config_subentries_data: tuple[dict[str, Any]],
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test QoS is set correctly on entities from MQTT device."""
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
]),
|
||||
'icon': 'mdi:speaker',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'mass_player_type': 'player',
|
||||
'media_album_name': 'Test Album',
|
||||
'media_artist': 'Test Artist',
|
||||
@@ -122,7 +121,6 @@
|
||||
]),
|
||||
'icon': 'mdi:speaker-multiple',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'mass_player_type': 'group',
|
||||
'media_album_name': 'Use Your Illusion I',
|
||||
'media_artist': "Guns N' Roses",
|
||||
@@ -200,7 +198,6 @@
|
||||
'group_members': list([
|
||||
]),
|
||||
'icon': 'mdi:speaker',
|
||||
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
|
||||
'mass_player_type': 'player',
|
||||
'sound_mode_list': list([
|
||||
'munich_translation',
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
}),
|
||||
'friendly_name': 'TX-NR7100',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'preset': 1,
|
||||
'sound_mode': 'DIRECT',
|
||||
'sound_mode_list': list([
|
||||
@@ -128,7 +127,6 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'TX-NR7100 Zone 2',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'sound_mode': 'Stereo',
|
||||
'sound_mode_list': list([
|
||||
'Stereo',
|
||||
@@ -195,7 +193,6 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'TX-NR7100 Zone 3',
|
||||
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
|
||||
'source_list': list([
|
||||
'TV',
|
||||
'FM Radio',
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'receiver',
|
||||
'friendly_name': 'PlayStation Vita',
|
||||
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -98,7 +97,6 @@
|
||||
'entity_picture': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG',
|
||||
'entity_picture_local': '/api/media_player_proxy/media_player.playstation_vita?token=123456789&cache=c7c916a6e18aec3d',
|
||||
'friendly_name': 'PlayStation Vita',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_id': 'PCSB00074_00',
|
||||
'media_content_type': <MediaType.GAME: 'game'>,
|
||||
'media_title': "Assassin's Creed® III Liberation",
|
||||
@@ -156,7 +154,6 @@
|
||||
'device_class': 'receiver',
|
||||
'entity_picture_local': None,
|
||||
'friendly_name': 'PlayStation Vita',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_type': <MediaType.GAME: 'game'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 0>,
|
||||
}),
|
||||
@@ -212,7 +209,6 @@
|
||||
'device_class': 'receiver',
|
||||
'entity_picture_local': None,
|
||||
'friendly_name': 'PlayStation 4',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_type': <MediaType.GAME: 'game'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 0>,
|
||||
}),
|
||||
@@ -267,7 +263,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'receiver',
|
||||
'friendly_name': 'PlayStation 4',
|
||||
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -323,7 +318,6 @@
|
||||
'entity_picture': 'http://gs2-sec.ww.prod.dl.playstation.net/gs2-sec/appkgo/prod/CUSA23081_00/5/i_f5d2adec7665af80b8550fb33fe808df10d292cdd47629a991debfdf72bdee34/i/icon0.png',
|
||||
'entity_picture_local': '/api/media_player_proxy/media_player.playstation_4?token=123456789&cache=924f463745523102',
|
||||
'friendly_name': 'PlayStation 4',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_id': 'CUSA23081_00',
|
||||
'media_content_type': <MediaType.GAME: 'game'>,
|
||||
'media_title': 'Untitled Goose Game',
|
||||
@@ -381,7 +375,6 @@
|
||||
'device_class': 'receiver',
|
||||
'entity_picture_local': None,
|
||||
'friendly_name': 'PlayStation 5',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_type': <MediaType.GAME: 'game'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 0>,
|
||||
}),
|
||||
@@ -436,7 +429,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'receiver',
|
||||
'friendly_name': 'PlayStation 5',
|
||||
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -492,7 +484,6 @@
|
||||
'entity_picture': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png',
|
||||
'entity_picture_local': '/api/media_player_proxy/media_player.playstation_5?token=123456789&cache=50dfb7140be0060b',
|
||||
'friendly_name': 'PlayStation 5',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_content_id': 'PPSA07784_00',
|
||||
'media_content_type': <MediaType.GAME: 'game'>,
|
||||
'media_title': 'STAR WARS Jedi: Survivor™',
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Robot Vacuum',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'repeat': <RepeatMode.ALL: 'all'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 284045>,
|
||||
'volume_level': 0.2,
|
||||
@@ -106,7 +105,6 @@
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Soundbar',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_artist': 'Rick Astley',
|
||||
'media_title': 'Never Gonna Give You Up',
|
||||
'source': 'wifi',
|
||||
@@ -172,7 +170,6 @@
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Galaxy Home Mini',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
'supported_features': <MediaPlayerEntityFeature: 318477>,
|
||||
@@ -230,7 +227,6 @@
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Elliots Rum',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_artist': 'David Guetta',
|
||||
'media_title': 'Forever Young',
|
||||
'supported_features': <MediaPlayerEntityFeature: 21517>,
|
||||
@@ -288,7 +284,6 @@
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Soundbar Living',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_artist': '',
|
||||
'media_title': '',
|
||||
'source': 'HDMI1',
|
||||
@@ -346,7 +341,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Soundbar 1',
|
||||
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 1420>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -407,7 +401,6 @@
|
||||
'device_class': 'tv',
|
||||
'friendly_name': '[TV] Samsung 8 Series (49)',
|
||||
'is_volume_muted': True,
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'source': 'HDMI1',
|
||||
'source_list': list([
|
||||
'digitalTv',
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
'media_player.test_client_1_snapcast_client',
|
||||
]),
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'latency': 6,
|
||||
'media_album_artist': 'Test Album Artist 1, Test Album Artist 2',
|
||||
'media_album_name': 'Test Album',
|
||||
@@ -128,7 +127,6 @@
|
||||
'media_player.test_client_2_snapcast_client',
|
||||
]),
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'latency': 6,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'source': 'test_stream_2',
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
'media_player.zone_a',
|
||||
]),
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'entity_picture': '/api/media_player_proxy/media_player.spotify_spotify_1?token=mock-token&cache=7bb89748322acb6c',
|
||||
'friendly_name': 'Spotify spotify_1',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_album_name': 'Permanent Waves',
|
||||
'media_artist': 'Rush',
|
||||
'media_content_id': 'spotify:track:4e9hUiLsN4mx61ARosFi7p',
|
||||
@@ -119,7 +118,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'entity_picture': '/api/media_player_proxy/media_player.spotify_spotify_1?token=mock-token&cache=cf1e6e1e830f08d3',
|
||||
'friendly_name': 'Spotify spotify_1',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_artist': 'Safety Third',
|
||||
'media_content_id': 'spotify:episode:3o0RYoo5iOMKSmEbunsbvW',
|
||||
'media_content_type': <MediaType.PODCAST: 'podcast'>,
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
'group_members': list([
|
||||
]),
|
||||
'is_volume_muted': True,
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_duration': 1,
|
||||
'media_position': 1,
|
||||
'query_result': dict({
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_album_name': 'Elon Musk',
|
||||
'media_artist': 'Walter Isaacson',
|
||||
'media_duration': 651.0,
|
||||
@@ -66,7 +65,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_album_name': '',
|
||||
'media_artist': '',
|
||||
'media_playlist': '',
|
||||
@@ -126,7 +124,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_album_name': 'Elon Musk',
|
||||
'media_artist': 'Walter Isaacson',
|
||||
'media_duration': 651.0,
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_album_name': 'Elon Musk',
|
||||
'media_artist': 'Walter Isaacson',
|
||||
'media_duration': 651.0,
|
||||
@@ -66,7 +65,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'media_album_name': '',
|
||||
'media_artist': '',
|
||||
'media_duration': 0.0,
|
||||
@@ -127,7 +125,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_album_name': 'Elon Musk',
|
||||
'media_artist': 'Walter Isaacson',
|
||||
'media_duration': 651.0,
|
||||
@@ -151,7 +148,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 16437>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -167,7 +163,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_album_name': 'Test Album',
|
||||
'media_artist': 'Test Artist',
|
||||
'media_duration': 60,
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 0>,
|
||||
'volume_level': 0.2258032258064516,
|
||||
}),
|
||||
@@ -59,7 +58,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Test Media player',
|
||||
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
|
||||
'media_album_name': 'Album',
|
||||
'media_artist': 'Artist',
|
||||
'media_duration': 60.0,
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
from unittest.mock import MagicMock, Mock, call, patch, sentinel
|
||||
|
||||
import pytest
|
||||
from serialx import SerialPortInfo
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import usb
|
||||
@@ -1296,112 +1297,55 @@ async def test_register_port_event_callback_failure(
|
||||
assert "Failure 2" in caplog.text
|
||||
|
||||
|
||||
async def test_async_scan_serial_ports_with_unique_symlinks(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test async_scan_serial_ports returns devices with unique /dev/serial/by-id paths."""
|
||||
entry1 = MagicMock(spec_set=os.DirEntry)
|
||||
entry1.is_symlink.return_value = True
|
||||
entry1.path = "/dev/serial/by-id/usb-device1"
|
||||
|
||||
entry2 = MagicMock(spec_set=os.DirEntry)
|
||||
entry2.is_symlink.return_value = True
|
||||
entry2.path = "/dev/serial/by-id/usb-device2"
|
||||
|
||||
mock_port1 = MagicMock()
|
||||
mock_port1.device = "/dev/ttyUSB0"
|
||||
mock_port1.vid = 0x1234
|
||||
mock_port1.pid = 0x5678
|
||||
mock_port1.serial_number = "ABC123"
|
||||
mock_port1.manufacturer = "Test Manufacturer"
|
||||
mock_port1.description = "Test Device"
|
||||
|
||||
mock_port2 = MagicMock()
|
||||
mock_port2.device = "/dev/ttyUSB1"
|
||||
mock_port2.vid = 0xABCD
|
||||
mock_port2.pid = 0xEF01
|
||||
mock_port2.serial_number = "XYZ789"
|
||||
mock_port2.manufacturer = "Another Manufacturer"
|
||||
mock_port2.description = "Another Device"
|
||||
|
||||
def mock_realpath(path: str) -> str:
|
||||
realpath_map = {
|
||||
"/dev/serial/by-id/usb-device1": "/dev/ttyUSB0",
|
||||
"/dev/serial/by-id/usb-device2": "/dev/ttyUSB1",
|
||||
}
|
||||
return realpath_map.get(path, path)
|
||||
|
||||
with (
|
||||
patch("os.path.isdir", return_value=True),
|
||||
patch("os.scandir", return_value=[entry1, entry2]),
|
||||
patch("os.path.realpath", side_effect=mock_realpath),
|
||||
patch(
|
||||
"homeassistant.components.usb.utils.comports",
|
||||
return_value=[mock_port1, mock_port2],
|
||||
),
|
||||
async def test_async_scan_serial_ports(hass: HomeAssistant) -> None:
|
||||
"""Test async_scan_serial_ports parsing."""
|
||||
with patch(
|
||||
"homeassistant.components.usb.utils.list_serial_ports",
|
||||
return_value=[
|
||||
SerialPortInfo(
|
||||
device="/dev/ttyAMA1",
|
||||
resolved_device="/dev/ttyAMA1",
|
||||
vid=None,
|
||||
pid=None,
|
||||
serial_number=None,
|
||||
manufacturer=None,
|
||||
product=None,
|
||||
bcd_device=None,
|
||||
interface_description=None,
|
||||
interface_num=None,
|
||||
),
|
||||
SerialPortInfo(
|
||||
device="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_10B41DE589FC-if00",
|
||||
resolved_device="/dev/ttyACM0",
|
||||
vid=12346,
|
||||
pid=16385,
|
||||
serial_number="10B41DE589FC",
|
||||
manufacturer="Nabu Casa",
|
||||
product="ZBT-2",
|
||||
bcd_device=257,
|
||||
interface_description="Nabu Casa ZBT-2",
|
||||
interface_num=0,
|
||||
),
|
||||
],
|
||||
):
|
||||
devices = await async_scan_serial_ports(hass)
|
||||
|
||||
assert len(devices) == 2
|
||||
assert devices[0].device == "/dev/serial/by-id/usb-device1"
|
||||
assert devices[0].vid == "1234"
|
||||
assert devices[1].device == "/dev/serial/by-id/usb-device2"
|
||||
assert devices[1].vid == "ABCD"
|
||||
|
||||
|
||||
async def test_async_scan_serial_ports_without_unique_symlinks(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test async_scan_serial_ports returns devices with original paths when no symlinks exist."""
|
||||
mock_port = MagicMock()
|
||||
mock_port.device = "/dev/ttyUSB0"
|
||||
mock_port.vid = 0x1234
|
||||
mock_port.pid = 0x5678
|
||||
mock_port.serial_number = "ABC123"
|
||||
mock_port.manufacturer = "Test Manufacturer"
|
||||
mock_port.description = "Test Device"
|
||||
|
||||
with (
|
||||
patch("os.path.isdir", return_value=False),
|
||||
patch("os.path.realpath", side_effect=lambda x: x),
|
||||
patch(
|
||||
"homeassistant.components.usb.utils.comports",
|
||||
return_value=[mock_port],
|
||||
assert devices == [
|
||||
SerialDevice(
|
||||
device="/dev/ttyAMA1",
|
||||
serial_number=None,
|
||||
manufacturer=None,
|
||||
description=None,
|
||||
),
|
||||
):
|
||||
devices = await async_scan_serial_ports(hass)
|
||||
|
||||
assert len(devices) == 1
|
||||
assert devices[0].device == "/dev/ttyUSB0"
|
||||
assert devices[0].vid == "1234"
|
||||
|
||||
|
||||
async def test_async_scan_serial_ports_no_vid_pid(hass: HomeAssistant) -> None:
|
||||
"""Test async_scan_serial_ports returns devices without VID:PID."""
|
||||
mock_port = MagicMock()
|
||||
mock_port.device = "/dev/ttyAMA1"
|
||||
mock_port.vid = None
|
||||
mock_port.pid = None
|
||||
mock_port.serial_number = None
|
||||
mock_port.manufacturer = None
|
||||
mock_port.description = None
|
||||
|
||||
with (
|
||||
patch("os.path.isdir", return_value=False),
|
||||
patch("os.path.realpath", side_effect=lambda x: x),
|
||||
patch(
|
||||
"homeassistant.components.usb.utils.comports",
|
||||
return_value=[mock_port],
|
||||
USBDevice(
|
||||
device="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_10B41DE589FC-if00",
|
||||
vid="303A",
|
||||
pid="4001",
|
||||
serial_number="10B41DE589FC",
|
||||
manufacturer="Nabu Casa",
|
||||
description="ZBT-2",
|
||||
),
|
||||
):
|
||||
devices = await async_scan_serial_ports(hass)
|
||||
|
||||
assert len(devices) == 1
|
||||
assert isinstance(devices[0], SerialDevice)
|
||||
assert devices[0].device == "/dev/ttyAMA1"
|
||||
assert devices[0].serial_number is None
|
||||
assert devices[0].manufacturer is None
|
||||
assert devices[0].description is None
|
||||
]
|
||||
|
||||
|
||||
def test_usb_device_from_path_finds_by_symlink() -> None:
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Vizio',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'sound_mode': 'Music',
|
||||
'sound_mode_list': list([
|
||||
'Music',
|
||||
@@ -131,7 +130,6 @@
|
||||
'device_class': 'tv',
|
||||
'friendly_name': 'Vizio',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'sound_mode': 'Music',
|
||||
'sound_mode_list': list([
|
||||
'Music',
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
'device_class': 'tv',
|
||||
'friendly_name': 'LG webOS TV MODEL',
|
||||
'is_volume_muted': False,
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_type': <MediaType.CHANNEL: 'channel'>,
|
||||
'media_title': 'Channel 1',
|
||||
'sound_output': 'speaker',
|
||||
|
||||
@@ -168,7 +168,6 @@
|
||||
'entity_picture': 'https://store-images.s-microsoft.com/image/apps.9815.9007199266246365.7dc5d343-fe4a-40c3-93dd-c78e77f97331.45eebdef-f725-4799-bbf8-9ad8391a8279',
|
||||
'entity_picture_local': '/api/media_player_proxy/media_player.xone?token=mock_token&cache=1cae983bd1c4c429',
|
||||
'friendly_name': 'XONE',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_id': '9WZDNCRFJ3TJ',
|
||||
'media_content_type': <MediaType.APP: 'app'>,
|
||||
'media_title': 'Netflix',
|
||||
@@ -226,7 +225,6 @@
|
||||
'entity_picture': 'https://store-images.s-microsoft.com/image/apps.9815.9007199266246365.7dc5d343-fe4a-40c3-93dd-c78e77f97331.45eebdef-f725-4799-bbf8-9ad8391a8279',
|
||||
'entity_picture_local': '/api/media_player_proxy/media_player.xonex?token=mock_token&cache=1cae983bd1c4c429',
|
||||
'friendly_name': 'XONEX',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_id': '9WZDNCRFJ3TJ',
|
||||
'media_content_type': <MediaType.APP: 'app'>,
|
||||
'media_title': 'Netflix',
|
||||
@@ -283,7 +281,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'entity_picture_local': None,
|
||||
'friendly_name': 'XONE',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_type': <MediaType.APP: 'app'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 149385>,
|
||||
}),
|
||||
@@ -338,7 +335,6 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'entity_picture_local': None,
|
||||
'friendly_name': 'XONEX',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_type': <MediaType.APP: 'app'>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 149385>,
|
||||
}),
|
||||
@@ -394,7 +390,6 @@
|
||||
'entity_picture': 'https://images-eds-ssl.xboxlive.com/image?url=8Oaj9Ryq1G1_p3lLnXlsaZgGzAie6Mnu24_PawYuDYIoH77pJ.X5Z.MqQPibUVTcbx57bBxf63xu2Ef8acP3S7Uz80NbHc5nza..4R00GT1V5G760cdfX7Hl0uIHdHCbkzTikdvNE0TedhKgQfQy.2gjOGbd8kXZXzy4VzeJiNPLhLq2QUQbo8q3sVoSPaw73J4BxM7gaNX8V8qLcWtO5sn6vgbTso51OaEIn4zeAiw-',
|
||||
'entity_picture_local': '/api/media_player_proxy/media_player.xone?token=mock_token&cache=cf419ddd9fb966d6',
|
||||
'friendly_name': 'XONE',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_id': '9VWGNH0VBZJX',
|
||||
'media_content_type': <MediaType.APP: 'app'>,
|
||||
'media_title': 'TV',
|
||||
@@ -452,7 +447,6 @@
|
||||
'entity_picture': 'https://images-eds-ssl.xboxlive.com/image?url=8Oaj9Ryq1G1_p3lLnXlsaZgGzAie6Mnu24_PawYuDYIoH77pJ.X5Z.MqQPibUVTcbx57bBxf63xu2Ef8acP3S7Uz80NbHc5nza..4R00GT1V5G760cdfX7Hl0uIHdHCbkzTikdvNE0TedhKgQfQy.2gjOGbd8kXZXzy4VzeJiNPLhLq2QUQbo8q3sVoSPaw73J4BxM7gaNX8V8qLcWtO5sn6vgbTso51OaEIn4zeAiw-',
|
||||
'entity_picture_local': '/api/media_player_proxy/media_player.xonex?token=mock_token&cache=cf419ddd9fb966d6',
|
||||
'friendly_name': 'XONEX',
|
||||
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
|
||||
'media_content_id': '9VWGNH0VBZJX',
|
||||
'media_content_type': <MediaType.APP: 'app'>,
|
||||
'media_title': 'TV',
|
||||
|
||||
@@ -17,7 +17,6 @@ from unittest.mock import (
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from serial.tools.list_ports_common import ListPortInfo
|
||||
from zha.application.const import RadioType
|
||||
from zigpy.application import ControllerApplication
|
||||
from zigpy.backups import BackupManager
|
||||
@@ -33,7 +32,7 @@ import zigpy.types
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.hassio import AddonError, AddonState
|
||||
from homeassistant.components.usb import USBDevice
|
||||
from homeassistant.components.usb import SerialDevice, USBDevice
|
||||
from homeassistant.components.zha import config_flow, radio_manager
|
||||
from homeassistant.components.zha.const import (
|
||||
CONF_BAUDRATE,
|
||||
@@ -66,9 +65,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
type RadioPicker = Callable[
|
||||
[RadioType], Coroutine[Any, Any, tuple[ConfigFlowResult, ListPortInfo]]
|
||||
]
|
||||
type RadioPicker = Callable[[RadioType], Coroutine[Any, Any, ConfigFlowResult]]
|
||||
PROBE_FUNCTION_PATH = "zigbee.application.ControllerApplication.probe"
|
||||
|
||||
|
||||
@@ -168,15 +165,14 @@ def mock_detect_radio_type(
|
||||
return detect
|
||||
|
||||
|
||||
def com_port(device="/dev/ttyUSB1234") -> ListPortInfo:
|
||||
def com_port(device="/dev/ttyUSB1234") -> SerialDevice:
|
||||
"""Mock of a serial port."""
|
||||
port = ListPortInfo(device)
|
||||
port.serial_number = "1234"
|
||||
port.manufacturer = "Virtual serial port"
|
||||
port.device = device
|
||||
port.description = "Some serial port"
|
||||
|
||||
return port
|
||||
return SerialDevice(
|
||||
device=device,
|
||||
serial_number="1234",
|
||||
manufacturer="Virtual serial port",
|
||||
description="Some serial port",
|
||||
)
|
||||
|
||||
|
||||
def usb_port(device="/dev/ttyUSB1234") -> USBDevice:
|
||||
@@ -1129,7 +1125,7 @@ async def test_user_flow_not_detected(hass: HomeAssistant) -> None:
|
||||
"""Test user flow, radio not detected."""
|
||||
|
||||
port = com_port()
|
||||
port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}"
|
||||
port_select = f"{port.device} - {port.description}, s/n: {port.serial_number} - {port.manufacturer}"
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
@@ -1700,14 +1696,12 @@ def test_prevent_overwrite_ezsp_ieee() -> None:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def advanced_pick_radio(
|
||||
hass: HomeAssistant,
|
||||
) -> Generator[RadioPicker]:
|
||||
def advanced_pick_radio(hass: HomeAssistant) -> Generator[RadioPicker]:
|
||||
"""Fixture for the first step of the config flow (where a radio is picked)."""
|
||||
|
||||
async def wrapper(radio_type: RadioType) -> tuple[ConfigFlowResult, ListPortInfo]:
|
||||
async def wrapper(radio_type: RadioType) -> ConfigFlowResult:
|
||||
port = com_port()
|
||||
port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}"
|
||||
port_select = f"{port.device} - {port.description}, s/n: {port.serial_number} - {port.manufacturer}"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type",
|
||||
|
||||
@@ -4,7 +4,6 @@ from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
|
||||
|
||||
import pytest
|
||||
import serial.tools.list_ports
|
||||
from zha.application.const import RadioType
|
||||
from zigpy.backups import BackupManager
|
||||
import zigpy.config
|
||||
@@ -77,17 +76,6 @@ def mock_detect_radio_type(
|
||||
return detect
|
||||
|
||||
|
||||
def com_port(device="/dev/ttyUSB1234"):
|
||||
"""Mock of a serial port."""
|
||||
port = serial.tools.list_ports_common.ListPortInfo("/dev/ttyUSB1234")
|
||||
port.serial_number = "1234"
|
||||
port.manufacturer = "Virtual serial port"
|
||||
port.device = device
|
||||
port.description = "Some serial port"
|
||||
|
||||
return port
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_create_zigpy_app() -> Generator[MagicMock]:
|
||||
"""Mock the radio connection."""
|
||||
|
||||
@@ -583,26 +583,26 @@ async def test_get_action_capabilities(
|
||||
assert capabilities and "extra_fields" in capabilities
|
||||
|
||||
cc_options = [
|
||||
(133, "Association"),
|
||||
(89, "Association Group Information"),
|
||||
(128, "Battery"),
|
||||
(129, "Clock"),
|
||||
(112, "Configuration"),
|
||||
(90, "Device Reset Locally"),
|
||||
(122, "Firmware Update Meta Data"),
|
||||
(135, "Indicator"),
|
||||
(114, "Manufacturer Specific"),
|
||||
(96, "Multi Channel"),
|
||||
(142, "Multi Channel Association"),
|
||||
(49, "Multilevel Sensor"),
|
||||
(115, "Powerlevel"),
|
||||
(68, "Thermostat Fan Mode"),
|
||||
(69, "Thermostat Fan State"),
|
||||
(64, "Thermostat Mode"),
|
||||
(66, "Thermostat Operating State"),
|
||||
(67, "Thermostat Setpoint"),
|
||||
(134, "Version"),
|
||||
(94, "Z-Wave Plus Info"),
|
||||
("133", "Association"),
|
||||
("89", "Association Group Information"),
|
||||
("128", "Battery"),
|
||||
("129", "Clock"),
|
||||
("112", "Configuration"),
|
||||
("90", "Device Reset Locally"),
|
||||
("122", "Firmware Update Meta Data"),
|
||||
("135", "Indicator"),
|
||||
("114", "Manufacturer Specific"),
|
||||
("96", "Multi Channel"),
|
||||
("142", "Multi Channel Association"),
|
||||
("49", "Multilevel Sensor"),
|
||||
("115", "Powerlevel"),
|
||||
("68", "Thermostat Fan Mode"),
|
||||
("69", "Thermostat Fan State"),
|
||||
("64", "Thermostat Mode"),
|
||||
("66", "Thermostat Operating State"),
|
||||
("67", "Thermostat Setpoint"),
|
||||
("134", "Version"),
|
||||
("94", "Z-Wave Plus Info"),
|
||||
]
|
||||
|
||||
assert voluptuous_serialize.convert(
|
||||
@@ -649,11 +649,11 @@ async def test_get_action_capabilities(
|
||||
"name": "value",
|
||||
"required": True,
|
||||
"options": [
|
||||
(0, "Disabled"),
|
||||
(1, "0.5° F"),
|
||||
(2, "1.0° F"),
|
||||
(3, "1.5° F"),
|
||||
(4, "2.0° F"),
|
||||
("0", "Disabled"),
|
||||
("1", "0.5° F"),
|
||||
("2", "1.0° F"),
|
||||
("3", "1.5° F"),
|
||||
("4", "2.0° F"),
|
||||
],
|
||||
"type": "select",
|
||||
}
|
||||
@@ -835,3 +835,22 @@ async def test_unavailable_entity_actions(
|
||||
action.get("entity_id") == entity_id_unavailable for action in actions
|
||||
)
|
||||
assert not any(action.get("entity_id") == binary_sensor.id for action in actions)
|
||||
|
||||
|
||||
def test_action_schema_coerces_string_command_class() -> None:
|
||||
"""Test that SET_VALUE action schema accepts both int and string command_class."""
|
||||
for command_class_value in (
|
||||
CommandClass.DOOR_LOCK.value,
|
||||
str(CommandClass.DOOR_LOCK.value),
|
||||
):
|
||||
config = device_action.SET_VALUE_SCHEMA(
|
||||
{
|
||||
"device_id": "device123",
|
||||
"domain": DOMAIN,
|
||||
"type": "set_value",
|
||||
"command_class": command_class_value,
|
||||
"property": "targetMode",
|
||||
"value": 255,
|
||||
}
|
||||
)
|
||||
assert config["command_class"] == CommandClass.DOOR_LOCK.value
|
||||
|
||||
@@ -490,15 +490,15 @@ async def test_get_condition_capabilities_value(
|
||||
assert capabilities and "extra_fields" in capabilities
|
||||
|
||||
cc_options = [
|
||||
(133, "Association"),
|
||||
(128, "Battery"),
|
||||
(98, "Door Lock"),
|
||||
(122, "Firmware Update Meta Data"),
|
||||
(114, "Manufacturer Specific"),
|
||||
(113, "Notification"),
|
||||
(152, "Security"),
|
||||
(99, "User Code"),
|
||||
(134, "Version"),
|
||||
("133", "Association"),
|
||||
("128", "Battery"),
|
||||
("98", "Door Lock"),
|
||||
("122", "Firmware Update Meta Data"),
|
||||
("114", "Manufacturer Specific"),
|
||||
("113", "Notification"),
|
||||
("152", "Security"),
|
||||
("99", "User Code"),
|
||||
("134", "Version"),
|
||||
]
|
||||
|
||||
assert voluptuous_serialize.convert(
|
||||
@@ -552,11 +552,11 @@ async def test_get_condition_capabilities_config_parameter(
|
||||
"name": "value",
|
||||
"required": True,
|
||||
"options": [
|
||||
(0, "Disabled"),
|
||||
(1, "0.5° F"),
|
||||
(2, "1.0° F"),
|
||||
(3, "1.5° F"),
|
||||
(4, "2.0° F"),
|
||||
("0", "Disabled"),
|
||||
("1", "0.5° F"),
|
||||
("2", "1.0° F"),
|
||||
("3", "1.5° F"),
|
||||
("4", "2.0° F"),
|
||||
],
|
||||
"type": "select",
|
||||
}
|
||||
@@ -694,3 +694,23 @@ async def test_get_value_from_config_failure(
|
||||
"endpoint": 10,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_condition_schema_coerces_string_command_class() -> None:
|
||||
"""Test that VALUE condition schema accepts both int and string command_class."""
|
||||
for command_class_value in (
|
||||
CommandClass.DOOR_LOCK.value,
|
||||
str(CommandClass.DOOR_LOCK.value),
|
||||
):
|
||||
config = device_condition.CONDITION_SCHEMA(
|
||||
{
|
||||
"condition": "device",
|
||||
"domain": DOMAIN,
|
||||
"device_id": "device123",
|
||||
"type": "value",
|
||||
"command_class": command_class_value,
|
||||
"property": "latchStatus",
|
||||
"value": "open",
|
||||
}
|
||||
)
|
||||
assert config["command_class"] == CommandClass.DOOR_LOCK.value
|
||||
|
||||
@@ -997,7 +997,11 @@ async def test_get_trigger_capabilities_central_scene_value_notification(
|
||||
"optional": True,
|
||||
"required": False,
|
||||
"type": "select",
|
||||
"options": [(0, "KeyPressed"), (1, "KeyReleased"), (2, "KeyHeldDown")],
|
||||
"options": [
|
||||
("0", "KeyPressed"),
|
||||
("1", "KeyReleased"),
|
||||
("2", "KeyHeldDown"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1419,15 +1423,15 @@ async def test_get_trigger_capabilities_value_updated_value(
|
||||
"required": True,
|
||||
"type": "select",
|
||||
"options": [
|
||||
(133, "Association"),
|
||||
(128, "Battery"),
|
||||
(98, "Door Lock"),
|
||||
(122, "Firmware Update Meta Data"),
|
||||
(114, "Manufacturer Specific"),
|
||||
(113, "Notification"),
|
||||
(152, "Security"),
|
||||
(99, "User Code"),
|
||||
(134, "Version"),
|
||||
("133", "Association"),
|
||||
("128", "Battery"),
|
||||
("98", "Door Lock"),
|
||||
("122", "Firmware Update Meta Data"),
|
||||
("114", "Manufacturer Specific"),
|
||||
("113", "Notification"),
|
||||
("152", "Security"),
|
||||
("99", "User Code"),
|
||||
("134", "Version"),
|
||||
],
|
||||
},
|
||||
{"name": "property", "required": True, "type": "string"},
|
||||
@@ -1628,14 +1632,14 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_enumerate
|
||||
"name": "from",
|
||||
"optional": True,
|
||||
"required": False,
|
||||
"options": [(0, "Disable Beeper"), (255, "Enable Beeper")],
|
||||
"options": [("0", "Disable Beeper"), ("255", "Enable Beeper")],
|
||||
"type": "select",
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"optional": True,
|
||||
"required": False,
|
||||
"options": [(0, "Disable Beeper"), (255, "Enable Beeper")],
|
||||
"options": [("0", "Disable Beeper"), ("255", "Enable Beeper")],
|
||||
"type": "select",
|
||||
},
|
||||
]
|
||||
@@ -1746,3 +1750,44 @@ async def test_failure_scenarios(
|
||||
"endpoint": 9999,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_trigger_schema_coerces_string_values() -> None:
|
||||
"""Test that trigger schemas accept both int and string values for numeric fields."""
|
||||
for command_class_value in (
|
||||
CommandClass.CENTRAL_SCENE.value,
|
||||
str(CommandClass.CENTRAL_SCENE.value),
|
||||
):
|
||||
for value in (2, "2"):
|
||||
config = device_trigger.TRIGGER_SCHEMA(
|
||||
{
|
||||
"platform": "device",
|
||||
"domain": DOMAIN,
|
||||
"device_id": "device123",
|
||||
"type": "event.value_notification.central_scene",
|
||||
"command_class": command_class_value,
|
||||
"property": "scene",
|
||||
"property_key": "001",
|
||||
"endpoint": 0,
|
||||
"subtype": "Endpoint 0 Scene 001",
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
assert config["command_class"] == CommandClass.CENTRAL_SCENE.value
|
||||
assert config["value"] == 2
|
||||
|
||||
for command_class_value in (
|
||||
CommandClass.DOOR_LOCK.value,
|
||||
str(CommandClass.DOOR_LOCK.value),
|
||||
):
|
||||
config = device_trigger.TRIGGER_SCHEMA(
|
||||
{
|
||||
"platform": "device",
|
||||
"domain": DOMAIN,
|
||||
"device_id": "device123",
|
||||
"type": "zwave_js.value_updated.value",
|
||||
"command_class": command_class_value,
|
||||
"property": "latchStatus",
|
||||
}
|
||||
)
|
||||
assert config["command_class"] == CommandClass.DOOR_LOCK.value
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from collections.abc import Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch
|
||||
|
||||
@@ -22,6 +23,8 @@ from homeassistant.const import (
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfTemperature,
|
||||
@@ -41,6 +44,10 @@ from homeassistant.helpers.automation import (
|
||||
move_top_level_schema_fields_to_options,
|
||||
)
|
||||
from homeassistant.helpers.trigger import (
|
||||
ATTR_BEHAVIOR,
|
||||
BEHAVIOR_ANY,
|
||||
BEHAVIOR_FIRST,
|
||||
BEHAVIOR_LAST,
|
||||
DATA_PLUGGABLE_ACTIONS,
|
||||
TRIGGERS,
|
||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||
@@ -3080,3 +3087,280 @@ async def test_make_entity_origin_state_trigger(
|
||||
|
||||
# To-state still matches from_state — not valid
|
||||
assert not trig.is_valid_state(from_state)
|
||||
|
||||
|
||||
class _OffToOnTrigger(EntityTriggerBase):
|
||||
"""Test trigger that fires when state becomes 'on'."""
|
||||
|
||||
_domain_specs = {"test": DomainSpec()}
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Valid if transitioning from a non-'on' state."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
return from_state.state != STATE_ON
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Valid if the state is 'on'."""
|
||||
return state.state == STATE_ON
|
||||
|
||||
|
||||
async def _arm_off_to_on_trigger(
|
||||
hass: HomeAssistant,
|
||||
entity_ids: list[str],
|
||||
behavior: str,
|
||||
calls: list[dict[str, Any]],
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Set up _OffToOnTrigger via async_initialize_triggers."""
|
||||
|
||||
async def async_get_triggers(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, type[Trigger]]:
|
||||
return {"off_to_on": _OffToOnTrigger}
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
|
||||
|
||||
trigger_config = {
|
||||
CONF_PLATFORM: "test.off_to_on",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: entity_ids},
|
||||
CONF_OPTIONS: {ATTR_BEHAVIOR: behavior},
|
||||
}
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@callback
|
||||
def action(run_variables: dict[str, Any], context: Context | None = None) -> None:
|
||||
calls.append(run_variables["trigger"])
|
||||
|
||||
validated_config = await async_validate_trigger_config(hass, [trigger_config])
|
||||
return await async_initialize_triggers(
|
||||
hass,
|
||||
validated_config,
|
||||
action,
|
||||
domain="test",
|
||||
name="test_off_to_on",
|
||||
log_cb=log.log,
|
||||
)
|
||||
|
||||
|
||||
def _set_or_remove_state(
|
||||
hass: HomeAssistant, entity_id: str, state: str | None
|
||||
) -> None:
|
||||
"""Set or remove state based on whether state is None."""
|
||||
if state is None:
|
||||
hass.states.async_remove(entity_id)
|
||||
else:
|
||||
hass.states.async_set(entity_id, state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("behavior", [BEHAVIOR_ANY, BEHAVIOR_FIRST, BEHAVIOR_LAST])
|
||||
async def test_entity_trigger_fires_on_valid_transition(
|
||||
hass: HomeAssistant, behavior: str
|
||||
) -> None:
|
||||
"""Test EntityTriggerBase fires on a valid off→on transition."""
|
||||
entity_id = "test.entity_1"
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(hass, [entity_id], behavior, calls)
|
||||
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_id
|
||||
|
||||
# Transition back and trigger again
|
||||
calls.clear()
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("behavior", [BEHAVIOR_ANY, BEHAVIOR_FIRST, BEHAVIOR_LAST])
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[STATE_UNAVAILABLE, STATE_UNKNOWN, None],
|
||||
ids=["unavailable", "unknown", "no_state"],
|
||||
)
|
||||
async def test_entity_trigger_from_invalid_initial_state(
|
||||
hass: HomeAssistant, behavior: str, initial_state: str | None
|
||||
) -> None:
|
||||
"""Test that the trigger does not fire when transitioning from unavailable, unknown, or no state."""
|
||||
entity_id = "test.entity_1"
|
||||
_set_or_remove_state(hass, entity_id, initial_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(hass, [entity_id], behavior, calls)
|
||||
|
||||
# Transition to "on" from the invalid initial state
|
||||
_set_or_remove_state(hass, entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Should NOT fire — transition from invalid state is rejected
|
||||
assert len(calls) == 0
|
||||
|
||||
# Now transition back to off and then to on — should fire
|
||||
_set_or_remove_state(hass, entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
_set_or_remove_state(hass, entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_last_requires_all(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test behavior last: trigger fires only when ALL entities are on."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_LAST, calls
|
||||
)
|
||||
|
||||
# Turn only A on — not all match, should not fire
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Turn B on — now all match, should fire
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_first_requires_exactly_one(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test behavior first: trigger fires only when exactly one entity matches."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls
|
||||
)
|
||||
|
||||
# Turn A on — exactly one matches, should fire
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
# Turn B on — now two match, B's transition should NOT fire
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_state",
|
||||
[STATE_UNAVAILABLE, STATE_UNKNOWN],
|
||||
ids=["unavailable", "unknown"],
|
||||
)
|
||||
async def test_entity_trigger_last_ignores_unavailable_and_unknownentity(
|
||||
hass: HomeAssistant, invalid_state: str
|
||||
) -> None:
|
||||
"""Test behavior last: unavailable/unknown entities are excluded from check_all_match.
|
||||
|
||||
With three entities (A=off, B=unavailable, C=off), turning A on should
|
||||
not fire because C is still off, so the available entities do not all
|
||||
match. Turning C on then fires because all *available* entities (A and C)
|
||||
match. Without the exclusion, B would fail the "all match" check.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
entity_c = "test.entity_c"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, invalid_state)
|
||||
hass.states.async_set(entity_c, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b, entity_c], BEHAVIOR_LAST, calls
|
||||
)
|
||||
|
||||
# Turn A on — B is unavailable and skipped, only A is on → all doesn't match
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Turn C on — B is unavailable and skipped, A and C are both on → all match
|
||||
hass.states.async_set(entity_c, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_c
|
||||
|
||||
# B recovers to off — now not all available entities match, so
|
||||
# turning A off→on should NOT fire
|
||||
calls.clear()
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_state",
|
||||
[STATE_UNAVAILABLE, STATE_UNKNOWN],
|
||||
ids=["unavailable", "unknown"],
|
||||
)
|
||||
async def test_entity_trigger_first_ignores_unavailable_and_unknown_entity(
|
||||
hass: HomeAssistant, invalid_state: str
|
||||
) -> None:
|
||||
"""Test behavior first: unavailable/unknown entities are excluded from check_one_match.
|
||||
|
||||
With three entities (A=off, B=unavailable, C=off), turning A on should
|
||||
fire because exactly one *available* entity matches. B is skipped.
|
||||
Then turning C on should NOT fire because now two available entities match.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
entity_c = "test.entity_c"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, invalid_state)
|
||||
hass.states.async_set(entity_c, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b, entity_c], BEHAVIOR_FIRST, calls
|
||||
)
|
||||
|
||||
# Turn A on — B is unavailable and skipped, only A matches → exactly one
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
|
||||
# Turn C on — now two available entities match (A and C), should NOT fire
|
||||
hass.states.async_set(entity_c, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -2078,6 +2078,398 @@ async def test_create_entry_options(
|
||||
assert entries[0].options == {"example": "option"}
|
||||
|
||||
|
||||
async def test_on_create_entry_with_subentry_flow(
|
||||
hass: HomeAssistant, manager: config_entries.ConfigEntries
|
||||
) -> None:
|
||||
"""Test use async_on_create_entry with creating a subentry flow."""
|
||||
|
||||
async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Mock setup."""
|
||||
return True
|
||||
|
||||
async_setup_entry = AsyncMock(return_value=True)
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
"comp",
|
||||
async_setup=mock_async_setup,
|
||||
async_setup_entry=async_setup_entry,
|
||||
),
|
||||
)
|
||||
mock_platform(hass, "comp.config_flow", None)
|
||||
|
||||
class TestFlow(config_entries.ConfigFlow):
|
||||
"""Test flow."""
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[config_entries.ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"sub_flow": TestSubentryFlowHandler}
|
||||
|
||||
async def async_on_create_entry(
|
||||
self, result: config_entries.ConfigFlowResult
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
config_entry_id = result["result"].entry_id
|
||||
new_flow = await hass.config_entries.subentries.async_init(
|
||||
handler=(config_entry_id, "sub_flow"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
result["next_flow"] = (
|
||||
config_entries.FlowType.CONFIG_SUBENTRIES_FLOW,
|
||||
new_flow["flow_id"],
|
||||
)
|
||||
return result
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Test next step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
return self.async_create_entry(
|
||||
title="user_flow",
|
||||
data={"flow": "user"},
|
||||
)
|
||||
|
||||
class TestSubentryFlowHandler(config_entries.ConfigSubentryFlow):
|
||||
"""Test subentry flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.SubentryFlowResult:
|
||||
"""User flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
return self.async_create_entry(title="subentry", data={"flow": "subentry"})
|
||||
|
||||
with mock_config_flow("comp", TestFlow):
|
||||
assert await async_setup_component(hass, "comp", {})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"comp", context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert len(flows) == 0
|
||||
sub_flows = hass.config_entries.subentries.async_progress()
|
||||
assert len(sub_flows) == 1
|
||||
subentry_flow = sub_flows[0]
|
||||
|
||||
entries = hass.config_entries.async_entries("comp")
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert result == {
|
||||
"context": {"source": "user"},
|
||||
"data": {"flow": "user"},
|
||||
"description_placeholders": None,
|
||||
"description": None,
|
||||
"flow_id": ANY,
|
||||
"handler": "comp",
|
||||
"minor_version": 1,
|
||||
"next_flow": (
|
||||
config_entries.FlowType.CONFIG_SUBENTRIES_FLOW,
|
||||
subentry_flow["flow_id"],
|
||||
),
|
||||
"options": {},
|
||||
"result": entry,
|
||||
"subentries": (),
|
||||
"title": "user_flow",
|
||||
"type": FlowResultType.CREATE_ENTRY,
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"], {}
|
||||
)
|
||||
sub_flows = hass.config_entries.subentries.async_progress()
|
||||
assert len(sub_flows) == 0
|
||||
|
||||
assert result == {
|
||||
"context": {"source": "user"},
|
||||
"data": {"flow": "subentry"},
|
||||
"description_placeholders": None,
|
||||
"description": None,
|
||||
"flow_id": ANY,
|
||||
"handler": (entry.entry_id, "sub_flow"),
|
||||
"title": "subentry",
|
||||
"type": FlowResultType.CREATE_ENTRY,
|
||||
"unique_id": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_on_create_entry_with_options_flow(
|
||||
hass: HomeAssistant, manager: config_entries.ConfigEntries
|
||||
) -> None:
|
||||
"""Test use async_on_create_entry with creating an options flow."""
|
||||
|
||||
async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Mock setup."""
|
||||
return True
|
||||
|
||||
async_setup_entry = AsyncMock(return_value=True)
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
"comp",
|
||||
async_setup=mock_async_setup,
|
||||
async_setup_entry=async_setup_entry,
|
||||
),
|
||||
)
|
||||
mock_platform(hass, "comp.config_flow", None)
|
||||
|
||||
class TestFlow(config_entries.ConfigFlow):
|
||||
"""Test flow."""
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> TestOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return TestOptionsFlowHandler()
|
||||
|
||||
async def async_on_create_entry(
|
||||
self, result: config_entries.ConfigFlowResult
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
config_entry_id = result["result"].entry_id
|
||||
new_flow = await hass.config_entries.options.async_init(
|
||||
config_entry_id, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result["next_flow"] = (
|
||||
config_entries.FlowType.OPTIONS_FLOW,
|
||||
new_flow["flow_id"],
|
||||
)
|
||||
return result
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Test next step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
return self.async_create_entry(
|
||||
title="user_flow",
|
||||
data={"flow": "user"},
|
||||
)
|
||||
|
||||
class TestOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Manage options."""
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="init")
|
||||
return self.async_create_entry(title="options", data={"flow": "options"})
|
||||
|
||||
with mock_config_flow("comp", TestFlow):
|
||||
assert await async_setup_component(hass, "comp", {})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"comp", context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert len(flows) == 0
|
||||
option_flows = hass.config_entries.options.async_progress()
|
||||
assert len(option_flows) == 1
|
||||
option_flow = option_flows[0]
|
||||
|
||||
entries = hass.config_entries.async_entries("comp")
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert result == {
|
||||
"context": {"source": "user"},
|
||||
"data": {"flow": "user"},
|
||||
"description_placeholders": None,
|
||||
"description": None,
|
||||
"flow_id": ANY,
|
||||
"handler": "comp",
|
||||
"minor_version": 1,
|
||||
"next_flow": (
|
||||
config_entries.FlowType.OPTIONS_FLOW,
|
||||
option_flow["flow_id"],
|
||||
),
|
||||
"options": {},
|
||||
"result": entry,
|
||||
"subentries": (),
|
||||
"title": "user_flow",
|
||||
"type": FlowResultType.CREATE_ENTRY,
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
option_flow["flow_id"], {}
|
||||
)
|
||||
option_flows = hass.config_entries.options.async_progress()
|
||||
assert len(option_flows) == 0
|
||||
|
||||
assert result == {
|
||||
"context": {"source": "user"},
|
||||
"data": {"flow": "options"},
|
||||
"description_placeholders": None,
|
||||
"description": None,
|
||||
"flow_id": ANY,
|
||||
"handler": entry.entry_id,
|
||||
"title": "options",
|
||||
"type": FlowResultType.CREATE_ENTRY,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invalid_next_flow", "error"),
|
||||
[
|
||||
((config_entries.FlowType.OPTIONS_FLOW, "invalid_flow_id"), HomeAssistantError),
|
||||
(
|
||||
(config_entries.FlowType.CONFIG_SUBENTRIES_FLOW, "invalid_flow_id"),
|
||||
HomeAssistantError,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_next_flow_with_unsupported_flow_type(
|
||||
hass: HomeAssistant,
|
||||
manager: config_entries.ConfigEntries,
|
||||
invalid_next_flow: tuple[str, str],
|
||||
error: type[Exception],
|
||||
) -> None:
|
||||
"""Test use next_flow parameter with unsupported flow types."""
|
||||
|
||||
async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Mock setup."""
|
||||
return True
|
||||
|
||||
async_setup_entry = AsyncMock(return_value=True)
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
"comp",
|
||||
async_setup=mock_async_setup,
|
||||
async_setup_entry=async_setup_entry,
|
||||
),
|
||||
)
|
||||
mock_platform(hass, "comp.config_flow", None)
|
||||
|
||||
class TestFlow(config_entries.ConfigFlow):
|
||||
"""Test flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Test next step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
return self.async_create_entry(
|
||||
title="user_flow", data={"flow": "user"}, next_flow=invalid_next_flow
|
||||
)
|
||||
|
||||
with mock_config_flow("comp", TestFlow):
|
||||
assert await async_setup_component(hass, "comp", {})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"comp", context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
with pytest.raises(
|
||||
error,
|
||||
match=(
|
||||
"next_flow only supports FlowType.CONFIG_FLOW;"
|
||||
" use async_on_create_entry for options or subentry flows"
|
||||
),
|
||||
):
|
||||
await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invalid_next_flow", "error"),
|
||||
[
|
||||
(("invalid_flow_type", "invalid_flow_id"), HomeAssistantError),
|
||||
((config_entries.FlowType.CONFIG_FLOW, "invalid_flow_id"), UnknownFlow),
|
||||
((config_entries.FlowType.OPTIONS_FLOW, "invalid_flow_id"), UnknownFlow),
|
||||
(
|
||||
(config_entries.FlowType.CONFIG_SUBENTRIES_FLOW, "invalid_flow_id"),
|
||||
UnknownFlow,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_invalid_on_create_entry(
|
||||
hass: HomeAssistant,
|
||||
manager: config_entries.ConfigEntries,
|
||||
invalid_next_flow: tuple[str, str],
|
||||
error: type[Exception],
|
||||
) -> None:
|
||||
"""Test use invalid flows in async_on_create_entry."""
|
||||
|
||||
async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Mock setup."""
|
||||
return True
|
||||
|
||||
async_setup_entry = AsyncMock(return_value=True)
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
"comp",
|
||||
async_setup=mock_async_setup,
|
||||
async_setup_entry=async_setup_entry,
|
||||
),
|
||||
)
|
||||
mock_platform(hass, "comp.config_flow", None)
|
||||
|
||||
class TestFlow(config_entries.ConfigFlow):
|
||||
"""Test flow."""
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> TestOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return TestOptionsFlowHandler()
|
||||
|
||||
async def async_on_create_entry(
|
||||
self, result: config_entries.ConfigFlowResult
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
result["next_flow"] = invalid_next_flow # type: ignore[arg-type]
|
||||
return result
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Test next step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
return self.async_create_entry(
|
||||
title="user_flow",
|
||||
data={"flow": "user"},
|
||||
)
|
||||
|
||||
class TestOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Manage options."""
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="init")
|
||||
return self.async_create_entry(title="options", data={"flow": "options"})
|
||||
|
||||
with mock_config_flow("comp", TestFlow):
|
||||
assert await async_setup_component(hass, "comp", {})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"comp", context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
with pytest.raises(error):
|
||||
await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
|
||||
async def test_entry_options(
|
||||
hass: HomeAssistant, manager: config_entries.ConfigEntries
|
||||
) -> None:
|
||||
@@ -9697,7 +10089,13 @@ async def test_config_flow_abort_with_invalid_next_flow_type(
|
||||
|
||||
with (
|
||||
mock_config_flow("test", TestFlow),
|
||||
pytest.raises(HomeAssistantError, match="Invalid next_flow type"),
|
||||
pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=(
|
||||
"next_flow only supports FlowType.CONFIG_FLOW;"
|
||||
" use async_on_create_entry for options or subentry flows"
|
||||
),
|
||||
),
|
||||
):
|
||||
await hass.config_entries.flow.async_init(
|
||||
"test", context={"source": config_entries.SOURCE_USER}
|
||||
|
||||
@@ -856,6 +856,114 @@ async def test_add_job_with_none(hass: HomeAssistant) -> None:
|
||||
hass.async_add_job(None, "test_arg")
|
||||
|
||||
|
||||
async def test_add_job_coroutine_object(hass: HomeAssistant) -> None:
|
||||
"""Test add_job with a coroutine object from an executor thread."""
|
||||
result: list[str] = []
|
||||
|
||||
async def my_coro() -> None:
|
||||
assert asyncio.get_running_loop() is hass.loop
|
||||
result.append("called")
|
||||
|
||||
await hass.async_add_executor_job(hass.add_job, my_coro())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result == ["called"]
|
||||
|
||||
|
||||
async def test_add_job_coroutine_function(hass: HomeAssistant) -> None:
|
||||
"""Test add_job with a coroutine function from an executor thread."""
|
||||
result: list[str] = []
|
||||
|
||||
async def my_coro(value: str) -> None:
|
||||
assert asyncio.get_running_loop() is hass.loop
|
||||
result.append(value)
|
||||
|
||||
await hass.async_add_executor_job(hass.add_job, my_coro, "called")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result == ["called"]
|
||||
|
||||
|
||||
async def test_add_job_callback(hass: HomeAssistant) -> None:
|
||||
"""Test add_job with a @callback from an executor thread."""
|
||||
result: list[str] = []
|
||||
|
||||
@ha.callback
|
||||
def my_callback(value: str) -> None:
|
||||
assert asyncio.get_running_loop() is hass.loop
|
||||
result.append(value)
|
||||
|
||||
await hass.async_add_executor_job(hass.add_job, my_callback, "called")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result == ["called"]
|
||||
|
||||
|
||||
async def test_add_job_executor(hass: HomeAssistant) -> None:
|
||||
"""Test add_job with a regular function from an executor thread."""
|
||||
result: list[str] = []
|
||||
|
||||
def my_func(value: str) -> None:
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.get_running_loop()
|
||||
result.append(value)
|
||||
|
||||
await hass.async_add_executor_job(hass.add_job, my_func, "called")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result == ["called"]
|
||||
|
||||
|
||||
async def test_add_job_partial_callback(hass: HomeAssistant) -> None:
|
||||
"""Test add_job with a partial-wrapped @callback from an executor thread."""
|
||||
result: list[tuple[str, int]] = []
|
||||
|
||||
@ha.callback
|
||||
def my_callback(name: str, value: int) -> None:
|
||||
assert asyncio.get_running_loop() is hass.loop
|
||||
result.append((name, value))
|
||||
|
||||
await hass.async_add_executor_job(
|
||||
hass.add_job, functools.partial(my_callback, "partial"), 1
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result == [("partial", 1)]
|
||||
|
||||
|
||||
async def test_add_job_partial_coroutine_function(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test add_job with a partial-wrapped coroutine function from an executor thread."""
|
||||
result: list[tuple[str, int]] = []
|
||||
|
||||
async def my_coro(name: str, value: int) -> None:
|
||||
assert asyncio.get_running_loop() is hass.loop
|
||||
result.append((name, value))
|
||||
|
||||
await hass.async_add_executor_job(
|
||||
hass.add_job, functools.partial(my_coro, "partial"), 2
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result == [("partial", 2)]
|
||||
|
||||
|
||||
async def test_add_job_async_with_callback_decorator(hass: HomeAssistant) -> None:
|
||||
"""Test add_job with an async function incorrectly marked as @callback."""
|
||||
result: list[str] = []
|
||||
|
||||
@ha.callback
|
||||
async def my_async(value: str) -> None: # pylint: disable=hass-async-callback-decorator
|
||||
assert asyncio.get_running_loop() is hass.loop
|
||||
result.append(value)
|
||||
|
||||
await hass.async_add_executor_job(hass.add_job, my_async, "called")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result == ["called"]
|
||||
|
||||
|
||||
def test_event_eq() -> None:
|
||||
"""Test events."""
|
||||
now = dt_util.utcnow()
|
||||
|
||||
Reference in New Issue
Block a user