Compare commits

..

1 Commits

Author SHA1 Message Date
Erik 0f789a6797 Improve tests of helpers.trigger.extract_devices/entities 2026-05-28 10:06:24 +02:00
116 changed files with 842 additions and 4463 deletions
@@ -24,7 +24,6 @@ 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,7 +27,6 @@ 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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
#
# 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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+1 -1
View File
@@ -39,7 +39,7 @@ on:
env:
CACHE_VERSION: 3
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.7"
HA_SHORT_VERSION: "2026.6"
ADDITIONAL_PYTHON_VERSIONS: "[]"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
+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@851cffe46851ddd2051ea7147ebdc995113241c3 # v6.0.1
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"
-1
View File
@@ -6,7 +6,6 @@
"lg_netcast",
"lg_soundbar",
"lg_thinq",
"lg_tv_rs232",
"webostv"
]
}
+16 -41
View File
@@ -5,12 +5,8 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import labs, websocket_api
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.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -53,7 +49,6 @@ 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"
@@ -62,39 +57,18 @@ 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)
try:
await analytics.load()
except HassioNotReadyError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="supervisor_not_ready",
) from err
# Load stored data
await analytics.load()
started = False
@@ -106,8 +80,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if started:
await analytics.async_schedule()
async def start_schedule(hass: HomeAssistant) -> None:
"""Start the send schedule once Home Assistant has started."""
async def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
nonlocal started
started = True
await analytics.async_schedule()
@@ -115,7 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
)
async_at_started(hass, start_schedule)
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)
hass.data[DATA_COMPONENT] = analytics
return True
@@ -130,9 +109,7 @@ def websocket_analytics(
msg: dict[str, Any],
) -> None:
"""Return analytics preferences."""
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
analytics = hass.data[DATA_COMPONENT]
connection.send_result(
msg["id"],
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
@@ -153,10 +130,8 @@ 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,8 +299,12 @@ class Analytics:
self._data = AnalyticsData.from_dict(stored)
if self.supervisor and not self.onboarded:
# This may raise HassioNotReadyError if Supervisor was unreachable.
# The caller is responsible for handling this and triggering a retry.
# 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.
supervisor_info = hassio.get_supervisor_info(self._hass)
# User have not configured analytics, get this setting from the supervisor
@@ -345,10 +349,10 @@ class Analytics:
await self._save()
if self.supervisor:
# 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)
# 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)
with contextlib.suppress(hassio.HassioNotReadyError):
operating_system_info = hassio.get_os_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
@@ -1,19 +0,0 @@
"""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={})
@@ -14,6 +14,5 @@
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
}
},
"quality_scale": "internal",
"single_config_entry": true
"quality_scale": "internal"
}
@@ -1,9 +1,4 @@
{
"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).",
+3 -16
View File
@@ -11,7 +11,7 @@ from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback
from .const import DOMAIN, LOGGER
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
from .models import AgentBackup, BackupNotFound
from .util import read_backup, suggested_filename
@@ -54,13 +54,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
try:
backup = read_backup(backup_path)
backups[backup.backup_id] = (backup, backup_path)
except (
OSError,
TarError,
json.JSONDecodeError,
KeyError,
InvalidBackupFilename,
) as err:
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups
@@ -128,14 +122,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
def get_new_backup_path(self, backup: AgentBackup) -> Path:
"""Return the local path to a new backup."""
candidate = self._backup_dir / suggested_filename(backup)
# suggested_filename does not strip separators; refuse paths that would
# land outside the backup directory.
if candidate.parent != self._backup_dir:
raise InvalidBackupFilename(
f"Refusing to write outside {self._backup_dir}: {candidate}"
)
return candidate
return self._backup_dir / suggested_filename(backup)
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file."""
+1 -7
View File
@@ -1978,13 +1978,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
try:
backup = await async_add_executor_job(read_backup, temp_file)
except (
OSError,
tarfile.TarError,
json.JSONDecodeError,
KeyError,
InvalidBackupFilename,
) as err:
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
raise
+3 -10
View File
@@ -6,7 +6,7 @@ import copy
from dataclasses import dataclass, replace
from io import BytesIO
import json
from pathlib import Path, PurePath, PureWindowsPath
from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
import threading
@@ -34,7 +34,7 @@ from homeassistant.util.async_iterator import (
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION
from .models import AddonInfo, AgentBackup, Folder, InvalidBackupFilename
from .models import AddonInfo, AgentBackup, Folder
class DecryptError(HomeAssistantError):
@@ -109,13 +109,6 @@ def read_backup(backup_path: Path) -> AgentBackup:
extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
date = extra_metadata.get("supervisor.backup_request_date", data["date"])
name = cast(str, data["name"])
# The name is used to derive the on-disk filename via suggested_filename;
# reject anything that could escape the backup directory.
safe_name = PureWindowsPath(name).name
if safe_name != name or name in ("", ".", ".."):
raise InvalidBackupFilename(f"Invalid backup name: {name!r}")
return AgentBackup(
addons=addons,
backup_id=cast(str, data["slug"]),
@@ -125,7 +118,7 @@ def read_backup(backup_path: Path) -> AgentBackup:
folders=folders,
homeassistant_included=homeassistant_included,
homeassistant_version=homeassistant_version,
name=name,
name=cast(str, data["name"]),
protected=cast(bool, data.get("protected", False)),
size=backup_path.stat().st_size,
)
@@ -92,7 +92,7 @@ class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity):
"""Representation of a Broadlink RF transmitter."""
_attr_has_entity_name = True
_attr_translation_key = "rf_transmitter"
_attr_name = None
def __init__(self, device: BroadlinkDevice) -> None:
"""Initialize the entity."""
@@ -54,11 +54,6 @@
"name": "IR emitter"
}
},
"radio_frequency": {
"rf_transmitter": {
"name": "RF transmitter"
}
},
"select": {
"day_of_week": {
"name": "Day of week",
@@ -22,7 +22,6 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.trigger import (
TriggerActionType,
TriggerInfo,
# protected, but only used for legacy triggers
_async_attach_trigger_cls,
)
from homeassistant.helpers.typing import ConfigType
@@ -169,35 +169,11 @@ class BaseTrackerEntity(Entity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_source_type: SourceType
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if "battery_level" in cls.__dict__:
if cls.__module__.startswith("homeassistant.components."):
# Don't ask users to report issue for built in integrations,
# they already have issues opened on them.
return
report_issue = async_suggest_report_issue(
async_get_hass_or_none(), module=cls.__module__
)
_LOGGER.warning(
(
"%s::%s is overriding the deprecated battery_level property on "
"a subclass of BaseTrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
cls.__module__,
cls.__name__,
report_issue,
)
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
The property is deprecated and will be removed in Home Assistant 2027.7.
"""
return None
@@ -50,7 +50,7 @@ class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
"""Update Duck DNS."""
retry_after = BACKOFF_INTERVALS[
min(self.failed, len(BACKOFF_INTERVALS) - 1)
min(self.failed, len(BACKOFF_INTERVALS))
].total_seconds()
try:
+2 -12
View File
@@ -86,6 +86,7 @@ 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,
@@ -99,18 +100,7 @@ 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=rssi_wifi,
rssi_wifi=lan_info.rssi_wifi,
)
@@ -3,7 +3,7 @@
from datetime import timedelta
import logging
from env_canada import ECAirQuality, ECMap, ECWeather
from env_canada import ECAirQuality, ECRadar, 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 = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
radar_data = ECRadar(coordinates=(lat, lon))
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 ECMap
from env_canada import ECRadar
import voluptuous as vol
from homeassistant.components.camera import Camera
@@ -11,20 +11,13 @@ 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", "Precipitation type"]),
}
_RADAR_TYPE_TO_LAYER: dict[str, str] = {
"Rain": "rain",
"Snow": "snow",
"Precipitation type": "precip_type",
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow"]),
}
@@ -45,13 +38,13 @@ async def async_setup_entry(
)
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera):
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera):
"""Implementation of an Environment Canada radar camera."""
_attr_has_entity_name = True
_attr_translation_key = "radar"
def __init__(self, coordinator: ECDataUpdateCoordinator[ECMap]) -> None:
def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None:
"""Initialize the camera."""
super().__init__(coordinator)
Camera.__init__(self)
@@ -83,13 +76,6 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], 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()
await self.coordinator.async_request_refresh()
self.radar_object.precip_type = radar_type.lower()
await self.radar_object.update()
@@ -5,7 +5,7 @@ from datetime import timedelta
import logging
import xml.etree.ElementTree as ET
from env_canada import ECAirQuality, ECMap, ECWeather, ECWeatherUpdateFailed, ec_exc
from env_canada import ECAirQuality, ECRadar, 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 | ECMap | ECWeather
type ECDataType = ECAirQuality | ECRadar | ECWeather
@dataclass
@@ -25,7 +25,7 @@ class ECRuntimeData:
"""Class to hold EC runtime data."""
aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality]
radar_coordinator: ECDataUpdateCoordinator[ECMap]
radar_coordinator: ECDataUpdateCoordinator[ECRadar]
weather_coordinator: ECDataUpdateCoordinator[ECWeather]
@@ -12,11 +12,10 @@ set_radar_type:
fields:
radar_type:
required: true
example: Rain
example: Snow
selector:
select:
options:
- "Auto"
- "Rain"
- "Snow"
- "Precipitation type"
@@ -199,7 +199,6 @@ 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,11 +2728,7 @@ class ChannelTrait(_Trait):
if (
domain == media_player.DOMAIN
and (features & MediaPlayerEntityFeature.PLAY_MEDIA)
and device_class
in (
media_player.MediaPlayerDeviceClass.TV,
media_player.MediaPlayerDeviceClass.PROJECTOR,
)
and device_class == media_player.MediaPlayerDeviceClass.TV
):
return True
@@ -202,10 +202,7 @@ def get_accessory( # noqa: C901
if device_class == MediaPlayerDeviceClass.RECEIVER:
a_type = "ReceiverMediaPlayer"
elif device_class in (
MediaPlayerDeviceClass.TV,
MediaPlayerDeviceClass.PROJECTOR,
):
elif device_class == MediaPlayerDeviceClass.TV:
a_type = "TelevisionMediaPlayer"
elif validate_media_player_features(state, feature_list):
a_type = "MediaPlayer"
+1 -5
View File
@@ -695,11 +695,7 @@ 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,
MediaPlayerDeviceClass.PROJECTOR,
)
in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER)
) or (
state.domain == REMOTE_DOMAIN
and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+6 -10
View File
@@ -178,21 +178,17 @@ 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) and (
mirek_max := color_temp.mirek_schema.mirek_maximum
):
return mirek_max
# return a fallback value if the light doesn't provide valid limits
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
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) and (
mirek_min := color_temp.mirek_schema.mirek_minimum
):
return mirek_min
# return a fallback value if the light doesn't provide valid limits
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
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 (await disco.pi_disco.fetch_controllers()).values():
for controller in disco.pi_disco.controllers.values():
init_controller(controller)
# connect to register any further components
@@ -29,13 +29,12 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
async with asyncio.timeout(TIMEOUT_DISCOVERY):
await controller_ready.wait()
controllers = await disco.pi_disco.fetch_controllers()
if not controllers:
if not disco.pi_disco.controllers:
await async_stop_discovery_service(hass)
_LOGGER.debug("No controllers found")
return False
_LOGGER.debug("Controllers %s", controllers)
_LOGGER.debug("Controllers %s", disco.pi_disco.controllers)
return True
+4 -1
View File
@@ -105,7 +105,10 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
except ThinQAPIException as exc:
if on_fail_method:
on_fail_method()
raise ServiceValidationError(exc.message) from exc
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
exc.message, translation_domain=DOMAIN, translation_key=exc.code
) from exc
except ValueError as exc:
if on_fail_method:
on_fail_method()
+1 -3
View File
@@ -108,7 +108,5 @@ 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=lambda coro: hass.async_create_background_task(
coro, name="matter_ble_proxy"
),
task_factory=hass.async_create_task,
)
-13
View File
@@ -439,19 +439,6 @@ DISCOVERY_SCHEMAS = [
),
allow_multi=True, # also used for climate entity
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SoilMoistureSensor",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.MOISTURE,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(
clusters.SoilMeasurement.Attributes.SoilMoistureMeasuredValue,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
@@ -155,7 +155,6 @@ class MediaPlayerDeviceClass(StrEnum):
TV = "tv"
SPEAKER = "speaker"
RECEIVER = "receiver"
PROJECTOR = "projector"
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass))
@@ -34,12 +34,6 @@
"playing": "mdi:cast-connected"
}
},
"projector": {
"default": "mdi:projector",
"state": {
"off": "mdi:projector-off"
}
},
"receiver": {
"default": "mdi:audio-video",
"state": {
@@ -261,9 +261,6 @@
}
}
},
"projector": {
"name": "Projector"
},
"receiver": {
"name": "Receiver"
},
+1 -1
View File
@@ -1140,7 +1140,7 @@
},
"step": {
"confirm": {
"description": "Home Assistant needs to migrate to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will attempt to migrate your MQTT broker configuration to use protocol version 5 to fix this issue. If the broker cannot be reached using MQTT protocol version 5, for example because it does not support it, the migration will be aborted.",
"description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.",
"title": "MQTT protocol change required"
}
}
+6 -4
View File
@@ -271,7 +271,7 @@ class MqttValve(MqttEntity, ValveEntity):
self._range, float(position_payload)
)
except ValueError:
_LOGGER.debug(
_LOGGER.warning(
"Ignoring non numeric payload '%s' received on topic '%s'",
position_payload,
msg.topic,
@@ -279,9 +279,9 @@ class MqttValve(MqttEntity, ValveEntity):
else:
percentage_payload = min(max(percentage_payload, 0), 100)
self._attr_current_valve_position = percentage_payload
# Reset opening/closing when a position update is received
# without an explicit opening/closing transitional state.
state = state or RESET_CLOSING_OPENING
# Reset closing and opening if the valve is fully opened or fully closed
if state is None and percentage_payload in (0, 100):
state = RESET_CLOSING_OPENING
position_set = True
if state_payload and state is None and not position_set:
_LOGGER.warning(
@@ -291,6 +291,8 @@ class MqttValve(MqttEntity, ValveEntity):
state_payload,
)
return
if state is None:
return
self._update_state(state)
@callback
@@ -1,12 +1,6 @@
"""The OVHcloud AI Endpoints integration."""
from openai import (
AsyncOpenAI,
AuthenticationError,
BadRequestError,
OpenAIError,
PermissionDeniedError,
)
from openai import AsyncOpenAI, AuthenticationError, BadRequestError, OpenAIError
from openai.types.chat import ChatCompletionUserMessageParam
from homeassistant.config_entries import ConfigEntry
@@ -58,7 +52,7 @@ async def async_setup_entry(
try:
await _validate_api_key(client)
except (AuthenticationError, PermissionDeniedError) as err:
except AuthenticationError as err:
raise ConfigEntryAuthFailed(err) from err
except OpenAIError as err:
raise ConfigEntryNotReady(err) from err
@@ -1,10 +1,9 @@
"""Config flow for the OVHcloud AI Endpoints integration."""
from collections.abc import Mapping
import logging
from typing import Any
from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError
from openai import AsyncOpenAI, AuthenticationError, OpenAIError
import voluptuous as vol
from homeassistant.config_entries import (
@@ -31,8 +30,6 @@ from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
_LOGGER = logging.getLogger(__name__)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OVHcloud AI Endpoints."""
@@ -58,7 +55,7 @@ class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
client = _create_client(self.hass, user_input[CONF_API_KEY])
try:
await _validate_api_key(client)
except AuthenticationError, PermissionDeniedError:
except AuthenticationError:
errors["base"] = "invalid_auth"
except OpenAIError:
errors["base"] = "cannot_connect"
@@ -80,39 +77,6 @@ class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
if user_input is not None:
client = _create_client(self.hass, user_input[CONF_API_KEY])
try:
await _validate_api_key(client)
except AuthenticationError, PermissionDeniedError:
errors["base"] = "invalid_auth"
except OpenAIError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)
class ConversationFlowHandler(ConfigSubentryFlow):
"""Handle conversation subentry flow."""
@@ -44,7 +44,7 @@ rules:
status: exempt
comment: the integration only integrates stateless entities
parallel-updates: todo
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: done
# Gold
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -10,15 +9,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::ovhcloud_ai_endpoints::config::step::user::data_description::api_key%]"
},
"description": "The OVHcloud AI Endpoints API key is no longer valid. Please enter a new one."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
@@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -1,176 +0,0 @@
"""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,112 +19,6 @@
}
}
},
"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]==3.0.5",
"samsungtvws[async,encrypted]==2.7.2",
"wakeonlan==3.3.0",
"async-upnp-client==0.46.2"
],
@@ -6,7 +6,6 @@ import logging
from sense_energy import (
ASyncSenseable,
SenseAPIException,
SenseAuthenticationException,
SenseMFARequiredException,
)
@@ -89,10 +88,6 @@ 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,7 +6,6 @@ from typing import TYPE_CHECKING
from sense_energy import (
ASyncSenseable,
SenseAPIException,
SenseAuthenticationException,
SenseMFARequiredException,
)
@@ -94,8 +93,6 @@ class SenseRealtimeCoordinator(SenseCoordinator):
try:
await self._gateway.update_realtime()
except SENSE_TIMEOUT_EXCEPTIONS as ex:
raise UpdateFailed(f"Timeout retrieving realtime data: {ex}") from ex
_LOGGER.error("Timeout retrieving data: %s", ex)
except SENSE_WEBSOCKET_EXCEPTIONS as 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
_LOGGER.error("Failed to update data: %s", ex)
@@ -72,10 +72,8 @@ async def async_setup_entry(
for device in entry_data.devices.values()
for component in device.status
if (
Capability.SWITCH in device.status[component]
and any(
capability in device.status[component] for capability in CAPABILITIES
)
Capability.SWITCH in device.status[MAIN]
and any(capability in device.status[MAIN] for capability in CAPABILITIES)
and Capability.SAMSUNG_CE_LAMP not in device.status[component]
)
]
@@ -50,7 +50,6 @@ 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 = {
+1 -1
View File
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["wiim.sdk", "async_upnp_client"],
"quality_scale": "bronze",
"requirements": ["wiim==0.1.4"],
"requirements": ["wiim==0.1.2"],
"zeroconf": ["_linkplay._tcp.local."]
}
@@ -349,12 +349,15 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
sdk_status_str,
)
else:
self._device.playing_status = sdk_status
if sdk_status == SDKPlayingStatus.STOPPED:
LOGGER.debug(
"Device %s: TransportState is STOPPED."
" Resetting media position and metadata",
self.entity_id,
)
self._device.current_position = 0
self._device.current_track_duration = 0
self._attr_media_position_updated_at = None
self._attr_media_duration = None
self._attr_media_position = None
@@ -63,7 +63,7 @@ class MusicCastDeviceEntity(MusicCastEntity):
},
manufacturer=BRAND,
model=self.coordinator.data.model_name,
sw_version=str(self.coordinator.data.system_version),
sw_version=self.coordinator.data.system_version,
)
if self._zone_id == DEFAULT_ZONE:
+6
View File
@@ -10,6 +10,12 @@
},
"left": {
"trigger": "mdi:map-marker-minus"
},
"occupancy_cleared": {
"trigger": "mdi:account-off"
},
"occupancy_detected": {
"trigger": "mdi:account-group"
}
}
}
@@ -43,6 +43,32 @@
}
},
"name": "Left zone"
},
"occupancy_cleared": {
"description": "Triggers when a zone transitions from occupied to unoccupied.",
"fields": {
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "[%key:component::zone::triggers::occupancy_detected::fields::zone::description%]",
"name": "[%key:component::zone::triggers::occupancy_detected::fields::zone::name%]"
}
},
"name": "Zone occupancy cleared"
},
"occupancy_detected": {
"description": "Triggers when a zone transitions to an occupied state.",
"fields": {
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "The zone to monitor.",
"name": "Zone"
}
},
"name": "Zone occupancy detected"
}
}
}
+68
View File
@@ -10,7 +10,9 @@ from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_FOR,
CONF_OPTIONS,
CONF_TARGET,
CONF_ZONE,
)
from homeassistant.core import (
@@ -203,10 +205,76 @@ class LeftZoneTrigger(ZoneTriggerBase):
return not self._in_target_zone(state)
_OCCUPANCY_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(CONF_ZONE): cv.entity_id,
vol.Optional(CONF_FOR): cv.positive_time_period,
},
}
)
class _ZoneOccupancyTriggerBase(EntityTriggerBase):
"""Base for zone occupancy triggers (single zone, no behavior)."""
_domain_specs = {"zone": DomainSpec()}
_schema = _OCCUPANCY_TRIGGER_SCHEMA
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config and synthesize a target from the zone option."""
config = cast(ConfigType, cls._schema(config))
config[CONF_TARGET] = {CONF_ENTITY_ID: [config[CONF_OPTIONS][CONF_ZONE]]}
return config
@staticmethod
def _occupancy_count(state: State) -> int | None:
"""Return the zone's persons-in-zone count; None if unparsable."""
try:
return int(state.state)
except TypeError, ValueError:
return None
@classmethod
def _is_occupied(cls, state: State) -> bool:
"""Return True if the zone has at least one occupant."""
count = cls._occupancy_count(state)
return count is not None and count >= 1
class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions to an occupied state."""
def is_valid_state(self, state: State) -> bool:
"""Check that the zone is occupied."""
return self._is_occupied(state)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the zone was previously not occupied."""
return not self._is_occupied(from_state)
class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions from occupied to unoccupied."""
def is_valid_state(self, state: State) -> bool:
"""Check that the zone is empty (count == 0)."""
return self._occupancy_count(state) == 0
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the zone was previously occupied."""
return self._is_occupied(from_state)
TRIGGERS: dict[str, type[Trigger]] = {
"_": LegacyZoneTrigger,
"entered": EnteredZoneTrigger,
"left": LeftZoneTrigger,
"occupancy_detected": OccupancyDetectedTrigger,
"occupancy_cleared": OccupancyClearedTrigger,
}
@@ -24,3 +24,19 @@
entered: *trigger_zone
left: *trigger_zone
.trigger_occupancy: &trigger_occupancy
fields:
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
occupancy_detected: *trigger_occupancy
occupancy_cleared: *trigger_occupancy
+1 -1
View File
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 7
MINOR_VERSION: Final = 6
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
+6 -6
View File
@@ -3759,12 +3759,6 @@
"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,
@@ -3773,6 +3767,12 @@
}
}
},
"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,6 +31,7 @@ from homeassistant.const import (
MAX_LENGTH_STATE_DOMAIN,
MAX_LENGTH_STATE_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
EntityCategory,
Platform,
)
@@ -1950,10 +1951,9 @@ class EntityRegistry(BaseRegistry):
This should only be used when an entity needs to be migrated between
integrations.
"""
# import here to avoid circular import
from .entity import entity_sources # noqa: PLC0415
if entity_id in entity_sources(self.hass):
if (
state := self.hass.states.get(entity_id)
) is not None and state.state != STATE_UNKNOWN:
raise ValueError("Only entities that haven't been loaded can be migrated")
old = self.entities[entity_id]
-23
View File
@@ -99,8 +99,6 @@ 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 |
| `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly |
@@ -344,27 +342,6 @@ 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_setup` checker
@@ -1,138 +0,0 @@
"""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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.7.0.dev0"
version = "2026.6.0.dev0"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+2 -2
View File
@@ -2929,7 +2929,7 @@ rxv==0.7.0
samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
samsungtvws[async,encrypted]==3.0.5
samsungtvws[async,encrypted]==2.7.2
# homeassistant.components.sanix
sanix==1.0.6
@@ -3357,7 +3357,7 @@ whois==0.9.27
wiffi==1.1.2
# homeassistant.components.wiim
wiim==0.1.4
wiim==0.1.2
# homeassistant.components.wirelesstag
wirelesstagpy==0.8.1
+1 -116
View File
@@ -6,18 +6,15 @@ from unittest.mock import patch
import pytest
from homeassistant.components.analytics import CONF_SNAPSHOTS_URL, LABS_SNAPSHOT_FEATURE
from homeassistant.components.analytics import 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
@@ -40,118 +37,6 @@ 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,
-30
View File
@@ -2088,36 +2088,6 @@ async def test_receive_backup_path_traversal(
assert resp.status == 400
@pytest.mark.parametrize(
"name",
[
"/absolute/path",
"../parent",
"with/slash",
],
)
async def test_receive_backup_rejects_unsafe_inner_name(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
name: str,
) -> None:
"""Test receive backup rejects an inner name that would escape the backup dir."""
await setup_backup_integration(hass)
client = await hass_client()
backup = replace(TEST_BACKUP_ABC123, name=name)
with patch(
"homeassistant.components.backup.manager.read_backup",
return_value=backup,
):
resp = await client.post(
"/api/backup/upload?agent_id=backup.local",
data={"file": StringIO("test")},
)
assert resp.status == 400
async def test_receive_backup_busy_manager(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
-32
View File
@@ -14,7 +14,6 @@ import pytest
import securetar
from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder
from homeassistant.components.backup.models import InvalidBackupFilename
from homeassistant.components.backup.util import (
DecryptedBackupStreamer,
EncryptedBackupStreamer,
@@ -159,37 +158,6 @@ def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -
assert backup == expected_backup
@pytest.mark.parametrize(
"name",
[
"/absolute/path",
"../parent",
"with/slash",
"with\\backslash",
"C:\\drive\\path",
"",
".",
"..",
],
)
def test_read_backup_rejects_unsafe_name(name: str) -> None:
"""Test that read_backup rejects names that could escape the backup directory."""
backup_json_content = (
b'{"compressed":true,"date":"2024-12-02T07:23:58.261875-05:00","homeassistant":'
b'{"exclude_database":true,"version":"2024.12.0.dev0"},"name":"'
+ name.encode().replace(b"\\", b"\\\\")
+ b'","protected":true,"slug":"455645fe","type":"partial","version":2}'
)
mock_path = Mock()
mock_path.stat.return_value.st_size = 1234
with patch("homeassistant.components.backup.util.tarfile.open") as mock_open_tar:
tar_ctx = mock_open_tar.return_value.__enter__.return_value
tar_ctx.extractfile.return_value.read.return_value = backup_json_content
with pytest.raises(InvalidBackupFilename):
read_backup(mock_path)
@pytest.mark.parametrize(
("backup", "password", "validation_result", "expected_messages"),
[
@@ -81,7 +81,6 @@ 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)
+29 -10
View File
@@ -1586,12 +1586,12 @@ async def _validate_trigger_options(
options: dict[str, Any] | None,
*,
valid: bool,
supports_target: bool = True,
) -> None:
"""Assert that a trigger accepts or rejects the given options during validation."""
trigger_config: dict[str, Any] = {
CONF_PLATFORM: trigger,
CONF_TARGET: {ATTR_LABEL_ID: "test_label"},
}
trigger_config: dict[str, Any] = {CONF_PLATFORM: trigger}
if supports_target:
trigger_config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"}
if options is not None:
trigger_config[CONF_OPTIONS] = options
if valid:
@@ -1608,6 +1608,7 @@ async def assert_trigger_options_supported(
*,
supports_behavior: bool,
supports_duration: bool,
supports_target: bool = True,
) -> None:
"""Assert which options a trigger supports.
@@ -1624,9 +1625,15 @@ async def assert_trigger_options_supported(
# Minimal config should always be valid
supports_empty = not bool(base_options)
await _validate_trigger_options(hass, trigger, None, valid=supports_empty)
await _validate_trigger_options(hass, trigger, {}, valid=supports_empty)
await _validate_trigger_options(hass, trigger, base_options, valid=True)
await _validate_trigger_options(
hass, trigger, None, valid=supports_empty, supports_target=supports_target
)
await _validate_trigger_options(
hass, trigger, {}, valid=supports_empty, supports_target=supports_target
)
await _validate_trigger_options(
hass, trigger, base_options, valid=True, supports_target=supports_target
)
def _merge(extra: dict[str, Any]) -> dict[str, Any]:
return {**(base_options or {}), **extra}
@@ -1634,18 +1641,30 @@ async def assert_trigger_options_supported(
# Behavior
for behavior in ("each", "first", "all"):
await _validate_trigger_options(
hass, trigger, _merge({"behavior": behavior}), valid=supports_behavior
hass,
trigger,
_merge({"behavior": behavior}),
valid=supports_behavior,
supports_target=supports_target,
)
# Duration
for for_value in ({"seconds": 5}, "00:00:05", 5):
await _validate_trigger_options(
hass, trigger, _merge({"for": for_value}), valid=supports_duration
hass,
trigger,
_merge({"for": for_value}),
valid=supports_duration,
supports_target=supports_target,
)
# Unknown option should always be rejected
await _validate_trigger_options(
hass, trigger, _merge({"unknown_option": True}), valid=False
hass,
trigger,
_merge({"unknown_option": True}),
valid=False,
supports_target=supports_target,
)
@@ -411,6 +411,7 @@ 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."},
)
@@ -436,6 +437,7 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None:
],
"description_placeholders": {
"url": "https://example.com",
"show_advanced_options": True,
},
"errors": {"username": "Should be unique."},
"last_step": None,
@@ -1379,31 +1379,6 @@ def test_base_tracker_entity() -> None:
entity.state_attributes # noqa: B018
def test_battery_level_override_deprecation_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that overriding battery_level in a subclass logs a warning."""
error_message = "is overriding the deprecated battery_level property"
caplog.clear()
class _SubclassWithOverride(TrackerEntity):
@property
def battery_level(self) -> int | None:
return 50
assert error_message in caplog.text
assert _SubclassWithOverride.__name__ in caplog.text
# No warning for a subclass that does not override battery_level
caplog.clear()
class _SubclassWithoutOverride(TrackerEntity):
pass
assert error_message not in caplog.text
async def test_attr_location_name_deprecation_warning(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
-23
View File
@@ -98,29 +98,6 @@ 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,
+6 -16
View File
@@ -122,34 +122,24 @@ 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_failures_keep_node_entities_available(
async def test_lan_info_duco_error_marks_unavailable(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test node entities stay available when LAN info retrieval fails."""
mock_duco_client.async_get_lan_info = AsyncMock(side_effect=exception)
"""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")
)
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 == "-60"
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
@@ -83,7 +83,6 @@ 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
@@ -102,7 +101,6 @@ 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,9 +43,6 @@ 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(
@@ -57,7 +54,7 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry:
return_value=mock_ec(),
),
patch(
"homeassistant.components.environment_canada.ECMap",
"homeassistant.components.environment_canada.ECRadar",
return_value=radar_mock,
),
patch(
@@ -1,118 +0,0 @@
"""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,12 +4176,6 @@ 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,
@@ -246,13 +246,6 @@ 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,29 +1046,3 @@ 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,7 +32,6 @@ 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
@@ -61,7 +60,6 @@ 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"
@@ -84,7 +82,6 @@ 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,7 +62,6 @@ 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.fetch_controllers = AsyncMock(return_value={})
disco.controllers = {}
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.fetch_controllers = AsyncMock(
return_value={mock_controller.device_uid: mock_controller}
)
mock_disco.return_value.controllers = {
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 AsyncMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
@@ -18,7 +18,7 @@ def mock_disco() -> Mock:
"""Mock discovery service."""
disco = Mock()
disco.pi_disco = Mock()
disco.pi_disco.fetch_controllers = AsyncMock(return_value={})
disco.pi_disco.controllers = {}
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.fetch_controllers = AsyncMock(return_value={"blah": object()})
mock_disco.pi_disco.controllers["blah"] = object()
with (
patch(
@@ -42,7 +42,6 @@ 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
-1
View File
@@ -78,7 +78,6 @@ FIXTURES = [
"mock_pressure_sensor",
"mock_pump",
"mock_room_airconditioner",
"mock_soil_sensor",
"mock_solar_inverter",
"mock_speaker",
"mock_switch_unit",
@@ -1,87 +0,0 @@
{
"node_id": 101,
"date_commissioned": "2024-11-27T00:00:00.000000",
"last_interview": "2024-11-27T00:00:00.000000",
"interview_version": 2,
"attributes": {
"0/29/0": [
{
"0": 22,
"1": 1
}
],
"0/29/1": [
4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63,
64, 65
],
"0/29/2": [41],
"0/29/3": [1],
"0/29/65532": 0,
"0/29/65533": 1,
"0/29/65528": [],
"0/29/65529": [],
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/40/0": 1,
"0/40/1": "Nabu Casa",
"0/40/2": 65521,
"0/40/3": "Mock SoilSensor",
"0/40/4": 32768,
"0/40/5": "Mock Soil Sensor",
"0/40/6": "XX",
"0/40/7": 0,
"0/40/8": "v1.0",
"0/40/9": 1,
"0/40/10": "v1.0",
"0/40/11": "20241127",
"0/40/12": "",
"0/40/13": "",
"0/40/14": "",
"0/40/15": "TEST_SN",
"0/40/16": false,
"0/40/17": true,
"0/40/18": "mock-soil-sensor",
"0/40/19": {
"0": 3,
"1": 3
},
"0/40/65532": 0,
"0/40/65533": 1,
"0/40/65528": [],
"0/40/65529": [],
"0/40/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
65528, 65529, 65531, 65532, 65533
],
"1/3/65529": [0, 64],
"1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
"1/29/0": [
{
"0": 69,
"1": 1
}
],
"1/29/1": [3, 29, 57, 1072, 40],
"1/29/2": [],
"1/29/3": [9, 10],
"1/29/65532": null,
"1/29/65533": 1,
"1/29/65528": [],
"1/29/65529": [],
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"1/1072/0": {
"0": 17,
"1": true,
"2": 0,
"3": 100,
"4": []
},
"1/1072/1": 50,
"1/1072/65532": 0,
"1/1072/65533": 1,
"1/1072/65528": [],
"1/1072/65529": [],
"1/1072/65531": [0, 1, 65528, 65529, 65531, 65532, 65533]
},
"available": true,
"attribute_subscriptions": []
}
@@ -18619,61 +18619,6 @@
'state': '0.0',
})
# ---
# name: test_sensors[mock_soil_sensor][sensor.mock_soil_sensor_moisture-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_soil_sensor_moisture',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Moisture',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MOISTURE: 'moisture'>,
'original_icon': None,
'original_name': 'Moisture',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000065-MatterNodeDevice-1-SoilMoistureSensor-1072-1',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[mock_soil_sensor][sensor.mock_soil_sensor_moisture-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'moisture',
'friendly_name': 'Mock Soil Sensor Moisture',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_soil_sensor_moisture',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '50',
})
# ---
# name: test_sensors[mock_solar_inverter][sensor.mock_solar_inverter_active_current-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+1 -7
View File
@@ -69,15 +69,9 @@ 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,
-19
View File
@@ -85,25 +85,6 @@ async def test_humidity_sensor(
assert state.state == "40.0"
@pytest.mark.parametrize("node_fixture", ["mock_soil_sensor"])
async def test_soil_moisture_sensor(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test soil moisture sensor."""
state = hass.states.get("sensor.mock_soil_sensor_moisture")
assert state
assert state.state == "50"
set_node_attribute(matter_node, 1, 1072, 1, 75)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_soil_sensor_moisture")
assert state
assert state.state == "75"
@pytest.mark.parametrize("node_fixture", ["mock_light_sensor"])
async def test_light_sensor(
hass: HomeAssistant,
+5 -7
View File
@@ -270,7 +270,7 @@ async def test_state_via_state_topic_through_position(
) -> None:
"""Test the controlling state via topic through position.
Test it is still possible to process a `opening` or `closing`
Test is still possible to process a `opening` or `closing`
state update. Additional we test json messages can be
processed containing both position and state. Incoming
rendered positions are clamped between 0..100.
@@ -308,7 +308,7 @@ async def test_opening_closing_state_is_reset(
) -> None:
"""Test the controlling state via topic through position.
Test an `opening` or `closing` state update is reset
Test a `opening` or `closing` state update is reset
correctly after sequential updates.
"""
await mqtt_mock_entry()
@@ -320,13 +320,11 @@ async def test_opening_closing_state_is_reset(
messages = [
('{"position": 0, "state": "opening"}', ValveState.OPENING, 0),
('{"position": 50, "state": "opening"}', ValveState.OPENING, 50),
# Position-only update at intermediate position resets opening state
('{"position": 60}', ValveState.OPEN, 60),
('{"position": 60}', ValveState.OPENING, 60),
('{"position": 100, "state": "opening"}', ValveState.OPENING, 100),
('{"position": 100, "state": null}', ValveState.OPEN, 100),
('{"position": 90, "state": "closing"}', ValveState.CLOSING, 90),
# Position-only update at intermediate position resets closing state
('{"position": 40}', ValveState.OPEN, 40),
('{"position": 40}', ValveState.CLOSING, 40),
('{"position": 0}', ValveState.CLOSED, 0),
('{"position": 10}', ValveState.OPEN, 10),
('{"position": 0, "state": "opening"}', ValveState.OPENING, 0),
@@ -440,7 +438,7 @@ async def test_state_via_state_trough_position_with_alt_range(
asserted_state: str,
valve_position: int | None,
) -> None:
"""Test controlling state via position with an alternative range.
"""Test controlling state via position with alternative range.
Test is still possible to process a `opening` or `closing`
state update. Additional we test json messages can be
@@ -246,75 +246,3 @@ async def test_subentry_entry_not_loaded(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "entry_not_loaded"
async def test_reauth_flow(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the reauth flow updates the API key."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_KEY: "new_key"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new_key"
@pytest.mark.parametrize(
("exception", "error"),
[
(
AuthenticationError(
message="invalid key",
response=httpx.Response(
status_code=401,
request=httpx.Request(method="POST", url="https://example.com"),
),
body=None,
),
"invalid_auth",
),
(OpenAIError("boom"), "cannot_connect"),
(Exception("boom"), "unknown"),
],
)
async def test_reauth_flow_errors(
hass: HomeAssistant,
mock_openai_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
error: str,
) -> None:
"""Test errors during reauth and recovery."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
mock_openai_client.chat.completions.create.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_KEY: "new_key"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_openai_client.chat.completions.create.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_KEY: "new_key"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new_key"
File diff suppressed because it is too large Load Diff
@@ -1,101 +0,0 @@
"""Tests for the Samsung Infrared button platform."""
from infrared_protocols.codes.samsung.tv import SamsungTVCode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
from tests.components.common import assert_availability_follows_source_entity
from tests.components.infrared import EMITTER_ENTITY_ID
from tests.components.infrared.common import MockInfraredEmitterEntity
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.BUTTON]
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test button entities are created with correct attributes."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity_id", "expected_code"),
[
("button.samsung_tv_power", SamsungTVCode.POWER),
("button.samsung_tv_source", SamsungTVCode.SOURCE),
("button.samsung_tv_settings", SamsungTVCode.SETTINGS),
("button.samsung_tv_info", SamsungTVCode.INFO),
("button.samsung_tv_exit", SamsungTVCode.EXIT),
("button.samsung_tv_return", SamsungTVCode.RETURN),
("button.samsung_tv_home", SamsungTVCode.HOME),
("button.samsung_tv_red", SamsungTVCode.RED),
("button.samsung_tv_green", SamsungTVCode.GREEN),
("button.samsung_tv_yellow", SamsungTVCode.YELLOW),
("button.samsung_tv_blue", SamsungTVCode.BLUE),
("button.samsung_tv_up", SamsungTVCode.NAV_UP),
("button.samsung_tv_down", SamsungTVCode.NAV_DOWN),
("button.samsung_tv_left", SamsungTVCode.NAV_LEFT),
("button.samsung_tv_right", SamsungTVCode.NAV_RIGHT),
("button.samsung_tv_ok", SamsungTVCode.OK),
("button.samsung_tv_previous_channel", SamsungTVCode.PREVIOUS_CHANNEL),
("button.samsung_tv_number_0", SamsungTVCode.NUM_0),
("button.samsung_tv_number_1", SamsungTVCode.NUM_1),
("button.samsung_tv_number_2", SamsungTVCode.NUM_2),
("button.samsung_tv_number_3", SamsungTVCode.NUM_3),
("button.samsung_tv_number_4", SamsungTVCode.NUM_4),
("button.samsung_tv_number_5", SamsungTVCode.NUM_5),
("button.samsung_tv_number_6", SamsungTVCode.NUM_6),
("button.samsung_tv_number_7", SamsungTVCode.NUM_7),
("button.samsung_tv_number_8", SamsungTVCode.NUM_8),
("button.samsung_tv_number_9", SamsungTVCode.NUM_9),
("button.samsung_tv_fast_forward", SamsungTVCode.FAST_FORWARD),
("button.samsung_tv_rewind", SamsungTVCode.REWIND),
("button.samsung_tv_record", SamsungTVCode.RECORD),
("button.samsung_tv_tools", SamsungTVCode.TOOLS),
("button.samsung_tv_browser", SamsungTVCode.BROWSER),
("button.samsung_tv_ad_subtitle", SamsungTVCode.AD_SUBTITLE),
("button.samsung_tv_e_manual", SamsungTVCode.E_MANUAL),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_button_press_sends_correct_code(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
entity_id: str,
expected_code: SamsungTVCode,
) -> None:
"""Test pressing each button sends the correct IR code."""
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
assert (
mock_infrared_emitter_entity.send_command_calls[0] == expected_code.to_command()
)
@pytest.mark.usefixtures("init_integration")
async def test_button_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test button becomes unavailable when IR entity is unavailable."""
entity_id = "button.samsung_tv_source"
await assert_availability_follows_source_entity(hass, entity_id, EMITTER_ENTITY_ID)
+4 -53
View File
@@ -1,21 +1,19 @@
"""The tests for Sense binary sensor platform."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from sense_energy import SenseAPIException
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.sense.const import ACTIVE_UPDATE_RATE, DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.components.sense.const import ACTIVE_UPDATE_RATE
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util.dt import utcnow
from . import setup_platform
from .const import DEVICE_1_ID, DEVICE_1_NAME, DEVICE_2_NAME, MONITOR_ID
from .const import DEVICE_1_NAME, DEVICE_2_NAME
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -68,50 +66,3 @@ async def test_on_off_sensors(
state = hass.states.get(f"binary_sensor.{DEVICE_2_NAME.lower()}_power")
assert state.state == STATE_ON
async def test_realtime_update_exception(
hass: HomeAssistant,
mock_sense: MagicMock,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that binary sensor entities become unavailable on realtime coordinator failure."""
await setup_platform(hass, config_entry, Platform.BINARY_SENSOR)
state = hass.states.get(f"binary_sensor.{DEVICE_1_NAME.lower()}_power")
assert state is not None
assert state.state != STATE_UNAVAILABLE
mock_sense.update_realtime.side_effect = SenseAPIException("api error")
freezer.tick(timedelta(seconds=ACTIVE_UPDATE_RATE))
async_fire_time_changed(hass, freezer())
await hass.async_block_till_done()
state = hass.states.get(f"binary_sensor.{DEVICE_1_NAME.lower()}_power")
assert state.state == STATE_UNAVAILABLE
async def test_migrate_unique_ids(
hass: HomeAssistant,
mock_sense: MagicMock,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that entities registered under the old bare device-ID unique_id are migrated."""
config_entry.add_to_hass(hass)
old_entry = entity_registry.async_get_or_create(
BINARY_SENSOR_DOMAIN,
DOMAIN,
DEVICE_1_ID,
config_entry=config_entry,
)
assert old_entry.unique_id == DEVICE_1_ID
with patch("homeassistant.components.sense.PLATFORMS", [Platform.BINARY_SENSOR]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
migrated = entity_registry.async_get(old_entry.entity_id)
assert migrated.unique_id == f"{MONITOR_ID}-{DEVICE_1_ID}"
+189 -127
View File
@@ -1,7 +1,6 @@
"""Test the Sense config flow."""
from collections.abc import Iterator
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
from sense_energy import (
@@ -22,9 +21,9 @@ from .const import MOCK_CONFIG
from tests.common import MockConfigEntry
@pytest.fixture(name="mock_flow_sense")
def mock_flow_sense_fixture() -> Iterator[MagicMock]:
"""Mock Sense object for authentication."""
@pytest.fixture(name="mock_sense")
def mock_sense():
"""Mock Sense object for authenticatation."""
with patch(
"homeassistant.components.sense.config_flow.ASyncSenseable"
) as mock_sense:
@@ -38,196 +37,259 @@ def mock_flow_sense_fixture() -> Iterator[MagicMock]:
yield mock_sense
@pytest.mark.usefixtures("mock_flow_sense")
async def test_form(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
async def test_form(hass: HomeAssistant, mock_sense) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
await hass.async_block_till_done()
with patch(
"homeassistant.components.sense.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-email"
assert result["data"] == MOCK_CONFIG
mock_setup_entry.assert_called_once()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "test-email"
assert result2["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(SenseAuthenticationException(), "invalid_auth"),
(SenseAPITimeoutException(), "cannot_connect"),
(SenseAPIException(), "cannot_connect"),
(Exception("unknown exception"), "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_form_exceptions(
hass: HomeAssistant,
mock_flow_sense: MagicMock,
exception: Exception,
error: str,
) -> None:
"""Test we handle all exceptions in the user flow and can recover."""
mock_flow_sense.return_value.authenticate.side_effect = exception
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
with patch(
"sense_energy.ASyncSenseable.authenticate",
side_effect=SenseAuthenticationException,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
# Verify recovery: clear the error and complete the flow successfully
mock_flow_sense.return_value.authenticate.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
@pytest.mark.usefixtures("mock_setup_entry")
async def test_form_mfa_required(
hass: HomeAssistant,
mock_flow_sense: MagicMock,
) -> None:
"""Test we handle the MFA flow."""
mock_flow_sense.return_value.authenticate.side_effect = SenseMFARequiredException()
async def test_form_mfa_required(hass: HomeAssistant, mock_sense) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
mock_sense.return_value.authenticate.side_effect = SenseMFARequiredException
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "validation"
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "validation"
result = await hass.config_entries.flow.async_configure(
mock_sense.return_value.validate_mfa.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_CODE: "012345"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-email"
assert result["data"] == MOCK_CONFIG
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "test-email"
assert result3["data"] == MOCK_CONFIG
@pytest.mark.parametrize(
("exception", "error"),
[
(SenseAuthenticationException(), "invalid_auth"),
(SenseAPITimeoutException(), "cannot_connect"),
(SenseAPIException(), "cannot_connect"),
(Exception("Unknown exception"), "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_form_mfa_exceptions(
hass: HomeAssistant,
mock_flow_sense: MagicMock,
exception: Exception,
error: str,
) -> None:
"""Test we handle all MFA validation exceptions and can recover."""
mock_flow_sense.return_value.authenticate.side_effect = SenseMFARequiredException()
async def test_form_mfa_required_wrong(hass: HomeAssistant, mock_sense) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
mock_sense.return_value.authenticate.side_effect = SenseMFARequiredException
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "validation"
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "validation"
mock_flow_sense.return_value.validate_mfa.side_effect = exception
result = await hass.config_entries.flow.async_configure(
mock_sense.return_value.validate_mfa.side_effect = SenseAuthenticationException
# Try with the WRONG verification code give us the form back again
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_CODE: "000000"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
assert result["step_id"] == "validation"
assert result3["type"] is FlowResultType.FORM
assert result3["errors"] == {"base": "invalid_auth"}
assert result3["step_id"] == "validation"
# Verify recovery: clear the error and complete MFA successfully
mock_flow_sense.return_value.validate_mfa.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_CODE: "012345"},
async def test_form_mfa_required_timeout(hass: HomeAssistant, mock_sense) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-email"
assert result["data"] == MOCK_CONFIG
mock_sense.return_value.authenticate.side_effect = SenseMFARequiredException
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "validation"
mock_sense.return_value.validate_mfa.side_effect = SenseAPITimeoutException
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_CODE: "000000"},
)
assert result3["type"] is FlowResultType.FORM
assert result3["errors"] == {"base": "cannot_connect"}
@pytest.mark.usefixtures("mock_flow_sense")
async def test_reauth_no_form(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
async def test_form_mfa_required_exception(hass: HomeAssistant, mock_sense) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_sense.return_value.authenticate.side_effect = SenseMFARequiredException
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "validation"
mock_sense.return_value.validate_mfa.side_effect = Exception
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_CODE: "000000"},
)
assert result3["type"] is FlowResultType.FORM
assert result3["errors"] == {"base": "unknown"}
async def test_form_timeout(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"sense_energy.ASyncSenseable.authenticate",
side_effect=SenseAPITimeoutException,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"sense_energy.ASyncSenseable.authenticate",
side_effect=SenseAPIException,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_unknown_exception(hass: HomeAssistant) -> None:
"""Test we handle unknown error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"sense_energy.ASyncSenseable.authenticate",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"timeout": "6", "email": "test-email", "password": "test-password"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
async def test_reauth_no_form(hass: HomeAssistant, mock_sense) -> None:
"""Test reauth where no form needed."""
# set up initially
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_CONFIG,
unique_id="test-email",
)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
await hass.async_block_till_done()
with patch(
"homeassistant.config_entries.ConfigEntries.async_reload",
return_value=True,
):
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
mock_setup_entry.assert_called_once()
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reauth_password(
hass: HomeAssistant,
mock_flow_sense: MagicMock,
) -> None:
async def test_reauth_password(hass: HomeAssistant, mock_sense) -> None:
"""Test reauth form."""
# set up initially
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_CONFIG,
unique_id="test-email",
)
entry.add_to_hass(hass)
mock_flow_sense.return_value.authenticate.side_effect = SenseAuthenticationException
mock_sense.return_value.authenticate.side_effect = SenseAuthenticationException
# Reauth success without user input
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
mock_flow_sense.return_value.authenticate.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"password": "test-password"},
)
await hass.async_block_till_done()
mock_sense.return_value.authenticate.side_effect = None
with patch(
"homeassistant.components.sense.async_setup_entry",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"password": "test-password"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
@@ -1,53 +0,0 @@
"""Tests for the Sense coordinators."""
from datetime import timedelta
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from sense_energy import SenseAuthenticationException, SenseMFARequiredException
from homeassistant.components.sense.const import DOMAIN, TREND_UPDATE_RATE
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from . import setup_platform
from .const import MONITOR_ID
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.parametrize(
"exception",
[
SenseAuthenticationException("auth expired"),
SenseMFARequiredException("auth expired"),
],
)
async def test_trend_coordinator_auth_failure(
hass: HomeAssistant,
mock_sense: MagicMock,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test that auth errors from the trend coordinator start a reauth flow."""
await setup_platform(hass, config_entry, Platform.SENSOR)
mock_sense.update_trend_data.side_effect = exception
freezer.tick(timedelta(seconds=TREND_UPDATE_RATE))
async_fire_time_changed(hass, freezer())
await hass.async_block_till_done()
state = hass.states.get(f"sensor.sense_{MONITOR_ID}_daily_energy")
assert state.state == STATE_UNAVAILABLE
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow.get("step_id") == "reauth_validate"
assert flow.get("handler") == DOMAIN
assert flow["context"].get("source") == SOURCE_REAUTH
assert flow["context"].get("entry_id") == config_entry.entry_id
-123
View File
@@ -1,123 +0,0 @@
"""Tests for the Sense integration setup."""
import socket
from unittest.mock import MagicMock
import pytest
from sense_energy import (
SenseAPIException,
SenseAPITimeoutException,
SenseAuthenticationException,
SenseMFARequiredException,
SenseWebsocketException,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
"exception",
[
SenseAPITimeoutException(),
SenseWebsocketException(),
],
)
async def test_setup_entry_exceptions(
hass: HomeAssistant,
mock_sense: MagicMock,
config_entry: MockConfigEntry,
exception: Exception,
) -> None:
"""Test we handle exceptions during async_setup_entry and can recover."""
mock_sense.update_realtime.side_effect = exception
config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
# Verify recovery: clear the error and reload the entry
mock_sense.update_realtime.side_effect = None
assert await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
@pytest.mark.parametrize(
"exception",
[
SenseAuthenticationException(),
SenseMFARequiredException(),
],
)
async def test_setup_get_monitor_data_auth_exceptions(
hass: HomeAssistant,
mock_sense: MagicMock,
config_entry: MockConfigEntry,
exception: Exception,
) -> None:
"""Test auth exceptions from get_monitor_data result in a failed entry."""
mock_sense.get_monitor_data.side_effect = exception
config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
@pytest.mark.parametrize(
"exception",
[
SenseAPITimeoutException(),
TimeoutError(),
SenseAPIException("connect error"),
socket.gaierror(),
],
)
async def test_setup_get_monitor_data_retry_exceptions(
hass: HomeAssistant,
mock_sense: MagicMock,
config_entry: MockConfigEntry,
exception: Exception,
) -> None:
"""Test timeout and connect exceptions from get_monitor_data result in a retryable entry."""
mock_sense.get_monitor_data.side_effect = exception
config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
"exception",
[
SenseAPITimeoutException(),
TimeoutError(),
SenseAPIException("connect error"),
socket.gaierror(),
SenseWebsocketException("ws error"),
SenseAPIException(),
],
)
async def test_setup_get_realtime_retry_exceptions(
hass: HomeAssistant,
mock_sense: MagicMock,
config_entry: MockConfigEntry,
exception: Exception,
) -> None:
"""Test timeout and connect exceptions from update_realtime result in a retryable entry."""
mock_sense.update_realtime.side_effect = exception
config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
+2 -93
View File
@@ -1,22 +1,16 @@
"""The tests for Sense sensor platform."""
from datetime import timedelta
import socket
from unittest.mock import MagicMock, PropertyMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from sense_energy import (
Scale,
SenseAPIException,
SenseAPITimeoutException,
SenseWebsocketException,
)
from sense_energy import Scale
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.sense.const import ACTIVE_UPDATE_RATE, TREND_UPDATE_RATE
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util.dt import utcnow
@@ -238,88 +232,3 @@ async def test_trend_energy_sensors(
state = hass.states.get(f"sensor.sense_{MONITOR_ID}_daily_net_production")
assert state.state == "5000"
@pytest.mark.parametrize(
"exception",
[
SenseAPIException("api error"),
SenseAPITimeoutException("timeout"),
TimeoutError("timeout"),
socket.gaierror("addr info error"),
],
)
async def test_trend_coordinator_update_failure(
hass: HomeAssistant,
mock_sense: MagicMock,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test that connection errors from the trend coordinator mark entities unavailable."""
await setup_platform(hass, config_entry, Platform.SENSOR)
state = hass.states.get(f"sensor.sense_{MONITOR_ID}_daily_energy")
assert state is not None
assert state.state != STATE_UNAVAILABLE
mock_sense.update_trend_data.side_effect = exception
freezer.tick(timedelta(seconds=TREND_UPDATE_RATE))
async_fire_time_changed(hass, freezer())
await hass.async_block_till_done()
state = hass.states.get(f"sensor.sense_{MONITOR_ID}_daily_energy")
assert state.state == STATE_UNAVAILABLE
mock_sense.update_trend_data.side_effect = None
freezer.tick(timedelta(seconds=TREND_UPDATE_RATE))
async_fire_time_changed(hass, freezer())
await hass.async_block_till_done()
state = hass.states.get(f"sensor.sense_{MONITOR_ID}_daily_energy")
assert state.state != STATE_UNAVAILABLE
@pytest.mark.parametrize(
"exception",
[
SenseAPIException("api error"),
SenseAPITimeoutException("timeout"),
TimeoutError("timeout"),
SenseWebsocketException("ws error"),
socket.gaierror("addr info error"),
],
)
async def test_realtime_coordinator_update_failure(
hass: HomeAssistant,
mock_sense: MagicMock,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test that errors from the realtime coordinator mark entities unavailable."""
await setup_platform(hass, config_entry, Platform.SENSOR)
state = hass.states.get(f"sensor.sense_{MONITOR_ID}_energy")
assert state is not None
assert state.state != STATE_UNAVAILABLE
mock_sense.update_realtime.side_effect = exception
freezer.tick(timedelta(seconds=ACTIVE_UPDATE_RATE))
async_fire_time_changed(hass, freezer())
await hass.async_block_till_done()
state = hass.states.get(f"sensor.sense_{MONITOR_ID}_energy")
assert state.state == STATE_UNAVAILABLE
mock_sense.update_realtime.side_effect = None
freezer.tick(timedelta(seconds=ACTIVE_UPDATE_RATE))
async_fire_time_changed(hass, freezer())
await hass.async_block_till_done()
state = hass.states.get(f"sensor.sense_{MONITOR_ID}_energy")
assert state.state != STATE_UNAVAILABLE

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