Compare commits

..

4 Commits

Author SHA1 Message Date
Paulus Schoutsen
258dfdda8f Remove None support from ESPHome supported_frequency_ranges
Always return frequency ranges from device info, matching the
updated RadioFrequencyTransmitterEntity contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:43:58 -04:00
Paulus Schoutsen
dcc0745fd2 Remove None support from supported_frequency_ranges
Require all RadioFrequencyTransmitterEntity subclasses to provide
explicit frequency ranges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:43:11 -04:00
Paulus Schoutsen
174c86fd36 Add radio_frequency platform to ESPHome
Implement the new radio_frequency platform on ESPHome, mirroring
the infrared platform pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:31:56 -04:00
Paulus Schoutsen
e0d7e3702c Add radio_frequency entity platform
Add a new radio_frequency entity domain that acts as an abstraction
layer between RF transceiver hardware and device-specific integrations,
following the same pattern as the infrared entity platform.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:31:30 -04:00
117 changed files with 2669 additions and 3004 deletions

View File

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

View File

@@ -36,6 +36,7 @@ base_platforms: &base_platforms
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/radio_frequency/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
- homeassistant/components/media_player/**

View File

@@ -8,7 +8,7 @@ repos:
- id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
rev: v2.4.1
hooks:
- id: codespell
args:

16
Dockerfile generated
View File

@@ -19,23 +19,25 @@ 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 \
# 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 \
uv pip install \
--no-build \
-r homeassistant/requirements.txt

View File

@@ -152,7 +152,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"water_heater",

View File

@@ -35,6 +35,7 @@ from aioesphomeapi import (
MediaPlayerInfo,
MediaPlayerSupportedFormat,
NumberInfo,
RadioFrequencyInfo,
SelectInfo,
SensorInfo,
SensorState,
@@ -88,6 +89,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
FanInfo: Platform.FAN,
InfraredInfo: Platform.INFRARED,
LightInfo: Platform.LIGHT,
RadioFrequencyInfo: Platform.RADIO_FREQUENCY,
LockInfo: Platform.LOCK,
MediaPlayerInfo: Platform.MEDIA_PLAYER,
NumberInfo: Platform.NUMBER,

View File

@@ -0,0 +1,79 @@
"""Radio Frequency platform for ESPHome."""
from __future__ import annotations
from functools import partial
import logging
from aioesphomeapi import (
EntityState,
RadioFrequencyCapability,
RadioFrequencyInfo,
RadioFrequencyModulation,
)
from rf_protocols import ModulationType, RadioFrequencyCommand
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.core import callback
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
platform_async_setup_entry,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = {
ModulationType.OOK: RadioFrequencyModulation.OOK,
}
class EsphomeRadioFrequencyEntity(
EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity
):
"""ESPHome radio frequency entity using native API."""
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return supported frequency ranges from device info."""
return [(self._static_info.frequency_min, self._static_info.frequency_max)]
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
self.async_write_ha_state()
@convert_api_error_ha_error
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command."""
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
_LOGGER.debug("Sending RF command: %s", timings)
self._client.radio_frequency_transmit_raw_timings(
self._static_info.key,
frequency=command.frequency,
timings=timings,
modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation],
repeat_count=command.repeat_count + 1,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,
info_type=RadioFrequencyInfo,
entity_type=EsphomeRadioFrequencyEntity,
state_type=EntityState,
info_filter=lambda info: bool(
info.capabilities & RadioFrequencyCapability.TRANSMITTER
),
)

View File

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

View File

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

View File

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

View File

@@ -75,6 +75,7 @@ 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,
@@ -587,6 +588,8 @@ 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:
@@ -1124,7 +1127,12 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
state_attr: dict[str, Any] = {}
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
}
if self.support_grouping:
state_attr[ATTR_GROUP_MEMBERS] = self.group_members

View File

@@ -13,6 +13,7 @@ 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,20 +123,8 @@
}
},
"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,50 +433,14 @@
},
"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.",
"description": "Triggers after one or more media players stop playing media.",
"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,29 +7,6 @@ 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={
@@ -43,32 +20,6 @@ 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 @@
.trigger_common: &trigger_common
stopped_playing:
target:
entity:
domain: media_player
@@ -13,9 +13,3 @@
- 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,6 +1,5 @@
"""Device tracker for Mobile app."""
from collections.abc import Callable
from typing import Any
from homeassistant.components.device_tracker import (
@@ -54,11 +53,11 @@ async def async_setup_entry(
class MobileAppEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device."""
def __init__(self, entry: ConfigEntry) -> None:
def __init__(self, entry, data=None):
"""Set up Mobile app entity."""
self._entry = entry
self._data: dict[str, Any] = {}
self._dispatch_unsub: Callable[[], None] | None = None
self._data = data
self._dispatch_unsub = None
@property
def unique_id(self) -> str:
@@ -133,7 +132,12 @@ 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
@@ -154,7 +158,7 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
self._dispatch_unsub = None
@callback
def update_data(self, data: dict[str, Any]) -> None:
def update_data(self, data):
"""Mark the device as seen."""
self._data = data
self.async_write_ha_state()

View File

@@ -54,7 +54,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity):
def __init__(
self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry
) -> None:
"""Initialize sensor entity."""
"""Initialize sensor entiry."""
super().__init__(mm, config_entry)
self._attr_unique_id = f"{self._base_unique_id}-error-status"

View File

@@ -0,0 +1,189 @@
"""Provides functionality to interact with radio frequency devices."""
from __future__ import annotations
from abc import abstractmethod
from datetime import timedelta
import logging
from typing import final
from rf_protocols import ModulationType, RadioFrequencyCommand
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
__all__ = [
"DOMAIN",
"ModulationType",
"RadioFrequencyTransmitterEntity",
"RadioFrequencyTransmitterEntityDescription",
"async_get_transmitters",
"async_send_command",
]
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[RadioFrequencyTransmitterEntity]] = HassKey(
DOMAIN
)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the radio_frequency domain."""
component = hass.data[DATA_COMPONENT] = EntityComponent[
RadioFrequencyTransmitterEntity
](_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
await component.async_setup(config)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@callback
def async_get_transmitters(
hass: HomeAssistant,
frequency: int,
modulation: ModulationType,
) -> list[str]:
"""Get entity IDs of all RF transmitters supporting the given frequency.
An empty list means no compatible transmitters.
Raises:
HomeAssistantError: If no transmitters exist.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
entities = list(component.entities)
if not entities:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="no_transmitters",
)
return [
entity.entity_id
for entity in entities
if any(
low <= frequency <= high for low, high in entity.supported_frequency_ranges
)
]
async def async_send_command(
hass: HomeAssistant,
entity_id_or_uuid: str,
command: RadioFrequencyCommand,
context: Context | None = None,
) -> None:
"""Send an RF command to the specified radio_frequency entity.
Raises:
HomeAssistantError: If the radio_frequency entity is not found.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
ent_reg = er.async_get(hass)
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
entity = component.get_entity(entity_id)
if entity is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={"entity_id": entity_id},
)
if context is not None:
entity.async_set_context(context)
await entity.async_send_command_internal(command)
class RadioFrequencyTransmitterEntityDescription(
EntityDescription, frozen_or_thawed=True
):
"""Describes radio frequency transmitter entities."""
class RadioFrequencyTransmitterEntity(RestoreEntity):
"""Base class for radio frequency transmitter entities."""
entity_description: RadioFrequencyTransmitterEntityDescription
_attr_should_poll = False
_attr_state: None = None
__last_command_sent: str | None = None
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return list of (min_hz, max_hz) tuples."""
raise NotImplementedError
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
return self.__last_command_sent
@final
async def async_send_command_internal(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command and update state.
Should not be overridden, handles setting last sent timestamp.
"""
await self.async_send_command(command)
self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds")
self.async_write_ha_state()
@final
async def async_internal_added_to_hass(self) -> None:
"""Call when the radio frequency entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
self.__last_command_sent = state.state
@abstractmethod
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command.
Args:
command: The RF command to send.
Raises:
HomeAssistantError: If transmission fails.
"""

View File

@@ -0,0 +1,5 @@
"""Constants for the Radio Frequency integration."""
from typing import Final
DOMAIN: Final = "radio_frequency"

View File

@@ -0,0 +1,7 @@
{
"entity_component": {
"_": {
"default": "mdi:radio-tower"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"domain": "radio_frequency",
"name": "Radio Frequency",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["rf-protocols==0.0.1"]
}

View File

@@ -0,0 +1,13 @@
{
"exceptions": {
"component_not_loaded": {
"message": "Radio Frequency component not loaded"
},
"entity_not_found": {
"message": "Radio Frequency entity `{entity_id}` not found"
},
"no_transmitters": {
"message": "No Radio Frequency transmitters available"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"exceptions": {
"component_not_loaded": {
"message": "Radio Frequency component not loaded"
},
"entity_not_found": {
"message": "Radio Frequency entity `{entity_id}` not found"
},
"no_transmitters": {
"message": "No Radio Frequency transmitters available"
}
}
}

View File

@@ -192,7 +192,7 @@ ID_TYPE = BigInteger().with_variant(sqlite.INTEGER, "sqlite")
# For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32
# for sqlite and postgresql we use a bigint
UINT_32_TYPE = BigInteger().with_variant(
mysql.INTEGER(unsigned=True),
mysql.INTEGER(unsigned=True), # type: ignore[no-untyped-call]
"mysql",
"mariadb",
)
@@ -206,12 +206,12 @@ JSONB_VARIANT_CAST = Text().with_variant(
)
DATETIME_TYPE = (
DateTime(timezone=True)
.with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb")
.with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb") # type: ignore[no-untyped-call]
.with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") # type: ignore[no-untyped-call]
)
DOUBLE_TYPE = (
Float()
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb")
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb") # type: ignore[no-untyped-call]
.with_variant(oracle.DOUBLE_PRECISION(), "oracle")
.with_variant(postgresql.DOUBLE_PRECISION(), "postgresql")
)

View File

@@ -7,7 +7,7 @@
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": [
"SQLAlchemy==2.0.49",
"SQLAlchemy==2.0.41",
"fnv-hash-fast==2.0.0",
"psutil-home-assistant==0.0.1"
]

View File

@@ -447,10 +447,10 @@ def setup_connection_for_dialect(
slow_dependent_subquery = False
if dialect_name == SupportedDialect.SQLITE:
if first_connection:
old_isolation = dbapi_connection.isolation_level
dbapi_connection.isolation_level = None
old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined]
dbapi_connection.isolation_level = None # type: ignore[attr-defined]
execute_on_connection(dbapi_connection, "PRAGMA journal_mode=WAL")
dbapi_connection.isolation_level = old_isolation
dbapi_connection.isolation_level = old_isolation # type: ignore[attr-defined]
# WAL mode only needs to be setup once
# instead of every time we open the sqlite connection
# as its persistent and isn't free to call every time.

View File

@@ -4,40 +4,27 @@ from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from copy import deepcopy
from datetime import timedelta
import logging
from types import MappingProxyType
from typing import Any
import voluptuous as vol
from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config
from homeassistant.components.sensor import CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_AUTHENTICATION,
CONF_DEVICE_CLASS,
CONF_HEADERS,
CONF_NAME,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_TIMEOUT,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
CONF_VALUE_TEMPLATE,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY,
TEMPLATE_SENSOR_BASE_SCHEMA,
@@ -45,22 +32,11 @@ from homeassistant.helpers.trigger_template_entity import (
)
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_ADVANCED,
CONF_AUTH,
CONF_ENCODING,
CONF_INDEX,
CONF_SELECT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
PLATFORMS,
)
from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS
from .coordinator import ScrapeCoordinator
type ScrapeConfigEntry = ConfigEntry[ScrapeCoordinator]
_LOGGER = logging.getLogger(__name__)
SENSOR_SCHEMA = vol.Schema(
{
**TEMPLATE_SENSOR_BASE_SCHEMA.schema,
@@ -127,13 +103,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
"""Set up Scrape from a config entry."""
config: dict[str, Any] = dict(entry.options)
# Config flow uses sections but the COMBINED SCHEMA does not
# so we need to flatten the config here
config.update(config.pop(CONF_ADVANCED, {}))
config.update(config.pop(CONF_AUTH, {}))
rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(config))
rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(entry.options))
rest = create_rest_data_from_config(hass, rest_config)
coordinator = ScrapeCoordinator(
@@ -147,159 +117,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bo
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 2:
# Don't migrate from future version
return False
if entry.version == 1:
old_to_new_sensor_id = {}
for sensor_config in entry.options[SENSOR_DOMAIN]:
# Create a new sub config entry per sensor
title = sensor_config[CONF_NAME]
old_unique_id = sensor_config[CONF_UNIQUE_ID]
subentry_config = {
CONF_INDEX: sensor_config[CONF_INDEX],
CONF_SELECT: sensor_config[CONF_SELECT],
CONF_ADVANCED: {},
}
for sensor_advanced_key in (
CONF_ATTRIBUTE,
CONF_VALUE_TEMPLATE,
CONF_AVAILABILITY,
CONF_DEVICE_CLASS,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
):
if sensor_advanced_key not in sensor_config:
continue
subentry_config[CONF_ADVANCED][sensor_advanced_key] = sensor_config[
sensor_advanced_key
]
new_sub_entry = ConfigSubentry(
data=MappingProxyType(subentry_config),
subentry_type="entity",
title=title,
unique_id=None,
)
_LOGGER.debug(
"Migrating sensor %s with unique id %s to sub config entry id %s, old data %s, new data %s",
title,
old_unique_id,
new_sub_entry.subentry_id,
sensor_config,
subentry_config,
)
old_to_new_sensor_id[old_unique_id] = new_sub_entry.subentry_id
hass.config_entries.async_add_subentry(entry, new_sub_entry)
# Use the new sub config entry id as the unique id for the sensor entity
entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id)
for entity in entities:
if (old_unique_id := entity.unique_id) in old_to_new_sensor_id:
new_unique_id = old_to_new_sensor_id[old_unique_id]
_LOGGER.debug(
"Migrating entity %s with unique id %s to new unique id %s",
entity.entity_id,
entity.unique_id,
new_unique_id,
)
entity_reg.async_update_entity(
entity.entity_id,
config_entry_id=entry.entry_id,
config_subentry_id=new_unique_id,
new_unique_id=new_unique_id,
)
# Use the new sub config entry id as the identifier for the sensor device
device_reg = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(device_reg, entry.entry_id)
for device in devices:
for domain, identifier in device.identifiers:
if domain != DOMAIN or identifier not in old_to_new_sensor_id:
continue
subentry_id = old_to_new_sensor_id[identifier]
new_identifiers = deepcopy(device.identifiers)
new_identifiers.remove((domain, identifier))
new_identifiers.add((domain, old_to_new_sensor_id[identifier]))
_LOGGER.debug(
"Migrating device %s with identifiers %s to new identifiers %s",
device.id,
device.identifiers,
new_identifiers,
)
device_reg.async_update_device(
device.id,
add_config_entry_id=entry.entry_id,
add_config_subentry_id=subentry_id,
new_identifiers=new_identifiers,
)
# Removing None from the list of subentries if existing
# as the device should only belong to the subentry
# and not the main config entry
device_reg.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
# Update the resource config
new_config_entry_data = dict(entry.options)
new_config_entry_data[CONF_AUTH] = {}
new_config_entry_data[CONF_ADVANCED] = {}
new_config_entry_data.pop(SENSOR_DOMAIN, None)
for resource_advanced_key in (
CONF_HEADERS,
CONF_VERIFY_SSL,
CONF_TIMEOUT,
CONF_ENCODING,
):
if resource_advanced_key in new_config_entry_data:
new_config_entry_data[CONF_ADVANCED][resource_advanced_key] = (
new_config_entry_data.pop(resource_advanced_key)
)
for resource_auth_key in (CONF_AUTHENTICATION, CONF_USERNAME, CONF_PASSWORD):
if resource_auth_key in new_config_entry_data:
new_config_entry_data[CONF_AUTH][resource_auth_key] = (
new_config_entry_data.pop(resource_auth_key)
)
_LOGGER.debug(
"Migrating config entry %s from version 1 to version 2 with data %s",
entry.entry_id,
new_config_entry_data,
)
hass.config_entries.async_update_entry(
entry, version=2, options=new_config_entry_data
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Scrape config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ScrapeConfigEntry) -> None:
"""Handle config entry update."""
hass.config_entries.async_schedule_reload(entry.entry_id)
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
) -> bool:
"""Remove Scrape config entry from a device."""
entity_registry = er.async_get(hass)

View File

@@ -2,13 +2,12 @@
from __future__ import annotations
from copy import deepcopy
import logging
from typing import Any
from collections.abc import Mapping
from typing import Any, cast
import uuid
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.rest import create_rest_data_from_config
from homeassistant.components.rest.data import ( # pylint: disable=hass-component-root-import
DEFAULT_TIMEOUT,
@@ -19,17 +18,10 @@ from homeassistant.components.rest.schema import ( # pylint: disable=hass-compo
)
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
OptionsFlow,
SubentryFlowResult,
)
from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_AUTHENTICATION,
@@ -41,6 +33,7 @@ from homeassistant.const import (
CONF_PAYLOAD,
CONF_RESOURCE,
CONF_TIMEOUT,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
CONF_VALUE_TEMPLATE,
@@ -49,7 +42,15 @@ from homeassistant.const import (
HTTP_DIGEST_AUTHENTICATION,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import async_get_hass
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
SchemaFlowMenuStep,
)
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
@@ -68,8 +69,6 @@ from homeassistant.helpers.trigger_template_entity import CONF_AVAILABILITY
from . import COMBINED_SCHEMA
from .const import (
CONF_ADVANCED,
CONF_AUTH,
CONF_ENCODING,
CONF_INDEX,
CONF_SELECT,
@@ -79,212 +78,243 @@ from .const import (
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
RESOURCE_SETUP = {
vol.Required(CONF_RESOURCE): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector(
SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN)
),
vol.Optional(CONF_PAYLOAD): ObjectSelector(),
vol.Optional(CONF_AUTHENTICATION): SelectSelector(
SelectSelectorConfig(
options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_USERNAME): TextSelector(),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
vol.Optional(CONF_HEADERS): ObjectSelector(),
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(),
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(),
}
RESOURCE_SETUP = vol.Schema(
{
vol.Required(CONF_RESOURCE): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector(
SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN)
),
vol.Optional(CONF_PAYLOAD): ObjectSelector(),
vol.Required(CONF_AUTH): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_AUTHENTICATION): SelectSelector(
SelectSelectorConfig(
options=[
HTTP_BASIC_AUTHENTICATION,
HTTP_DIGEST_AUTHENTICATION,
],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_USERNAME): TextSelector(
TextSelectorConfig(
type=TextSelectorType.TEXT, autocomplete="username"
)
),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
),
data_entry_flow.SectionConfig(collapsed=True),
),
vol.Required(CONF_ADVANCED): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_HEADERS): ObjectSelector(),
vol.Optional(
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
): BooleanSelector(),
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
vol.Optional(
CONF_ENCODING, default=DEFAULT_ENCODING
): TextSelector(),
}
),
data_entry_flow.SectionConfig(collapsed=True),
),
}
)
SENSOR_SETUP = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
vol.Required(CONF_SELECT): TextSelector(),
vol.Optional(CONF_INDEX, default=0): vol.All(
NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
vol.Coerce(int),
),
vol.Required(CONF_ADVANCED): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_ATTRIBUTE): TextSelector(),
vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(),
vol.Optional(CONF_AVAILABILITY): TemplateSelector(),
vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
SelectSelectorConfig(
options=[
cls.value
for cls in SensorDeviceClass
if cls != SensorDeviceClass.ENUM
],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
sort=True,
)
),
vol.Optional(CONF_STATE_CLASS): SelectSelector(
SelectSelectorConfig(
options=[cls.value for cls in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="state_class",
sort=True,
)
),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector(
SelectSelectorConfig(
options=[cls.value for cls in UnitOfTemperature],
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
translation_key="unit_of_measurement",
sort=True,
)
),
}
),
data_entry_flow.SectionConfig(collapsed=True),
),
}
)
SENSOR_SETUP = {
vol.Required(CONF_SELECT): TextSelector(),
vol.Optional(CONF_INDEX, default=0): NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
vol.Optional(CONF_ATTRIBUTE): TextSelector(),
vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(),
vol.Optional(CONF_AVAILABILITY): TemplateSelector(),
vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
SelectSelectorConfig(
options=[
cls.value for cls in SensorDeviceClass if cls != SensorDeviceClass.ENUM
],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
sort=True,
)
),
vol.Optional(CONF_STATE_CLASS): SelectSelector(
SelectSelectorConfig(
options=[cls.value for cls in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="state_class",
sort=True,
)
),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector(
SelectSelectorConfig(
options=[cls.value for cls in UnitOfTemperature],
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
translation_key="unit_of_measurement",
sort=True,
)
),
}
async def validate_rest_setup(
hass: HomeAssistant, user_input: dict[str, Any]
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate rest setup."""
config = deepcopy(user_input)
config.update(config.pop(CONF_ADVANCED, {}))
config.update(config.pop(CONF_AUTH, {}))
rest_config: dict[str, Any] = COMBINED_SCHEMA(config)
hass = async_get_hass()
rest_config: dict[str, Any] = COMBINED_SCHEMA(user_input)
try:
rest = create_rest_data_from_config(hass, rest_config)
await rest.async_update()
except Exception:
_LOGGER.exception("Error when getting resource %s", config[CONF_RESOURCE])
return {"base": "resource_error"}
except Exception as err:
raise SchemaFlowError("resource_error") from err
if rest.data is None:
return {"base": "no_data"}
raise SchemaFlowError("resource_error")
return user_input
async def validate_sensor_setup(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate sensor input."""
user_input[CONF_INDEX] = int(user_input[CONF_INDEX])
user_input[CONF_UNIQUE_ID] = str(uuid.uuid1())
# Standard behavior is to merge the result with the options.
# In this case, we want to add a sub-item so we update the options directly.
sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, [])
sensors.append(user_input)
return {}
class ScrapeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Scrape configuration flow."""
VERSION = 2
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> ScrapeOptionFlow:
"""Get the options flow for this handler."""
return ScrapeOptionFlow()
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {"entity": ScrapeSubentryFlowHandler}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""User flow to create the main config entry."""
errors: dict[str, str] = {}
if user_input is not None:
errors = await validate_rest_setup(self.hass, user_input)
title = user_input[CONF_RESOURCE]
if not errors:
return self.async_create_entry(data={}, options=user_input, title=title)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
RESOURCE_SETUP, user_input or {}
),
errors=errors,
)
async def validate_select_sensor(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Store sensor index in flow state."""
handler.flow_state["_idx"] = int(user_input[CONF_INDEX])
return {}
class ScrapeOptionFlow(OptionsFlow):
"""Scrape Options flow."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage Scrape options."""
errors: dict[str, str] = {}
if user_input is not None:
errors = await validate_rest_setup(self.hass, user_input)
if not errors:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
RESOURCE_SETUP,
user_input or self.config_entry.options,
),
errors=errors,
)
async def get_select_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Return schema for selecting a sensor."""
return vol.Schema(
{
vol.Required(CONF_INDEX): vol.In(
{
str(index): config[CONF_NAME]
for index, config in enumerate(handler.options[SENSOR_DOMAIN])
},
)
}
)
class ScrapeSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow."""
async def get_edit_sensor_suggested_values(
handler: SchemaCommonFlowHandler,
) -> dict[str, Any]:
"""Return suggested values for sensor editing."""
idx: int = handler.flow_state["_idx"]
return dict(handler.options[SENSOR_DOMAIN][idx])
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to create a sensor subentry."""
if user_input is not None:
title = user_input.pop("name")
return self.async_create_entry(data=user_input, title=title)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
SENSOR_SETUP, user_input or {}
),
)
async def validate_sensor_edit(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Update edited sensor."""
user_input[CONF_INDEX] = int(user_input[CONF_INDEX])
# Standard behavior is to merge the result with the options.
# In this case, we want to add a sub-item so we update the options directly,
# including popping omitted optional schema items.
idx: int = handler.flow_state["_idx"]
handler.options[SENSOR_DOMAIN][idx].update(user_input)
for key in DATA_SCHEMA_EDIT_SENSOR.schema:
if isinstance(key, vol.Optional) and key not in user_input:
# Key not present, delete keys old value (if present) too
handler.options[SENSOR_DOMAIN][idx].pop(key, None)
return {}
async def get_remove_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Return schema for sensor removal."""
return vol.Schema(
{
vol.Required(CONF_INDEX): cv.multi_select(
{
str(index): config[CONF_NAME]
for index, config in enumerate(handler.options[SENSOR_DOMAIN])
},
)
}
)
async def validate_remove_sensor(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate remove sensor."""
removed_indexes: set[str] = set(user_input[CONF_INDEX])
# Standard behavior is to merge the result with the options.
# In this case, we want to remove sub-items so we update the options directly.
entity_registry = er.async_get(handler.parent_handler.hass)
sensors: list[dict[str, Any]] = []
sensor: dict[str, Any]
for index, sensor in enumerate(handler.options[SENSOR_DOMAIN]):
if str(index) not in removed_indexes:
sensors.append(sensor)
elif entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, sensor[CONF_UNIQUE_ID]
):
entity_registry.async_remove(entity_id)
handler.options[SENSOR_DOMAIN] = sensors
return {}
DATA_SCHEMA_RESOURCE = vol.Schema(RESOURCE_SETUP)
DATA_SCHEMA_EDIT_SENSOR = vol.Schema(SENSOR_SETUP)
DATA_SCHEMA_SENSOR = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
**SENSOR_SETUP,
}
)
CONFIG_FLOW = {
"user": SchemaFlowFormStep(
schema=DATA_SCHEMA_RESOURCE,
next_step="sensor",
validate_user_input=validate_rest_setup,
),
"sensor": SchemaFlowFormStep(
schema=DATA_SCHEMA_SENSOR,
validate_user_input=validate_sensor_setup,
),
}
OPTIONS_FLOW = {
"init": SchemaFlowMenuStep(
["resource", "add_sensor", "select_edit_sensor", "remove_sensor"]
),
"resource": SchemaFlowFormStep(
DATA_SCHEMA_RESOURCE,
validate_user_input=validate_rest_setup,
),
"add_sensor": SchemaFlowFormStep(
DATA_SCHEMA_SENSOR,
suggested_values=None,
validate_user_input=validate_sensor_setup,
),
"select_edit_sensor": SchemaFlowFormStep(
get_select_sensor_schema,
suggested_values=None,
validate_user_input=validate_select_sensor,
next_step="edit_sensor",
),
"edit_sensor": SchemaFlowFormStep(
DATA_SCHEMA_EDIT_SENSOR,
suggested_values=get_edit_sensor_suggested_values,
validate_user_input=validate_sensor_edit,
),
"remove_sensor": SchemaFlowFormStep(
get_remove_sensor_schema,
suggested_values=None,
validate_user_input=validate_remove_sensor,
),
}
class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for Scrape."""
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
return cast(str, options[CONF_RESOURCE])

View File

@@ -14,8 +14,6 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
PLATFORMS = [Platform.SENSOR]
CONF_ADVANCED = "advanced"
CONF_AUTH = "auth"
CONF_ENCODING = "encoding"
CONF_SELECT = "select"
CONF_INDEX = "index"

View File

@@ -1,21 +0,0 @@
{
"config": {
"step": {
"user": {
"sections": {
"advanced": "mdi:cog",
"auth": "mdi:lock"
}
}
}
},
"options": {
"step": {
"init": {
"sections": {
"advanced": "mdi:cog"
}
}
}
}
}

View File

@@ -46,10 +46,9 @@ TRIGGER_ENTITY_OPTIONS = (
CONF_AVAILABILITY,
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_NAME,
CONF_PICTURE,
CONF_STATE_CLASS,
CONF_UNIQUE_ID,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
)
@@ -71,7 +70,7 @@ async def async_setup_platform(
entities: list[ScrapeSensor] = []
for sensor_config in sensors_config:
trigger_entity_config = {}
trigger_entity_config = {CONF_NAME: sensor_config[CONF_NAME]}
for key in TRIGGER_ENTITY_OPTIONS:
if key not in sensor_config:
continue
@@ -99,24 +98,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Scrape sensor entry."""
coordinator = entry.runtime_data
for subentry in entry.subentries.values():
sensor = dict(subentry.data)
sensor.update(sensor.pop("advanced", {}))
sensor[CONF_UNIQUE_ID] = subentry.subentry_id
sensor[CONF_NAME] = subentry.title
entities: list = []
coordinator = entry.runtime_data
config = dict(entry.options)
for sensor in config["sensor"]:
sensor_config: ConfigType = vol.Schema(
TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA
)(sensor)
name: str = sensor_config[CONF_NAME]
value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE)
value_template: ValueTemplate | None = (
ValueTemplate(value_string, hass) if value_string is not None else None
)
trigger_entity_config: dict[str, str | Template | None] = {}
trigger_entity_config: dict[str, str | Template | None] = {CONF_NAME: name}
for key in TRIGGER_ENTITY_OPTIONS:
if key not in sensor_config:
continue
@@ -125,22 +123,21 @@ async def async_setup_entry(
continue
trigger_entity_config[key] = sensor_config[key]
async_add_entities(
[
ScrapeSensor(
hass,
coordinator,
trigger_entity_config,
sensor_config[CONF_SELECT],
sensor_config.get(CONF_ATTRIBUTE),
sensor_config[CONF_INDEX],
value_template,
False,
)
],
config_subentry_id=subentry.subentry_id,
entities.append(
ScrapeSensor(
hass,
coordinator,
trigger_entity_config,
sensor_config[CONF_SELECT],
sensor_config.get(CONF_ATTRIBUTE),
sensor_config[CONF_INDEX],
value_template,
False,
)
)
async_add_entities(entities)
class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEntity):
"""Representation of a web scrape sensor."""

View File

@@ -4,140 +4,134 @@
"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"
"resource_error": "Could not update rest data. Verify your configuration"
},
"step": {
"user": {
"sensor": {
"data": {
"method": "Method",
"payload": "Payload",
"resource": "Resource"
"attribute": "Attribute",
"availability": "Availability template",
"device_class": "Device class",
"index": "Index",
"name": "[%key:common::config_flow::data::name%]",
"select": "Select",
"state_class": "State class",
"unit_of_measurement": "Unit of measurement",
"value_template": "Value template"
},
"data_description": {
"payload": "Payload to use when method is POST.",
"resource": "The URL to the website that contains the value."
},
"sections": {
"advanced": {
"data": {
"encoding": "Character encoding",
"headers": "Headers",
"timeout": "Timeout",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"encoding": "Character encoding to use. Defaults to UTF-8.",
"headers": "Headers to use for the web request.",
"timeout": "Timeout for connection to website.",
"verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed."
},
"description": "Provide additional advanced settings for the resource.",
"name": "Advanced settings"
},
"auth": {
"data": {
"authentication": "Select authentication method",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"authentication": "Type of the HTTP authentication. Either basic or digest."
},
"description": "Provide authentication details to access the resource.",
"name": "Authentication settings"
}
"attribute": "Get value of an attribute on the selected tag.",
"availability": "Defines a template to get the availability of the sensor.",
"device_class": "The type/class of the sensor to set the icon in the frontend.",
"index": "Defines which of the elements returned by the CSS selector to use.",
"select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details.",
"state_class": "The state_class of the sensor.",
"unit_of_measurement": "Choose unit of measurement or create your own.",
"value_template": "Defines a template to get the state of the sensor."
}
}
}
},
"config_subentries": {
"entity": {
"entry_type": "Sensor",
"initiate_flow": {
"user": "Add sensor"
},
"step": {
"user": {
"data": {
"index": "Index",
"select": "Select"
},
"data_description": {
"index": "Defines which of the elements returned by the CSS selector to use.",
"select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details."
},
"sections": {
"advanced": {
"data": {
"attribute": "Attribute",
"availability": "Availability template",
"device_class": "Device class",
"state_class": "State class",
"unit_of_measurement": "Unit of measurement",
"value_template": "Value template"
},
"data_description": {
"attribute": "Get value of an attribute on the selected tag.",
"availability": "Defines a template to get the availability of the sensor.",
"device_class": "The type/class of the sensor to set the icon in the frontend.",
"state_class": "The state_class of the sensor.",
"unit_of_measurement": "Choose unit of measurement or create your own.",
"value_template": "Defines a template to get the state of the sensor."
},
"description": "Provide additional advanced settings for the sensor.",
"name": "Advanced settings"
}
}
"user": {
"data": {
"authentication": "Select authentication method",
"encoding": "Character encoding",
"headers": "Headers",
"method": "Method",
"password": "[%key:common::config_flow::data::password%]",
"payload": "Payload",
"resource": "Resource",
"timeout": "Timeout",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"authentication": "Type of the HTTP authentication. Either basic or digest.",
"encoding": "Character encoding to use. Defaults to UTF-8.",
"headers": "Headers to use for the web request.",
"payload": "Payload to use when method is POST.",
"resource": "The URL to the website that contains the value.",
"timeout": "Timeout for connection to website.",
"verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed."
}
}
}
},
"options": {
"error": {
"no_data": "[%key:component::scrape::config::error::no_data%]",
"resource_error": "[%key:component::scrape::config::error::resource_error%]"
},
"step": {
"init": {
"add_sensor": {
"data": {
"method": "[%key:component::scrape::config::step::user::data::method%]",
"payload": "[%key:component::scrape::config::step::user::data::payload%]",
"resource": "[%key:component::scrape::config::step::user::data::resource%]"
"attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]",
"availability": "[%key:component::scrape::config::step::sensor::data::availability%]",
"device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]",
"index": "[%key:component::scrape::config::step::sensor::data::index%]",
"name": "[%key:common::config_flow::data::name%]",
"select": "[%key:component::scrape::config::step::sensor::data::select%]",
"state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]"
},
"data_description": {
"payload": "[%key:component::scrape::config::step::user::data_description::payload%]",
"resource": "[%key:component::scrape::config::step::user::data_description::resource%]"
"attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]",
"availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]",
"device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]",
"index": "[%key:component::scrape::config::step::sensor::data_description::index%]",
"select": "[%key:component::scrape::config::step::sensor::data_description::select%]",
"state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]"
}
},
"edit_sensor": {
"data": {
"attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]",
"availability": "[%key:component::scrape::config::step::sensor::data::availability%]",
"device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]",
"index": "[%key:component::scrape::config::step::sensor::data::index%]",
"name": "[%key:common::config_flow::data::name%]",
"select": "[%key:component::scrape::config::step::sensor::data::select%]",
"state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]"
},
"sections": {
"advanced": {
"data": {
"encoding": "[%key:component::scrape::config::step::user::sections::advanced::data::encoding%]",
"headers": "[%key:component::scrape::config::step::user::sections::advanced::data::headers%]",
"timeout": "[%key:component::scrape::config::step::user::sections::advanced::data::timeout%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"encoding": "[%key:component::scrape::config::step::user::sections::advanced::data_description::encoding%]",
"headers": "[%key:component::scrape::config::step::user::sections::advanced::data_description::headers%]",
"timeout": "[%key:component::scrape::config::step::user::sections::advanced::data_description::timeout%]",
"verify_ssl": "[%key:component::scrape::config::step::user::sections::advanced::data_description::verify_ssl%]"
},
"description": "[%key:component::scrape::config::step::user::sections::advanced::description%]",
"name": "[%key:component::scrape::config::step::user::sections::advanced::name%]"
},
"auth": {
"data": {
"authentication": "[%key:component::scrape::config::step::user::sections::auth::data::authentication%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"authentication": "[%key:component::scrape::config::step::user::sections::auth::data_description::authentication%]"
},
"description": "[%key:component::scrape::config::step::user::sections::auth::description%]",
"name": "[%key:component::scrape::config::step::user::sections::auth::name%]"
}
"data_description": {
"attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]",
"availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]",
"device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]",
"index": "[%key:component::scrape::config::step::sensor::data_description::index%]",
"select": "[%key:component::scrape::config::step::sensor::data_description::select%]",
"state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]"
}
},
"init": {
"menu_options": {
"add_sensor": "Add sensor",
"remove_sensor": "Remove sensor",
"resource": "Configure resource",
"select_edit_sensor": "Configure sensor"
}
},
"resource": {
"data": {
"authentication": "[%key:component::scrape::config::step::user::data::authentication%]",
"encoding": "[%key:component::scrape::config::step::user::data::encoding%]",
"headers": "[%key:component::scrape::config::step::user::data::headers%]",
"method": "[%key:component::scrape::config::step::user::data::method%]",
"password": "[%key:common::config_flow::data::password%]",
"payload": "[%key:component::scrape::config::step::user::data::payload%]",
"resource": "[%key:component::scrape::config::step::user::data::resource%]",
"timeout": "[%key:component::scrape::config::step::user::data::timeout%]",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]",
"encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]",
"headers": "[%key:component::scrape::config::step::user::data_description::headers%]",
"payload": "[%key:component::scrape::config::step::user::data_description::payload%]",
"resource": "[%key:component::scrape::config::step::user::data_description::resource%]",
"timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]",
"verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]"
}
}
}

View File

@@ -807,7 +807,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
)
ssid_options = [network["ssid"] for network in sorted_networks]
# Preselect SSID if returning from failed provisioning attempt
# Pre-select SSID if returning from failed provisioning attempt
suggested_values: dict[str, Any] = {}
if self.selected_ssid:
suggested_values[CONF_SSID] = self.selected_ssid
@@ -1086,7 +1086,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle failed provisioning - allow retry."""
if user_input is not None:
# User wants to retry - keep selected_ssid so it's preselected
# User wants to retry - keep selected_ssid so it's pre-selected
self.wifi_networks = []
return await self.async_step_wifi_scan()

View File

@@ -6,5 +6,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sql",
"iot_class": "local_polling",
"requirements": ["SQLAlchemy==2.0.49", "sqlparse==0.5.5"]
"requirements": ["SQLAlchemy==2.0.41", "sqlparse==0.5.5"]
}

View File

@@ -25,6 +25,7 @@ from homeassistant.components.recorder.statistics import (
)
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
@@ -91,40 +92,11 @@ def _build_home_data(home: tibber.TibberHome) -> TibberHomeData:
return result
class TibberCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""Base Tibber coordinator."""
class TibberDataCoordinator(DataUpdateCoordinator[None]):
"""Handle Tibber data and insert statistics."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: TibberConfigEntry,
*,
name: str,
update_interval: timedelta,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self._runtime_data = config_entry.runtime_data
async def _async_get_client(self) -> tibber.Tibber:
"""Get the Tibber client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except (ClientError, TimeoutError, tibber.exceptions.HttpExceptionError) as err:
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
class TibberDataCoordinator(TibberCoordinator[None]):
"""Handle Tibber data and insert statistics."""
def __init__(
self,
hass: HomeAssistant,
@@ -134,14 +106,17 @@ class TibberDataCoordinator(TibberCoordinator[None]):
"""Initialize the data handler."""
super().__init__(
hass,
config_entry,
_LOGGER,
config_entry=config_entry,
name=f"Tibber {tibber_connection.name}",
update_interval=timedelta(minutes=20),
)
async def _async_update_data(self) -> None:
"""Update data via API."""
tibber_connection = await self._async_get_client()
tibber_connection = await self.config_entry.runtime_data.async_get_client(
self.hass
)
try:
await tibber_connection.fetch_consumption_data_active_homes()
@@ -157,7 +132,9 @@ class TibberDataCoordinator(TibberCoordinator[None]):
async def _insert_statistics(self) -> None:
"""Insert Tibber statistics."""
tibber_connection = await self._async_get_client()
tibber_connection = await self.config_entry.runtime_data.async_get_client(
self.hass
)
for home in tibber_connection.get_homes():
sensors: list[tuple[str, bool, str | None, str]] = []
if home.hourly_consumption_data:
@@ -277,9 +254,11 @@ class TibberDataCoordinator(TibberCoordinator[None]):
async_add_external_statistics(self.hass, metadata, statistics)
class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
"""Handle Tibber price data and insert statistics."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
@@ -288,7 +267,8 @@ class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
"""Initialize the price coordinator."""
super().__init__(
hass,
config_entry,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN} price",
update_interval=timedelta(minutes=1),
)
@@ -310,7 +290,9 @@ class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
async def _async_update_data(self) -> dict[str, TibberHomeData]:
"""Update data via API and return per-home data for sensors."""
tibber_connection = await self._async_get_client()
tibber_connection = await self.config_entry.runtime_data.async_get_client(
self.hass
)
active_homes = tibber_connection.get_homes(only_active=True)
now = dt_util.now()
@@ -365,9 +347,11 @@ class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
return result
class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]):
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
"""Fetch and cache Tibber Data API device capabilities."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
@@ -376,10 +360,12 @@ class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]):
"""Initialize the coordinator."""
super().__init__(
hass,
entry,
_LOGGER,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = entry.runtime_data
self.sensors_by_device: dict[str, dict[str, tibber.data_api.Sensor]] = {}
def _build_sensor_lookup(self, devices: dict[str, TibberDevice]) -> None:
@@ -397,6 +383,15 @@ class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]):
return device_sensors.get(sensor_id)
return None
async def _async_get_client(self) -> tibber.Tibber:
"""Get the Tibber client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()

View File

@@ -1,17 +0,0 @@
"""Provides conditions for updates."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_available": make_entity_state_condition(DOMAIN, STATE_ON),
"is_not_available": make_entity_state_condition(DOMAIN, STATE_OFF),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the update conditions."""
return CONDITIONS

View File

@@ -1,17 +0,0 @@
.condition_common: &condition_common
target:
entity:
domain: update
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_available: *condition_common
is_not_available: *condition_common

View File

@@ -1,12 +1,4 @@
{
"conditions": {
"is_available": {
"condition": "mdi:package-up"
},
"is_not_available": {
"condition": "mdi:package"
}
},
"entity_component": {
"_": {
"default": "mdi:package-up",

View File

@@ -1,28 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_available": {
"description": "Tests if one or more updates are available.",
"fields": {
"behavior": {
"name": "[%key:component::update::common::condition_behavior_name%]"
}
},
"name": "Update is available"
},
"is_not_available": {
"description": "Tests if one or more updates are not available.",
"fields": {
"behavior": {
"name": "[%key:component::update::common::condition_behavior_name%]"
}
},
"name": "Update is not available"
}
},
"device_automation": {
"extra_fields": {
"for": "[%key:common::device_automation::extra_fields::for%]"
@@ -80,12 +59,6 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -26,20 +26,24 @@ 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
from homeassistant.helpers.service_info.usb import UsbServiceInfo as _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, USBDevice
from .models import (
SerialDevice, # noqa: F401
USBDevice,
)
from .utils import (
async_scan_serial_ports,
scan_serial_ports,
usb_device_from_path,
scan_serial_ports, # noqa: F401
usb_device_from_path, # noqa: F401
usb_device_from_port, # noqa: F401
usb_device_matches_matcher,
usb_service_info_from_device,
usb_unique_id_from_service_info,
usb_unique_id_from_service_info, # noqa: F401
)
_LOGGER = logging.getLogger(__name__)
@@ -52,17 +56,9 @@ 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)
@@ -362,7 +358,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", "serialx==1.2.2"]
"requirements": ["aiousbwatcher==1.1.1", "pyserial==3.5"]
}

View File

@@ -3,10 +3,12 @@
from __future__ import annotations
from collections.abc import Sequence
import dataclasses
import fnmatch
import os
from serialx import SerialPortInfo, list_serial_ports
from serial.tools.list_ports import comports
from serial.tools.list_ports_common import ListPortInfo
from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.usb import UsbServiceInfo
@@ -15,8 +17,8 @@ from homeassistant.loader import USBMatcher
from .models import SerialDevice, USBDevice
def usb_device_from_port(port: SerialPortInfo) -> USBDevice:
"""Convert serialx SerialPortInfo to USBDevice."""
def usb_device_from_port(port: ListPortInfo) -> USBDevice:
"""Convert serial ListPortInfo to USBDevice."""
assert port.vid is not None
assert port.pid is not None
@@ -26,30 +28,53 @@ def usb_device_from_port(port: SerialPortInfo) -> USBDevice:
pid=f"{hex(port.pid)[2:]:0>4}".upper(),
serial_number=port.serial_number,
manufacturer=port.manufacturer,
description=port.product,
description=port.description,
)
def serial_device_from_port(port: SerialPortInfo) -> SerialDevice:
"""Convert serialx SerialPortInfo to SerialDevice."""
def serial_device_from_port(port: ListPortInfo) -> SerialDevice:
"""Convert serial ListPortInfo to SerialDevice."""
return SerialDevice(
device=port.device,
serial_number=port.serial_number,
manufacturer=port.manufacturer,
description=port.product,
description=port.description,
)
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:
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
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."""
return [usb_serial_device_from_port(port) for port in list_serial_ports()]
# 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
async def async_scan_serial_ports(

View File

@@ -315,7 +315,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
return await self.async_step_verify_radio()
# Preselect the currently configured port
# Pre-select the currently configured port
default_port: vol.Undefined | str = vol.UNDEFINED
if self._radio_mgr.device_path is not None:
@@ -345,7 +345,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
)
return await self.async_step_manual_port_config()
# Preselect the current radio type
# Pre-select the current radio type
default: vol.Undefined | str = vol.UNDEFINED
if self._radio_mgr.radio_type is not None:

View File

@@ -3,7 +3,6 @@
from typing import Any
import voluptuous as vol
from zwave_js_server.const import CommandClass
from homeassistant.helpers import config_validation as cv
@@ -19,10 +18,6 @@ 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 COMMAND_CLASS_SCHEMA, VALUE_SCHEMA
from .config_validation import 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): COMMAND_CLASS_SCHEMA,
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
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(
{
str(CommandClass(cc.id).value): cc.name
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 COMMAND_CLASS_SCHEMA, VALUE_SCHEMA
from .config_validation import 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): COMMAND_CLASS_SCHEMA,
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
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(
{
str(CommandClass(cc.id).value): cc.name
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 COMMAND_CLASS_SCHEMA, VALUE_SCHEMA
from .config_validation import 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): COMMAND_CLASS_SCHEMA,
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
}
)
@@ -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): COMMAND_CLASS_SCHEMA,
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
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(
{
str(CommandClass(cc.id).value): cc.name
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({str(int(k)): v for k, v in value.metadata.states.items()})
return vol.In({int(k): v for k, v in value.metadata.states.items()})
return None
if value.metadata.states:
return vol.In({str(int(k)): v for k, v in value.metadata.states.items()})
return vol.In({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.All(
vol.Coerce(int), vol.In({cc.value: cc.name for cc in CommandClass})
vol.Required(ATTR_COMMAND_CLASS): 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 supported in `next_flow` of ConfigFlowResult."""
"""Flow type."""
CONFIG_FLOW = "config_flow"
OPTIONS_FLOW = "options_flow"
CONFIG_SUBENTRIES_FLOW = "config_subentries_flow"
# Add other flow types here as needed in the future,
# if we want to support them in the `next_flow` parameter.
def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> None:
@@ -1608,26 +1608,6 @@ 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],
@@ -1676,8 +1656,6 @@ 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.
@@ -1792,10 +1770,6 @@ 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(
@@ -3317,10 +3291,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
return
flow_type, flow_id = next_flow
if flow_type != FlowType.CONFIG_FLOW:
raise HomeAssistantError(
"next_flow only supports FlowType.CONFIG_FLOW; "
"use async_on_create_entry for options or subentry flows"
)
raise HomeAssistantError("Invalid next_flow type")
# Raises UnknownFlow if the flow does not exist.
self.hass.config_entries.flow.async_get(flow_id)
result["next_flow"] = next_flow
@@ -3341,15 +3312,6 @@ 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,9 +544,8 @@ class HomeAssistant:
) -> None:
"""Add a job to be executed by the event loop or 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.
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.
target: target to call.
args: parameters for method to call.
@@ -558,14 +557,6 @@ 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)
)
@@ -607,9 +598,8 @@ class HomeAssistant:
) -> asyncio.Future[_R] | None:
"""Add a job to be executed by the event loop or 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.
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.
This method must be run in the event loop.

View File

@@ -31,6 +31,7 @@ class EntityPlatforms(StrEnum):
IMAGE_PROCESSING = "image_processing"
INFRARED = "infrared"
LAWN_MOWER = "lawn_mower"
RADIO_FREQUENCY = "radio_frequency"
LIGHT = "light"
LOCK = "lock"
MEDIA_PLAYER = "media_player"

View File

@@ -349,9 +349,6 @@ 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
@@ -395,7 +392,6 @@ 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:
@@ -405,7 +401,6 @@ 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==26.1.0
attrs==25.4.0
audioop-lts==0.2.1
av==16.0.1
awesomeversion==25.8.0
@@ -47,7 +47,7 @@ Jinja2==3.1.6
lru-dict==1.3.0
mutagen==1.47.0
openai==2.21.0
orjson==3.11.8
orjson==3.11.7
packaging>=23.1
paho-mqtt==2.1.0
Pillow==12.2.0
@@ -57,21 +57,21 @@ 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
SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
urllib3>=2.0
uv==0.11.1
voluptuous-openapi==0.3.0
voluptuous-openapi==0.2.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==26.1.0",
"attrs==25.4.0",
"atomicwrites-homeassistant==1.4.1",
"audioop-lts==0.2.1",
"awesomeversion==25.8.0",
@@ -61,14 +61,14 @@ dependencies = [
"Pillow==12.2.0",
"propcache==0.4.1",
"pyOpenSSL==26.0.0",
"orjson==3.11.8",
"orjson==3.11.7",
"packaging>=23.1",
"psutil-home-assistant==0.0.1",
"python-slugify==8.0.4",
"PyYAML==6.0.3",
"requests==2.33.1",
"securetar==2026.4.1",
"SQLAlchemy==2.0.49",
"SQLAlchemy==2.0.41",
"standard-aifc==3.13.0",
"standard-telnetlib==3.13.0",
"typing-extensions>=4.15.0,<5.0",
@@ -77,7 +77,7 @@ dependencies = [
"uv==0.11.1",
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.3.0",
"voluptuous-openapi==0.2.0",
"yarl==1.23.0",
"webrtc-models==0.3.0",
"zeroconf==0.148.0",

8
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==26.1.0
attrs==25.4.0
audioop-lts==0.2.1
awesomeversion==25.8.0
bcrypt==5.0.0
@@ -34,7 +34,7 @@ infrared-protocols==1.1.0
Jinja2==3.1.6
lru-dict==1.3.0
mutagen==1.47.0
orjson==3.11.8
orjson==3.11.7
packaging>=23.1
Pillow==12.2.0
propcache==0.4.1
@@ -48,14 +48,14 @@ PyTurboJPEG==1.8.0
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
SQLAlchemy==2.0.49
SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
urllib3>=2.0
uv==0.11.1
voluptuous-openapi==0.3.0
voluptuous-openapi==0.2.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
webrtc-models==0.3.0

7
requirements_all.txt generated
View File

@@ -115,7 +115,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
SQLAlchemy==2.0.49
SQLAlchemy==2.0.41
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -2469,6 +2469,7 @@ pysenz==1.0.2
pyserial-asyncio-fast==0.16
# homeassistant.components.acer_projector
# homeassistant.components.usb
pyserial==3.5
# homeassistant.components.sesame
@@ -2830,6 +2831,9 @@ renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.19.1
# homeassistant.components.radio_frequency
rf-protocols==0.0.1
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2928,7 +2932,6 @@ 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

@@ -8,7 +8,7 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
astroid==4.0.4
coverage==7.13.5
coverage==7.10.6
freezegun==1.5.5
# librt is an internal mypy dependency
librt==0.8.1
@@ -22,18 +22,18 @@ pylint-per-file-ignores==3.2.1
pipdeptree==2.26.1
pytest-asyncio==1.3.0
pytest-aiohttp==1.1.0
pytest-cov==7.1.0
pytest-cov==7.0.0
pytest-freezer==0.4.9
pytest-github-actions-annotate-failures==0.4.0
pytest-github-actions-annotate-failures==0.3.0
pytest-socket==0.7.0
pytest-sugar==1.1.1
pytest-sugar==1.0.0
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.23.1
respx==0.22.0
syrupy==5.1.0
tqdm==4.67.1
types-aiofiles==24.1.0.20250822

View File

@@ -112,7 +112,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
SQLAlchemy==2.0.49
SQLAlchemy==2.0.41
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -2109,6 +2109,10 @@ 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
@@ -2405,6 +2409,9 @@ renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.19.1
# homeassistant.components.radio_frequency
rf-protocols==0.0.1
# homeassistant.components.rflink
rflink==0.0.67
@@ -2482,7 +2489,6 @@ 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

@@ -1,6 +1,6 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.4.2
codespell==2.4.1
ruff==0.15.1
yamllint==1.38.0
zizmor==1.23.1

View File

@@ -34,23 +34,25 @@ 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 \
# 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 \
uv pip install \
--no-build \
-r homeassistant/requirements.txt
@@ -141,12 +143,12 @@ WORKDIR "/github/workspace"
COPY . /usr/src/homeassistant
# Uv creates a lock file in /tmp
RUN --mount=type=tmpfs,target=/tmp \
# 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 \
# 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 \
@@ -215,7 +217,8 @@ def _generate_files(config: Config) -> list[File]:
+ 10
) * 1000
package_versions = _get_package_versions(
package_versions = _get_package_versions(config.root / "requirements.txt", {"uv"})
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 creates a lock file in /tmp
RUN --mount=type=tmpfs,target=/tmp \
# 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 \
# 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

@@ -252,12 +252,6 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = {
"coinbase": {"homeassistant": {"coinbase-advanced-py"}},
# https://github.com/u9n/dlms-cosem
"dsmr": {"dsmr-parser": {"dlms-cosem"}},
# https://github.com/tkdrob/pyefergy
# pyefergy declares codecov as a runtime dependency, which pulls in
# coverage; coverage ships an 'a1_coverage.pth' file starting from
# 7.13.x. Upstream fix pending in
# https://github.com/tkdrob/pyefergy/pull/47
"efergy": {"codecov": {"coverage"}},
# https://github.com/ChrisMandich/PyFlume # Fixed with >=0.7.1
"fitbit": {
# Setuptools - distutils-precedence.pth

View File

@@ -41,6 +41,7 @@
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,
}),
@@ -94,6 +95,7 @@
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,6 +63,7 @@
'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,
@@ -185,6 +186,7 @@
'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,6 +23,7 @@
'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,
@@ -71,6 +72,7 @@
'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,
@@ -120,6 +122,7 @@
'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,
@@ -169,6 +172,7 @@
'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,
@@ -218,6 +222,7 @@
'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,
@@ -267,6 +272,7 @@
'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,
@@ -315,6 +321,7 @@
'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,
@@ -363,6 +370,7 @@
'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,
@@ -411,6 +419,7 @@
'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,
@@ -459,6 +468,7 @@
'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,
@@ -507,6 +517,7 @@
'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,
@@ -555,6 +566,7 @@
'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,
@@ -603,6 +615,7 @@
'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,
@@ -652,6 +665,7 @@
'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,
@@ -701,6 +715,7 @@
'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,
@@ -750,6 +765,7 @@
'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,
@@ -799,6 +815,7 @@
'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'>,
@@ -849,6 +866,7 @@
'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,
@@ -898,6 +916,7 @@
'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,
@@ -947,6 +966,7 @@
'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,
@@ -996,6 +1016,7 @@
'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,
@@ -1042,6 +1063,7 @@
'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,
@@ -1090,6 +1112,7 @@
'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,6 +5,7 @@
'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,8 +54,6 @@ 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,6 +46,7 @@
'device_class': 'tv',
'friendly_name': 'Living Room',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'source_list': list([
'TV',
]),

View File

@@ -0,0 +1,211 @@
"""Test ESPHome radio frequency platform."""
from aioesphomeapi import (
APIClient,
APIConnectionError,
RadioFrequencyCapability,
RadioFrequencyInfo,
RadioFrequencyModulation,
)
import pytest
from rf_protocols import ModulationType, OOKCommand, Timing
from homeassistant.components import radio_frequency
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import MockESPHomeDevice, MockESPHomeDeviceType
ENTITY_ID = "radio_frequency.test_rf"
async def _mock_rf_device(
mock_esphome_device: MockESPHomeDeviceType,
mock_client: APIClient,
capabilities: RadioFrequencyCapability = RadioFrequencyCapability.TRANSMITTER,
frequency_min: int = 433_000_000,
frequency_max: int = 434_000_000,
supported_modulations: int = 1,
) -> MockESPHomeDevice:
entity_info = [
RadioFrequencyInfo(
object_id="rf",
key=1,
name="RF",
capabilities=capabilities,
frequency_min=frequency_min,
frequency_max=frequency_max,
supported_modulations=supported_modulations,
)
]
return await mock_esphome_device(
mock_client=mock_client, entity_info=entity_info, states=[]
)
@pytest.mark.parametrize(
("capabilities", "entity_created"),
[
(RadioFrequencyCapability.TRANSMITTER, True),
(RadioFrequencyCapability.RECEIVER, False),
(
RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER,
True,
),
(RadioFrequencyCapability(0), False),
],
)
async def test_radio_frequency_entity_transmitter(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
capabilities: RadioFrequencyCapability,
entity_created: bool,
) -> None:
"""Test radio frequency entity with transmitter capability is created."""
await _mock_rf_device(mock_esphome_device, mock_client, capabilities)
state = hass.states.get(ENTITY_ID)
assert (state is not None) == entity_created
async def test_radio_frequency_multiple_entities_mixed_capabilities(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test multiple radio frequency entities with mixed capabilities."""
entity_info = [
RadioFrequencyInfo(
object_id="rf_transmitter",
key=1,
name="RF Transmitter",
capabilities=RadioFrequencyCapability.TRANSMITTER,
),
RadioFrequencyInfo(
object_id="rf_receiver",
key=2,
name="RF Receiver",
capabilities=RadioFrequencyCapability.RECEIVER,
),
RadioFrequencyInfo(
object_id="rf_transceiver",
key=3,
name="RF Transceiver",
capabilities=(
RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER
),
),
]
await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
states=[],
)
# Only transmitter and transceiver should be created
assert hass.states.get("radio_frequency.test_rf_transmitter") is not None
assert hass.states.get("radio_frequency.test_rf_receiver") is None
assert hass.states.get("radio_frequency.test_rf_transceiver") is not None
async def test_radio_frequency_send_command_success(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test sending RF command successfully."""
await _mock_rf_device(mock_esphome_device, mock_client)
command = OOKCommand(
frequency=433_920_000,
timings=[
Timing(high_us=350, low_us=1050),
Timing(high_us=350, low_us=350),
],
)
await radio_frequency.async_send_command(hass, ENTITY_ID, command)
mock_client.radio_frequency_transmit_raw_timings.assert_called_once()
call_args = mock_client.radio_frequency_transmit_raw_timings.call_args
assert call_args[0][0] == 1 # key
assert call_args[1]["frequency"] == 433_920_000
assert call_args[1]["modulation"] == RadioFrequencyModulation.OOK
assert call_args[1]["repeat_count"] == 1
assert call_args[1]["device_id"] == 0
assert call_args[1]["timings"] == [350, -1050, 350, -350]
async def test_radio_frequency_send_command_failure(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test sending RF command with APIConnectionError raises HomeAssistantError."""
await _mock_rf_device(mock_esphome_device, mock_client)
mock_client.radio_frequency_transmit_raw_timings.side_effect = APIConnectionError(
"Connection lost"
)
command = OOKCommand(
frequency=433_920_000,
timings=[Timing(high_us=350, low_us=1050)],
)
with pytest.raises(HomeAssistantError) as exc_info:
await radio_frequency.async_send_command(hass, ENTITY_ID, command)
assert exc_info.value.translation_domain == "esphome"
assert exc_info.value.translation_key == "error_communicating_with_device"
async def test_radio_frequency_entity_availability(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test radio frequency entity becomes available after device reconnects."""
mock_device = await _mock_rf_device(mock_esphome_device, mock_client)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
await mock_device.mock_disconnect(False)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
await mock_device.mock_connect()
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
async def test_radio_frequency_supported_frequency_ranges(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test supported frequency ranges are exposed from device info."""
await _mock_rf_device(
mock_esphome_device,
mock_client,
frequency_min=433_000_000,
frequency_max=434_000_000,
)
transmitters = radio_frequency.async_get_transmitters(
hass, 433_920_000, ModulationType.OOK
)
assert len(transmitters) == 1
transmitters = radio_frequency.async_get_transmitters(
hass, 868_000_000, ModulationType.OOK
)
assert len(transmitters) == 0

View File

@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -22,7 +22,17 @@ from tests.components.common import (
@pytest.fixture
async def target_fans(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple fan entities associated with different targets."""
return await target_entities(hass, "fan", domain_excluded="switch")
return await target_entities(hass, "fan")
@pytest.fixture
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple switch entities associated with different targets.
Note: The switches are used to ensure that only fan entities are considered
in the condition evaluation and not other toggle entities.
"""
return await target_entities(hass, "switch")
@pytest.mark.parametrize(
@@ -51,19 +61,18 @@ async def test_fan_conditions_gated_by_labs_flag(
condition="fan.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_any(
condition="fan.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
async def test_fan_state_condition_behavior_any(
hass: HomeAssistant,
target_fans: dict[str, list[str]],
target_switches: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
@@ -72,17 +81,39 @@ async def test_fan_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the fan state condition with the 'any' behavior."""
await assert_condition_behavior_any(
other_entity_ids = set(target_fans["included_entities"]) - {entity_id}
# Set all fans, including the tested fan, to the initial state
for eid in target_fans["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
target_entities=target_fans,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
target=condition_target_config,
behavior="any",
)
# Set state for switches to ensure that they don't impact the condition
for state in states:
for eid in target_switches["included_entities"]:
set_or_remove_state(hass, eid, state["included_state"])
await hass.async_block_till_done()
assert condition(hass) is False
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other fans also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -96,13 +127,11 @@ async def test_fan_state_condition_behavior_any(
condition="fan.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_all(
condition="fan.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
@@ -117,13 +146,33 @@ async def test_fan_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the fan state condition with the 'all' behavior."""
await assert_condition_behavior_all(
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
other_entity_ids = set(target_fans["included_entities"]) - {entity_id}
# Set all fans, including the tested fan, to the initial state
for eid in target_fans["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
target_entities=target_fans,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
target=condition_target_config,
behavior="all",
)
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -489,7 +489,7 @@ LIGHT_ATTRS = [
]
LOCK_ATTRS = [{"supported_features": 1}, {}]
NOTIFY_ATTRS = [{"supported_features": 0}, {}]
MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}]
MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {"last_non_buffering_state": "on"}]
SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}]
VALVE_ATTRS = [{"supported_features": 0}, {"is_closed": False}]

View File

@@ -1,5 +1,6 @@
"""Tests for the HDMI-CEC media player platform."""
from collections.abc import Callable
from typing import Any
from pycec.const import (
@@ -57,6 +58,39 @@ 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,
@@ -108,6 +142,7 @@ 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"})
@@ -122,17 +157,19 @@ 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_ON
assert_state(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"})
@@ -151,7 +188,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_OFF
assert_state(state.state, STATE_OFF)
@pytest.mark.parametrize(
@@ -280,6 +317,7 @@ 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)
@@ -310,6 +348,7 @@ 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)
@@ -334,6 +373,7 @@ 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,
@@ -349,12 +389,13 @@ 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 == expected_state
assert_state(state.state, expected_state)
@pytest.mark.xfail(reason="PLAY feature isn't enabled")
@@ -362,6 +403,7 @@ 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"})
@@ -376,12 +418,13 @@ 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_PAUSED
assert_state(state.state, STATE_PAUSED)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
@@ -389,6 +432,7 @@ 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)
@@ -483,6 +527,9 @@ 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,
@@ -494,7 +541,6 @@ 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,6 +314,7 @@
'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,6 +208,7 @@
'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,6 +18319,7 @@
'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,6 +43,7 @@
'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

@@ -13,9 +13,11 @@ from tests.components.common import (
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -133,7 +135,17 @@ def parametrize_brightness_condition_states_all(
@pytest.fixture
async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple light entities associated with different targets."""
return await target_entities(hass, "light", domain_excluded="switch")
return await target_entities(hass, "light")
@pytest.fixture
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple switch entities associated with different targets.
Note: The switches are used to ensure that only light entities are considered
in the condition evaluation and not other toggle entities.
"""
return await target_entities(hass, "switch")
@pytest.mark.parametrize(
@@ -163,19 +175,18 @@ async def test_light_conditions_gated_by_labs_flag(
condition="light.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_any(
condition="light.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
async def test_light_state_condition_behavior_any(
hass: HomeAssistant,
target_lights: dict[str, list[str]],
target_switches: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
@@ -184,17 +195,39 @@ async def test_light_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the light state condition with the 'any' behavior."""
await assert_condition_behavior_any(
other_entity_ids = set(target_lights["included_entities"]) - {entity_id}
# Set all lights, including the tested light, to the initial state
for eid in target_lights["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
target_entities=target_lights,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
target=condition_target_config,
behavior="any",
)
# Set state for switches to ensure that they don't impact the condition
for state in states:
for eid in target_switches["included_entities"]:
set_or_remove_state(hass, eid, state["included_state"])
await hass.async_block_till_done()
assert condition(hass) is False
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other lights also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -208,13 +241,11 @@ async def test_light_state_condition_behavior_any(
condition="light.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_all(
condition="light.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
@@ -229,17 +260,37 @@ async def test_light_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the light state condition with the 'all' behavior."""
await assert_condition_behavior_all(
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
other_entity_ids = set(target_lights["included_entities"]) - {entity_id}
# Set all lights, including the tested light, to the initial state
for eid in target_lights["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
target_entities=target_lights,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
target=condition_target_config,
behavior="all",
)
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(

View File

@@ -12,10 +12,12 @@ 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,
)
@@ -24,11 +26,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, STATE_OFF
from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockEntityPlatform
from tests.common import MockEntityPlatform, setup_test_component_platform
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@@ -635,3 +637,62 @@ 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,11 +28,7 @@ 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(
@@ -50,29 +46,6 @@ 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=[
@@ -86,32 +59,6 @@ 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() -> str:
def _write_yaml_config() -> None:
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

@@ -2830,7 +2830,7 @@ async def test_clean_up_registry_monitoring(
}
# Publish it config
# Since it is not enabled_by_default the sensor will not be loaded
# it should register a hook for monitoring the entity registry
# it should register a hook for monitoring the entiry registry
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/sbfspot_12345/config",

View File

@@ -536,6 +536,7 @@ 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:
@@ -564,9 +565,10 @@ async def test_loading_subentry_with_bad_component_schema(
)
],
)
async def test_qos_on_mqtt_device_from_subentry(
async def test_qos_on_mqt_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,6 +49,7 @@
]),
'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',
@@ -121,6 +122,7 @@
]),
'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",
@@ -198,6 +200,7 @@
'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,6 +53,7 @@
}),
'friendly_name': 'TX-NR7100',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'preset': 1,
'sound_mode': 'DIRECT',
'sound_mode_list': list([
@@ -127,6 +128,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'TX-NR7100 Zone 2',
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'sound_mode': 'Stereo',
'sound_mode_list': list([
'Stereo',
@@ -193,6 +195,7 @@
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,6 +42,7 @@
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'PlayStation Vita',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
@@ -97,6 +98,7 @@
'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",
@@ -154,6 +156,7 @@
'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>,
}),
@@ -209,6 +212,7 @@
'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>,
}),
@@ -263,6 +267,7 @@
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'PlayStation 4',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
@@ -318,6 +323,7 @@
'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',
@@ -375,6 +381,7 @@
'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>,
}),
@@ -429,6 +436,7 @@
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'PlayStation 5',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
@@ -484,6 +492,7 @@
'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

@@ -0,0 +1 @@
"""Tests for the Radio Frequency integration."""

View File

@@ -0,0 +1,71 @@
"""Common fixtures for the Radio Frequency tests."""
from typing import override
import pytest
from rf_protocols import ModulationType, RadioFrequencyCommand, Timing
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.components.radio_frequency.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@pytest.fixture
async def init_integration(hass: HomeAssistant) -> None:
"""Set up the Radio Frequency integration for testing."""
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
class MockRadioFrequencyCommand(RadioFrequencyCommand):
"""Mock RF command for testing."""
def __init__(
self,
*,
frequency: int = 433_920_000,
modulation: ModulationType = ModulationType.OOK,
repeat_count: int = 0,
) -> None:
"""Initialize mock command."""
super().__init__(
frequency=frequency, modulation=modulation, repeat_count=repeat_count
)
@override
def get_raw_timings(self) -> list[Timing]:
"""Return mock timings."""
return [Timing(high_us=350, low_us=1050), Timing(high_us=350, low_us=350)]
class MockRadioFrequencyEntity(RadioFrequencyTransmitterEntity):
"""Mock radio frequency entity for testing."""
_attr_has_entity_name = True
_attr_name = "Test RF transmitter"
def __init__(
self,
unique_id: str,
frequency_ranges: list[tuple[int, int]] | None = None,
) -> None:
"""Initialize mock entity."""
self._attr_unique_id = unique_id
self._frequency_ranges = frequency_ranges or [(433_000_000, 434_000_000)]
self.send_command_calls: list[RadioFrequencyCommand] = []
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return supported frequency ranges."""
return self._frequency_ranges
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Mock send command."""
self.send_command_calls.append(command)
@pytest.fixture
def mock_rf_entity() -> MockRadioFrequencyEntity:
"""Return a mock radio frequency entity."""
return MockRadioFrequencyEntity("test_rf_transmitter")

View File

@@ -0,0 +1,171 @@
"""Tests for the Radio Frequency integration setup."""
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from rf_protocols import ModulationType
from homeassistant.components.radio_frequency import (
DATA_COMPONENT,
DOMAIN,
async_get_transmitters,
async_send_command,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import MockRadioFrequencyCommand, MockRadioFrequencyEntity
from tests.common import mock_restore_cache
async def test_get_transmitters_component_not_loaded(hass: HomeAssistant) -> None:
"""Test getting transmitters raises when the component is not loaded."""
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
async_get_transmitters(hass, 433_920_000, ModulationType.OOK)
@pytest.mark.usefixtures("init_integration")
async def test_get_transmitters_no_entities(hass: HomeAssistant) -> None:
"""Test getting transmitters raises when none are registered."""
with pytest.raises(
HomeAssistantError,
match="No Radio Frequency transmitters available",
):
async_get_transmitters(hass, 433_920_000, ModulationType.OOK)
@pytest.mark.usefixtures("init_integration")
async def test_get_transmitters_with_frequency_ranges(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
"""Test transmitter with frequency ranges filters correctly."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
# 433.92 MHz is within 433-434 MHz range
result = async_get_transmitters(hass, 433_920_000, ModulationType.OOK)
assert result == [mock_rf_entity.entity_id]
# 868 MHz is outside the range
result = async_get_transmitters(hass, 868_000_000, ModulationType.OOK)
assert result == []
@pytest.mark.usefixtures("init_integration")
async def test_rf_entity_initial_state(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
"""Test radio frequency entity has no state before any command is sent."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_success(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sending command via async_send_command helper."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
now = dt_util.utcnow()
freezer.move_to(now)
command = MockRadioFrequencyCommand(frequency=433_920_000)
await async_send_command(hass, mock_rf_entity.entity_id, command)
assert len(mock_rf_entity.send_command_calls) == 1
assert mock_rf_entity.send_command_calls[0] is command
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == now.isoformat(timespec="milliseconds")
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_error_does_not_update_state(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
"""Test that state is not updated when async_send_command raises an error."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
command = MockRadioFrequencyCommand(frequency=433_920_000)
mock_rf_entity.async_send_command = AsyncMock(
side_effect=HomeAssistantError("Transmission failed")
)
with pytest.raises(HomeAssistantError, match="Transmission failed"):
await async_send_command(hass, mock_rf_entity.entity_id, command)
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None:
"""Test async_send_command raises error when entity not found."""
command = MockRadioFrequencyCommand(frequency=433_920_000)
with pytest.raises(
HomeAssistantError,
match="Radio Frequency entity `radio_frequency.nonexistent_entity` not found",
):
await async_send_command(hass, "radio_frequency.nonexistent_entity", command)
async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> None:
"""Test async_send_command raises error when component not loaded."""
command = MockRadioFrequencyCommand(frequency=433_920_000)
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
await async_send_command(hass, "radio_frequency.some_entity", command)
@pytest.mark.parametrize(
("restored_value", "expected_state"),
[
("2026-01-01T12:00:00.000+00:00", "2026-01-01T12:00:00.000+00:00"),
(STATE_UNAVAILABLE, STATE_UNKNOWN),
],
)
async def test_rf_entity_state_restore(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
restored_value: str,
expected_state: str,
) -> None:
"""Test radio frequency entity state restore."""
mock_restore_cache(
hass, [State("radio_frequency.test_rf_transmitter", restored_value)]
)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == expected_state

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, patch
import uuid
import pytest
@@ -25,8 +26,10 @@ from homeassistant.components.scrape.const import (
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
CONF_METHOD,
CONF_NAME,
CONF_RESOURCE,
CONF_TIMEOUT,
CONF_UNIQUE_ID,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
@@ -46,9 +49,9 @@ def mock_setup_entry() -> Generator[AsyncMock]:
yield mock_setup_entry
@pytest.fixture(name="get_resource_config")
async def get_resource_config_to_integration_load() -> dict[str, Any]:
"""Return default minimal configuration for resource.
@pytest.fixture(name="get_config")
async def get_config_to_integration_load() -> dict[str, Any]:
"""Return default minimal configuration.
To override the config, tests can be marked with:
@pytest.mark.parametrize("get_config", [{...}])
@@ -56,33 +59,20 @@ async def get_resource_config_to_integration_load() -> dict[str, Any]:
return {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
"auth": {},
"advanced": {
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
},
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
}
@pytest.fixture(name="get_sensor_config")
async def get_sensor_config_to_integration_load() -> tuple[dict[str, Any], ...]:
"""Return default minimal configuration for sensor.
To override the config, tests can be marked with:
@pytest.mark.parametrize("get_config", [{...}])
"""
return (
{
"data": {"advanced": {}, CONF_INDEX: 0, CONF_SELECT: ".current-version h1"},
"subentry_id": "01JZN07D8D23994A49YKS649S7",
"subentry_type": "entity",
"title": "Current version",
"unique_id": None,
},
)
@pytest.fixture(name="get_data")
async def get_data_to_integration_load() -> MockRestData:
"""Return RestData.
@@ -95,19 +85,14 @@ async def get_data_to_integration_load() -> MockRestData:
@pytest.fixture(name="loaded_entry")
async def load_integration(
hass: HomeAssistant,
get_resource_config: dict[str, Any],
get_sensor_config: tuple[dict[str, Any], ...],
get_data: MockRestData,
hass: HomeAssistant, get_config: dict[str, Any], get_data: MockRestData
) -> MockConfigEntry:
"""Set up the Scrape integration in Home Assistant."""
config_entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
options=get_resource_config,
entry_id="01JZN04ZJ9BQXXGXDS05WS7D6P",
subentries_data=get_sensor_config,
version=2,
options=get_config,
entry_id="1",
)
config_entry.add_to_hass(hass)
@@ -120,3 +105,13 @@ async def load_integration(
await hass.async_block_till_done()
return config_entry
@pytest.fixture(autouse=True)
def uuid_fixture() -> str:
"""Automatically path uuid generator."""
with patch(
"homeassistant.components.scrape.config_flow.uuid.uuid1",
return_value=uuid.UUID("3699ef88-69e6-11ed-a1eb-0242ac120002"),
):
yield

View File

@@ -1,153 +0,0 @@
# serializer version: 1
# name: test_migrate_from_version_1_to_2[device_registry]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'scrape',
'01JZQ1G63X2DX66GZ9ZTFY9PEH',
),
}),
'labels': set({
}),
'manufacturer': 'Scrape',
'model': None,
'model_id': None,
'name': 'Current version',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_migrate_from_version_1_to_2[entity_registry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.current_version',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'scrape',
'previous_unique_id': 'a0bde946-5c96-11f0-b55f-0242ac110002',
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '01JZQ1G63X2DX66GZ9ZTFY9PEH',
'unit_of_measurement': None,
})
# ---
# name: test_migrate_from_version_1_to_2[post_migration_config_entry]
ConfigEntrySnapshot({
'data': dict({
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'scrape',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
'advanced': dict({
'encoding': 'UTF-8',
'timeout': 10.0,
'verify_ssl': True,
}),
'auth': dict({
'password': 'pass',
'username': 'user',
}),
'method': 'GET',
'resource': 'http://www.home-assistant.io',
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
dict({
'data': dict({
'advanced': dict({
'value_template': '{{ value }}',
}),
'index': 0,
'select': '.release-date',
}),
'subentry_id': '01JZQ1G63X2DX66GZ9ZTFY9PEH',
'subentry_type': 'entity',
'title': 'Current version',
'unique_id': None,
}),
]),
'title': 'Mock Title',
'unique_id': None,
'version': 2,
})
# ---
# name: test_migrate_from_version_1_to_2[pre_migration_config_entry]
ConfigEntrySnapshot({
'data': dict({
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'scrape',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
'encoding': 'UTF-8',
'method': 'GET',
'password': 'pass',
'resource': 'http://www.home-assistant.io',
'sensor': list([
dict({
'index': 0,
'name': 'Current version',
'select': '.release-date',
'unique_id': 'a0bde946-5c96-11f0-b55f-0242ac110002',
'value_template': '{{ value }}',
}),
]),
'timeout': 10.0,
'username': 'user',
'verify_ssl': True,
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Mock Title',
'unique_id': None,
'version': 1,
})
# ---

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import uuid
from homeassistant import config_entries
from homeassistant.components.rest.data import ( # pylint: disable=hass-component-root-import
@@ -13,21 +14,25 @@ from homeassistant.components.rest.schema import ( # pylint: disable=hass-compo
)
from homeassistant.components.scrape import DOMAIN
from homeassistant.components.scrape.const import (
CONF_ADVANCED,
CONF_AUTH,
CONF_ENCODING,
CONF_INDEX,
CONF_SELECT,
DEFAULT_ENCODING,
DEFAULT_VERIFY_SSL,
)
from homeassistant.components.sensor import CONF_STATE_CLASS
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_METHOD,
CONF_NAME,
CONF_PASSWORD,
CONF_PAYLOAD,
CONF_RESOURCE,
CONF_TIMEOUT,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
CONF_VALUE_TEMPLATE,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
@@ -39,7 +44,7 @@ from . import MockRestData
from tests.common import MockConfigEntry
async def test_entry_and_subentry(
async def test_form(
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
) -> None:
"""Test we get the form."""
@@ -54,55 +59,47 @@ async def test_entry_and_subentry(
"homeassistant.components.rest.RestData",
return_value=get_data,
) as mock_data:
result = await hass.config_entries.flow.async_configure(
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
)
await hass.async_block_till_done()
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["version"] == 2
assert result["options"] == {
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["version"] == 1
assert result3["options"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
},
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
}
assert len(mock_data.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
entry_id = result["result"].entry_id
result = await hass.config_entries.subentries.async_init(
(entry_id, "entity"), context={"source": config_entries.SOURCE_USER}
)
assert result["step_id"] == "user"
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_INDEX: 0, CONF_SELECT: ".current-version h1", CONF_ADVANCED: {}},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_INDEX: 0,
CONF_SELECT: ".current-version h1",
CONF_ADVANCED: {},
}
async def test_form_with_post(
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
@@ -119,32 +116,44 @@ async def test_form_with_post(
"homeassistant.components.rest.RestData",
return_value=get_data,
) as mock_data:
result = await hass.config_entries.flow.async_configure(
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_PAYLOAD: "POST",
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
)
await hass.async_block_till_done()
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["version"] == 2
assert result["options"] == {
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["version"] == 1
assert result3["options"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_PAYLOAD: "POST",
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
},
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
}
assert len(mock_data.mock_calls) == 1
@@ -167,68 +176,74 @@ async def test_flow_fails(
"homeassistant.components.rest.RestData",
side_effect=HomeAssistantError,
):
result = await hass.config_entries.flow.async_configure(
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
)
assert result["errors"] == {"base": "resource_error"}
assert result2["errors"] == {"base": "resource_error"}
with patch(
"homeassistant.components.rest.RestData",
return_value=MockRestData("test_scrape_sensor_no_data"),
):
result = await hass.config_entries.flow.async_configure(
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
)
assert result["errors"] == {"base": "no_data"}
assert result2["errors"] == {"base": "resource_error"}
with patch(
"homeassistant.components.rest.RestData",
return_value=get_data,
):
result = await hass.config_entries.flow.async_configure(
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
)
await hass.async_block_till_done()
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "https://www.home-assistant.io"
assert result["options"] == {
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == "https://www.home-assistant.io"
assert result4["options"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
},
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
}
@@ -242,9 +257,17 @@ async def test_options_resource_flow(
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "resource"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "resource"
mocker = MockRestData("test_scrape_sensor2")
with patch("homeassistant.components.rest.RestData", return_value=mocker):
result = await hass.config_entries.options.async_configure(
@@ -252,15 +275,11 @@ async def test_options_resource_flow(
user_input={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
CONF_AUTH: {
CONF_USERNAME: "secret_username",
CONF_PASSWORD: "secret_password",
},
CONF_ADVANCED: {
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
},
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
CONF_USERNAME: "secret_username",
CONF_PASSWORD: "secret_password",
},
)
await hass.async_block_till_done()
@@ -269,15 +288,19 @@ async def test_options_resource_flow(
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_AUTH: {
CONF_USERNAME: "secret_username",
CONF_PASSWORD: "secret_password",
},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
},
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
CONF_USERNAME: "secret_username",
CONF_PASSWORD: "secret_password",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
}
await hass.async_block_till_done()
@@ -288,3 +311,351 @@ async def test_options_resource_flow(
# Check the state of the entity has changed as expected
state = hass.states.get("sensor.current_version")
assert state.state == "Hidden Version: 2021.12.10"
async def test_options_add_remove_sensor_flow(
hass: HomeAssistant, loaded_entry: MockConfigEntry
) -> None:
"""Test options flow to add and remove a sensor."""
state = hass.states.get("sensor.current_version")
assert state.state == "Current Version: 2021.12.10"
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "add_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "add_sensor"
mocker = MockRestData("test_scrape_sensor2")
with (
patch("homeassistant.components.rest.RestData", return_value=mocker),
patch(
"homeassistant.components.scrape.config_flow.uuid.uuid1",
return_value=uuid.UUID("3699ef88-69e6-11ed-a1eb-0242ac120003"),
),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_NAME: "Template",
CONF_SELECT: "template",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
{
CONF_NAME: "Template",
CONF_SELECT: "template",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120003",
},
],
}
await hass.async_block_till_done()
# Check the entity was updated, with the new entity
assert len(hass.states.async_all()) == 2
# Check the state of the entity has changed as expected
state = hass.states.get("sensor.current_version")
assert state.state == "Hidden Version: 2021.12.10"
state = hass.states.get("sensor.template")
assert state.state == "Trying to get"
# Now remove the original sensor
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "remove_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "remove_sensor"
mocker = MockRestData("test_scrape_sensor2")
with patch("homeassistant.components.rest.RestData", return_value=mocker):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_INDEX: ["0"],
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Template",
CONF_SELECT: "template",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120003",
},
],
}
await hass.async_block_till_done()
# Check the original entity was removed, with only the new entity left
assert len(hass.states.async_all()) == 1
# Check the state of the new entity
state = hass.states.get("sensor.template")
assert state.state == "Trying to get"
async def test_options_edit_sensor_flow(
hass: HomeAssistant, loaded_entry: MockConfigEntry
) -> None:
"""Test options flow to edit a sensor."""
state = hass.states.get("sensor.current_version")
assert state.state == "Current Version: 2021.12.10"
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "select_edit_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"index": "0"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit_sensor"
mocker = MockRestData("test_scrape_sensor2")
with patch("homeassistant.components.rest.RestData", return_value=mocker):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_SELECT: "template",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: "template",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
],
}
await hass.async_block_till_done()
# Check the entity was updated
assert len(hass.states.async_all()) == 1
# Check the state of the entity has changed as expected
state = hass.states.get("sensor.current_version")
assert state.state == "Trying to get"
async def test_sensor_options_add_device_class(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test options flow to edit a sensor."""
entry = MockConfigEntry(
domain=DOMAIN,
options={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
},
entry_id="1",
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "select_edit_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"index": "0"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0.0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_DEVICE_CLASS: "temperature",
CONF_STATE_CLASS: "measurement",
CONF_UNIT_OF_MEASUREMENT: "°C",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_INDEX: 0,
CONF_DEVICE_CLASS: "temperature",
CONF_STATE_CLASS: "measurement",
CONF_UNIT_OF_MEASUREMENT: "°C",
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
],
}
async def test_sensor_options_remove_device_class(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test options flow to edit a sensor."""
entry = MockConfigEntry(
domain=DOMAIN,
options={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_DEVICE_CLASS: "temperature",
CONF_STATE_CLASS: "measurement",
CONF_UNIT_OF_MEASUREMENT: "°C",
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
},
entry_id="1",
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "select_edit_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"index": "0"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0.0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
],
}

View File

@@ -2,18 +2,14 @@
from __future__ import annotations
from dataclasses import dataclass
from http import HTTPStatus
from typing import Any
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, ConfigSubentry
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
@@ -197,137 +193,3 @@ async def test_resource_template(
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("sensor.template_sensor")
assert state.state == "Second"
async def test_migrate_from_future(
hass: HomeAssistant,
get_resource_config: dict[str, Any],
get_sensor_config: tuple[dict[str, Any], ...],
get_data: MockRestData,
) -> None:
"""Test migration from future version fails."""
config_entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
options=get_resource_config,
entry_id="01JZN04ZJ9BQXXGXDS05WS7D6P",
subentries_data=get_sensor_config,
version=3,
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.rest.RestData",
return_value=get_data,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
async def test_migrate_from_version_1_to_2(
hass: HomeAssistant,
get_data: MockRestData,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test migration from version 1.1 to 2.1 with config subentries."""
@dataclass(frozen=True, kw_only=True)
class MockConfigSubentry(ConfigSubentry):
"""Container for a configuration subentry."""
subentry_id: str = "01JZQ1G63X2DX66GZ9ZTFY9PEH"
config_entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
options={
"encoding": "UTF-8",
"method": "GET",
"resource": "http://www.home-assistant.io",
"username": "user",
"password": "pass",
"sensor": [
{
"index": 0,
"name": "Current version",
"select": ".release-date",
"unique_id": "a0bde946-5c96-11f0-b55f-0242ac110002",
"value_template": "{{ value }}",
}
],
"timeout": 10.0,
"verify_ssl": True,
},
entry_id="01JZN04ZJ9BQXXGXDS05WS7D6P",
version=1,
)
config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
entry_type=dr.DeviceEntryType.SERVICE,
identifiers={(DOMAIN, "a0bde946-5c96-11f0-b55f-0242ac110002")},
manufacturer="Scrape",
name="Current version",
)
entity_registry.async_get_or_create(
SENSOR_DOMAIN,
DOMAIN,
"a0bde946-5c96-11f0-b55f-0242ac110002",
config_entry=config_entry,
device_id=device.id,
original_name="Current version",
has_entity_name=True,
suggested_object_id="current_version",
)
assert hass.config_entries.async_get_entry(config_entry.entry_id) == snapshot(
name="pre_migration_config_entry"
)
with (
patch(
"homeassistant.components.rest.RestData",
return_value=get_data,
),
patch("homeassistant.components.scrape.ConfigSubentry", MockConfigSubentry),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
assert config_entry.state is ConfigEntryState.LOADED
assert hass.config_entries.async_get_entry(config_entry.entry_id) == snapshot(
name="post_migration_config_entry"
)
device = device_registry.async_get(device.id)
assert device == snapshot(name="device_registry")
entity = entity_registry.async_get("sensor.current_version")
assert entity == snapshot(name="entity_registry")
assert config_entry.subentries == {
"01JZQ1G63X2DX66GZ9ZTFY9PEH": MockConfigSubentry(
data={
"advanced": {"value_template": "{{ value }}"},
"index": 0,
"select": ".release-date",
},
subentry_id="01JZQ1G63X2DX66GZ9ZTFY9PEH",
subentry_type="entity",
title="Current version",
unique_id=None,
),
}
assert device.config_entries == {"01JZN04ZJ9BQXXGXDS05WS7D6P"}
assert device.config_entries_subentries == {
"01JZN04ZJ9BQXXGXDS05WS7D6P": {
"01JZQ1G63X2DX66GZ9ZTFY9PEH",
},
}
assert entity.config_entry_id == config_entry.entry_id
assert entity.config_subentry_id == "01JZQ1G63X2DX66GZ9ZTFY9PEH"
state = hass.states.get("sensor.current_version")
assert state.state == "January 17, 2022"

View File

@@ -18,6 +18,7 @@ from homeassistant.components.scrape.const import (
)
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorStateClass,
)
@@ -603,7 +604,7 @@ async def test_setup_config_entry(
entity = entity_registry.async_get("sensor.current_version")
assert entity.unique_id == "01JZN07D8D23994A49YKS649S7"
assert entity.unique_id == "3699ef88-69e6-11ed-a1eb-0242ac120002"
async def test_templates_with_yaml(hass: HomeAssistant) -> None:
@@ -687,38 +688,27 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("get_resource_config", "get_sensor_config"),
"get_config",
[
(
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
"auth": {},
"advanced": {
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: 10,
CONF_ENCODING: DEFAULT_ENCODING,
},
},
(
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: 10,
CONF_ENCODING: DEFAULT_ENCODING,
SENSOR_DOMAIN: [
{
"data": {
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0,
"advanced": {
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}',
CONF_ICON: 'mdi:o{{ "n" if states("sensor.input1")=="on" else "ff" }}',
CONF_PICTURE: 'o{{ "n" if states("sensor.input1")=="on" else "ff" }}.jpg',
},
},
# "subentry_id": "01JZN07D8D23994A49YKS649S7",
"subentry_type": "entity",
"title": "Current version",
"unique_id": None,
},
),
)
CONF_SELECT: ".current-version h1",
CONF_NAME: "Current version",
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}',
CONF_ICON: 'mdi:o{{ "n" if states("sensor.input1")=="on" else "ff" }}',
CONF_PICTURE: 'o{{ "n" if states("sensor.input1")=="on" else "ff" }}.jpg',
}
],
}
],
)
async def test_availability(

View File

@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -22,7 +22,17 @@ from tests.components.common import (
@pytest.fixture
async def target_sirens(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple siren entities associated with different targets."""
return await target_entities(hass, "siren", domain_excluded="switch")
return await target_entities(hass, "siren")
@pytest.fixture
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple switch entities associated with different targets.
Note: The switches are used to ensure that only siren entities are considered
in the condition evaluation and not other toggle entities.
"""
return await target_entities(hass, "switch")
@pytest.mark.parametrize(
@@ -51,19 +61,18 @@ async def test_siren_conditions_gated_by_labs_flag(
condition="siren.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_any(
condition="siren.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
async def test_siren_state_condition_behavior_any(
hass: HomeAssistant,
target_sirens: dict[str, list[str]],
target_switches: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
@@ -72,17 +81,39 @@ async def test_siren_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the siren state condition with the 'any' behavior."""
await assert_condition_behavior_any(
other_entity_ids = set(target_sirens["included_entities"]) - {entity_id}
# Set all sirens, including the tested siren, to the initial state
for eid in target_sirens["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
target_entities=target_sirens,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
target=condition_target_config,
behavior="any",
)
# Set state for switches to ensure that they don't impact the condition
for state in states:
for eid in target_switches["included_entities"]:
set_or_remove_state(hass, eid, state["included_state"])
await hass.async_block_till_done()
assert condition(hass) is False
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other sirens also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -96,13 +127,11 @@ async def test_siren_state_condition_behavior_any(
condition="siren.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_all(
condition="siren.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
@@ -117,13 +146,32 @@ async def test_siren_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the siren state condition with the 'all' behavior."""
await assert_condition_behavior_all(
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
other_entity_ids = set(target_sirens["included_entities"]) - {entity_id}
# Set all sirens, including the tested siren, to the initial state
for eid in target_sirens["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
target_entities=target_sirens,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
target=condition_target_config,
behavior="all",
)
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -42,6 +42,7 @@
'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,
@@ -105,6 +106,7 @@
'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',
@@ -170,6 +172,7 @@
'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>,
@@ -227,6 +230,7 @@
'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>,
@@ -284,6 +288,7 @@
'device_class': 'speaker',
'friendly_name': 'Soundbar Living',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'media_artist': '',
'media_title': '',
'source': 'HDMI1',
@@ -341,6 +346,7 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Soundbar 1',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'supported_features': <MediaPlayerEntityFeature: 1420>,
}),
'context': <ANY>,
@@ -401,6 +407,7 @@
'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,6 +51,7 @@
'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',
@@ -127,6 +128,7 @@
'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,6 +46,7 @@
'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,6 +45,7 @@
'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',
@@ -118,6 +119,7 @@
'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'>,

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