mirror of
https://github.com/home-assistant/core.git
synced 2026-05-29 12:14:26 +02:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5669eb583e | |||
| f059eef5aa | |||
| 2bba907013 | |||
| 0dcb8fc507 | |||
| 18e6f67650 | |||
| e5fad17e17 | |||
| 219b9cbcaa | |||
| 1bde5f0d6c | |||
| 309b26f809 | |||
| e78cb0114d | |||
| 06a4247078 | |||
| 181e21dd2c | |||
| 31354d4129 | |||
| 57308d7760 | |||
| c07fed05df | |||
| 13ef737873 | |||
| 0a1510135c | |||
| 6f6b7888cd | |||
| b9173e36fb | |||
| a65ca9c86b | |||
| fc12d6fbb6 | |||
| 2a6b686254 | |||
| 4d841e4d84 | |||
| df08e9f311 | |||
| d53e40eea8 | |||
| 0b261b7198 | |||
| 3a9f32de25 | |||
| b5e54583c7 | |||
| 85ea7c1176 | |||
| 713f520bc8 | |||
| e4bb5a9395 | |||
| 936b2fe933 | |||
| c6c6f08885 | |||
| c621721851 | |||
| 5bb6b20641 | |||
| 37f41d8e09 | |||
| b02f312bed | |||
| 3520c821c5 | |||
| cbf737a03e | |||
| 5bd6d52e6a | |||
| d9a89beb3d | |||
| 41f783f14d | |||
| 35397b818d | |||
| d42d02f20a | |||
| 99c445f261 | |||
| 567fe85828 |
@@ -24,6 +24,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -530,7 +530,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +543,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
# - github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -39,7 +39,7 @@ on:
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.6"
|
||||
HA_SHORT_VERSION: "2026.7"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
- uses: dessant/lock-threads@851cffe46851ddd2051ea7147ebdc995113241c3 # v6.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
"lg_tv_rs232",
|
||||
"webostv"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
||||
|
||||
_attr_event_types = [EVENT_TYPE]
|
||||
coordinator: AmazonDevicesCoordinator
|
||||
_last_seen_timestamp: int | None = None
|
||||
_last_seen_timestamp: int = 0 # January 1, 1970 at 12:00:00 AM
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
@@ -71,7 +71,8 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
||||
)
|
||||
return
|
||||
|
||||
if vocal_record.timestamp == self._last_seen_timestamp:
|
||||
if vocal_record.timestamp <= self._last_seen_timestamp:
|
||||
# Discard old events that have already been processed
|
||||
return
|
||||
|
||||
self._last_seen_timestamp = vocal_record.timestamp
|
||||
|
||||
@@ -5,8 +5,12 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -49,6 +53,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
@@ -57,18 +62,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
snapshots_url: str | None = None
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
snapshots_url = None
|
||||
|
||||
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Analytics from a config entry."""
|
||||
snapshots_url = hass.data[_DATA_SNAPSHOTS_URL]
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
try:
|
||||
await analytics.load()
|
||||
except HassioNotReadyError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_ready",
|
||||
) from err
|
||||
|
||||
started = False
|
||||
|
||||
@@ -80,8 +106,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
async def start_schedule(hass: HomeAssistant) -> None:
|
||||
"""Start the send schedule once Home Assistant has started."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
@@ -89,12 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
async_at_started(hass, start_schedule)
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
@@ -109,7 +130,9 @@ def websocket_analytics(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return analytics preferences."""
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
|
||||
@@ -130,8 +153,10 @@ async def websocket_analytics_preferences(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
preferences = msg[ATTR_PREFERENCES]
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.async_schedule()
|
||||
|
||||
@@ -299,12 +299,8 @@ class Analytics:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable.
|
||||
# The caller is responsible for handling this and triggering a retry.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
@@ -349,10 +345,10 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
# Try to pull Supervisor information, but don't fail if some or all
|
||||
# of it is unavailable due to setup failures in the hassio integration.
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
operating_system_info = hassio.get_os_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Config flow for Analytics integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Analytics."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Analytics", data={})
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
@@ -14,5 +15,6 @@
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal"
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"supervisor_not_ready": {
|
||||
"message": "Supervisor was not ready during setup, will retry"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
|
||||
@@ -38,11 +38,13 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import AppleTvConfigEntry, AppleTVManager
|
||||
from .browse_media import build_app_list
|
||||
from .const import DOMAIN
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -126,7 +128,6 @@ class AppleTvMediaPlayer(
|
||||
@callback
|
||||
def async_device_connected(self, atv: AppleTV) -> None:
|
||||
"""Handle when connection is made to device."""
|
||||
# NB: Do not use _is_feature_available here as it only works when playing
|
||||
if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
|
||||
atv.push_updater.listener = self
|
||||
atv.push_updater.start()
|
||||
@@ -352,21 +353,41 @@ class AppleTvMediaPlayer(
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
media_type = MediaType.MUSIC
|
||||
|
||||
if self._is_feature_available(FeatureName.StreamFile) and (
|
||||
use_stream_file = self._is_feature_available(FeatureName.StreamFile) and (
|
||||
media_type == MediaType.MUSIC or await is_streamable(media_id)
|
||||
):
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
||||
):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Media streaming is not possible with current configuration for %s",
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
if use_stream_file:
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
||||
):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="streaming_not_supported",
|
||||
)
|
||||
except exceptions.NotSupportedError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="streaming_not_supported",
|
||||
) from ex
|
||||
except (
|
||||
exceptions.BlockedStateError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.InvalidStateError,
|
||||
exceptions.OperationTimeoutError,
|
||||
exceptions.PlaybackError,
|
||||
exceptions.ProtocolError,
|
||||
) as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="stream_failed",
|
||||
) from ex
|
||||
|
||||
@property
|
||||
def media_image_hash(self) -> str | None:
|
||||
@@ -460,7 +481,7 @@ class AppleTvMediaPlayer(
|
||||
|
||||
def _is_feature_available(self, feature: FeatureName) -> bool:
|
||||
"""Return if a feature is available."""
|
||||
if self.atv and self._playing:
|
||||
if self.atv:
|
||||
return self.atv.features.in_state(FeatureState.Available, feature)
|
||||
return False
|
||||
|
||||
|
||||
@@ -81,6 +81,12 @@
|
||||
},
|
||||
"not_connected": {
|
||||
"message": "Apple TV is not connected"
|
||||
},
|
||||
"stream_failed": {
|
||||
"message": "Failed to stream media to the Apple TV"
|
||||
},
|
||||
"streaming_not_supported": {
|
||||
"message": "Streaming the requested media is not supported"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .models import AgentBackup, BackupNotFound
|
||||
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
|
||||
from .util import read_backup, suggested_filename
|
||||
|
||||
|
||||
@@ -54,7 +54,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
try:
|
||||
backup = read_backup(backup_path)
|
||||
backups[backup.backup_id] = (backup, backup_path)
|
||||
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
|
||||
except (
|
||||
OSError,
|
||||
TarError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
InvalidBackupFilename,
|
||||
) as err:
|
||||
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
|
||||
return backups
|
||||
|
||||
@@ -122,7 +128,14 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
|
||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||
"""Return the local path to a new backup."""
|
||||
return self._backup_dir / suggested_filename(backup)
|
||||
candidate = self._backup_dir / suggested_filename(backup)
|
||||
# suggested_filename does not strip separators; refuse paths that would
|
||||
# land outside the backup directory.
|
||||
if candidate.parent != self._backup_dir:
|
||||
raise InvalidBackupFilename(
|
||||
f"Refusing to write outside {self._backup_dir}: {candidate}"
|
||||
)
|
||||
return candidate
|
||||
|
||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||
"""Delete a backup file."""
|
||||
|
||||
@@ -1978,7 +1978,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
|
||||
try:
|
||||
backup = await async_add_executor_job(read_backup, temp_file)
|
||||
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err:
|
||||
except (
|
||||
OSError,
|
||||
tarfile.TarError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
InvalidBackupFilename,
|
||||
) as err:
|
||||
LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
|
||||
raise
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import copy
|
||||
from dataclasses import dataclass, replace
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path, PurePath
|
||||
from pathlib import Path, PurePath, PureWindowsPath
|
||||
from queue import SimpleQueue
|
||||
import tarfile
|
||||
import threading
|
||||
@@ -34,7 +34,7 @@ from homeassistant.util.async_iterator import (
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION
|
||||
from .models import AddonInfo, AgentBackup, Folder
|
||||
from .models import AddonInfo, AgentBackup, Folder, InvalidBackupFilename
|
||||
|
||||
|
||||
class DecryptError(HomeAssistantError):
|
||||
@@ -109,6 +109,13 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
||||
extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
|
||||
date = extra_metadata.get("supervisor.backup_request_date", data["date"])
|
||||
|
||||
name = cast(str, data["name"])
|
||||
# The name is used to derive the on-disk filename via suggested_filename;
|
||||
# reject anything that could escape the backup directory.
|
||||
safe_name = PureWindowsPath(name).name
|
||||
if safe_name != name or name in ("", ".", ".."):
|
||||
raise InvalidBackupFilename(f"Invalid backup name: {name!r}")
|
||||
|
||||
return AgentBackup(
|
||||
addons=addons,
|
||||
backup_id=cast(str, data["slug"]),
|
||||
@@ -118,7 +125,7 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
||||
folders=folders,
|
||||
homeassistant_included=homeassistant_included,
|
||||
homeassistant_version=homeassistant_version,
|
||||
name=cast(str, data["name"]),
|
||||
name=name,
|
||||
protected=cast(bool, data.get("protected", False)),
|
||||
size=backup_path.stat().st_size,
|
||||
)
|
||||
|
||||
@@ -92,7 +92,7 @@ class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity):
|
||||
"""Representation of a Broadlink RF transmitter."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_translation_key = "rf_transmitter"
|
||||
|
||||
def __init__(self, device: BroadlinkDevice) -> None:
|
||||
"""Initialize the entity."""
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
"name": "IR emitter"
|
||||
}
|
||||
},
|
||||
"radio_frequency": {
|
||||
"rf_transmitter": {
|
||||
"name": "RF transmitter"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"day_of_week": {
|
||||
"name": "Day of week",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
|
||||
import caldav
|
||||
from caldav.lib.error import DAVError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -26,7 +27,7 @@ async def async_get_calendars(
|
||||
for calendar in client.principal().calendars():
|
||||
try:
|
||||
supported_components = calendar.get_supported_components()
|
||||
except KeyError:
|
||||
except KeyError, DAVError:
|
||||
needs_warning.append((str(calendar.url), calendar.name, component))
|
||||
|
||||
if component in ASSUMED_COMPONENTS:
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.trigger import (
|
||||
TriggerActionType,
|
||||
TriggerInfo,
|
||||
# protected, but only used for legacy triggers
|
||||
_async_attach_trigger_cls,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -169,11 +169,35 @@ class BaseTrackerEntity(Entity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_source_type: SourceType
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "battery_level" in cls.__dict__:
|
||||
if cls.__module__.startswith("homeassistant.components."):
|
||||
# Don't ask users to report issue for built in integrations,
|
||||
# they already have issues opened on them.
|
||||
return
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(), module=cls.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is overriding the deprecated battery_level property on "
|
||||
"a subclass of BaseTrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
cls.__module__,
|
||||
cls.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the device.
|
||||
|
||||
Percentage from 0-100.
|
||||
|
||||
The property is deprecated and will be removed in Home Assistant 2027.7.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Update Duck DNS."""
|
||||
|
||||
retry_after = BACKOFF_INTERVALS[
|
||||
min(self.failed, len(BACKOFF_INTERVALS))
|
||||
min(self.failed, len(BACKOFF_INTERVALS) - 1)
|
||||
].total_seconds()
|
||||
|
||||
try:
|
||||
|
||||
@@ -86,7 +86,6 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
"""Fetch node data from the Duco box."""
|
||||
try:
|
||||
nodes = await self.client.async_get_nodes()
|
||||
lan_info = await self.client.async_get_lan_info()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -100,7 +99,18 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
# LAN info only backs the diagnostic RSSI sensor, so failures on this
|
||||
# supplemental endpoint, including connection failures, should not make
|
||||
# the primary node entities unavailable.
|
||||
rssi_wifi = self.data.rssi_wifi if self.data else None
|
||||
try:
|
||||
lan_info = await self.client.async_get_lan_info()
|
||||
except DucoError as err:
|
||||
_LOGGER.debug("Could not fetch Duco LAN info", exc_info=err)
|
||||
else:
|
||||
rssi_wifi = lan_info.rssi_wifi
|
||||
|
||||
return DucoData(
|
||||
nodes={node.node_id: node for node in nodes},
|
||||
rssi_wifi=lan_info.rssi_wifi,
|
||||
rssi_wifi=rssi_wifi,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather
|
||||
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
|
||||
errors = errors + 1
|
||||
_LOGGER.warning("Unable to retrieve Environment Canada weather")
|
||||
|
||||
radar_data = ECRadar(coordinates=(lat, lon))
|
||||
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
|
||||
radar_coordinator = ECDataUpdateCoordinator(
|
||||
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for the Environment Canada radar imagery."""
|
||||
|
||||
from env_canada import ECRadar
|
||||
from env_canada import ECMap
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
@@ -11,13 +11,20 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_OBSERVATION_TIME
|
||||
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
|
||||
|
||||
SERVICE_SET_RADAR_TYPE = "set_radar_type"
|
||||
SET_RADAR_TYPE_SCHEMA: VolDictType = {
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow"]),
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow", "Precipitation type"]),
|
||||
}
|
||||
|
||||
_RADAR_TYPE_TO_LAYER: dict[str, str] = {
|
||||
"Rain": "rain",
|
||||
"Snow": "snow",
|
||||
"Precipitation type": "precip_type",
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +45,13 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera):
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera):
|
||||
"""Implementation of an Environment Canada radar camera."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "radar"
|
||||
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None:
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECMap]) -> None:
|
||||
"""Initialize the camera."""
|
||||
super().__init__(coordinator)
|
||||
Camera.__init__(self)
|
||||
@@ -76,6 +83,13 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera
|
||||
|
||||
async def async_set_radar_type(self, radar_type: str) -> None:
|
||||
"""Set the type of radar to retrieve."""
|
||||
if radar_type == "Auto":
|
||||
# Choose rain for months April through October, snow otherwise
|
||||
layer = "rain" if dt_util.now().month in range(4, 11) else "snow"
|
||||
else:
|
||||
layer = _RADAR_TYPE_TO_LAYER[radar_type]
|
||||
|
||||
# Apply new layer and clear cache to force refresh
|
||||
self.radar_object.layer = layer
|
||||
self.radar_object.clear_cache()
|
||||
self.radar_object.precip_type = radar_type.lower()
|
||||
await self.radar_object.update()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -17,7 +17,7 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type ECConfigEntry = ConfigEntry[ECRuntimeData]
|
||||
type ECDataType = ECAirQuality | ECRadar | ECWeather
|
||||
type ECDataType = ECAirQuality | ECMap | ECWeather
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -25,7 +25,7 @@ class ECRuntimeData:
|
||||
"""Class to hold EC runtime data."""
|
||||
|
||||
aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECRadar]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECMap]
|
||||
weather_coordinator: ECDataUpdateCoordinator[ECWeather]
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ set_radar_type:
|
||||
fields:
|
||||
radar_type:
|
||||
required: true
|
||||
example: Snow
|
||||
example: Rain
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "Auto"
|
||||
- "Rain"
|
||||
- "Snow"
|
||||
- "Precipitation type"
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.0"]
|
||||
"requirements": ["home-assistant-frontend==20260527.1"]
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.RECEIVER): TYPE_RECEIVER,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.SPEAKER): TYPE_SPEAKER,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.TV): TYPE_TV,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.PROJECTOR): TYPE_TV,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.AQI): TYPE_SENSOR,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.HUMIDITY): TYPE_SENSOR,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.TEMPERATURE): TYPE_SENSOR,
|
||||
|
||||
@@ -2728,7 +2728,11 @@ class ChannelTrait(_Trait):
|
||||
if (
|
||||
domain == media_player.DOMAIN
|
||||
and (features & MediaPlayerEntityFeature.PLAY_MEDIA)
|
||||
and device_class == media_player.MediaPlayerDeviceClass.TV
|
||||
and device_class
|
||||
in (
|
||||
media_player.MediaPlayerDeviceClass.TV,
|
||||
media_player.MediaPlayerDeviceClass.PROJECTOR,
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
|
||||
@@ -202,7 +202,10 @@ def get_accessory( # noqa: C901
|
||||
|
||||
if device_class == MediaPlayerDeviceClass.RECEIVER:
|
||||
a_type = "ReceiverMediaPlayer"
|
||||
elif device_class == MediaPlayerDeviceClass.TV:
|
||||
elif device_class in (
|
||||
MediaPlayerDeviceClass.TV,
|
||||
MediaPlayerDeviceClass.PROJECTOR,
|
||||
):
|
||||
a_type = "TelevisionMediaPlayer"
|
||||
elif validate_media_player_features(state, feature_list):
|
||||
a_type = "MediaPlayer"
|
||||
|
||||
@@ -695,7 +695,11 @@ def state_needs_accessory_mode(state: State) -> bool:
|
||||
return (
|
||||
state.domain == MEDIA_PLAYER_DOMAIN
|
||||
and state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER)
|
||||
in (
|
||||
MediaPlayerDeviceClass.TV,
|
||||
MediaPlayerDeviceClass.RECEIVER,
|
||||
MediaPlayerDeviceClass.PROJECTOR,
|
||||
)
|
||||
) or (
|
||||
state.domain == REMOTE_DOMAIN
|
||||
and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
@@ -178,17 +178,21 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
@property
|
||||
def max_color_temp_mireds(self) -> int:
|
||||
"""Return the warmest color_temp in mireds that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_maximum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
if (color_temp := self.resource.color_temperature) and (
|
||||
mirek_max := color_temp.mirek_schema.mirek_maximum
|
||||
):
|
||||
return mirek_max
|
||||
# return a fallback value if the light doesn't provide valid limits
|
||||
return FALLBACK_MAX_MIREDS
|
||||
|
||||
@property
|
||||
def min_color_temp_mireds(self) -> int:
|
||||
"""Return the coldest color_temp in mireds that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_minimum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
if (color_temp := self.resource.color_temperature) and (
|
||||
mirek_min := color_temp.mirek_schema.mirek_minimum
|
||||
):
|
||||
return mirek_min
|
||||
# return a fallback value if the light doesn't provide valid limits
|
||||
return FALLBACK_MIN_MIREDS
|
||||
|
||||
@property
|
||||
|
||||
@@ -94,7 +94,7 @@ async def async_setup_entry(
|
||||
async_add_entities(device.zones.values())
|
||||
|
||||
# create any components not yet created
|
||||
for controller in disco.pi_disco.controllers.values():
|
||||
for controller in (await disco.pi_disco.fetch_controllers()).values():
|
||||
init_controller(controller)
|
||||
|
||||
# connect to register any further components
|
||||
|
||||
@@ -29,12 +29,13 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
async with asyncio.timeout(TIMEOUT_DISCOVERY):
|
||||
await controller_ready.wait()
|
||||
|
||||
if not disco.pi_disco.controllers:
|
||||
controllers = await disco.pi_disco.fetch_controllers()
|
||||
if not controllers:
|
||||
await async_stop_discovery_service(hass)
|
||||
_LOGGER.debug("No controllers found")
|
||||
return False
|
||||
|
||||
_LOGGER.debug("Controllers %s", disco.pi_disco.controllers)
|
||||
_LOGGER.debug("Controllers %s", controllers)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -105,10 +105,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
|
||||
except ThinQAPIException as exc:
|
||||
if on_fail_method:
|
||||
on_fail_method()
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
exc.message, translation_domain=DOMAIN, translation_key=exc.code
|
||||
) from exc
|
||||
raise ServiceValidationError(exc.message) from exc
|
||||
except ValueError as exc:
|
||||
if on_fail_method:
|
||||
on_fail_method()
|
||||
|
||||
@@ -108,5 +108,7 @@ def create_matter_ble_proxy(hass: HomeAssistant, ws_url: str) -> MatterBleProxy:
|
||||
ws_url=ws_url,
|
||||
scan_source=HaBluetoothScanSource(hass),
|
||||
device_resolver=HaBluetoothDeviceResolver(hass),
|
||||
task_factory=hass.async_create_task,
|
||||
task_factory=lambda coro: hass.async_create_background_task(
|
||||
coro, name="matter_ble_proxy"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -439,6 +439,19 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
allow_multi=True, # also used for climate entity
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="SoilMoistureSensor",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.MOISTURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(
|
||||
clusters.SoilMeasurement.Attributes.SoilMoistureMeasuredValue,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
|
||||
@@ -155,6 +155,7 @@ class MediaPlayerDeviceClass(StrEnum):
|
||||
TV = "tv"
|
||||
SPEAKER = "speaker"
|
||||
RECEIVER = "receiver"
|
||||
PROJECTOR = "projector"
|
||||
|
||||
|
||||
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass))
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
"playing": "mdi:cast-connected"
|
||||
}
|
||||
},
|
||||
"projector": {
|
||||
"default": "mdi:projector",
|
||||
"state": {
|
||||
"off": "mdi:projector-off"
|
||||
}
|
||||
},
|
||||
"receiver": {
|
||||
"default": "mdi:audio-video",
|
||||
"state": {
|
||||
|
||||
@@ -261,6 +261,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"projector": {
|
||||
"name": "Projector"
|
||||
},
|
||||
"receiver": {
|
||||
"name": "Receiver"
|
||||
},
|
||||
|
||||
@@ -5457,7 +5457,6 @@ async def async_get_broker_settings(
|
||||
or current_client_certificate
|
||||
or current_client_key
|
||||
or current_tls_insecure
|
||||
or current_protocol != DEFAULT_PROTOCOL
|
||||
or current_config.get(SET_CA_CERT, "off") != "off"
|
||||
or current_config.get(SET_CLIENT_CERT)
|
||||
or current_transport == TRANSPORT_WEBSOCKETS
|
||||
@@ -5466,6 +5465,12 @@ async def async_get_broker_settings(
|
||||
# Build form
|
||||
fields[vol.Required(CONF_BROKER, default=current_broker)] = TEXT_SELECTOR
|
||||
fields[vol.Required(CONF_PORT, default=current_port)] = PORT_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_PROTOCOL,
|
||||
description={"suggested_value": current_protocol},
|
||||
)
|
||||
] = PROTOCOL_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
@@ -5556,12 +5561,6 @@ async def async_get_broker_settings(
|
||||
description={"suggested_value": current_tls_insecure},
|
||||
)
|
||||
] = BOOLEAN_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_PROTOCOL,
|
||||
description={"suggested_value": current_protocol},
|
||||
)
|
||||
] = PROTOCOL_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_TRANSPORT,
|
||||
|
||||
@@ -1140,7 +1140,7 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.",
|
||||
"description": "Home Assistant needs to migrate to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will attempt to migrate your MQTT broker configuration to use protocol version 5 to fix this issue. If the broker cannot be reached using MQTT protocol version 5, for example because it does not support it, the migration will be aborted.",
|
||||
"title": "MQTT protocol change required"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ class MqttValve(MqttEntity, ValveEntity):
|
||||
self._range, float(position_payload)
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
_LOGGER.debug(
|
||||
"Ignoring non numeric payload '%s' received on topic '%s'",
|
||||
position_payload,
|
||||
msg.topic,
|
||||
@@ -279,9 +279,9 @@ class MqttValve(MqttEntity, ValveEntity):
|
||||
else:
|
||||
percentage_payload = min(max(percentage_payload, 0), 100)
|
||||
self._attr_current_valve_position = percentage_payload
|
||||
# Reset closing and opening if the valve is fully opened or fully closed
|
||||
if state is None and percentage_payload in (0, 100):
|
||||
state = RESET_CLOSING_OPENING
|
||||
# Reset opening/closing when a position update is received
|
||||
# without an explicit opening/closing transitional state.
|
||||
state = state or RESET_CLOSING_OPENING
|
||||
position_set = True
|
||||
if state_payload and state is None and not position_set:
|
||||
_LOGGER.warning(
|
||||
@@ -291,8 +291,6 @@ class MqttValve(MqttEntity, ValveEntity):
|
||||
state_payload,
|
||||
)
|
||||
return
|
||||
if state is None:
|
||||
return
|
||||
self._update_state(state)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""The OVHcloud AI Endpoints integration."""
|
||||
|
||||
from openai import AsyncOpenAI, AuthenticationError, BadRequestError, OpenAIError
|
||||
from openai import (
|
||||
AsyncOpenAI,
|
||||
AuthenticationError,
|
||||
BadRequestError,
|
||||
OpenAIError,
|
||||
PermissionDeniedError,
|
||||
)
|
||||
from openai.types.chat import ChatCompletionUserMessageParam
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -52,7 +58,7 @@ async def async_setup_entry(
|
||||
|
||||
try:
|
||||
await _validate_api_key(client)
|
||||
except AuthenticationError as err:
|
||||
except (AuthenticationError, PermissionDeniedError) as err:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
except OpenAIError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Config flow for the OVHcloud AI Endpoints integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from openai import AsyncOpenAI, AuthenticationError, OpenAIError
|
||||
from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -30,6 +31,8 @@ from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
|
||||
|
||||
|
||||
class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OVHcloud AI Endpoints."""
|
||||
@@ -55,7 +58,7 @@ class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
client = _create_client(self.hass, user_input[CONF_API_KEY])
|
||||
try:
|
||||
await _validate_api_key(client)
|
||||
except AuthenticationError:
|
||||
except AuthenticationError, PermissionDeniedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except OpenAIError:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -77,6 +80,39 @@ class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
client = _create_client(self.hass, user_input[CONF_API_KEY])
|
||||
try:
|
||||
await _validate_api_key(client)
|
||||
except AuthenticationError, PermissionDeniedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except OpenAIError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates=user_input,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class ConversationFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle conversation subentry flow."""
|
||||
|
||||
@@ -12,6 +12,8 @@ from . import OVHcloudAIEndpointsConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import OVHcloudAIEndpointsEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Diagnostics support for OVHcloud AI Endpoints."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from openai import __title__, __version__
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PROMPT
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import OVHcloudAIEndpointsConfigEntry
|
||||
|
||||
|
||||
TO_REDACT = {CONF_API_KEY, CONF_PROMPT}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"client": f"{__title__}=={__version__}",
|
||||
"title": entry.title,
|
||||
"entry_id": entry.entry_id,
|
||||
"entry_version": f"{entry.version}.{entry.minor_version}",
|
||||
"state": entry.state.value,
|
||||
"data": async_redact_data(entry.data, TO_REDACT),
|
||||
"options": async_redact_data(entry.options, TO_REDACT),
|
||||
"subentries": {
|
||||
subentry.subentry_id: {
|
||||
"title": subentry.title,
|
||||
"subentry_type": subentry.subentry_type,
|
||||
"data": async_redact_data(subentry.data, TO_REDACT),
|
||||
}
|
||||
for subentry in entry.subentries.values()
|
||||
},
|
||||
"entities": {
|
||||
entity_entry.entity_id: entity_entry.extended_dict
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
er.async_get(hass), entry.entry_id
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ovhcloud_ai_endpoints",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["openai==2.21.0"]
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
@@ -43,13 +45,13 @@ rules:
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: the integration only integrates stateless entities
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Service can't be discovered
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,6 +10,15 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::ovhcloud_ai_endpoints::config::step::user::data_description::api_key%]"
|
||||
},
|
||||
"description": "The OVHcloud AI Endpoints API key is no longer valid. Please enter a new one."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
|
||||
@@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Button platform for Samsung IR integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from infrared_protocols.codes.samsung.tv import SamsungTVCode
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_EMITTER_ENTITY_ID, SamsungDeviceType
|
||||
from .entity import SamsungIrEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SamsungIrButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Samsung IR button entity."""
|
||||
|
||||
command_code: SamsungTVCode
|
||||
|
||||
|
||||
TV_BUTTON_DESCRIPTIONS: tuple[SamsungIrButtonEntityDescription, ...] = (
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="power", translation_key="power", command_code=SamsungTVCode.POWER
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="source", translation_key="source", command_code=SamsungTVCode.SOURCE
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="settings", translation_key="settings", command_code=SamsungTVCode.SETTINGS
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="info", translation_key="info", command_code=SamsungTVCode.INFO
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="exit", translation_key="exit", command_code=SamsungTVCode.EXIT
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="return", translation_key="return", command_code=SamsungTVCode.RETURN
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="home", translation_key="home", command_code=SamsungTVCode.HOME
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="red", translation_key="red", command_code=SamsungTVCode.RED
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="green", translation_key="green", command_code=SamsungTVCode.GREEN
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="yellow", translation_key="yellow", command_code=SamsungTVCode.YELLOW
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="blue", translation_key="blue", command_code=SamsungTVCode.BLUE
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="up", translation_key="up", command_code=SamsungTVCode.NAV_UP
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="down", translation_key="down", command_code=SamsungTVCode.NAV_DOWN
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="left", translation_key="left", command_code=SamsungTVCode.NAV_LEFT
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="right", translation_key="right", command_code=SamsungTVCode.NAV_RIGHT
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="ok", translation_key="ok", command_code=SamsungTVCode.OK
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="previous_channel",
|
||||
translation_key="previous_channel",
|
||||
command_code=SamsungTVCode.PREVIOUS_CHANNEL,
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_0", translation_key="num_0", command_code=SamsungTVCode.NUM_0
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_1", translation_key="num_1", command_code=SamsungTVCode.NUM_1
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_2", translation_key="num_2", command_code=SamsungTVCode.NUM_2
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_3", translation_key="num_3", command_code=SamsungTVCode.NUM_3
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_4", translation_key="num_4", command_code=SamsungTVCode.NUM_4
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_5", translation_key="num_5", command_code=SamsungTVCode.NUM_5
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_6", translation_key="num_6", command_code=SamsungTVCode.NUM_6
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_7", translation_key="num_7", command_code=SamsungTVCode.NUM_7
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_8", translation_key="num_8", command_code=SamsungTVCode.NUM_8
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_9", translation_key="num_9", command_code=SamsungTVCode.NUM_9
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="fast_forward",
|
||||
translation_key="fast_forward",
|
||||
command_code=SamsungTVCode.FAST_FORWARD,
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="rewind", translation_key="rewind", command_code=SamsungTVCode.REWIND
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="record", translation_key="record", command_code=SamsungTVCode.RECORD
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="tools", translation_key="tools", command_code=SamsungTVCode.TOOLS
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="browser", translation_key="browser", command_code=SamsungTVCode.BROWSER
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="ad_subtitle",
|
||||
translation_key="ad_subtitle",
|
||||
command_code=SamsungTVCode.AD_SUBTITLE,
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="e_manual",
|
||||
translation_key="e_manual",
|
||||
command_code=SamsungTVCode.E_MANUAL,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Samsung IR buttons from config entry."""
|
||||
infrared_emitter_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID]
|
||||
device_type = entry.data[CONF_DEVICE_TYPE]
|
||||
if device_type != SamsungDeviceType.TV:
|
||||
return
|
||||
async_add_entities(
|
||||
[
|
||||
SamsungIrButton(entry, infrared_emitter_entity_id, description)
|
||||
for description in TV_BUTTON_DESCRIPTIONS
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class SamsungIrButton(SamsungIrEntity, InfraredEmitterConsumerEntity, ButtonEntity):
|
||||
"""Samsung IR button entity."""
|
||||
|
||||
entity_description: SamsungIrButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
infrared_emitter_entity_id: str,
|
||||
description: SamsungIrButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Samsung IR button."""
|
||||
super().__init__(entry, unique_id_suffix=description.key)
|
||||
self._infrared_emitter_entity_id = infrared_emitter_entity_id
|
||||
self.entity_description = description
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
await self._send_command(self.entity_description.command_code.to_command())
|
||||
@@ -19,6 +19,112 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"ad_subtitle": {
|
||||
"name": "AD/Subtitle"
|
||||
},
|
||||
"blue": {
|
||||
"name": "Blue"
|
||||
},
|
||||
"browser": {
|
||||
"name": "Browser"
|
||||
},
|
||||
"down": {
|
||||
"name": "[%key:common::entity::button::down::name%]"
|
||||
},
|
||||
"e_manual": {
|
||||
"name": "E-Manual"
|
||||
},
|
||||
"exit": {
|
||||
"name": "[%key:common::entity::button::exit::name%]"
|
||||
},
|
||||
"fast_forward": {
|
||||
"name": "Fast forward"
|
||||
},
|
||||
"green": {
|
||||
"name": "Green"
|
||||
},
|
||||
"home": {
|
||||
"name": "[%key:common::entity::button::home::name%]"
|
||||
},
|
||||
"info": {
|
||||
"name": "[%key:common::entity::button::info::name%]"
|
||||
},
|
||||
"left": {
|
||||
"name": "[%key:common::entity::button::left::name%]"
|
||||
},
|
||||
"num_0": {
|
||||
"name": "[%key:common::entity::button::num_0::name%]"
|
||||
},
|
||||
"num_1": {
|
||||
"name": "[%key:common::entity::button::num_1::name%]"
|
||||
},
|
||||
"num_2": {
|
||||
"name": "[%key:common::entity::button::num_2::name%]"
|
||||
},
|
||||
"num_3": {
|
||||
"name": "[%key:common::entity::button::num_3::name%]"
|
||||
},
|
||||
"num_4": {
|
||||
"name": "[%key:common::entity::button::num_4::name%]"
|
||||
},
|
||||
"num_5": {
|
||||
"name": "[%key:common::entity::button::num_5::name%]"
|
||||
},
|
||||
"num_6": {
|
||||
"name": "[%key:common::entity::button::num_6::name%]"
|
||||
},
|
||||
"num_7": {
|
||||
"name": "[%key:common::entity::button::num_7::name%]"
|
||||
},
|
||||
"num_8": {
|
||||
"name": "[%key:common::entity::button::num_8::name%]"
|
||||
},
|
||||
"num_9": {
|
||||
"name": "[%key:common::entity::button::num_9::name%]"
|
||||
},
|
||||
"ok": {
|
||||
"name": "[%key:common::entity::button::ok::name%]"
|
||||
},
|
||||
"power": {
|
||||
"name": "[%key:common::entity::button::power::name%]"
|
||||
},
|
||||
"previous_channel": {
|
||||
"name": "Previous channel"
|
||||
},
|
||||
"record": {
|
||||
"name": "Record"
|
||||
},
|
||||
"red": {
|
||||
"name": "Red"
|
||||
},
|
||||
"return": {
|
||||
"name": "Return"
|
||||
},
|
||||
"rewind": {
|
||||
"name": "Rewind"
|
||||
},
|
||||
"right": {
|
||||
"name": "[%key:common::entity::button::right::name%]"
|
||||
},
|
||||
"settings": {
|
||||
"name": "Settings"
|
||||
},
|
||||
"source": {
|
||||
"name": "Source"
|
||||
},
|
||||
"tools": {
|
||||
"name": "Tools"
|
||||
},
|
||||
"up": {
|
||||
"name": "[%key:common::entity::button::up::name%]"
|
||||
},
|
||||
"yellow": {
|
||||
"name": "Yellow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"device_type": {
|
||||
"options": {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"requirements": [
|
||||
"getmac==0.9.5",
|
||||
"samsungctl[websocket]==0.7.1",
|
||||
"samsungtvws[async,encrypted]==2.7.2",
|
||||
"samsungtvws[async,encrypted]==3.0.5",
|
||||
"wakeonlan==3.3.0",
|
||||
"async-upnp-client==0.46.2"
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
|
||||
from sense_energy import (
|
||||
ASyncSenseable,
|
||||
SenseAPIException,
|
||||
SenseAuthenticationException,
|
||||
SenseMFARequiredException,
|
||||
)
|
||||
@@ -88,6 +89,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> boo
|
||||
) from err
|
||||
except SENSE_WEBSOCKET_EXCEPTIONS as err:
|
||||
raise ConfigEntryNotReady(str(err) or "Error during realtime update") from err
|
||||
except SenseAPIException as err:
|
||||
raise ConfigEntryNotReady(
|
||||
str(err) or "API error retrieving realtime data"
|
||||
) from err
|
||||
|
||||
trends_coordinator = SenseTrendCoordinator(hass, entry, gateway)
|
||||
realtime_coordinator = SenseRealtimeCoordinator(hass, entry, gateway)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from sense_energy import (
|
||||
ASyncSenseable,
|
||||
SenseAPIException,
|
||||
SenseAuthenticationException,
|
||||
SenseMFARequiredException,
|
||||
)
|
||||
@@ -93,6 +94,8 @@ class SenseRealtimeCoordinator(SenseCoordinator):
|
||||
try:
|
||||
await self._gateway.update_realtime()
|
||||
except SENSE_TIMEOUT_EXCEPTIONS as ex:
|
||||
_LOGGER.error("Timeout retrieving data: %s", ex)
|
||||
raise UpdateFailed(f"Timeout retrieving realtime data: {ex}") from ex
|
||||
except SENSE_WEBSOCKET_EXCEPTIONS as ex:
|
||||
_LOGGER.error("Failed to update data: %s", ex)
|
||||
raise UpdateFailed(f"Failed to update realtime data: {ex}") from ex
|
||||
except SenseAPIException as ex:
|
||||
raise UpdateFailed(f"API error retrieving realtime data: {ex}") from ex
|
||||
|
||||
@@ -72,8 +72,10 @@ async def async_setup_entry(
|
||||
for device in entry_data.devices.values()
|
||||
for component in device.status
|
||||
if (
|
||||
Capability.SWITCH in device.status[MAIN]
|
||||
and any(capability in device.status[MAIN] for capability in CAPABILITIES)
|
||||
Capability.SWITCH in device.status[component]
|
||||
and any(
|
||||
capability in device.status[component] for capability in CAPABILITIES
|
||||
)
|
||||
and Capability.SAMSUNG_CE_LAMP not in device.status[component]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -50,6 +50,7 @@ DEVICE_CLASS_MAP: dict[Category | str, MediaPlayerDeviceClass] = {
|
||||
Category.SPEAKER: MediaPlayerDeviceClass.SPEAKER,
|
||||
Category.TELEVISION: MediaPlayerDeviceClass.TV,
|
||||
Category.RECEIVER: MediaPlayerDeviceClass.RECEIVER,
|
||||
Category.PROJECTOR: MediaPlayerDeviceClass.PROJECTOR,
|
||||
}
|
||||
|
||||
VALUE_TO_STATE = {
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["wiim.sdk", "async_upnp_client"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["wiim==0.1.2"],
|
||||
"requirements": ["wiim==0.1.4"],
|
||||
"zeroconf": ["_linkplay._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -349,15 +349,12 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
|
||||
sdk_status_str,
|
||||
)
|
||||
else:
|
||||
self._device.playing_status = sdk_status
|
||||
if sdk_status == SDKPlayingStatus.STOPPED:
|
||||
LOGGER.debug(
|
||||
"Device %s: TransportState is STOPPED."
|
||||
" Resetting media position and metadata",
|
||||
self.entity_id,
|
||||
)
|
||||
self._device.current_position = 0
|
||||
self._device.current_track_duration = 0
|
||||
self._attr_media_position_updated_at = None
|
||||
self._attr_media_duration = None
|
||||
self._attr_media_position = None
|
||||
|
||||
@@ -63,7 +63,7 @@ class MusicCastDeviceEntity(MusicCastEntity):
|
||||
},
|
||||
manufacturer=BRAND,
|
||||
model=self.coordinator.data.model_name,
|
||||
sw_version=self.coordinator.data.system_version,
|
||||
sw_version=str(self.coordinator.data.system_version),
|
||||
)
|
||||
|
||||
if self._zone_id == DEFAULT_ZONE:
|
||||
|
||||
@@ -10,12 +10,6 @@
|
||||
},
|
||||
"left": {
|
||||
"trigger": "mdi:map-marker-minus"
|
||||
},
|
||||
"occupancy_cleared": {
|
||||
"trigger": "mdi:account-off"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"trigger": "mdi:account-group"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,32 +43,6 @@
|
||||
}
|
||||
},
|
||||
"name": "Left zone"
|
||||
},
|
||||
"occupancy_cleared": {
|
||||
"description": "Triggers when a zone transitions from occupied to unoccupied.",
|
||||
"fields": {
|
||||
"for": {
|
||||
"name": "[%key:component::zone::common::trigger_for_name%]"
|
||||
},
|
||||
"zone": {
|
||||
"description": "[%key:component::zone::triggers::occupancy_detected::fields::zone::description%]",
|
||||
"name": "[%key:component::zone::triggers::occupancy_detected::fields::zone::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Zone occupancy cleared"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"description": "Triggers when a zone transitions to an occupied state.",
|
||||
"fields": {
|
||||
"for": {
|
||||
"name": "[%key:component::zone::common::trigger_for_name%]"
|
||||
},
|
||||
"zone": {
|
||||
"description": "The zone to monitor.",
|
||||
"name": "Zone"
|
||||
}
|
||||
},
|
||||
"name": "Zone occupancy detected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ from homeassistant.const import (
|
||||
ATTR_FRIENDLY_NAME,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_EVENT,
|
||||
CONF_FOR,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
@@ -205,76 +203,10 @@ class LeftZoneTrigger(ZoneTriggerBase):
|
||||
return not self._in_target_zone(state)
|
||||
|
||||
|
||||
_OCCUPANCY_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS, default={}): {
|
||||
vol.Required(CONF_ZONE): cv.entity_id,
|
||||
vol.Optional(CONF_FOR): cv.positive_time_period,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _ZoneOccupancyTriggerBase(EntityTriggerBase):
|
||||
"""Base for zone occupancy triggers (single zone, no behavior)."""
|
||||
|
||||
_domain_specs = {"zone": DomainSpec()}
|
||||
_schema = _OCCUPANCY_TRIGGER_SCHEMA
|
||||
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config and synthesize a target from the zone option."""
|
||||
config = cast(ConfigType, cls._schema(config))
|
||||
config[CONF_TARGET] = {CONF_ENTITY_ID: [config[CONF_OPTIONS][CONF_ZONE]]}
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _occupancy_count(state: State) -> int | None:
|
||||
"""Return the zone's persons-in-zone count; None if unparsable."""
|
||||
try:
|
||||
return int(state.state)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _is_occupied(cls, state: State) -> bool:
|
||||
"""Return True if the zone has at least one occupant."""
|
||||
count = cls._occupancy_count(state)
|
||||
return count is not None and count >= 1
|
||||
|
||||
|
||||
class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase):
|
||||
"""Trigger when a zone transitions to an occupied state."""
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the zone is occupied."""
|
||||
return self._is_occupied(state)
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check that the zone was previously not occupied."""
|
||||
return not self._is_occupied(from_state)
|
||||
|
||||
|
||||
class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase):
|
||||
"""Trigger when a zone transitions from occupied to unoccupied."""
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the zone is empty (count == 0)."""
|
||||
return self._occupancy_count(state) == 0
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check that the zone was previously occupied."""
|
||||
return self._is_occupied(from_state)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"_": LegacyZoneTrigger,
|
||||
"entered": EnteredZoneTrigger,
|
||||
"left": LeftZoneTrigger,
|
||||
"occupancy_detected": OccupancyDetectedTrigger,
|
||||
"occupancy_cleared": OccupancyClearedTrigger,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,19 +24,3 @@
|
||||
|
||||
entered: *trigger_zone
|
||||
left: *trigger_zone
|
||||
|
||||
.trigger_occupancy: &trigger_occupancy
|
||||
fields:
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
zone:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: zone
|
||||
|
||||
occupancy_detected: *trigger_occupancy
|
||||
occupancy_cleared: *trigger_occupancy
|
||||
|
||||
@@ -824,7 +824,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
auth_message,
|
||||
)
|
||||
logger.debug("Full exception", exc_info=True)
|
||||
self.async_start_reauth(hass)
|
||||
self.async_start_reauth_if_available(hass)
|
||||
except ConfigEntryNotReady as exc:
|
||||
message = str(exc)
|
||||
error_reason_translation_key = exc.translation_key
|
||||
@@ -1290,6 +1290,19 @@ class ConfigEntry[_DataT = Any]:
|
||||
eager_start=True,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_start_reauth_if_available(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
context: ConfigFlowContext | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Start a reauth flow only if the integration implements one."""
|
||||
handler = HANDLERS.get(self.domain)
|
||||
if handler is None or not hasattr(handler, "async_step_reauth"):
|
||||
return
|
||||
self.async_start_reauth(hass, context, data)
|
||||
|
||||
async def _async_init_reauth(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
|
||||
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 6
|
||||
MINOR_VERSION: Final = 7
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
|
||||
Generated
+1
@@ -59,6 +59,7 @@ FLOWS = {
|
||||
"amberelectric",
|
||||
"ambient_network",
|
||||
"ambient_station",
|
||||
"analytics",
|
||||
"analytics_insights",
|
||||
"android_ip_webcam",
|
||||
"androidtv",
|
||||
|
||||
@@ -3759,6 +3759,12 @@
|
||||
"iot_class": "cloud_push",
|
||||
"name": "LG ThinQ"
|
||||
},
|
||||
"lg_tv_rs232": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
"name": "LG TV via Serial"
|
||||
},
|
||||
"webostv": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
@@ -3767,12 +3773,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"lg_tv_rs232": {
|
||||
"name": "LG TV via Serial",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"libre_hardware_monitor": {
|
||||
"name": "Libre Hardware Monitor",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -31,7 +31,6 @@ from homeassistant.const import (
|
||||
MAX_LENGTH_STATE_DOMAIN,
|
||||
MAX_LENGTH_STATE_ENTITY_ID,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
Platform,
|
||||
)
|
||||
@@ -1951,9 +1950,10 @@ class EntityRegistry(BaseRegistry):
|
||||
This should only be used when an entity needs to be migrated between
|
||||
integrations.
|
||||
"""
|
||||
if (
|
||||
state := self.hass.states.get(entity_id)
|
||||
) is not None and state.state != STATE_UNKNOWN:
|
||||
# import here to avoid circular import
|
||||
from .entity import entity_sources # noqa: PLC0415
|
||||
|
||||
if entity_id in entity_sources(self.hass):
|
||||
raise ValueError("Only entities that haven't been loaded can be migrated")
|
||||
|
||||
old = self.entities[entity_id]
|
||||
|
||||
@@ -458,7 +458,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
|
||||
raise ConfigEntryAuthFailed from err
|
||||
|
||||
if self.config_entry:
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
self.config_entry.async_start_reauth_if_available(self.hass)
|
||||
return
|
||||
|
||||
# Recoverable error
|
||||
@@ -536,7 +536,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
|
||||
raise
|
||||
|
||||
if self.config_entry:
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
self.config_entry.async_start_reauth_if_available(self.hass)
|
||||
except NotImplementedError as err:
|
||||
self.last_exception = err
|
||||
self.last_update_success = False
|
||||
|
||||
@@ -39,7 +39,7 @@ habluetooth==6.7.9
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
home-assistant-frontend==20260527.0
|
||||
home-assistant-frontend==20260527.1
|
||||
home-assistant-intents==2026.5.5
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
@@ -99,6 +99,9 @@ Every check has a code following the
|
||||
| `W7407` | [`home-assistant-config-flow-polling-field`](#w7407-home-assistant-config-flow-polling-field) | Config flow should not include polling interval fields |
|
||||
| `W7408` | [`home-assistant-config-flow-name-field`](#w7408-home-assistant-config-flow-name-field) | Config flow should not include name fields |
|
||||
| `R7402` | [`home-assistant-unused-test-fixture-argument`](#r7402-home-assistant-unused-test-fixture-argument) | Unused test function argument should use `@pytest.mark.usefixtures` |
|
||||
| `W7418` | [`home-assistant-tests-direct-async-setup-entry`](#w7418-home-assistant-tests-direct-async-setup-entry) | Tests should not call an integration's `async_setup_entry` directly |
|
||||
| `W7420` | [`home-assistant-tests-direct-platform-async-setup-entry`](#w7420-home-assistant-tests-direct-platform-async-setup-entry) | Tests should not call a platform's `async_setup_entry` directly |
|
||||
| `W7421` | [`home-assistant-tests-direct-async-migrate-entry`](#w7421-home-assistant-tests-direct-async-migrate-entry) | Tests should not call an integration's `async_migrate_entry` directly |
|
||||
| `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly |
|
||||
|
||||
|
||||
@@ -342,6 +345,42 @@ only needed for its side effects.
|
||||
This rule only applies to `test_*` functions, not to fixture functions.
|
||||
|
||||
|
||||
## `home_assistant_tests_direct_async_setup_entry` checker
|
||||
|
||||
Detects tests that call an integration's `async_setup_entry` directly.
|
||||
|
||||
### `W7418`: `home-assistant-tests-direct-async-setup-entry`
|
||||
|
||||
Tests should not invoke an integration's `async_setup_entry` from
|
||||
`__init__.py` directly. Instead, tests should let Home Assistant perform
|
||||
the setup via `await hass.config_entries.async_setup(entry.entry_id)` so
|
||||
that the real setup pipeline (platforms, services, listeners, unload
|
||||
handlers, etc.) is exercised.
|
||||
|
||||
### `W7420`: `home-assistant-tests-direct-platform-async-setup-entry`
|
||||
|
||||
Same as `W7418`, but for an entity platform's `async_setup_entry` (e.g.
|
||||
`homeassistant.components.<integration>.sensor.async_setup_entry`).
|
||||
Tests should drive setup through `hass.config_entries.async_setup` so
|
||||
the platform is loaded via the normal Home Assistant flow.
|
||||
|
||||
See [epic #77](https://github.com/home-assistant/epics/issues/77).
|
||||
|
||||
|
||||
## `home_assistant_tests_direct_async_migrate_entry` checker
|
||||
|
||||
Detects tests that call an integration's `async_migrate_entry` directly.
|
||||
|
||||
### `W7421`: `home-assistant-tests-direct-async-migrate-entry`
|
||||
|
||||
Tests should not invoke an integration's `async_migrate_entry` from
|
||||
`__init__.py` directly. Instead, tests should let Home Assistant perform
|
||||
the setup via `await hass.config_entries.async_setup(entry.entry_id)` so
|
||||
that the real migration pipeline (version bumps, reloads, post-migration
|
||||
setup, etc.) is exercised.
|
||||
|
||||
See [epic #78](https://github.com/home-assistant/epics/issues/78).
|
||||
|
||||
|
||||
## `home_assistant_tests_direct_async_setup` checker
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Checker for direct calls to ``async_migrate_entry`` from tests.
|
||||
|
||||
Tests should not invoke an integration's ``async_migrate_entry``
|
||||
directly. Instead, tests should let Home Assistant trigger the
|
||||
migration as part of the normal setup pipeline via
|
||||
``await hass.config_entries.async_setup(entry.entry_id)`` so that the
|
||||
real migration flow (version updates, reloads, etc.) is exercised.
|
||||
|
||||
This checker flags any ``await <domain>.async_migrate_entry(...)``
|
||||
or ``await async_migrate_entry(...)`` call in a test module whose
|
||||
target resolves to a function defined in an integration's ``__init__``
|
||||
module under ``homeassistant.components.*``.
|
||||
"""
|
||||
|
||||
import astroid
|
||||
from astroid import nodes
|
||||
from pylint.checkers import BaseChecker
|
||||
from pylint.lint import PyLinter
|
||||
|
||||
from pylint_home_assistant.helpers.module_info import is_test_module, parse_module
|
||||
|
||||
|
||||
def _is_integration_async_migrate_entry(call: nodes.Call) -> bool:
|
||||
"""Return True if *call* targets an integration's ``async_migrate_entry``."""
|
||||
func = call.func
|
||||
match func:
|
||||
case nodes.Attribute(attrname="async_migrate_entry"):
|
||||
pass
|
||||
case nodes.Name(name="async_migrate_entry"):
|
||||
pass
|
||||
case _:
|
||||
return False
|
||||
|
||||
try:
|
||||
inferred_values = list(func.infer())
|
||||
except astroid.InferenceError, astroid.AstroidError:
|
||||
return False
|
||||
|
||||
seen_qnames: set[str] = set()
|
||||
for inferred in inferred_values:
|
||||
if inferred is astroid.Uninferable:
|
||||
continue
|
||||
if not isinstance(inferred, (nodes.FunctionDef, nodes.AsyncFunctionDef)):
|
||||
continue
|
||||
qname = inferred.qname()
|
||||
if not qname or qname in seen_qnames:
|
||||
continue
|
||||
seen_qnames.add(qname)
|
||||
# qname is the function's fully-qualified name, e.g.
|
||||
# ``homeassistant.components.sun.async_migrate_entry``. Strip the
|
||||
# function name to get the module and parse it.
|
||||
module_qname = qname.rsplit(".", 1)[0]
|
||||
parsed = parse_module(module_qname)
|
||||
if parsed is None:
|
||||
continue
|
||||
# ``async_migrate_entry`` lives in the integration's ``__init__``.
|
||||
if parsed.module is None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class DirectAsyncMigrateEntry(BaseChecker):
|
||||
"""Checker for direct calls to async_migrate_entry in tests."""
|
||||
|
||||
name = "home_assistant_tests_direct_async_migrate_entry"
|
||||
priority = -1
|
||||
msgs = {
|
||||
"W7421": (
|
||||
"Do not call `async_migrate_entry` directly from tests; use "
|
||||
"`await hass.config_entries.async_setup(entry.entry_id)` instead",
|
||||
"home-assistant-tests-direct-async-migrate-entry",
|
||||
"Used when a test module calls an integration's "
|
||||
"`async_migrate_entry` directly. Tests should let Home Assistant "
|
||||
"drive the setup so the migration flow is exercised through the "
|
||||
"normal pipeline.",
|
||||
),
|
||||
}
|
||||
options = ()
|
||||
|
||||
_in_test_module: bool = False
|
||||
|
||||
def visit_module(self, node: nodes.Module) -> None:
|
||||
"""Record whether the current module is a test module."""
|
||||
self._in_test_module = is_test_module(node.name)
|
||||
|
||||
def visit_call(self, node: nodes.Call) -> None:
|
||||
"""Flag direct calls to an integration's async_migrate_entry."""
|
||||
if not self._in_test_module:
|
||||
return
|
||||
if _is_integration_async_migrate_entry(node):
|
||||
self.add_message(
|
||||
"home-assistant-tests-direct-async-migrate-entry",
|
||||
node=node,
|
||||
)
|
||||
|
||||
|
||||
def register(linter: PyLinter) -> None:
|
||||
"""Register the checker."""
|
||||
linter.register_checker(DirectAsyncMigrateEntry(linter))
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Checker for direct calls to ``async_setup_entry`` from tests.
|
||||
|
||||
Tests should not invoke an integration's ``async_setup_entry`` directly
|
||||
(either the one in ``__init__.py`` or in an entity-platform module).
|
||||
Instead, tests should let Home Assistant perform the setup via
|
||||
``await hass.config_entries.async_setup(entry.entry_id)`` so that the
|
||||
real setup pipeline (platforms, services, listeners, unload handlers,
|
||||
etc.) is exercised.
|
||||
|
||||
This checker flags any call to ``async_setup_entry`` (whether awaited or
|
||||
not, accessed as a name or an attribute) made from a test module whose
|
||||
target resolves to a module-level function defined under
|
||||
``homeassistant.components.*``. The integration-init case and the
|
||||
entity-platform case get separate messages so violations can be tracked
|
||||
and fixed independently.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
import astroid
|
||||
from astroid import nodes
|
||||
from pylint.checkers import BaseChecker
|
||||
from pylint.lint import PyLinter
|
||||
|
||||
from pylint_home_assistant.helpers.module_info import is_test_module, parse_module
|
||||
|
||||
|
||||
class _SetupKind(Enum):
|
||||
"""The kind of integration ``async_setup_entry`` being called."""
|
||||
|
||||
INIT = "init"
|
||||
PLATFORM = "platform"
|
||||
|
||||
|
||||
def _resolve_integration_async_setup_entry(call: nodes.Call) -> _SetupKind | None:
|
||||
"""Return the kind of integration ``async_setup_entry`` *call* targets.
|
||||
|
||||
Returns ``_SetupKind.INIT`` if the target is in the integration's
|
||||
``__init__`` module, ``_SetupKind.PLATFORM`` if it is in an
|
||||
entity-platform module, or ``None`` if the call does not resolve to
|
||||
an integration's ``async_setup_entry``.
|
||||
"""
|
||||
func = call.func
|
||||
match func:
|
||||
case nodes.Attribute(attrname="async_setup_entry"):
|
||||
pass
|
||||
case nodes.Name(name="async_setup_entry"):
|
||||
pass
|
||||
case _:
|
||||
return None
|
||||
|
||||
seen_qnames: set[str] = set()
|
||||
try:
|
||||
for inferred in func.infer():
|
||||
if inferred is astroid.Uninferable:
|
||||
continue
|
||||
if not isinstance(inferred, (nodes.FunctionDef, nodes.AsyncFunctionDef)):
|
||||
continue
|
||||
# Require the function to be defined at module level so that
|
||||
# class methods named ``async_setup_entry`` (whose qname
|
||||
# includes the class name) are not classified as integration
|
||||
# setup functions.
|
||||
if not isinstance(inferred.parent, nodes.Module):
|
||||
continue
|
||||
module_qname = inferred.parent.qname()
|
||||
if not module_qname or module_qname in seen_qnames:
|
||||
continue
|
||||
seen_qnames.add(module_qname)
|
||||
parsed = parse_module(module_qname)
|
||||
if parsed is None:
|
||||
continue
|
||||
return _SetupKind.INIT if parsed.module is None else _SetupKind.PLATFORM
|
||||
except astroid.exceptions.InferenceError, astroid.exceptions.AstroidError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class DirectAsyncSetupEntry(BaseChecker):
|
||||
"""Checker for direct calls to async_setup_entry in tests."""
|
||||
|
||||
name = "home_assistant_tests_direct_async_setup_entry"
|
||||
priority = -1
|
||||
msgs = {
|
||||
"W7418": (
|
||||
(
|
||||
"Do not call `async_setup_entry` directly from tests; use "
|
||||
"`await hass.config_entries.async_setup(entry.entry_id)` instead"
|
||||
),
|
||||
"home-assistant-tests-direct-async-setup-entry",
|
||||
(
|
||||
"Used when a test module calls an integration's "
|
||||
"`async_setup_entry` from `__init__.py` directly. Tests should "
|
||||
"let Home Assistant drive the setup so the full setup pipeline "
|
||||
"is exercised."
|
||||
),
|
||||
),
|
||||
"W7420": (
|
||||
(
|
||||
"Do not call a platform's `async_setup_entry` directly from "
|
||||
"tests; use `await hass.config_entries.async_setup(entry.entry_id)`"
|
||||
" instead"
|
||||
),
|
||||
"home-assistant-tests-direct-platform-async-setup-entry",
|
||||
(
|
||||
"Used when a test module calls an integration entity platform's "
|
||||
"`async_setup_entry` directly. Tests should let Home Assistant "
|
||||
"drive the setup so the full setup pipeline is exercised."
|
||||
),
|
||||
),
|
||||
}
|
||||
options = ()
|
||||
|
||||
_in_test_module: bool = False
|
||||
|
||||
def visit_module(self, node: nodes.Module) -> None:
|
||||
"""Record whether the current module is a test module."""
|
||||
self._in_test_module = is_test_module(node.name)
|
||||
|
||||
def visit_call(self, node: nodes.Call) -> None:
|
||||
"""Flag direct calls to an integration's async_setup_entry."""
|
||||
if not self._in_test_module:
|
||||
return
|
||||
match _resolve_integration_async_setup_entry(node):
|
||||
case _SetupKind.INIT:
|
||||
self.add_message(
|
||||
"home-assistant-tests-direct-async-setup-entry",
|
||||
node=node,
|
||||
)
|
||||
case _SetupKind.PLATFORM:
|
||||
self.add_message(
|
||||
"home-assistant-tests-direct-platform-async-setup-entry",
|
||||
node=node,
|
||||
)
|
||||
|
||||
|
||||
def register(linter: PyLinter) -> None:
|
||||
"""Register the checker."""
|
||||
linter.register_checker(DirectAsyncSetupEntry(linter))
|
||||
+1
-1
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
|
||||
|
||||
from typing import Final
|
||||
|
||||
FRONTEND_VERSION: Final[str] = "20260527.0"
|
||||
FRONTEND_VERSION: Final[str] = "20260527.1"
|
||||
|
||||
MDI_ICONS: Final[set[str]] = {
|
||||
"ab-testing",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2026.6.0.dev0"
|
||||
version = "2026.7.0.dev0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
|
||||
Generated
+3
-3
@@ -1266,7 +1266,7 @@ hole==0.9.0
|
||||
holidays==0.97
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260527.0
|
||||
home-assistant-frontend==20260527.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.5.5
|
||||
@@ -2929,7 +2929,7 @@ rxv==0.7.0
|
||||
samsungctl[websocket]==0.7.1
|
||||
|
||||
# homeassistant.components.samsungtv
|
||||
samsungtvws[async,encrypted]==2.7.2
|
||||
samsungtvws[async,encrypted]==3.0.5
|
||||
|
||||
# homeassistant.components.sanix
|
||||
sanix==1.0.6
|
||||
@@ -3357,7 +3357,7 @@ whois==0.9.27
|
||||
wiffi==1.1.2
|
||||
|
||||
# homeassistant.components.wiim
|
||||
wiim==0.1.2
|
||||
wiim==0.1.4
|
||||
|
||||
# homeassistant.components.wirelesstag
|
||||
wirelesstagpy==0.8.1
|
||||
|
||||
@@ -6,15 +6,18 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.analytics import LABS_SNAPSHOT_FEATURE
|
||||
from homeassistant.components.analytics import CONF_SNAPSHOTS_URL, LABS_SNAPSHOT_FEATURE
|
||||
from homeassistant.components.analytics.const import (
|
||||
BASIC_ENDPOINT_URL,
|
||||
BASIC_ENDPOINT_URL_DEV,
|
||||
DOMAIN,
|
||||
SNAPSHOT_DEFAULT_URL,
|
||||
SNAPSHOT_URL_PATH,
|
||||
STORAGE_KEY,
|
||||
)
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.components.labs import async_update_preview_feature
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -37,6 +40,118 @@ async def test_setup(hass: HomeAssistant) -> None:
|
||||
assert DOMAIN in hass.data
|
||||
|
||||
|
||||
async def test_setup_with_snapshots_url(
|
||||
hass: HomeAssistant,
|
||||
hass_storage: dict[str, Any],
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test setup with snapshots_url in YAML config sends snapshots to that URL."""
|
||||
custom_url = "https://custom-snapshot-endpoint.example.com"
|
||||
snapshot_endpoint = custom_url + SNAPSHOT_URL_PATH
|
||||
aioclient_mock.post(snapshot_endpoint, status=200, json={})
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.analytics.analytics._async_snapshot_payload",
|
||||
return_value={"mock": {}},
|
||||
):
|
||||
assert await async_setup_component(hass, "labs", {})
|
||||
assert await async_setup_component(
|
||||
hass, DOMAIN, {DOMAIN: {CONF_SNAPSHOTS_URL: custom_url}}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json_auto_id(
|
||||
{"type": "analytics/preferences", "preferences": {"snapshots": True}}
|
||||
)
|
||||
assert (await ws_client.receive_json())["success"]
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert any(str(call[1]) == snapshot_endpoint for call in aioclient_mock.mock_calls)
|
||||
|
||||
|
||||
async def test_setup_entry_supervisor_not_ready(hass: HomeAssistant) -> None:
|
||||
"""Test that HassioNotReadyError raises ConfigEntryNotReady."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.analytics.analytics.is_hassio",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_supervisor_info",
|
||||
side_effect=HassioNotReadyError,
|
||||
),
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_schedule_starts_and_sends_analytics(
|
||||
hass: HomeAssistant,
|
||||
hass_storage: dict[str, Any],
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test that the analytics schedule fires and sends analytics after time travel."""
|
||||
aioclient_mock.post(BASIC_ENDPOINT_URL, status=200)
|
||||
aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200)
|
||||
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION):
|
||||
await ws_client.send_json_auto_id(
|
||||
{"type": "analytics/preferences", "preferences": {"base": True}}
|
||||
)
|
||||
assert (await ws_client.receive_json())["success"]
|
||||
|
||||
assert len(aioclient_mock.mock_calls) == 0
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=901))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(aioclient_mock.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ws_type", "ws_options"),
|
||||
[("analytics", {}), ("analytics/preferences", {"preferences": {"base": True}})],
|
||||
)
|
||||
async def test_websocket_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
ws_type: str,
|
||||
ws_options: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test websocket returns error when analytics entry failed to load."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.analytics.analytics.is_hassio",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_supervisor_info",
|
||||
side_effect=HassioNotReadyError,
|
||||
),
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json_auto_id({"type": ws_type} | ws_options)
|
||||
response = await ws_client.receive_json()
|
||||
|
||||
assert not response["success"]
|
||||
assert response["error"]["code"] == "not_found"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_snapshot_payload")
|
||||
async def test_labs_feature_toggle(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -3,20 +3,38 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pyatv.const import FeatureName, FeatureState
|
||||
from pyatv.exceptions import (
|
||||
BlockedStateError,
|
||||
ConnectionLostError,
|
||||
InvalidStateError,
|
||||
NotSupportedError,
|
||||
OperationTimeoutError,
|
||||
PlaybackError,
|
||||
ProtocolError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.apple_tv.const import DOMAIN
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
DOMAIN as MP_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.components.media_source import PlayMedia
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
ENTITY_ID = "media_player.living_room_living_room"
|
||||
_MUSIC_URL = "http://example.local:8123/api/tts_proxy/abc.mp3"
|
||||
_VIDEO_URL = "http://example.local:8123/video.mp4"
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("init_integration")
|
||||
|
||||
@@ -86,3 +104,188 @@ async def test_play_media_launches_app(
|
||||
|
||||
mock_atv.apps.launch_app.assert_awaited_once_with("com.netflix.Netflix")
|
||||
mock_atv.stream.stream_file.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("media_type", "media_id", "called_method", "stream_file_state"),
|
||||
[
|
||||
pytest.param(
|
||||
MediaType.MUSIC,
|
||||
_MUSIC_URL,
|
||||
"stream_file",
|
||||
FeatureState.Available,
|
||||
id="music_via_raop",
|
||||
),
|
||||
pytest.param(
|
||||
MediaType.VIDEO,
|
||||
_VIDEO_URL,
|
||||
"play_url",
|
||||
FeatureState.Unsupported,
|
||||
id="video_via_airplay",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_play_media_selects_streaming_method(
|
||||
hass: HomeAssistant,
|
||||
mock_atv: AsyncMock,
|
||||
media_type: MediaType,
|
||||
media_id: str,
|
||||
called_method: str,
|
||||
stream_file_state: FeatureState,
|
||||
) -> None:
|
||||
"""Streaming path is selected from device feature state, not _playing."""
|
||||
mock_atv.features.set_state(FeatureName.StreamFile, stream_file_state)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: media_type,
|
||||
ATTR_MEDIA_CONTENT_ID: media_id,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
getattr(mock_atv.stream, called_method).assert_awaited_once_with(media_id)
|
||||
|
||||
|
||||
async def test_play_media_falls_back_to_play_url(
|
||||
hass: HomeAssistant,
|
||||
mock_atv: AsyncMock,
|
||||
) -> None:
|
||||
"""When StreamFile is unavailable, play_url is used for video."""
|
||||
mock_atv.features.set_state(FeatureName.StreamFile, FeatureState.Unsupported)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: MediaType.VIDEO,
|
||||
ATTR_MEDIA_CONTENT_ID: _VIDEO_URL,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_atv.stream.play_url.assert_awaited_once_with(_VIDEO_URL)
|
||||
mock_atv.stream.stream_file.assert_not_called()
|
||||
|
||||
|
||||
async def test_play_media_raises_when_no_streaming_method(
|
||||
hass: HomeAssistant,
|
||||
mock_atv: AsyncMock,
|
||||
) -> None:
|
||||
"""Raise HomeAssistantError when no streaming method is available."""
|
||||
mock_atv.features.set_state(FeatureName.StreamFile, FeatureState.Unsupported)
|
||||
mock_atv.features.set_state(FeatureName.PlayUrl, FeatureState.Unsupported)
|
||||
|
||||
with pytest.raises(HomeAssistantError) as exc_info:
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
|
||||
ATTR_MEDIA_CONTENT_ID: _MUSIC_URL,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert exc_info.value.translation_key == "streaming_not_supported"
|
||||
assert exc_info.value.translation_domain == DOMAIN
|
||||
mock_atv.stream.stream_file.assert_not_called()
|
||||
mock_atv.stream.play_url.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stream_attr", "media_type", "media_id", "stream_file_state"),
|
||||
[
|
||||
(
|
||||
"stream_file",
|
||||
MediaType.MUSIC,
|
||||
_MUSIC_URL,
|
||||
FeatureState.Available,
|
||||
),
|
||||
(
|
||||
"play_url",
|
||||
MediaType.VIDEO,
|
||||
_VIDEO_URL,
|
||||
FeatureState.Unsupported,
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("exc_class", "expected_translation_key"),
|
||||
[
|
||||
(BlockedStateError, "stream_failed"),
|
||||
(ConnectionLostError, "stream_failed"),
|
||||
(InvalidStateError, "stream_failed"),
|
||||
(NotSupportedError, "streaming_not_supported"),
|
||||
(OperationTimeoutError, "stream_failed"),
|
||||
(PlaybackError, "stream_failed"),
|
||||
(ProtocolError, "stream_failed"),
|
||||
],
|
||||
)
|
||||
async def test_play_media_raises_ha_error_on_pyatv_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_atv: AsyncMock,
|
||||
stream_attr: str,
|
||||
media_type: MediaType,
|
||||
media_id: str,
|
||||
stream_file_state: FeatureState,
|
||||
exc_class: type[Exception],
|
||||
expected_translation_key: str,
|
||||
) -> None:
|
||||
"""Pyatv streaming exceptions surface as a translated HomeAssistantError."""
|
||||
mock_atv.features.set_state(FeatureName.StreamFile, stream_file_state)
|
||||
getattr(mock_atv.stream, stream_attr).side_effect = exc_class("error")
|
||||
|
||||
with pytest.raises(HomeAssistantError) as exc_info:
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE: media_type,
|
||||
ATTR_MEDIA_CONTENT_ID: media_id,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert exc_info.value.translation_key == expected_translation_key
|
||||
assert exc_info.value.translation_domain == DOMAIN
|
||||
|
||||
|
||||
async def test_browse_media_uses_media_source(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""async_browse_media routes to media_source when streaming is available."""
|
||||
browse_result = BrowseMedia(
|
||||
title="Media",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id="",
|
||||
media_content_type="",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.apple_tv.media_player.media_source.async_browse_media",
|
||||
new_callable=AsyncMock,
|
||||
return_value=browse_result,
|
||||
) as mock_browse:
|
||||
client = await hass_ws_client()
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "media_player/browse_media",
|
||||
"entity_id": ENTITY_ID,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
mock_browse.assert_called_once()
|
||||
|
||||
@@ -2088,6 +2088,36 @@ async def test_receive_backup_path_traversal(
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"/absolute/path",
|
||||
"../parent",
|
||||
"with/slash",
|
||||
],
|
||||
)
|
||||
async def test_receive_backup_rejects_unsafe_inner_name(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Test receive backup rejects an inner name that would escape the backup dir."""
|
||||
await setup_backup_integration(hass)
|
||||
client = await hass_client()
|
||||
|
||||
backup = replace(TEST_BACKUP_ABC123, name=name)
|
||||
with patch(
|
||||
"homeassistant.components.backup.manager.read_backup",
|
||||
return_value=backup,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/backup/upload?agent_id=backup.local",
|
||||
data={"file": StringIO("test")},
|
||||
)
|
||||
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_receive_backup_busy_manager(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
|
||||
@@ -14,6 +14,7 @@ import pytest
|
||||
import securetar
|
||||
|
||||
from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder
|
||||
from homeassistant.components.backup.models import InvalidBackupFilename
|
||||
from homeassistant.components.backup.util import (
|
||||
DecryptedBackupStreamer,
|
||||
EncryptedBackupStreamer,
|
||||
@@ -158,6 +159,37 @@ def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -
|
||||
assert backup == expected_backup
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"/absolute/path",
|
||||
"../parent",
|
||||
"with/slash",
|
||||
"with\\backslash",
|
||||
"C:\\drive\\path",
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
],
|
||||
)
|
||||
def test_read_backup_rejects_unsafe_name(name: str) -> None:
|
||||
"""Test that read_backup rejects names that could escape the backup directory."""
|
||||
backup_json_content = (
|
||||
b'{"compressed":true,"date":"2024-12-02T07:23:58.261875-05:00","homeassistant":'
|
||||
b'{"exclude_database":true,"version":"2024.12.0.dev0"},"name":"'
|
||||
+ name.encode().replace(b"\\", b"\\\\")
|
||||
+ b'","protected":true,"slug":"455645fe","type":"partial","version":2}'
|
||||
)
|
||||
mock_path = Mock()
|
||||
mock_path.stat.return_value.st_size = 1234
|
||||
|
||||
with patch("homeassistant.components.backup.util.tarfile.open") as mock_open_tar:
|
||||
tar_ctx = mock_open_tar.return_value.__enter__.return_value
|
||||
tar_ctx.extractfile.return_value.read.return_value = backup_json_content
|
||||
with pytest.raises(InvalidBackupFilename):
|
||||
read_backup(mock_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("backup", "password", "validation_result", "expected_messages"),
|
||||
[
|
||||
|
||||
@@ -81,6 +81,7 @@ async def test_config_exceptions(
|
||||
),
|
||||
pytest.raises(config_error),
|
||||
):
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-setup-entry
|
||||
await async_setup_entry(hass, config_entry)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
import zoneinfo
|
||||
|
||||
from caldav.lib.error import NotFoundError
|
||||
from caldav.objects import Event
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
@@ -1328,15 +1329,23 @@ async def test_add_vevent(
|
||||
assert calendars[0].add_event.call_args[1] == expected_ics_fields
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
pytest.param(KeyError(), id="key_error"),
|
||||
pytest.param(NotFoundError(), id="not_found_error"),
|
||||
],
|
||||
)
|
||||
async def test_missing_supported_components(
|
||||
hass: HomeAssistant,
|
||||
calendars: list[Mock],
|
||||
setup_platform_cb: Callable[[], Awaitable[None]],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
"""Test setup works when calendar raises KeyError on get_supported_components."""
|
||||
"""Test setup works when calendar raises on get_supported_components."""
|
||||
caplog.set_level(logging.WARNING, logger="homeassistant.components.caldav.api")
|
||||
calendars[0].get_supported_components.side_effect = KeyError()
|
||||
calendars[0].get_supported_components.side_effect = exception
|
||||
await setup_platform_cb()
|
||||
|
||||
assert hass.states.get(TEST_ENTITY)
|
||||
@@ -1367,14 +1376,22 @@ async def test_missing_supported_components(
|
||||
assert vjournal_warning in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
pytest.param(KeyError(), id="key_error"),
|
||||
pytest.param(NotFoundError(), id="not_found_error"),
|
||||
],
|
||||
)
|
||||
async def test_missing_supported_components_not_assumed(
|
||||
hass: HomeAssistant,
|
||||
calendars: list[Mock],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
"""Test get_calendars excludes calendars on KeyError."""
|
||||
"""Test get_calendars excludes calendars when components unavailable."""
|
||||
caplog.set_level(logging.WARNING, logger="homeassistant.components.caldav.api")
|
||||
calendars[0].get_supported_components.side_effect = KeyError()
|
||||
calendars[0].get_supported_components.side_effect = exception
|
||||
client = MagicMock()
|
||||
client.principal().calendars.return_value = calendars
|
||||
|
||||
|
||||
+10
-29
@@ -1586,12 +1586,12 @@ async def _validate_trigger_options(
|
||||
options: dict[str, Any] | None,
|
||||
*,
|
||||
valid: bool,
|
||||
supports_target: bool = True,
|
||||
) -> None:
|
||||
"""Assert that a trigger accepts or rejects the given options during validation."""
|
||||
trigger_config: dict[str, Any] = {CONF_PLATFORM: trigger}
|
||||
if supports_target:
|
||||
trigger_config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"}
|
||||
trigger_config: dict[str, Any] = {
|
||||
CONF_PLATFORM: trigger,
|
||||
CONF_TARGET: {ATTR_LABEL_ID: "test_label"},
|
||||
}
|
||||
if options is not None:
|
||||
trigger_config[CONF_OPTIONS] = options
|
||||
if valid:
|
||||
@@ -1608,7 +1608,6 @@ async def assert_trigger_options_supported(
|
||||
*,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
supports_target: bool = True,
|
||||
) -> None:
|
||||
"""Assert which options a trigger supports.
|
||||
|
||||
@@ -1625,15 +1624,9 @@ async def assert_trigger_options_supported(
|
||||
|
||||
# Minimal config should always be valid
|
||||
supports_empty = not bool(base_options)
|
||||
await _validate_trigger_options(
|
||||
hass, trigger, None, valid=supports_empty, supports_target=supports_target
|
||||
)
|
||||
await _validate_trigger_options(
|
||||
hass, trigger, {}, valid=supports_empty, supports_target=supports_target
|
||||
)
|
||||
await _validate_trigger_options(
|
||||
hass, trigger, base_options, valid=True, supports_target=supports_target
|
||||
)
|
||||
await _validate_trigger_options(hass, trigger, None, valid=supports_empty)
|
||||
await _validate_trigger_options(hass, trigger, {}, valid=supports_empty)
|
||||
await _validate_trigger_options(hass, trigger, base_options, valid=True)
|
||||
|
||||
def _merge(extra: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**(base_options or {}), **extra}
|
||||
@@ -1641,30 +1634,18 @@ async def assert_trigger_options_supported(
|
||||
# Behavior
|
||||
for behavior in ("each", "first", "all"):
|
||||
await _validate_trigger_options(
|
||||
hass,
|
||||
trigger,
|
||||
_merge({"behavior": behavior}),
|
||||
valid=supports_behavior,
|
||||
supports_target=supports_target,
|
||||
hass, trigger, _merge({"behavior": behavior}), valid=supports_behavior
|
||||
)
|
||||
|
||||
# Duration
|
||||
for for_value in ({"seconds": 5}, "00:00:05", 5):
|
||||
await _validate_trigger_options(
|
||||
hass,
|
||||
trigger,
|
||||
_merge({"for": for_value}),
|
||||
valid=supports_duration,
|
||||
supports_target=supports_target,
|
||||
hass, trigger, _merge({"for": for_value}), valid=supports_duration
|
||||
)
|
||||
|
||||
# Unknown option should always be rejected
|
||||
await _validate_trigger_options(
|
||||
hass,
|
||||
trigger,
|
||||
_merge({"unknown_option": True}),
|
||||
valid=False,
|
||||
supports_target=supports_target,
|
||||
hass, trigger, _merge({"unknown_option": True}), valid=False
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -411,7 +411,6 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None:
|
||||
data_schema=vol.Schema(schema),
|
||||
description_placeholders={
|
||||
"url": "https://example.com",
|
||||
"show_advanced_options": self.show_advanced_options,
|
||||
},
|
||||
errors={"username": "Should be unique."},
|
||||
)
|
||||
@@ -437,7 +436,6 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None:
|
||||
],
|
||||
"description_placeholders": {
|
||||
"url": "https://example.com",
|
||||
"show_advanced_options": True,
|
||||
},
|
||||
"errors": {"username": "Should be unique."},
|
||||
"last_step": None,
|
||||
|
||||
@@ -1379,6 +1379,31 @@ def test_base_tracker_entity() -> None:
|
||||
entity.state_attributes # noqa: B018
|
||||
|
||||
|
||||
def test_battery_level_override_deprecation_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that overriding battery_level in a subclass logs a warning."""
|
||||
error_message = "is overriding the deprecated battery_level property"
|
||||
|
||||
caplog.clear()
|
||||
|
||||
class _SubclassWithOverride(TrackerEntity):
|
||||
@property
|
||||
def battery_level(self) -> int | None:
|
||||
return 50
|
||||
|
||||
assert error_message in caplog.text
|
||||
assert _SubclassWithOverride.__name__ in caplog.text
|
||||
|
||||
# No warning for a subclass that does not override battery_level
|
||||
caplog.clear()
|
||||
|
||||
class _SubclassWithoutOverride(TrackerEntity):
|
||||
pass
|
||||
|
||||
assert error_message not in caplog.text
|
||||
|
||||
|
||||
async def test_attr_location_name_deprecation_warning(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
|
||||
@@ -98,6 +98,29 @@ async def test_setup_entry_success(
|
||||
assert init_integration.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
pytest.param(DucoError("lan info error"), id="duco_error"),
|
||||
pytest.param(DucoConnectionError("lan info offline"), id="connection_error"),
|
||||
],
|
||||
)
|
||||
async def test_setup_entry_ignores_lan_info_failures(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
"""Test setup succeeds when the supplemental LAN info endpoint fails."""
|
||||
mock_duco_client.async_get_lan_info.side_effect = exception
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unsupported_board_info", UNSUPPORTED_BOARD_INFOS)
|
||||
async def test_setup_entry_unsupported_board_info(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -122,24 +122,34 @@ async def test_coordinator_update_duco_error_marks_unavailable(
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
pytest.param(DucoError("lan info error"), id="duco_error"),
|
||||
pytest.param(DucoConnectionError("lan info offline"), id="connection_error"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_lan_info_duco_error_marks_unavailable(
|
||||
async def test_lan_info_failures_keep_node_entities_available(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
"""Test entities become unavailable when async_get_lan_info raises DucoError."""
|
||||
mock_duco_client.async_get_lan_info = AsyncMock(
|
||||
side_effect=DucoError("lan info error")
|
||||
)
|
||||
"""Test node entities stay available when LAN info retrieval fails."""
|
||||
mock_duco_client.async_get_lan_info = AsyncMock(side_effect=exception)
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get("sensor.office_co2_carbon_dioxide")
|
||||
assert state is not None
|
||||
assert state.state == "405"
|
||||
|
||||
state = hass.states.get("sensor.living_signal_strength")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert state.state == "-60"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -83,6 +83,7 @@ async def test_setup_entry_successful(hass: HomeAssistant) -> None:
|
||||
"homeassistant.components.emulated_roku.binding.EmulatedRokuServer",
|
||||
return_value=Mock(start=AsyncMock(), close=AsyncMock()),
|
||||
) as instantiate:
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-setup-entry
|
||||
assert await emulated_roku.async_setup_entry(hass, entry) is True
|
||||
|
||||
assert len(instantiate.mock_calls) == 1
|
||||
@@ -101,6 +102,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
|
||||
"homeassistant.components.emulated_roku.binding.EmulatedRokuServer",
|
||||
return_value=Mock(start=AsyncMock(), close=AsyncMock()),
|
||||
):
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-setup-entry
|
||||
assert await emulated_roku.async_setup_entry(hass, entry) is True
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -43,6 +43,9 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry:
|
||||
radar_mock = mock_ec()
|
||||
radar_mock.image = b"GIF..."
|
||||
radar_mock.timestamp = datetime(2022, 10, 4, tzinfo=UTC)
|
||||
radar_mock.layer = "precip_type"
|
||||
radar_mock.metadata = {"attribution": "Data provided by Environment Canada"}
|
||||
radar_mock.clear_cache = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -54,7 +57,7 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry:
|
||||
return_value=mock_ec(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.environment_canada.ECRadar",
|
||||
"homeassistant.components.environment_canada.ECMap",
|
||||
return_value=radar_mock,
|
||||
),
|
||||
patch(
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Test Environment Canada camera."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.environment_canada.camera import SERVICE_SET_RADAR_TYPE
|
||||
from homeassistant.components.environment_canada.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.dt import UTC
|
||||
|
||||
from . import init_integration
|
||||
|
||||
|
||||
async def test_camera_entity(hass: HomeAssistant, ec_data: dict[str, Any]) -> None:
|
||||
"""Test camera entity setup."""
|
||||
await init_integration(hass, ec_data)
|
||||
|
||||
state = hass.states.get("camera.home_radar")
|
||||
# Camera is disabled by default, so state should be None
|
||||
assert state is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
@pytest.mark.parametrize(
|
||||
("radar_type", "expected_layer"),
|
||||
[
|
||||
("Rain", "rain"),
|
||||
("Snow", "snow"),
|
||||
("Precipitation type", "precip_type"),
|
||||
],
|
||||
)
|
||||
async def test_set_radar_type(
|
||||
hass: HomeAssistant,
|
||||
ec_data: dict[str, Any],
|
||||
radar_type: str,
|
||||
expected_layer: str,
|
||||
) -> None:
|
||||
"""Test setting radar type."""
|
||||
config_entry = await init_integration(hass, ec_data)
|
||||
radar_coordinator = config_entry.runtime_data.radar_coordinator
|
||||
radar_mock = radar_coordinator.ec_data
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_RADAR_TYPE,
|
||||
{"entity_id": "camera.home_radar", "radar_type": radar_type},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert radar_mock.layer == expected_layer
|
||||
radar_mock.update.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
@pytest.mark.parametrize(
|
||||
("month", "expected_layer"),
|
||||
[
|
||||
(1, "snow"), # January - winter
|
||||
(2, "snow"), # February - winter
|
||||
(3, "snow"), # March - winter
|
||||
(4, "rain"), # April - spring/summer
|
||||
(5, "rain"), # May - spring/summer
|
||||
(6, "rain"), # June - summer
|
||||
(7, "rain"), # July - summer
|
||||
(8, "rain"), # August - summer
|
||||
(9, "rain"), # September - summer
|
||||
(10, "rain"), # October - fall
|
||||
(11, "snow"), # November - winter
|
||||
(12, "snow"), # December - winter
|
||||
],
|
||||
)
|
||||
async def test_set_radar_type_auto(
|
||||
hass: HomeAssistant,
|
||||
ec_data: dict[str, Any],
|
||||
month: int,
|
||||
expected_layer: str,
|
||||
) -> None:
|
||||
"""Test auto radar type selects rain or snow based on month."""
|
||||
config_entry = await init_integration(hass, ec_data)
|
||||
radar_coordinator = config_entry.runtime_data.radar_coordinator
|
||||
radar_mock = radar_coordinator.ec_data
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.environment_canada.camera.dt_util.now",
|
||||
return_value=datetime(2024, month, 15, tzinfo=UTC),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_RADAR_TYPE,
|
||||
{"entity_id": "camera.home_radar", "radar_type": "Auto"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert radar_mock.layer == expected_layer
|
||||
radar_mock.update.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_set_radar_type_clears_cache(
|
||||
hass: HomeAssistant, ec_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test that setting radar type clears the cache."""
|
||||
config_entry = await init_integration(hass, ec_data)
|
||||
radar_coordinator = config_entry.runtime_data.radar_coordinator
|
||||
radar_mock = radar_coordinator.ec_data
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_RADAR_TYPE,
|
||||
{"entity_id": "camera.home_radar", "radar_type": "Rain"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Verify clear_cache was called on the radar object
|
||||
radar_mock.clear_cache.assert_called_once()
|
||||
@@ -4176,6 +4176,12 @@ async def test_channel(hass: HomeAssistant) -> None:
|
||||
media_player.MediaPlayerDeviceClass.TV,
|
||||
None,
|
||||
)
|
||||
assert trait.ChannelTrait.supported(
|
||||
media_player.DOMAIN,
|
||||
MediaPlayerEntityFeature.PLAY_MEDIA,
|
||||
media_player.MediaPlayerDeviceClass.PROJECTOR,
|
||||
None,
|
||||
)
|
||||
assert (
|
||||
trait.ChannelTrait.supported(
|
||||
media_player.DOMAIN,
|
||||
|
||||
@@ -235,7 +235,7 @@ async def test_setup_oauth_reauth_error(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
mock_async_start_reauth.assert_called_once_with(hass)
|
||||
mock_async_start_reauth.assert_called_once_with(hass, None, None)
|
||||
|
||||
|
||||
async def test_setup_oauth_transient_error(
|
||||
|
||||
@@ -346,6 +346,7 @@ async def test_migrate_config_without_auth_type(
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Execute migration
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-migrate-entry
|
||||
migration_result = await async_migrate_entry(hass, mock_config_entry)
|
||||
assert migration_result is True
|
||||
|
||||
@@ -377,6 +378,7 @@ async def test_migrate_legacy_config_no_auth_fields(
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Migration should succeed (only updates version)
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-migrate-entry
|
||||
migration_result = await async_migrate_entry(hass, mock_config_entry)
|
||||
assert migration_result is True
|
||||
|
||||
@@ -632,6 +634,7 @@ async def test_migrate_version_bump(
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Execute migration
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-migrate-entry
|
||||
migration_result = await async_migrate_entry(hass, mock_config_entry)
|
||||
assert migration_result is True
|
||||
|
||||
@@ -708,6 +711,7 @@ async def test_setup_reuses_cached_api_from_migration(
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Run migration first (resolves plant_id and caches authenticated API)
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-migrate-entry
|
||||
await async_migrate_entry(hass, mock_config_entry)
|
||||
|
||||
# Verify migration successfully resolved plant_id
|
||||
@@ -772,6 +776,7 @@ async def test_migrate_failure_returns_false(
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Execute migration (should fail gracefully)
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-migrate-entry
|
||||
migration_result = await async_migrate_entry(hass, mock_config_entry)
|
||||
|
||||
# Verify migration returned False (will retry on next restart)
|
||||
@@ -811,6 +816,7 @@ async def test_migrate_already_migrated(
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Call migration function
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-migrate-entry
|
||||
migration_result = await async_migrate_entry(hass, mock_config_entry)
|
||||
assert migration_result is True
|
||||
|
||||
|
||||
@@ -246,6 +246,13 @@ def test_type_covers(type_name, entity_id, state, attrs) -> None:
|
||||
{ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.RECEIVER},
|
||||
{},
|
||||
),
|
||||
(
|
||||
"TelevisionMediaPlayer",
|
||||
"media_player.projector",
|
||||
"on",
|
||||
{ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.PROJECTOR},
|
||||
{},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_type_media_player(type_name, entity_id, state, attrs, config) -> None:
|
||||
|
||||
@@ -1046,3 +1046,29 @@ async def test_light_turn_on_service_deprecation(
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_bridge_v2.mock_requests[0]["json"]["effects"]["effect"] == "no_effect"
|
||||
|
||||
|
||||
async def test_light_with_zero_mirek(
|
||||
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
|
||||
) -> None:
|
||||
"""Test light doesn't crash when bridge reports zero mirek values.
|
||||
|
||||
Regression test for https://github.com/home-assistant/core/issues/116258
|
||||
"""
|
||||
# Patch the fixture data to have zero mirek values before loading
|
||||
for resource in v2_resources_test_data:
|
||||
if resource.get("type") == "light" and "color_temperature" in resource:
|
||||
resource["color_temperature"]["mirek_schema"]["mirek_minimum"] = 0
|
||||
resource["color_temperature"]["mirek_schema"]["mirek_maximum"] = 0
|
||||
break
|
||||
|
||||
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
|
||||
|
||||
# Should not raise ZeroDivisionError during setup
|
||||
await setup_platform(hass, mock_bridge_v2, Platform.LIGHT)
|
||||
|
||||
test_light = hass.states.get("light.hue_light_with_color_and_color_temperature_1")
|
||||
assert test_light is not None
|
||||
# Should fall back to defaults instead of crashing
|
||||
assert test_light.attributes["max_color_temp_kelvin"] == 6535
|
||||
assert test_light.attributes["min_color_temp_kelvin"] == 2000
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user