mirror of
https://github.com/home-assistant/core.git
synced 2026-04-15 22:26:12 +02:00
Compare commits
20 Commits
timer_add_
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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'
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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}:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -41,7 +41,6 @@ ATTR_REMAINING = "remaining"
|
||||
ATTR_FINISHES_AT = "finishes_at"
|
||||
ATTR_RESTORE = "restore"
|
||||
ATTR_FINISHED_AT = "finished_at"
|
||||
ATTR_LAST_ACTION = "last_action"
|
||||
|
||||
CONF_DURATION = "duration"
|
||||
CONF_RESTORE = "restore"
|
||||
@@ -203,7 +202,6 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize a timer."""
|
||||
self._config: dict = config
|
||||
self._last_action: str | None = None
|
||||
self._state: str = STATUS_IDLE
|
||||
self._configured_duration = cv.time_period_str(config[CONF_DURATION])
|
||||
self._running_duration: timedelta = self._configured_duration
|
||||
@@ -251,7 +249,6 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
attrs: dict[str, Any] = {
|
||||
ATTR_DURATION: _format_timedelta(self._running_duration),
|
||||
ATTR_EDITABLE: self.editable,
|
||||
ATTR_LAST_ACTION: self._last_action,
|
||||
}
|
||||
if self._end is not None:
|
||||
attrs[ATTR_FINISHES_AT] = self._end.isoformat()
|
||||
@@ -277,7 +274,6 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
|
||||
# Begin restoring state
|
||||
self._state = state.state
|
||||
self._last_action = state.attributes.get(ATTR_LAST_ACTION)
|
||||
|
||||
# Nothing more to do if the timer is idle
|
||||
if self._state == STATUS_IDLE:
|
||||
@@ -325,7 +321,8 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
|
||||
self._end = start + self._remaining
|
||||
|
||||
self._fire_event_and_write_state(event)
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_id})
|
||||
|
||||
self._listener = async_track_point_in_utc_time(
|
||||
self.hass, self._async_finished, self._end
|
||||
@@ -352,8 +349,6 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._listener()
|
||||
self._end += duration
|
||||
self._remaining = new_remaining
|
||||
# We don't use _fire_event_and_write_state here because we don't want to
|
||||
# update last_action
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id})
|
||||
self._listener = async_track_point_in_utc_time(
|
||||
@@ -371,7 +366,8 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._remaining = self._end - dt_util.utcnow().replace(microsecond=0)
|
||||
self._state = STATUS_PAUSED
|
||||
self._end = None
|
||||
self._fire_event_and_write_state(EVENT_TIMER_PAUSED)
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id})
|
||||
|
||||
@callback
|
||||
def async_cancel(self) -> None:
|
||||
@@ -386,7 +382,10 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._end = None
|
||||
self._remaining = None
|
||||
self._running_duration = self._configured_duration
|
||||
self._fire_event_and_write_state(EVENT_TIMER_CANCELLED)
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id}
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_finish(self) -> None:
|
||||
@@ -404,8 +403,10 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._end = None
|
||||
self._remaining = None
|
||||
self._running_duration = self._configured_duration
|
||||
self._fire_event_and_write_state(
|
||||
EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()}
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TIMER_FINISHED,
|
||||
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -420,8 +421,10 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._end = None
|
||||
self._remaining = None
|
||||
self._running_duration = self._configured_duration
|
||||
self._fire_event_and_write_state(
|
||||
EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()}
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TIMER_FINISHED,
|
||||
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
|
||||
)
|
||||
|
||||
async def async_update_config(self, config: ConfigType) -> None:
|
||||
@@ -432,14 +435,3 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._running_duration = self._configured_duration
|
||||
self._restore = config.get(CONF_RESTORE, DEFAULT_RESTORE)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _fire_event_and_write_state(
|
||||
self, event: str, *, extra_attrs: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""Fire the event and write state."""
|
||||
self._last_action = event.partition(".")[2]
|
||||
self.async_write_ha_state()
|
||||
event_data = {ATTR_ENTITY_ID: self.entity_id}
|
||||
if extra_attrs:
|
||||
event_data.update(extra_attrs)
|
||||
self.hass.bus.async_fire(event, event_data)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
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",
|
||||
@@ -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",
|
||||
|
||||
4
requirements.txt
generated
4
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
|
||||
@@ -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
|
||||
|
||||
2
requirements_all.txt
generated
2
requirements_all.txt
generated
@@ -2469,7 +2469,6 @@ pysenz==1.0.2
|
||||
pyserial-asyncio-fast==0.16
|
||||
|
||||
# homeassistant.components.acer_projector
|
||||
# homeassistant.components.usb
|
||||
pyserial==3.5
|
||||
|
||||
# homeassistant.components.sesame
|
||||
@@ -2929,6 +2928,7 @@ sentence-stream==1.2.0
|
||||
sentry-sdk==2.48.0
|
||||
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
# homeassistant.components.usb
|
||||
# homeassistant.components.zha
|
||||
serialx==1.2.2
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
5
requirements_test_all.txt
generated
5
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -2486,6 +2482,7 @@ sentence-stream==1.2.0
|
||||
sentry-sdk==2.48.0
|
||||
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
# homeassistant.components.usb
|
||||
# homeassistant.components.zha
|
||||
serialx==1.2.2
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,6 @@ import pytest
|
||||
from homeassistant.components.timer import (
|
||||
ATTR_DURATION,
|
||||
ATTR_FINISHES_AT,
|
||||
ATTR_LAST_ACTION,
|
||||
ATTR_REMAINING,
|
||||
ATTR_RESTORE,
|
||||
CONF_DURATION,
|
||||
@@ -134,9 +133,8 @@ async def test_config_options(hass: HomeAssistant) -> None:
|
||||
|
||||
assert state_1.state == STATUS_IDLE
|
||||
assert state_1.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
ATTR_DURATION: "0:00:00",
|
||||
}
|
||||
|
||||
assert state_2.state == STATUS_IDLE
|
||||
@@ -145,14 +143,12 @@ async def test_config_options(hass: HomeAssistant) -> None:
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FRIENDLY_NAME: "Hello World",
|
||||
ATTR_ICON: "mdi:work",
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
assert state_3.state == STATUS_IDLE
|
||||
assert state_3.attributes == {
|
||||
ATTR_DURATION: str(cv.time_period(DEFAULT_DURATION)),
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +165,6 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
results: list[tuple[Event, State | None]] = []
|
||||
@@ -196,7 +191,6 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_STARTED,
|
||||
@@ -205,10 +199,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"call": SERVICE_PAUSE,
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_PAUSED,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_LAST_ACTION: "paused",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_extra_attributes": {ATTR_REMAINING: "0:00:10"},
|
||||
"expected_event": EVENT_TIMER_PAUSED,
|
||||
},
|
||||
{
|
||||
@@ -217,7 +208,6 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_LAST_ACTION: "restarted",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_RESTARTED,
|
||||
@@ -226,14 +216,14 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"call": SERVICE_CANCEL,
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": EVENT_TIMER_CANCELLED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_CANCEL,
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": None,
|
||||
},
|
||||
{
|
||||
@@ -242,7 +232,6 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_STARTED,
|
||||
@@ -251,14 +240,14 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"call": SERVICE_FINISH,
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": EVENT_TIMER_FINISHED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_FINISH,
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": None,
|
||||
},
|
||||
{
|
||||
@@ -267,7 +256,6 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_STARTED,
|
||||
@@ -276,17 +264,14 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"call": SERVICE_PAUSE,
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_PAUSED,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_LAST_ACTION: "paused",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_extra_attributes": {ATTR_REMAINING: "0:00:10"},
|
||||
"expected_event": EVENT_TIMER_PAUSED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_CANCEL,
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": EVENT_TIMER_CANCELLED,
|
||||
},
|
||||
{
|
||||
@@ -295,7 +280,6 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_STARTED,
|
||||
@@ -306,7 +290,6 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_5,
|
||||
ATTR_LAST_ACTION: "started", # Change does not set last_action
|
||||
ATTR_REMAINING: "0:00:05",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_CHANGED,
|
||||
@@ -317,7 +300,6 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_5,
|
||||
ATTR_LAST_ACTION: "restarted",
|
||||
ATTR_REMAINING: "0:00:05",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_RESTARTED,
|
||||
@@ -326,17 +308,14 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"call": SERVICE_PAUSE,
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_PAUSED,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_LAST_ACTION: "paused",
|
||||
ATTR_REMAINING: "0:00:05",
|
||||
},
|
||||
"expected_extra_attributes": {ATTR_REMAINING: "0:00:05"},
|
||||
"expected_event": EVENT_TIMER_PAUSED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_FINISH,
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": EVENT_TIMER_FINISHED,
|
||||
},
|
||||
]
|
||||
@@ -393,7 +372,6 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -407,7 +385,6 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
@@ -421,7 +398,6 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_LAST_ACTION: "cancelled",
|
||||
}
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
@@ -446,7 +422,6 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:15",
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
}
|
||||
|
||||
@@ -485,7 +460,6 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:15",
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=12)).isoformat(),
|
||||
ATTR_LAST_ACTION: "started", # Change does not set last_action
|
||||
ATTR_REMAINING: "0:00:12",
|
||||
}
|
||||
|
||||
@@ -502,7 +476,6 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:15",
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=14)).isoformat(),
|
||||
ATTR_LAST_ACTION: "started", # Change does not set last_action
|
||||
ATTR_REMAINING: "0:00:14",
|
||||
}
|
||||
|
||||
@@ -516,7 +489,6 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_LAST_ACTION: "cancelled",
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
@@ -536,7 +508,6 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_LAST_ACTION: "cancelled", # Change does not set last_action
|
||||
}
|
||||
|
||||
|
||||
@@ -555,7 +526,6 @@ async def test_wait_till_timer_expires(
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
results = []
|
||||
@@ -583,7 +553,6 @@ async def test_wait_till_timer_expires(
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=20)).isoformat(),
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:20",
|
||||
}
|
||||
|
||||
@@ -605,7 +574,6 @@ async def test_wait_till_timer_expires(
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
}
|
||||
|
||||
@@ -623,7 +591,6 @@ async def test_wait_till_timer_expires(
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=5)).isoformat(),
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
}
|
||||
|
||||
@@ -637,7 +604,6 @@ async def test_wait_till_timer_expires(
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: "finished",
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_TIMER_FINISHED
|
||||
@@ -656,7 +622,6 @@ async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> Non
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
|
||||
@@ -703,7 +668,6 @@ async def test_config_reload(
|
||||
assert state_1.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
assert state_2.state == STATUS_IDLE
|
||||
@@ -712,7 +676,6 @@ async def test_config_reload(
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FRIENDLY_NAME: "Hello World",
|
||||
ATTR_ICON: "mdi:work",
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
with patch(
|
||||
@@ -763,14 +726,12 @@ async def test_config_reload(
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FRIENDLY_NAME: "Hello World reloaded",
|
||||
ATTR_ICON: "mdi:work-reloaded",
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
assert state_3.state == STATUS_IDLE
|
||||
assert state_3.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
|
||||
@@ -787,7 +748,6 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
results = []
|
||||
@@ -814,7 +774,6 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
@@ -832,7 +791,6 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_LAST_ACTION: "restarted",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
@@ -849,7 +807,6 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: "paused",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
@@ -867,7 +824,6 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_LAST_ACTION: "restarted",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
@@ -888,7 +844,6 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
results = []
|
||||
@@ -911,7 +866,6 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_LAST_ACTION: "started",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
@@ -929,7 +883,6 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_LAST_ACTION: "restarted",
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
@@ -937,78 +890,6 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-05 17:47:50")
|
||||
async def test_last_action_after_restarted_timer_expires(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test that last_action changes from restarted to finished when timer expires."""
|
||||
hass.set_state(CoreState.starting)
|
||||
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}})
|
||||
|
||||
# Start the timer
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Restart the timer
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes[ATTR_LAST_ACTION] == "restarted"
|
||||
|
||||
# Let the timer expire
|
||||
freezer.tick(15)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes[ATTR_LAST_ACTION] == "finished"
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-05 17:47:50")
|
||||
async def test_last_action_persists_across_config_update(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that last_action is preserved when the timer config is updated."""
|
||||
hass.set_state(CoreState.starting)
|
||||
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}})
|
||||
|
||||
# Start and cancel to set last_action to "cancelled"
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
|
||||
)
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes[ATTR_LAST_ACTION] == "cancelled"
|
||||
|
||||
# Reload with a new duration — last_action should persist
|
||||
with patch(
|
||||
"homeassistant.config.load_yaml_config_file",
|
||||
autospec=True,
|
||||
return_value={DOMAIN: {"test1": {CONF_DURATION: 20}}},
|
||||
):
|
||||
await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes[ATTR_DURATION] == "0:00:20"
|
||||
assert state.attributes[ATTR_LAST_ACTION] == "cancelled"
|
||||
|
||||
|
||||
async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None:
|
||||
"""Test set up from storage."""
|
||||
assert await storage_setup()
|
||||
@@ -1018,7 +899,6 @@ async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None:
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "timer from storage",
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
|
||||
@@ -1032,7 +912,6 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "timer from storage",
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
state = hass.states.get(f"{DOMAIN}.from_yaml")
|
||||
@@ -1040,7 +919,6 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
|
||||
|
||||
@@ -1115,7 +993,6 @@ async def test_update(
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "timer from storage",
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
|
||||
@@ -1151,7 +1028,6 @@ async def test_update(
|
||||
ATTR_DURATION: "0:00:33",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "timer from storage",
|
||||
ATTR_LAST_ACTION: None,
|
||||
ATTR_RESTORE: True,
|
||||
}
|
||||
|
||||
@@ -1191,7 +1067,6 @@ async def test_ws_create(
|
||||
ATTR_DURATION: "0:00:42",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "New Timer",
|
||||
ATTR_LAST_ACTION: None,
|
||||
}
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
|
||||
@@ -1217,46 +1092,6 @@ async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) -
|
||||
assert count_start == len(hass.states.async_entity_ids())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("last_action", [None, "cancelled", "finished"])
|
||||
async def test_restore_idle(hass: HomeAssistant, last_action: str | None) -> None:
|
||||
"""Test entity restore logic when timer is idle."""
|
||||
utc_now = utcnow()
|
||||
attrs: dict[str, Any] = {ATTR_DURATION: "0:00:30"}
|
||||
if last_action is not None:
|
||||
attrs[ATTR_LAST_ACTION] = last_action
|
||||
stored_state = StoredState(
|
||||
State("timer.test", STATUS_IDLE, attrs),
|
||||
None,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
data = async_get(hass)
|
||||
await data.store.async_save([stored_state.as_dict()])
|
||||
await data.async_load()
|
||||
|
||||
entity = Timer.from_storage(
|
||||
{
|
||||
CONF_ID: "test",
|
||||
CONF_NAME: "test",
|
||||
CONF_DURATION: "0:01:00",
|
||||
CONF_RESTORE: True,
|
||||
}
|
||||
)
|
||||
entity.hass = hass
|
||||
entity.entity_id = "timer.test"
|
||||
|
||||
await entity.async_added_to_hass()
|
||||
await hass.async_block_till_done()
|
||||
assert entity.state == STATUS_IDLE
|
||||
assert entity.extra_state_attributes == {
|
||||
# Idle timers reset to the configured duration, not the stored one
|
||||
ATTR_DURATION: "0:01:00",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_LAST_ACTION: last_action,
|
||||
ATTR_RESTORE: True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-05 17:47:50")
|
||||
async def test_restore_paused(hass: HomeAssistant) -> None:
|
||||
"""Test entity restore logic when timer is paused."""
|
||||
@@ -1265,11 +1100,7 @@ async def test_restore_paused(hass: HomeAssistant) -> None:
|
||||
State(
|
||||
"timer.test",
|
||||
STATUS_PAUSED,
|
||||
{
|
||||
ATTR_DURATION: "0:00:30",
|
||||
ATTR_LAST_ACTION: "paused",
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
},
|
||||
{ATTR_DURATION: "0:00:30", ATTR_REMAINING: "0:00:15"},
|
||||
),
|
||||
None,
|
||||
utc_now,
|
||||
@@ -1296,17 +1127,13 @@ async def test_restore_paused(hass: HomeAssistant) -> None:
|
||||
assert entity.extra_state_attributes == {
|
||||
ATTR_DURATION: "0:00:30",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_LAST_ACTION: "paused",
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
ATTR_RESTORE: True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-05 17:47:50")
|
||||
@pytest.mark.parametrize("last_action", [None, "started", "restarted"])
|
||||
async def test_restore_active_resume(
|
||||
hass: HomeAssistant, last_action: str | None
|
||||
) -> None:
|
||||
async def test_restore_active_resume(hass: HomeAssistant) -> None:
|
||||
"""Test entity restore logic when timer is active and end time is after startup."""
|
||||
events = async_capture_events(hass, EVENT_TIMER_RESTARTED)
|
||||
assert not events
|
||||
@@ -1317,11 +1144,7 @@ async def test_restore_active_resume(
|
||||
State(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{
|
||||
ATTR_DURATION: "0:00:30",
|
||||
ATTR_FINISHES_AT: finish.isoformat(),
|
||||
ATTR_LAST_ACTION: last_action,
|
||||
},
|
||||
{ATTR_DURATION: "0:00:30", ATTR_FINISHES_AT: finish.isoformat()},
|
||||
),
|
||||
None,
|
||||
utc_now,
|
||||
@@ -1355,17 +1178,13 @@ async def test_restore_active_resume(
|
||||
ATTR_DURATION: "0:00:30",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FINISHES_AT: finish.isoformat(),
|
||||
ATTR_LAST_ACTION: "restarted",
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
ATTR_RESTORE: True,
|
||||
}
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("last_action", [None, "started", "restarted"])
|
||||
async def test_restore_active_finished_outside_grace(
|
||||
hass: HomeAssistant, last_action: str | None
|
||||
) -> None:
|
||||
async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> None:
|
||||
"""Test entity restore logic: timer is active, ended while Home Assistant was stopped."""
|
||||
events = async_capture_events(hass, EVENT_TIMER_FINISHED)
|
||||
assert not events
|
||||
@@ -1376,11 +1195,7 @@ async def test_restore_active_finished_outside_grace(
|
||||
State(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{
|
||||
ATTR_DURATION: "0:00:30",
|
||||
ATTR_FINISHES_AT: finish.isoformat(),
|
||||
ATTR_LAST_ACTION: last_action,
|
||||
},
|
||||
{ATTR_DURATION: "0:00:30", ATTR_FINISHES_AT: finish.isoformat()},
|
||||
),
|
||||
None,
|
||||
utc_now,
|
||||
@@ -1411,7 +1226,6 @@ async def test_restore_active_finished_outside_grace(
|
||||
assert entity.extra_state_attributes == {
|
||||
ATTR_DURATION: "0:01:00",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_LAST_ACTION: "finished",
|
||||
ATTR_RESTORE: True,
|
||||
}
|
||||
assert len(events) == 1
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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