Compare commits

..

35 Commits

Author SHA1 Message Date
abmantis 5669eb583e Fix google sheets test 2026-05-28 20:00:31 +01:00
abmantis f059eef5aa Merge branch 'dev' of github.com:home-assistant/core into authfailed_noflow 2026-05-28 19:49:48 +01:00
Mike Degatano 2bba907013 Migrate analytics integration to config entry setup (#171801)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 20:42:25 +02:00
Crocmagnon 0dcb8fc507 ovhcloud_ai_endpoints: update quality scale to silver (#172440) 2026-05-28 20:40:41 +02:00
Jan Bouwhuis 18e6f67650 Move MQTT protocol setting to main options (#172482) 2026-05-28 20:36:39 +02:00
Joost Lekkerkerker e5fad17e17 Add pylint rule for checking async_migrate_entry calls in tests (#171877) 2026-05-28 20:22:41 +02:00
Boris Obmoroshev 219b9cbcaa Add regression test for ONVIF setup against a real ONVIFDevice (#172194)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-28 19:18:24 +01:00
abmantis 1bde5f0d6c Skip reauth flow on ConfigEntryAuthFailed when integration has none 2026-05-28 19:08:04 +01:00
Franck Nijhof 309b26f809 Handle DAVError in CalDAV get_supported_components (#172479) 2026-05-28 19:53:20 +02:00
Bram Kragten e78cb0114d Bump frontend to 20260527.1 (#172462)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-28 19:52:47 +02:00
Crocmagnon 06a4247078 ovhcloud_ai_endpoints: increase test coverage (#172439) 2026-05-28 19:48:08 +02:00
Daniel Feinberg 181e21dd2c Fix apple_tv HomePod streaming failures when device is idle (#170033)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 19:47:32 +02:00
Crocmagnon 31354d4129 ovhcloud_ai_endpoints: add diagnostics (#172444) 2026-05-28 19:42:49 +02:00
Simone Chemelli 57308d7760 Discard old events for Alexa Devices (#172446) 2026-05-28 19:42:19 +02:00
Joost Lekkerkerker c07fed05df Add pylint rule for checking async_setup_entry calls in tests (#171864) 2026-05-28 19:28:29 +02:00
jtjart 13ef737873 Add projector as media player device class (#169274) 2026-05-28 19:27:21 +02:00
TheJulianJES 0a1510135c Fix Matter BLE proxy blocking startup (#172456) 2026-05-28 19:25:36 +02:00
Simone Chemelli 6f6b7888cd Bump samsungtvws to 3.0.5 (#172471) 2026-05-28 19:02:30 +02:00
Paul Bottein b9173e36fb Name the Broadlink RF transmitter entity (#172468) 2026-05-28 19:02:14 +02:00
Ronald van der Meer a65ca9c86b Fix Duco regression where entities become unavailable when LAN info fetch fails (#172448) 2026-05-28 19:00:43 +02:00
Paulus Schoutsen fc12d6fbb6 Add lg_tv_rs232 to LG brand (#172458)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-28 18:52:55 +02:00
Keilin Bickar 2a6b686254 Add Sense API exception handling (#169957)
Co-authored-by: Inca <inca@popre.net>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 17:42:43 +01:00
G Johansson 4d841e4d84 Update async_update_entity_platform to not allow loaded entities (#171773) 2026-05-28 18:17:23 +02:00
Lukas df08e9f311 Add button platform for Samsung Infrared integration (#171791)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 17:14:47 +01:00
Abílio Costa d53e40eea8 Add skill instruction on not duplicating entity base class behavior (#172362) 2026-05-28 16:03:43 +01:00
Franck Nijhof 0b261b7198 Fix SmartThings light checking wrong component for capabilities (#172430)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-28 16:27:57 +02:00
dependabot[bot] 3a9f32de25 Bump github/gh-aw-actions from 0.74.4 to 0.74.9 (#172398)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 13:52:56 +02:00
dependabot[bot] b5e54583c7 Bump docker/build-push-action from 7.1.0 to 7.2.0 (#172397)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 13:51:38 +02:00
Franck Nijhof 85ea7c1176 Fix Hue light ZeroDivisionError when mirek value is zero (#172442) 2026-05-28 13:50:45 +02:00
Franck Nijhof 713f520bc8 Fix iZone integration broken by python-izone 1.2.10 API change (#172427) 2026-05-28 13:48:19 +02:00
Michael Davie e4bb5a9395 Use ECMap for Environment Canada radar with layer support (#161602)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-28 13:47:58 +02:00
LG-ThinQ-Integration 936b2fe933 Remove unused translation in lg_thinq (#172394)
Co-authored-by: YunseonPark-LGE <yunseon.park@lge.com>
2026-05-28 13:44:56 +02:00
dependabot[bot] c6c6f08885 Bump dessant/lock-threads from 6.0.0 to 6.0.1 (#172399)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 13:40:03 +02:00
Ariel Ebersberger c621721851 Remove advanced options from config/test_config_entires (#172423) 2026-05-28 13:37:31 +02:00
Erik Montnemery 5bb6b20641 Add zone entered left triggers (#172412) 2026-05-28 13:22:38 +02:00
123 changed files with 5441 additions and 587 deletions
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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 }}
+1 -1
View File
@@ -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"
+1
View File
@@ -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
+41 -16
View File
@@ -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": {
@@ -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",
+2 -1
View File
@@ -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:
@@ -12,13 +12,19 @@ from homeassistant.const import (
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_OPTIONS,
CONF_PLATFORM,
CONF_TYPE,
CONF_ZONE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.trigger import (
TriggerActionType,
TriggerInfo,
# protected, but only used for legacy triggers
_async_attach_trigger_cls,
)
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -79,16 +85,18 @@ async def async_attach_trigger(
event = zone.EVENT_ENTER
else:
event = zone.EVENT_LEAVE
zone_config = {
CONF_PLATFORM: ZONE_DOMAIN,
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
CONF_ZONE: config[CONF_ZONE],
CONF_EVENT: event,
}
zone_config = await zone.async_validate_trigger_config(hass, zone_config)
return await zone.async_attach_trigger(
hass, zone_config, action, trigger_info, platform_type="device"
zone_config = await zone.LegacyZoneTrigger.async_validate_config(
hass,
{
CONF_OPTIONS: {
CONF_ENTITY_ID: [config[CONF_ENTITY_ID]],
CONF_ZONE: config[CONF_ZONE],
CONF_EVENT: event,
}
},
)
return await _async_attach_trigger_cls(
hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info
)
+12 -2
View File
@@ -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
@@ -8,7 +8,7 @@
"integration_type": "service",
"iot_class": "cloud_push",
"requirements": [
"google-cloud-texttospeech==2.36.0",
"google-cloud-speech==2.38.0"
"google-cloud-texttospeech==2.25.1",
"google-cloud-speech==2.31.1"
]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_pubsub",
"iot_class": "cloud_push",
"quality_scale": "legacy",
"requirements": ["google-cloud-pubsub==2.38.0"]
"requirements": ["google-cloud-pubsub==2.29.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google", "homeassistant.helpers.location"],
"requirements": ["google-maps-routing==0.10.0"]
"requirements": ["google-maps-routing==0.6.15"]
}
@@ -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"
+5 -1
View File
@@ -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)
+10 -6
View File
@@ -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
+1 -1
View File
@@ -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
+1 -4
View File
@@ -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()
+3 -1
View File
@@ -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"
),
)
@@ -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"
},
+6 -7
View File
@@ -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,
@@ -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
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
@@ -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
View File
@@ -3,5 +3,13 @@
"reload": {
"service": "mdi:reload"
}
},
"triggers": {
"entered": {
"trigger": "mdi:map-marker-plus"
},
"left": {
"trigger": "mdi:map-marker-minus"
}
}
}
@@ -1,8 +1,48 @@
{
"common": {
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_zone_description": "The zone to trigger on.",
"trigger_zone_name": "Zone"
},
"services": {
"reload": {
"description": "Reloads zones from the YAML-configuration.",
"name": "Reload zones"
}
},
"triggers": {
"entered": {
"description": "Triggers when one or more persons or device trackers enter a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::trigger_zone_description%]",
"name": "[%key:component::zone::common::trigger_zone_name%]"
}
},
"name": "Entered zone"
},
"left": {
"description": "Triggers when one or more persons or device trackers leave a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::trigger_zone_description%]",
"name": "[%key:component::zone::common::trigger_zone_name%]"
}
},
"name": "Left zone"
}
}
}
+161 -76
View File
@@ -1,22 +1,24 @@
"""Offer zone automation rules."""
import logging
from typing import TYPE_CHECKING, Any, cast
import voluptuous as vol
from homeassistant.components.device_tracker import ATTR_IN_ZONES
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_PLATFORM,
CONF_OPTIONS,
CONF_ZONE,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HassJob,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
@@ -24,8 +26,18 @@ from homeassistant.helpers import (
entity_registry as er,
location,
)
from homeassistant.helpers.automation import (
DomainSpec,
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityTriggerBase,
Trigger,
TriggerActionRunner,
TriggerConfig,
)
from homeassistant.helpers.typing import ConfigType
from . import condition
@@ -38,93 +50,166 @@ _LOGGER = logging.getLogger(__name__)
_EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"}
_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
_LEGACY_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
vol.Required(CONF_ZONE): cv.entity_id,
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(EVENT_ENTER, EVENT_LEAVE),
}
_LEGACY_TRIGGER_OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_PLATFORM): "zone",
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
vol.Required(CONF_ZONE): cv.entity_id,
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(
EVENT_ENTER, EVENT_LEAVE
),
vol.Required(CONF_OPTIONS): _LEGACY_OPTIONS_SCHEMA,
},
)
# New-style zone trigger schema
_ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_ZONE): cv.entity_domain("zone"),
},
}
)
async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate trigger config."""
config = _TRIGGER_SCHEMA(config)
registry = er.async_get(hass)
config[CONF_ENTITY_ID] = er.async_validate_entity_ids(
registry, config[CONF_ENTITY_ID]
)
return config
_DOMAIN_SPECS: dict[str, DomainSpec] = {
"person": DomainSpec(),
"device_tracker": DomainSpec(),
}
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
*,
platform_type: str = "zone",
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
trigger_data = trigger_info["trigger_data"]
entity_id: list[str] = config[CONF_ENTITY_ID]
zone_entity_id: str = config[CONF_ZONE]
event: str = config[CONF_EVENT]
job = HassJob(action)
class LegacyZoneTrigger(Trigger):
"""Legacy zone trigger (platform: zone)."""
@callback
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
"""Listen for state changes and calls action."""
entity = zone_event.data["entity_id"]
from_s = zone_event.data["old_state"]
to_s = zone_event.data["new_state"]
@classmethod
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
) -> ConfigType:
"""Validate complete config, migrating legacy format to options."""
complete_config = move_top_level_schema_fields_to_options(
complete_config, _LEGACY_OPTIONS_SCHEMA
)
return await super().async_validate_complete_config(hass, complete_config)
if (from_s and not location.has_location(from_s)) or (
to_s and not location.has_location(to_s)
):
return
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
config = cast(ConfigType, _LEGACY_TRIGGER_OPTIONS_SCHEMA(config))
registry = er.async_get(hass)
config[CONF_OPTIONS][CONF_ENTITY_ID] = er.async_validate_entity_ids(
registry, config[CONF_OPTIONS][CONF_ENTITY_ID]
)
return config
if not (zone_state := hass.states.get(zone_entity_id)):
_LOGGER.warning(
(
"Automation '%s' is referencing non-existing zone '%s' in a zone"
" trigger"
),
trigger_info["name"],
zone_entity_id,
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
entity_id: list[str] = self._options[CONF_ENTITY_ID]
zone_entity_id: str = self._options[CONF_ZONE]
event: str = self._options[CONF_EVENT]
@callback
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
"""Listen for state changes and calls action."""
entity = zone_event.data["entity_id"]
from_s = zone_event.data["old_state"]
to_s = zone_event.data["new_state"]
if (from_s and not location.has_location(from_s)) or (
to_s and not location.has_location(to_s)
):
return
if not (zone_state := self._hass.states.get(zone_entity_id)):
_LOGGER.warning(
"Non-existing zone '%s' in a zone trigger",
zone_entity_id,
)
return
from_match = (
condition.zone(self._hass, zone_state, from_s) if from_s else False
)
return
to_match = condition.zone(self._hass, zone_state, to_s) if to_s else False
from_match = condition.zone(hass, zone_state, from_s) if from_s else False
to_match = condition.zone(hass, zone_state, to_s) if to_s else False
if (event == EVENT_ENTER and not from_match and to_match) or (
event == EVENT_LEAVE and from_match and not to_match
):
description = (
f"{entity} {_EVENT_DESCRIPTION[event]}"
f" {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
)
hass.async_run_hass_job(
job,
{
"trigger": {
**trigger_data,
"platform": platform_type,
if (event == EVENT_ENTER and not from_match and to_match) or (
event == EVENT_LEAVE and from_match and not to_match
):
description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
run_action(
{
"entity_id": entity,
"from_state": from_s,
"to_state": to_s,
"zone": zone_state,
"event": event,
"description": description,
}
},
to_s.context if to_s else None,
)
},
description,
to_s.context if to_s else None,
)
return async_track_state_change_event(hass, entity_id, zone_automation_listener)
return async_track_state_change_event(
self._hass, entity_id, zone_automation_listener
)
class ZoneTriggerBase(EntityTriggerBase):
"""Base for zone-based triggers targeting person and device_tracker entities."""
_domain_specs = _DOMAIN_SPECS
_schema = _ZONE_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the trigger."""
super().__init__(hass, config)
self._zone: str = self._options[CONF_ZONE]
def _in_target_zone(self, state: State) -> bool:
"""Check if the entity is in the selected zone."""
in_zones = state.attributes.get(ATTR_IN_ZONES) or ()
return self._zone in in_zones
class EnteredZoneTrigger(ZoneTriggerBase):
"""Trigger when an entity enters the selected zone."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the entity was not already in the selected zone."""
return not self._in_target_zone(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the entity is now in the selected zone."""
return self._in_target_zone(state)
class LeftZoneTrigger(ZoneTriggerBase):
"""Trigger when an entity leaves the selected zone."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the entity was previously in the selected zone."""
return self._in_target_zone(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the entity is no longer in the selected zone."""
return not self._in_target_zone(state)
TRIGGERS: dict[str, type[Trigger]] = {
"_": LegacyZoneTrigger,
"entered": EnteredZoneTrigger,
"left": LeftZoneTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for zones."""
return TRIGGERS
@@ -0,0 +1,26 @@
.trigger_zone: &trigger_zone
target:
entity:
domain:
- person
- device_tracker
fields:
behavior:
required: true
default: each
selector:
automation_behavior:
mode: trigger
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
entered: *trigger_zone
left: *trigger_zone
+14 -1
View File
@@ -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,
+1
View File
@@ -59,6 +59,7 @@ FLOWS = {
"amberelectric",
"ambient_network",
"ambient_station",
"analytics",
"analytics_insights",
"android_ip_webcam",
"androidtv",
+6 -6
View File
@@ -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",
+4 -4
View File
@@ -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]
+8 -1
View File
@@ -1714,7 +1714,14 @@ def async_extract_entities(trigger_conf: dict) -> list[str]:
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
if trigger_conf[CONF_PLATFORM] == "zone":
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]
options = trigger_conf[CONF_OPTIONS]
return [*options[CONF_ENTITY_ID], options[CONF_ZONE]]
if trigger_conf[CONF_PLATFORM] in ("zone.entered", "zone.left"):
return [
*async_extract_targets(trigger_conf, CONF_ENTITY_ID),
trigger_conf[CONF_OPTIONS][CONF_ZONE],
]
if trigger_conf[CONF_PLATFORM] == "geo_location":
return [trigger_conf[CONF_ZONE]]
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
@@ -148,7 +148,7 @@ iso4217!=1.10.20220401
# protobuf must be in package constraints for the wheel
# builder to build binary wheels
protobuf==7.34.1
protobuf==6.32.0
# faust-cchardet: Ensure we have a version we can build wheels
# 2.1.18 is the first version that works with our wheel builder
+39
View File
@@ -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
View File
@@ -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",
+6 -6
View File
@@ -1122,19 +1122,19 @@ goodwe==0.4.10
google-api-python-client==2.71.0
# homeassistant.components.google_pubsub
google-cloud-pubsub==2.38.0
google-cloud-pubsub==2.29.0
# homeassistant.components.google_cloud
google-cloud-speech==2.38.0
google-cloud-speech==2.31.1
# homeassistant.components.google_cloud
google-cloud-texttospeech==2.36.0
google-cloud-texttospeech==2.25.1
# homeassistant.components.google_generative_ai_conversation
google-genai==1.59.0
# homeassistant.components.google_travel_time
google-maps-routing==0.10.0
google-maps-routing==0.6.15
# homeassistant.components.nest
google-nest-sdm==9.1.2
@@ -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
+1 -1
View File
@@ -46,7 +46,7 @@ types-caldav==1.3.0.20250516
types-chardet==0.1.5
types-decorator==5.2.0.20260408
types-pexpect==4.9.0.20260408
types-protobuf==7.34.1.20260408
types-protobuf==6.32.1.20260221
types-psutil==7.2.2.20260408
types-pyserial==3.5.0.20260408
types-python-dateutil==2.9.0.20260408
+1 -1
View File
@@ -132,7 +132,7 @@ iso4217!=1.10.20220401
# protobuf must be in package constraints for the wheel
# builder to build binary wheels
protobuf==7.34.1
protobuf==6.32.0
# faust-cchardet: Ensure we have a version we can build wheels
# 2.1.18 is the first version that works with our wheel builder
+116 -1
View File
@@ -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()
@@ -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)
+21 -4
View File
@@ -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
@@ -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,
+23
View File
@@ -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,
+16 -6
View File
@@ -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,
+1 -1
View File
@@ -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:
+26
View File
@@ -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
+3
View File
@@ -32,6 +32,7 @@ async def test_ha_mqtt_publish(
mock_discovery.start.return_value = []
mock_discovery_class.return_value = mock_discovery
# pylint: disable-next=home-assistant-tests-direct-async-setup-entry
await inels.async_setup_entry(hass, config_entry)
topic, payload, qos, retain = "test/topic", "test_payload", 1, True
@@ -60,6 +61,7 @@ async def test_ha_mqtt_subscribe(
mock_discovery.start.return_value = []
mock_discovery_class.return_value = mock_discovery
# pylint: disable-next=home-assistant-tests-direct-async-setup-entry
await inels.async_setup_entry(hass, config_entry)
topic = "test/topic"
@@ -82,6 +84,7 @@ async def test_ha_mqtt_not_available(
),
pytest.raises(ConfigEntryNotReady, match="MQTT integration not available"),
):
# pylint: disable-next=home-assistant-tests-direct-async-setup-entry
await inels.async_setup_entry(hass, config_entry)
+1
View File
@@ -62,6 +62,7 @@ async def test_async_setup_entry_connection_error(
)
with pytest.raises(ConfigEntryNotReady):
# pylint: disable-next=home-assistant-tests-direct-async-setup-entry
await async_setup_entry(hass, mock_config_entry)
assert mock_iometer_client.get_current_status.await_count == 1
+4 -4
View File
@@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry:
def mock_pizone_discovery_service() -> Mock:
"""Create a mock pizone discovery service."""
disco = Mock()
disco.controllers = {}
disco.fetch_controllers = AsyncMock(return_value={})
disco.start_discovery = AsyncMock()
disco.close = AsyncMock()
return disco
@@ -96,9 +96,9 @@ async def mock_discovery(
"homeassistant.components.izone.discovery.pizone.discovery", autospec=True
) as mock_disco:
mock_disco.return_value.start_discovery = AsyncMock()
mock_disco.return_value.controllers = {
mock_controller.device_uid: mock_controller
}
mock_disco.return_value.fetch_controllers = AsyncMock(
return_value={mock_controller.device_uid: mock_controller}
)
mock_disco.return_value.close = AsyncMock()
yield mock_disco
+3 -3
View File
@@ -2,7 +2,7 @@
from collections.abc import Callable
from typing import Any
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -18,7 +18,7 @@ def mock_disco() -> Mock:
"""Mock discovery service."""
disco = Mock()
disco.pi_disco = Mock()
disco.pi_disco.controllers = {}
disco.pi_disco.fetch_controllers = AsyncMock(return_value={})
return disco
@@ -60,7 +60,7 @@ async def test_not_found(hass: HomeAssistant, mock_disco: Mock) -> None:
async def test_found(hass: HomeAssistant, mock_disco: Mock) -> None:
"""Test not finding iZone controller."""
mock_disco.pi_disco.controllers["blah"] = object()
mock_disco.pi_disco.fetch_controllers = AsyncMock(return_value={"blah": object()})
with (
patch(
@@ -42,6 +42,7 @@ async def test_setup_demo_platform(hass: HomeAssistant) -> None:
"""Test setup."""
mock = MagicMock()
add_entities = mock.MagicMock()
# pylint: disable-next=home-assistant-tests-direct-platform-async-setup-entry
await demo.async_setup_entry(hass, {}, add_entities)
assert add_entities.call_count == 1
+7 -1
View File
@@ -69,9 +69,15 @@ def test_create_matter_ble_proxy_wires_ha_backends(hass: HomeAssistant) -> None:
assert kwargs["ws_url"] == "ws://localhost:5580/ble"
assert isinstance(kwargs["scan_source"], HaBluetoothScanSource)
assert isinstance(kwargs["device_resolver"], HaBluetoothDeviceResolver)
assert kwargs["task_factory"] == hass.async_create_task
assert result is proxy_cls.return_value
coro = MagicMock()
with patch.object(hass, "async_create_background_task") as bg_task:
task = kwargs["task_factory"](coro)
bg_task.assert_called_once_with(coro, name="matter_ble_proxy")
assert task is bg_task.return_value
async def test_scan_source_start_registers_passive_callback(
hass: HomeAssistant,
+67 -1
View File
@@ -2,6 +2,7 @@
import collections
from collections import defaultdict
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from onvif.exceptions import ONVIFError
@@ -53,8 +54,17 @@ def setup_mock_onvif_camera(
no_profiles=False,
auth_failure=False,
wrong_port=False,
with_full_setup=False,
):
"""Prepare mock onvif.ONVIFCamera."""
"""Prepare mock onvif.ONVIFCamera.
When ``with_full_setup`` is set, the mock is additionally configured with
every service ``ONVIFDevice.async_setup`` invokes, so that a config entry
can be fully set up end-to-end. In this mode the profile and device
information responses are replaced, so the flags that control them
(``with_h264``, ``two_profiles``, ``no_profiles``, ``auth_fail`` and
``profiles_transient_failure``) have no effect.
"""
devicemgmt = MagicMock()
device_info = MagicMock()
@@ -111,6 +121,62 @@ def setup_mock_onvif_camera(
mock_onvif_camera.xaddrs = {}
mock_onvif_camera.services = {}
if with_full_setup:
devicemgmt.GetSystemDateAndTime = AsyncMock(return_value=None)
devicemgmt.GetDeviceInformation = AsyncMock(
return_value=SimpleNamespace(
Manufacturer=MANUFACTURER,
Model=MODEL,
FirmwareVersion=FIRMWARE_VERSION,
SerialNumber=SERIAL_NUMBER if with_serial else None,
)
)
media_service.GetServiceCapabilities = AsyncMock(
return_value=SimpleNamespace(SnapshotUri=False)
)
media_service.GetProfiles = AsyncMock(
return_value=[
SimpleNamespace(
token="profile_token",
Name="MainStream",
VideoEncoderConfiguration=SimpleNamespace(
Resolution=SimpleNamespace(Width=1920, Height=1080),
Encoding="H264",
),
VideoSourceConfiguration=MagicMock(),
PTZConfiguration=MagicMock(),
)
]
)
ptz_service = MagicMock()
ptz_service.GetPresets = AsyncMock(return_value=[])
mock_onvif_camera.create_ptz_service = AsyncMock(return_value=ptz_service)
mock_onvif_camera.create_imaging_service = AsyncMock(return_value=MagicMock())
mock_onvif_camera.get_snapshot = AsyncMock(return_value=False)
mock_onvif_camera.get_capabilities = AsyncMock(
return_value={
"Media": {"XAddr": "http://media"},
"PTZ": {"XAddr": "http://ptz"},
"Imaging": {"XAddr": "http://imaging"},
"Events": {
"XAddr": None,
"WSPullPointSupport": False,
"WSSubscriptionPolicySupport": False,
},
}
)
# Let the real event managers run but fail gracefully at the onvif
# library boundary, so async_start_events returns False and setup
# still reaches LOADED (models a camera without working events).
mock_onvif_camera.create_pullpoint_manager = AsyncMock(
side_effect=Fault("no pullpoint support")
)
mock_onvif_camera.create_notification_manager = AsyncMock(
side_effect=Fault("no notification support")
)
def mock_constructor(
host,
port,
+48 -1
View File
@@ -2,10 +2,27 @@
from unittest.mock import MagicMock, patch
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import MAC, setup_mock_device
from . import (
HOST,
MAC,
NAME,
PASSWORD,
PORT,
USERNAME,
setup_mock_device,
setup_mock_onvif_camera,
)
from tests.common import MockConfigEntry
@@ -97,3 +114,33 @@ async def test_migrate_camera_entities_unique_ids(hass: HomeAssistant) -> None:
# Make sure the unexisting index entity is unchanged
assert entity_unexisting_index is not None
assert entity_unexisting_index.unique_id == f"{MAC}_9"
async def test_setup_entry(hass: HomeAssistant) -> None:
"""Test setting up the config entry."""
entry = MockConfigEntry(
domain="onvif",
title=NAME,
unique_id=MAC,
data={
CONF_NAME: NAME,
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
},
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.onvif.device.ONVIFCamera"
) as mock_onvif_camera_cls:
setup_mock_onvif_camera(mock_onvif_camera_cls, with_full_setup=True)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
mock_onvif_camera_cls.assert_called_once()
host, port, username, password = mock_onvif_camera_cls.call_args.args[:4]
assert (host, port, username, password) == (HOST, PORT, USERNAME, PASSWORD)
+6 -83
View File
@@ -1,6 +1,5 @@
"""Test ONVIF PTZ capabilities."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from homeassistant.const import (
@@ -12,19 +11,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from . import (
FIRMWARE_VERSION,
HOST,
MAC,
MANUFACTURER,
MODEL,
NAME,
PASSWORD,
PORT,
SERIAL_NUMBER,
USERNAME,
setup_mock_onvif_camera,
)
from . import HOST, MAC, NAME, PASSWORD, PORT, USERNAME, setup_mock_onvif_camera
from tests.common import MockConfigEntry
@@ -32,71 +19,15 @@ from tests.common import MockConfigEntry
def _setup_ptz_camera_mocks(mock_onvif_camera_cls: MagicMock) -> MagicMock:
"""Configure the patched ONVIFCamera class for PTZ tests.
Builds on the shared `setup_mock_onvif_camera` helper and only adds the
extra mocks needed for `ONVIFDevice.async_setup` to complete end-to-end
with PTZ capability. Returns the ptz_service mock so callers can assert
against ContinuousMove/Stop calls.
Uses the shared ``setup_mock_onvif_camera`` full-setup helper and only adds
the PTZ service mocks the tests assert against, returning the ptz_service
mock so callers can check ContinuousMove/Stop calls.
"""
setup_mock_onvif_camera(mock_onvif_camera_cls)
setup_mock_onvif_camera(mock_onvif_camera_cls, with_full_setup=True)
# Override GetProfiles with a profile that has the attributes the PTZ
# code path reads from (PTZConfiguration, VideoSourceConfiguration,
# Resolution width/height, and a stable Name for entity_id generation).
media_service = MagicMock()
media_service.GetServiceCapabilities = AsyncMock(
return_value=SimpleNamespace(SnapshotUri=False)
)
media_service.GetProfiles = AsyncMock(
return_value=[
SimpleNamespace(
token="profile_token",
Name="MainStream",
VideoEncoderConfiguration=SimpleNamespace(
Resolution=SimpleNamespace(Width=1920, Height=1080),
Encoding="H264",
),
VideoSourceConfiguration=MagicMock(),
PTZConfiguration=MagicMock(),
)
]
)
mock_onvif_camera_cls.create_media_service = AsyncMock(return_value=media_service)
ptz_service = MagicMock()
ptz_service = mock_onvif_camera_cls.create_ptz_service.return_value
ptz_service.ContinuousMove = AsyncMock()
ptz_service.Stop = AsyncMock()
ptz_service.GetPresets = AsyncMock(return_value=[])
mock_onvif_camera_cls.create_ptz_service = AsyncMock(return_value=ptz_service)
mock_onvif_camera_cls.create_imaging_service = AsyncMock(return_value=MagicMock())
mock_onvif_camera_cls.create_pullpoint_manager = AsyncMock(return_value=MagicMock())
mock_onvif_camera_cls.get_snapshot = AsyncMock(return_value=False)
mock_onvif_camera_cls.get_capabilities = AsyncMock(
return_value={
"Media": {"XAddr": "http://media"},
"PTZ": {"XAddr": "http://ptz"},
"Imaging": {"XAddr": "http://imaging"},
"Events": {
"XAddr": None,
"WSPullPointSupport": False,
"WSSubscriptionPolicySupport": False,
},
}
)
# Short-circuit async_check_date_and_time by returning None, and return
# serializable device information so the device registry entry can be
# persisted (the shared helper only sets SerialNumber).
devicemgmt = mock_onvif_camera_cls.create_devicemgmt_service.return_value
devicemgmt.GetSystemDateAndTime = AsyncMock(return_value=None)
devicemgmt.GetDeviceInformation = AsyncMock(
return_value=SimpleNamespace(
Manufacturer=MANUFACTURER,
Model=MODEL,
FirmwareVersion=FIRMWARE_VERSION,
SerialNumber=SERIAL_NUMBER,
)
)
return ptz_service
@@ -149,10 +80,6 @@ async def test_ptz_continuous_move_calls_stop_when_duration_nonzero(
"homeassistant.components.onvif.device.asyncio.sleep",
new=AsyncMock(),
) as mock_sleep,
patch(
"homeassistant.components.onvif.device.ONVIFDevice.async_start_events",
new=AsyncMock(return_value=False),
),
):
ptz_service = _setup_ptz_camera_mocks(mock_onvif_camera_cls)
@@ -183,10 +110,6 @@ async def test_ptz_continuous_move_does_not_call_stop_when_duration_zero(
"homeassistant.components.onvif.device.asyncio.sleep",
new=AsyncMock(),
) as mock_sleep,
patch(
"homeassistant.components.onvif.device.ONVIFDevice.async_start_events",
new=AsyncMock(return_value=False),
),
):
ptz_service = _setup_ptz_camera_mocks(mock_onvif_camera_cls)
@@ -0,0 +1,54 @@
# serializer version: 1
# name: test_entry_diagnostics
dict({
'data': dict({
'api_key': '**REDACTED**',
}),
'entities': dict({
'conversation.meta_llama_3_3_70b_instruct': dict({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'device_class': None,
'disabled_by': None,
'entity_category': None,
'entity_id': 'conversation.meta_llama_3_3_70b_instruct',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'labels': list([
]),
'name': None,
'options': dict({
'conversation': dict({
'should_expose': False,
}),
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'ovhcloud_ai_endpoints',
'translation_key': None,
}),
}),
'entry_version': '1.1',
'options': dict({
}),
'state': 'loaded',
'subentries': list([
dict({
'data': dict({
'model': 'Meta-Llama-3_3-70B-Instruct',
'prompt': '**REDACTED**',
}),
'subentry_type': 'conversation',
'title': 'Meta-Llama-3_3-70B-Instruct',
}),
]),
'title': 'OVHcloud AI Endpoints',
})
# ---
@@ -17,7 +17,13 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import conversation
from homeassistant.components.ovhcloud_ai_endpoints.entity import (
_convert_content_to_chat_message,
_decode_tool_arguments,
)
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, intent
from homeassistant.helpers.llm import ToolInput
@@ -459,3 +465,58 @@ async def test_openai_error(
)
assert result.response.response_type == intent.IntentResponseType.ERROR
async def test_supported_languages(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
) -> None:
"""The conversation entity must advertise universal language support."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
agent = conversation.async_get_agent(
hass, "conversation.meta_llama_3_3_70b_instruct"
)
assert agent is not None
assert agent.supported_languages == MATCH_ALL
async def test_converse_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
) -> None:
"""A ConverseError from chat_log.async_provide_llm_data surfaces as ERROR."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
subentry = next(iter(mock_config_entry.subentries.values()))
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={**subentry.data, CONF_LLM_HASS_API: "invalid_llm_api"},
)
await hass.async_block_till_done()
result = await conversation.async_converse(
hass,
"hello",
None,
Context(),
agent_id="conversation.meta_llama_3_3_70b_instruct",
)
assert result.response.response_type is intent.IntentResponseType.ERROR
def test_decode_tool_arguments_invalid_json() -> None:
"""Malformed tool-call JSON arguments raise HomeAssistantError."""
with pytest.raises(HomeAssistantError, match="Unexpected tool argument response"):
_decode_tool_arguments("{not-json")
def test_convert_content_unmapped() -> None:
"""Content that cannot be mapped to a Completions message returns None."""
assert (
_convert_content_to_chat_message(conversation.SystemContent(content="")) is None
)
@@ -0,0 +1,46 @@
"""Test OVHcloud AI Endpoints diagnostics."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_openai_client: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test config entry diagnostics."""
await setup_integration(hass, mock_config_entry, mock_openai_client)
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
assert diagnostics.pop("client").startswith("openai==")
diagnostics.pop("entry_id")
subentries = diagnostics.pop("subentries")
diagnostics["subentries"] = list(subentries.values())
for entity in diagnostics["entities"].values():
for key in (
"config_entry_id",
"config_subentry_id",
"created_at",
"device_id",
"id",
"modified_at",
"unique_id",
):
entity.pop(key, None)
assert diagnostics == snapshot
@@ -2,7 +2,9 @@
from unittest.mock import AsyncMock
from openai import OpenAIError
import httpx
from openai import AuthenticationError, BadRequestError, OpenAIError
import pytest
from homeassistant.components.ovhcloud_ai_endpoints.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
@@ -29,16 +31,47 @@ async def test_setup_unload(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_setup_cannot_connect(
@pytest.mark.parametrize(
("exception", "state"),
[
(
AuthenticationError(
message="invalid key",
response=httpx.Response(
status_code=401,
request=httpx.Request(method="POST", url="https://example.com"),
),
body=None,
),
ConfigEntryState.SETUP_ERROR,
),
(
BadRequestError(
message="invalid parameter",
response=httpx.Response(
status_code=400,
request=httpx.Request(method="POST", url="https://example.com"),
),
body=None,
),
ConfigEntryState.LOADED,
),
(OpenAIError("boom"), ConfigEntryState.SETUP_RETRY),
(Exception("boom"), ConfigEntryState.SETUP_ERROR),
],
)
async def test_setup_errors(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
state: ConfigEntryState,
) -> None:
"""Test that a connection error surfaces a setup retry."""
mock_openai_client.chat.completions.create.side_effect = OpenAIError("boom")
"""Assert appropriate behavior according to various HTTP responses."""
mock_openai_client.chat.completions.create.side_effect = exception
await setup_integration(hass, mock_config_entry, mock_openai_client)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_config_entry.state is state
async def test_new_subentry_creates_entity_and_device(
+1
View File
@@ -168,6 +168,7 @@ async def test_config_flow_entry_migrate(
return_value=entity_registry,
),
):
# pylint: disable-next=home-assistant-tests-direct-async-migrate-entry
await ps4.async_migrate_entry(hass, mock_entry)
await hass.async_block_till_done()
File diff suppressed because it is too large Load Diff

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