Compare commits

..

20 Commits

Author SHA1 Message Date
Ariel Ebersberger
49e5b03c08 Migrate hdmi_cec to async (#168306) 2026-04-15 21:51:07 +02:00
Jan Bouwhuis
6bc3fcef36 Fix minor issues in MQTT tests (#168303) 2026-04-15 21:34:44 +02:00
puddly
e3e87185c5 Switch USB integration to list serial ports with serialx (#167615) 2026-04-15 19:22:45 +02:00
epenet
6d83b73cbb Simplify raise-pull-request agent push step (#167739)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:10:31 +01:00
Ariel Ebersberger
533871babb Optimize add_job to skip double-deferral for @callback targets (#168198) 2026-04-15 18:50:33 +02:00
Erik Montnemery
1dc93a80c4 Improve type annotations and remove unused code in mobile_app (#168298) 2026-04-15 18:09:10 +02:00
Erik Montnemery
f8a94c6f22 Fix climate trigger labs flag test (#168299) 2026-04-15 17:53:26 +02:00
Erik Montnemery
b127d13587 Add additional media_player triggers (#156927)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-04-15 17:34:36 +02:00
renovate[bot]
1895f8ebce Update attrs to 26.1.0 (#168276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-04-15 17:22:33 +02:00
renovate[bot]
b6916954dc Update respx to 0.23.1 (#168272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 17:10:28 +02:00
renovate[bot]
23181f5275 Update pytest-github-actions-annotate-failures to 0.4.0 (#168269)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 16:59:51 +02:00
Robert Resch
607a10d1e1 Use pip to install dynamically extracted version from requirements.txt (#168246) 2026-04-15 16:34:01 +02:00
Ariel Ebersberger
ecb814adb0 Add test coverage for add_job and fix docstring (#168291) 2026-04-15 16:17:01 +02:00
G Johansson
67df556e84 Add async_on_create_entry method to create config entries (#155016)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 15:57:32 +02:00
AlCalzone
4d472418c5 Ensure extra_fields in Z-Wave automation config are strings (#168281) 2026-04-15 15:12:18 +02:00
renovate[bot]
cf6441561c Update voluptuous-openapi to 0.3.0 (#168275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 15:06:24 +02:00
Erik Montnemery
6d8d447355 Revert "Add last_non_buffering_state media_player state attribute (#166941)" (#168285) 2026-04-15 14:41:02 +02:00
Erik Montnemery
ab5ae33290 Exclude unavailable and unknown in trigger first and last checks (#168224)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 14:20:49 +02:00
renovate[bot]
c0bf9a2bd2 Update pytest-sugar to 1.1.1 (#168270)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 13:07:21 +02:00
Norbert Rittel
d862b999ae Capitalize "REST" abbreviation in scrape error messages (#168280) 2026-04-15 11:36:39 +02:00
72 changed files with 1355 additions and 767 deletions

View File

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

16
Dockerfile generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
stopped_playing:
.trigger_common: &trigger_common
target:
entity:
domain: media_player
@@ -13,3 +13,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

View File

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

View File

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

View File

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

View File

@@ -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"]:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}),

View File

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

View File

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

View File

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

View File

@@ -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",
],
)

View File

@@ -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',
]),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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™',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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