Compare commits

..

25 Commits

Author SHA1 Message Date
Sören 840243db9c Improve Avea Bluetooth discovery flow (#172623) 2026-05-30 09:49:36 -05:00
Avi Miller 740778f00b fix: bump aiolifx and aiolifx-themes (#172619)
Signed-off-by: Avi Miller <me@dje.li>
2026-05-30 16:56:20 +03:00
J. Nick Koston 1ec5e25b6b Fix ESPHome update entity stuck on for project versions with build suffix (#172571) 2026-05-30 08:50:26 -05:00
J. Nick Koston 83c35b8b4d Bump pyroute2 to 0.9.6 (#172521) 2026-05-30 08:50:16 -05:00
J. Nick Koston 02b760f142 Expose bluetooth address reachability diagnostics API (#172578) 2026-05-30 08:49:56 -05:00
Erwin Douna 0c10c2c16b Proxmox refactor config flow to support no nodes (#172615) 2026-05-30 15:45:45 +02:00
Michael 144257a377 Show error about missing api permissions while browsing Immich media (#172609) 2026-05-30 11:57:37 +02:00
epenet c5341b2ff6 Fix incorrect use of Platform enum in component tests (#172574) 2026-05-30 11:31:42 +02:00
J. Nick Koston 6aebf78961 Bump yalexs to 9.2.7 (#172582) 2026-05-30 11:53:00 +03:00
On Freund 759039728b Bump pyrisco to 0.8.0 (#172591) 2026-05-30 10:35:43 +02:00
J. Nick Koston 1d2f0793d7 Bump habluetooth to 6.8.0 (#172577) 2026-05-29 18:11:51 +02:00
epenet 14fcb6c2d6 Import notify domain in notify tests (#172572) 2026-05-29 18:10:59 +02:00
Jan Bouwhuis 5763829b4b Silent migrate MQTT protocol version to version 5 if the broker supports it or raise an issue (#172500)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-29 17:33:11 +02:00
dependabot[bot] 7dfec6ef3d Bump docker/setup-buildx-action from 4.0.0 to 4.1.0 (#172526) 2026-05-29 16:22:05 +02:00
dependabot[bot] efe55f247a Bump docker/metadata-action from 6.0.0 to 6.1.0 (#172528) 2026-05-29 16:20:53 +02:00
epenet 85f3141776 Fix CI failure due to missing ssdp patching in braviatv (#172561) 2026-05-29 14:18:08 +02:00
Michael a175c7c4be Handle FileNotFoundError in Immich upload_file action (#172490) 2026-05-29 13:22:26 +02:00
Zach Wolf 03c83091ab Catch network errors during Roborock config entry setup (#172492)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 13:21:01 +02:00
mhuiskes accebd7f38 Remove diagnostic category and dead translation key from pac sensor (#172548) 2026-05-29 12:51:17 +02:00
epenet 9d3bb346e9 Refactor Renault to use StrEnum (#172546) 2026-05-29 11:42:04 +02:00
mhuiskes d13721980e Fix zeversolar coordinator to raise UpdateFailed on errors (#170507) 2026-05-29 11:26:27 +02:00
Franck Nijhof ac6b5a5850 Add missing ssdp dependency to BraviaTV manifest (#172536) 2026-05-29 11:17:36 +02:00
Franck Nijhof 16dfa99673 Use state-based icon for Hue grouped light (#172535) 2026-05-29 11:17:00 +02:00
Franck Nijhof f51a02bbda Fix Volvo lock crash when API field is missing from coordinator data (#172465) 2026-05-29 10:50:55 +02:00
Paul Bottein 6a51b21242 Fix Yoto OAuth flow with cloud credentials (#172544)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-29 10:30:52 +02:00
69 changed files with 1174 additions and 682 deletions
+2 -2
View File
@@ -380,7 +380,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -394,7 +394,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
}
+13 -6
View File
@@ -8,6 +8,7 @@ import avea
from bleak.exc import BleakError
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -66,6 +67,15 @@ def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
return AVEA_SERVICE_UUID in discovery_info.service_uuids
def _discovery_label(discovery_info: BluetoothServiceInfoBleak) -> str:
"""Return a label for a discovered Avea bulb."""
if (
name := _normalize_name(discovery_info.name)
) and name != discovery_info.address:
return f"{name} ({discovery_info.address})"
return discovery_info.address
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Avea."""
@@ -150,6 +160,7 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
@@ -165,11 +176,10 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
if self._discovery_info:
disc = self._discovery_info
label = f"{disc.name or disc.address} ({disc.address})"
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS, default=disc.address): vol.In(
{disc.address: label}
{disc.address: _discovery_label(disc)}
)
}
)
@@ -178,10 +188,7 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: (
f"{service_info.name or service_info.address}"
f" ({service_info.address})"
)
service_info.address: _discovery_label(service_info)
for service_info in self._discovered_devices.values()
}
),
@@ -27,6 +27,7 @@ from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothReachabilityIntent,
BluetoothScannerDevice,
BluetoothScanningMode,
HaBluetoothConnector,
@@ -55,6 +56,7 @@ from . import passive_update_processor, websocket_api
from .api import (
_get_manager,
async_address_present,
async_address_reachability_diagnostics,
async_ble_device_from_address,
async_clear_address_from_match_history,
async_clear_advertisement_history,
@@ -108,12 +110,14 @@ __all__ = [
"BluetoothCallback",
"BluetoothCallbackMatcher",
"BluetoothChange",
"BluetoothReachabilityIntent",
"BluetoothScannerDevice",
"BluetoothScanningMode",
"BluetoothServiceInfo",
"BluetoothServiceInfoBleak",
"HaBluetoothConnector",
"async_address_present",
"async_address_reachability_diagnostics",
"async_ble_device_from_address",
"async_clear_address_from_match_history",
"async_clear_advertisement_history",
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
from habluetooth import (
BaseHaScanner,
BluetoothReachabilityIntent,
BluetoothScannerDevice,
BluetoothScanningMode,
HaBleakScannerWrapper,
@@ -108,6 +109,14 @@ def async_ble_device_from_address(
return _get_manager(hass).async_ble_device_from_address(address, connectable)
@hass_callback
def async_address_reachability_diagnostics(
hass: HomeAssistant, address: str, intent: BluetoothReachabilityIntent
) -> str:
"""Return a human readable explanation of why an address may be unreachable."""
return _get_manager(hass).async_address_reachability_diagnostics(address, intent)
@hass_callback
def async_scanner_devices_by_address(
hass: HomeAssistant, address: str, connectable: bool = True
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.16",
"habluetooth==6.7.9"
"habluetooth==6.8.0"
]
}
@@ -3,6 +3,7 @@
"name": "Sony Bravia TV",
"codeowners": ["@bieniu", "@Drafteed"],
"config_flow": true,
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/braviatv",
"integration_type": "device",
"iot_class": "local_polling",
@@ -284,6 +284,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
UpdateDeviceClass, static_info.device_class
)
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
"""Return True if latest_version is newer than installed_version.
ESPHome project versions can carry a build suffix (e.g.
2025.11.5_c51f7548) that AwesomeVersion cannot parse. Without stripping
it the base comparison raises and the entity is forced on for every
build mismatch. Drop the suffix so the versions compare cleanly and we
only report genuinely newer firmware.
"""
return super().version_is_newer(
latest_version.partition("_")[0], installed_version.partition("_")[0]
)
@property
@esphome_state_property
def installed_version(self) -> str:
+6
View File
@@ -1,6 +1,12 @@
{
"entity": {
"light": {
"hue_grouped_light": {
"default": "mdi:lightbulb-group",
"state": {
"off": "mdi:lightbulb-group-off"
}
},
"hue_light": {
"state_attributes": {
"effect": {
-1
View File
@@ -85,7 +85,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
entity_description = LightEntityDescription(
key="hue_grouped_light",
icon="mdi:lightbulb-group",
has_entity_name=True,
name=None,
)
@@ -4,7 +4,7 @@ from logging import getLogger
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse
from aioimmich.assets.models import ImmichAsset
from aioimmich.exceptions import ImmichError
from aioimmich.exceptions import ImmichError, ImmichForbiddenError
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import BrowseError, MediaClass
@@ -79,7 +79,7 @@ class ImmichMediaSource(MediaSource):
],
)
async def _async_build_immich(
async def _async_build_immich( # noqa: C901
self, item: MediaSourceItem, entries: list[ConfigEntry]
) -> list[BrowseMediaSource]:
"""Handle browsing different immich instances."""
@@ -137,6 +137,12 @@ class ImmichMediaSource(MediaSource):
LOGGER.debug("Render all albums for %s", entry.title)
try:
albums = await immich_api.albums.async_get_all_albums()
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
@@ -158,6 +164,12 @@ class ImmichMediaSource(MediaSource):
LOGGER.debug("Render all tags for %s", entry.title)
try:
tags = await immich_api.tags.async_get_all_tags()
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
@@ -178,6 +190,12 @@ class ImmichMediaSource(MediaSource):
LOGGER.debug("Render all people for %s", entry.title)
try:
people = await immich_api.people.async_get_all_people()
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
@@ -211,6 +229,12 @@ class ImmichMediaSource(MediaSource):
identifier.collection_id
)
assets = album_info.assets
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
@@ -223,6 +247,12 @@ class ImmichMediaSource(MediaSource):
assets = await immich_api.search.async_get_all_by_tag_ids(
[identifier.collection_id]
)
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
@@ -235,12 +265,24 @@ class ImmichMediaSource(MediaSource):
assets = await immich_api.search.async_get_all_by_person_ids(
[identifier.collection_id]
)
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
elif identifier.collection == "favorites":
LOGGER.debug("Render all assets for favorites collection")
try:
assets = await immich_api.search.async_get_all_favorites()
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
+1 -1
View File
@@ -67,7 +67,7 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
await coordinator.api.albums.async_add_assets_to_album(
target_album, [upload_result.asset_id]
)
except ImmichError as ex:
except (ImmichError, FileNotFoundError) as ex:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="upload_failed",
@@ -102,6 +102,9 @@
"identifier_unresolvable": {
"message": "Could not parse identifier: {identifier}"
},
"missing_api_permission": {
"message": "Missing API permission ({msg})."
},
"not_configured": {
"message": "Immich is not configured."
},
+2 -2
View File
@@ -53,8 +53,8 @@
"iot_class": "local_polling",
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
"requirements": [
"aiolifx==1.2.1",
"aiolifx==1.2.2",
"aiolifx-effects==0.3.2",
"aiolifx-themes==1.0.2"
"aiolifx-themes==1.0.4"
]
}
@@ -80,12 +80,5 @@ class MeteoAlertBinarySensor(BinarySensorEntity):
expiration_date = dt_util.parse_datetime(alert["expires"])
if expiration_date is not None and expiration_date > dt_util.utcnow():
self._attr_extra_state_attributes = {
key: (
value.encode("utf-8", errors="replace").decode("utf-8")
if isinstance(value, str)
else value
)
for key, value in alert.items()
}
self._attr_extra_state_attributes = alert
self._attr_is_on = True
+43 -20
View File
@@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DISCOVERY,
CONF_PLATFORM,
CONF_PORT,
CONF_PROTOCOL,
SERVICE_RELOAD,
)
@@ -50,6 +51,7 @@ from .client import (
async_subscribe_internal,
publish,
subscribe,
try_connection,
)
from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA
from .config_integration import CONFIG_SCHEMA_BASE
@@ -79,14 +81,15 @@ from .const import (
CONFIG_ENTRY_VERSION,
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_PORT,
DEFAULT_PREFIX,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
PROTOCOL_5,
PROTOCOL_311,
TEMPLATE_ERRORS,
Platform,
@@ -496,25 +499,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry."""
mqtt_data: MqttData
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL:
broker: str = entry.data[CONF_BROKER]
async_create_issue(
hass,
DOMAIN,
"protocol_5_migration",
issue_domain=DOMAIN,
is_fixable=True,
breaks_in_ha_version="2027.1.0",
severity=IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol",
data={
"entry_id": entry.entry_id,
"broker": broker,
"protocol": protocol,
},
translation_placeholders={"broker": broker, "protocol": protocol},
translation_key="protocol_5_migration",
)
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != PROTOCOL_5:
# Automatically migrate the broker protocol to v5 if possible
# Can be removed with HA Core 2027.1
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Try the connection with protocol version 5
# And update the protocol if successful
if await hass.async_add_executor_job(
try_connection,
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
):
hass.config_entries.async_update_entry(
entry,
data=new_entry_data,
)
ir.async_delete_issue(hass, DOMAIN, "protocol_5_migration")
_LOGGER.info(
"The MQTT protocol version was successfully updated to version 5"
)
else:
broker: str = entry.data[CONF_BROKER]
async_create_issue(
hass,
DOMAIN,
"protocol_5_migration",
issue_domain=DOMAIN,
is_fixable=False,
breaks_in_ha_version="2027.1.0",
severity=IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/"
"#mqtt-protocol",
data={
"entry_id": entry.entry_id,
"broker": broker,
"protocol": protocol,
},
translation_placeholders={"broker": broker, "protocol": protocol},
translation_key="protocol_5_migration",
)
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
"""Set up the MQTT client."""
+37
View File
@@ -9,6 +9,7 @@ from functools import lru_cache, partial
from itertools import chain, groupby
import logging
from operator import attrgetter
import queue
import socket
import ssl
import time
@@ -92,6 +93,8 @@ from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabl
_LOGGER = logging.getLogger(__name__)
MQTT_TIMEOUT = 5
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
PREFERRED_BUFFER_SIZE = 8 * 1024 * 1024 # Set receive buffer size to 8MiB
@@ -433,6 +436,40 @@ class MqttClientSetup:
return self._client
def try_connection(
user_input: dict[str, Any],
) -> bool:
"""Test if we can connect to an MQTT broker."""
mqtt_client_setup = MqttClientSetup(user_input)
mqtt_client_setup.setup()
client = mqtt_client_setup.client
result: queue.Queue[bool] = queue.Queue(maxsize=1)
def on_connect(
_mqttc: mqtt.Client,
_userdata: None,
_connect_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None = None,
) -> None:
"""Handle connection result."""
result.put(not reason_code.is_failure)
client.on_connect = on_connect
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
client.loop_start()
try:
return result.get(timeout=MQTT_TIMEOUT)
except queue.Empty:
return False
finally:
client.disconnect()
client.loop_stop()
class MQTT:
"""Home Assistant MQTT client."""
+1 -39
View File
@@ -8,7 +8,6 @@ from dataclasses import dataclass
from enum import IntEnum
import json
import logging
import queue
from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, cast
@@ -22,7 +21,6 @@ from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
)
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
import paho.mqtt.client as mqtt
import voluptuous as vol
import yaml
@@ -143,7 +141,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
from homeassistant.util.unit_conversion import TemperatureConverter
from .addon import get_addon_manager
from .client import MqttClientSetup
from .client import try_connection
from .const import (
ALARM_CONTROL_PANEL_SUPPORTED_FEATURES,
ATTR_PAYLOAD,
@@ -444,8 +442,6 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5
CONF_CLIENT_KEY_PASSWORD = "client_key_password"
MQTT_TIMEOUT = 5
ADVANCED_OPTIONS = "advanced_options"
SET_CA_CERT = "set_ca_cert"
SET_CLIENT_CERT = "set_client_cert"
@@ -5581,40 +5577,6 @@ async def async_get_broker_settings(
return False
def try_connection(
user_input: dict[str, Any],
) -> bool:
"""Test if we can connect to an MQTT broker."""
mqtt_client_setup = MqttClientSetup(user_input)
mqtt_client_setup.setup()
client = mqtt_client_setup.client
result: queue.Queue[bool] = queue.Queue(maxsize=1)
def on_connect(
_mqttc: mqtt.Client,
_userdata: None,
_connect_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None = None,
) -> None:
"""Handle connection result."""
result.put(not reason_code.is_failure)
client.on_connect = on_connect
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
client.loop_start()
try:
return result.get(timeout=MQTT_TIMEOUT)
except queue.Empty:
return False
finally:
client.disconnect()
client.loop_stop()
def check_certicate_chain() -> str | None:
"""Check the MQTT certificates."""
if client_certificate := get_file_path(CONF_CLIENT_CERT):
+1 -56
View File
@@ -5,12 +5,10 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .config_flow import try_connection
from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5
from .const import DOMAIN
URL_MQTT_BROKER_CONFIGURATION = (
"https://www.home-assistant.io/integrations/mqtt/#broker-configuration"
@@ -55,55 +53,6 @@ class MQTTDeviceEntryMigration(RepairsFlow):
)
class MQTTProtocolV5Migration(RepairsFlow):
"""Handler to migrate to MQTT protocol version 5."""
def __init__(self, entry_id: str, broker: str, protocol: str) -> None:
"""Initialize the flow."""
self.entry_id = entry_id
self.broker = broker
self.protocol = protocol
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
entry = self.hass.config_entries.async_get_entry(self.entry_id)
if TYPE_CHECKING:
assert entry is not None
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Try the connection with protocol version 5
if await self.hass.async_add_executor_job(
try_connection,
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
):
self.hass.config_entries.async_update_entry(entry, data=new_entry_data)
return self.async_create_entry(data={})
return self.async_abort(
reason="mqtt_broker_migration_to_v5_failed",
description_placeholders={
"broker": self.broker,
"protocol": self.protocol,
"url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION,
},
)
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders={"broker": self.broker, "protocol": self.protocol},
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@@ -113,10 +62,6 @@ async def async_create_fix_flow(
if TYPE_CHECKING:
assert data is not None
entry_id: str = data["entry_id"] # type: ignore[assignment]
if issue_id == "protocol_5_migration":
broker: str = data["broker"] # type: ignore[assignment]
protocol: str = data["protocol"] # type: ignore[assignment]
return MQTTProtocolV5Migration(entry_id, broker, protocol)
subentry_id: str = data["subentry_id"] # type: ignore[assignment]
name: str = data["name"] # type: ignore[assignment]
return MQTTDeviceEntryMigration(
+3 -13
View File
@@ -56,7 +56,7 @@
"keepalive": "A value less than 90 seconds is advised.",
"password": "The password to log in to your MQTT broker.",
"port": "The port your MQTT broker listens to. For example 1883.",
"protocol": "The MQTT protocol your broker operates at. For example 3.1.1.",
"protocol": "The MQTT protocol version that is used. Note that Home Assistant will silently change to version 5 if your broker supports it.",
"set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT broker's certificate.",
"set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.",
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
@@ -1134,18 +1134,8 @@
"title": "Invalid config found for MQTT {domain} item"
},
"protocol_5_migration": {
"fix_flow": {
"abort": {
"mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue."
},
"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.",
"title": "MQTT protocol change required"
}
}
},
"title": "Deprecated MQTT protocol {protocol} in use"
"description": "The automatic migration to MQTT protocol version 5 failed. The currently configured protocol version for MQTT broker {broker} is {protocol}, but this protocol version is deprecated, and support for it will be removed.\n\nMake sure your broker supports protocol version 5. Update your MQTT broker's connection settings, and restart Home Assistant to fix this issue.",
"title": "MQTT protocol migration failed"
},
"subentry_migration_discovery": {
"fix_flow": {
@@ -79,14 +79,14 @@ TOKEN_SCHEMA = vol.Schema(
def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Validate the user input and fetch data (sync, for executor)."""
auth_kwargs = {
"password": data.get(CONF_PASSWORD),
}
if data.get(CONF_TOKEN):
auth_kwargs = {
auth_kwargs = (
{
"token_name": data[CONF_TOKEN_ID],
"token_value": data[CONF_TOKEN_SECRET],
}
if data.get(CONF_TOKEN)
else {"password": data.get(CONF_PASSWORD)}
)
data = sanitize_config_entry(data)
try:
client = ProxmoxAPI(
@@ -122,6 +122,9 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
except requests.exceptions.ConnectionError as err:
raise ProxmoxConnectionError from err
if not nodes:
raise ProxmoxNoNodesFound("No nodes found")
nodes_data: list[dict[str, Any]] = []
for node in nodes:
if node.get("status") != NODE_ONLINE:
+2 -2
View File
@@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import CONF_LOCALE, DOMAIN, PLATFORMS
from .const import DOMAIN, PLATFORMS, RenaultConfigurationKeys
from .renault_hub import RenaultHub
from .services import async_setup_services
@@ -28,7 +28,7 @@ async def async_setup_entry(
hass: HomeAssistant, config_entry: RenaultConfigEntry
) -> bool:
"""Load a config entry."""
renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE])
renault_hub = RenaultHub(hass, config_entry.data[RenaultConfigurationKeys.LOCALE])
try:
await renault_hub.async_initialise(config_entry)
except NotAuthenticatedException as exc:
+38 -18
View File
@@ -14,21 +14,22 @@ from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, CONF_LOGIN_TOKEN, DOMAIN
from .const import DOMAIN, RenaultConfigurationKeys
from .renault_hub import RenaultHub
_LOGGER = logging.getLogger(__name__)
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(RenaultConfigurationKeys.LOCALE.value): vol.In(
AVAILABLE_LOCALES.keys()
),
vol.Required(RenaultConfigurationKeys.USERNAME.value): str,
vol.Required(RenaultConfigurationKeys.PASSWORD.value): str,
}
)
REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
REAUTH_SCHEMA = vol.Schema({vol.Required(RenaultConfigurationKeys.PASSWORD.value): str})
class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -50,13 +51,14 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
suggested_values: Mapping[str, Any] | None = None
if user_input:
locale = user_input[CONF_LOCALE]
locale = user_input[RenaultConfigurationKeys.LOCALE]
self.renault_config.update(user_input)
self.renault_config.update(AVAILABLE_LOCALES[locale])
self.renault_hub = RenaultHub(self.hass, locale)
try:
login_success = await self.renault_hub.attempt_login(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
user_input[RenaultConfigurationKeys.USERNAME],
user_input[RenaultConfigurationKeys.PASSWORD],
)
except aiohttp.ClientConnectionError, GigyaException:
errors["base"] = "cannot_connect"
@@ -67,7 +69,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
if login_success:
if TYPE_CHECKING:
assert self.renault_hub.login_token
self.renault_config[CONF_LOGIN_TOKEN] = self.renault_hub.login_token
self.renault_config[RenaultConfigurationKeys.LOGIN_TOKEN] = (
self.renault_hub.login_token
)
return await self.async_step_kamereon()
errors["base"] = "invalid_credentials"
suggested_values = user_input
@@ -87,7 +91,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Select Kamereon account."""
if user_input:
await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID])
await self.async_set_unique_id(
user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
self.renault_config.update(user_input)
@@ -100,7 +106,8 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
self.renault_config.update(user_input)
return self.async_create_entry(
title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config
title=user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID],
data=self.renault_config,
)
accounts = await self.renault_hub.get_account_ids()
@@ -108,13 +115,17 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="kamereon_no_account")
if len(accounts) == 1:
return await self.async_step_kamereon(
user_input={CONF_KAMEREON_ACCOUNT_ID: accounts[0]}
user_input={RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: accounts[0]}
)
return self.async_show_form(
step_id="kamereon",
data_schema=vol.Schema(
{vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)}
{
vol.Required(
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID.value
): vol.In(accounts)
}
),
)
@@ -132,17 +143,22 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry()
if user_input:
# Check credentials
self.renault_hub = RenaultHub(self.hass, reauth_entry.data[CONF_LOCALE])
self.renault_hub = RenaultHub(
self.hass, reauth_entry.data[RenaultConfigurationKeys.LOCALE]
)
if await self.renault_hub.attempt_login(
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
reauth_entry.data[RenaultConfigurationKeys.USERNAME],
user_input[RenaultConfigurationKeys.PASSWORD],
):
if TYPE_CHECKING:
assert self.renault_hub.login_token
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_LOGIN_TOKEN: self.renault_hub.login_token,
RenaultConfigurationKeys.PASSWORD: user_input[
RenaultConfigurationKeys.PASSWORD
],
RenaultConfigurationKeys.LOGIN_TOKEN: self.renault_hub.login_token,
},
)
errors = {"base": "invalid_credentials"}
@@ -151,7 +167,11 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="reauth_confirm",
data_schema=REAUTH_SCHEMA,
errors=errors,
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
description_placeholders={
RenaultConfigurationKeys.USERNAME: reauth_entry.data[
RenaultConfigurationKeys.USERNAME
]
},
)
async def async_step_reconfigure(
+12 -3
View File
@@ -1,12 +1,21 @@
"""Constants for the Renault component."""
from enum import StrEnum
from homeassistant.const import Platform
DOMAIN = "renault"
CONF_LOCALE = "locale"
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
CONF_LOGIN_TOKEN = "login_token"
class RenaultConfigurationKeys(StrEnum):
"""Configuration keys."""
LOCALE = "locale"
KAMEREON_ACCOUNT_ID = "kamereon_account_id"
LOGIN_TOKEN = "login_token"
USERNAME = "username"
PASSWORD = "password"
# normal number of allowed calls per hour to the API
# for a single car and the 7 coordinator, it is a scan every 7mn
@@ -3,19 +3,18 @@
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from . import RenaultConfigEntry
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOGIN_TOKEN
from .const import RenaultConfigurationKeys
from .renault_vehicle import RenaultVehicleProxy
TO_REDACT = {
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOGIN_TOKEN,
CONF_PASSWORD,
CONF_USERNAME,
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID,
RenaultConfigurationKeys.LOGIN_TOKEN,
RenaultConfigurationKeys.PASSWORD,
RenaultConfigurationKeys.USERNAME,
"radioCode",
"registrationNumber",
"vin",
+15 -13
View File
@@ -3,6 +3,7 @@
import asyncio
from datetime import timedelta
import logging
from time import time
from typing import TYPE_CHECKING
from renault_api.exceptions import NotAuthenticatedException
@@ -17,27 +18,22 @@ from homeassistant.const import (
ATTR_MODEL,
ATTR_MODEL_ID,
ATTR_NAME,
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
if TYPE_CHECKING:
from . import RenaultConfigEntry
from time import time
from .const import (
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOGIN_TOKEN,
COOLING_UPDATES_SECONDS,
MAX_CALLS_PER_HOURS,
RenaultConfigurationKeys,
)
from .renault_vehicle import COORDINATORS, RenaultVehicleProxy
if TYPE_CHECKING:
from . import RenaultConfigEntry
LOGGER = logging.getLogger(__name__)
@@ -106,20 +102,26 @@ class RenaultHub:
async def async_initialise(self, config_entry: RenaultConfigEntry) -> None:
"""Set up proxy."""
# Reuse the stored login token, or fall back to a password login.
if login_token := config_entry.data.get(CONF_LOGIN_TOKEN):
if login_token := config_entry.data.get(RenaultConfigurationKeys.LOGIN_TOKEN):
self._client.session.set_login_token(login_token)
elif await self.attempt_login(
config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]
config_entry.data[RenaultConfigurationKeys.USERNAME],
config_entry.data[RenaultConfigurationKeys.PASSWORD],
):
# Persist the login token so the next setup can skip the password.
self._hass.config_entries.async_update_entry(
config_entry,
data={**config_entry.data, CONF_LOGIN_TOKEN: self.login_token},
data={
**config_entry.data,
RenaultConfigurationKeys.LOGIN_TOKEN: self.login_token,
},
)
else:
raise NotAuthenticatedException
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID]
account_id: str = config_entry.data[
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID
]
self._account = await self._client.get_api_account(account_id)
vehicle_links = await _get_filtered_vehicles(self._account)
+26 -16
View File
@@ -1,12 +1,12 @@
"""Support for Renault services."""
from datetime import datetime
from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -19,24 +19,30 @@ if TYPE_CHECKING:
LOGGER = logging.getLogger(__name__)
ATTR_SCHEDULES = "schedules"
ATTR_VEHICLE = "vehicle"
ATTR_WHEN = "when"
class RenaultServiceArgument(StrEnum):
"""Service argument names."""
SCHEDULES = "schedules"
TEMPERATURE = "temperature"
VEHICLE = "vehicle"
WHEN = "when"
SERVICE_VEHICLE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_VEHICLE): cv.string,
vol.Required(RenaultServiceArgument.VEHICLE.value): cv.string,
}
)
SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
{
vol.Required(ATTR_TEMPERATURE): cv.positive_float,
vol.Optional(ATTR_WHEN): cv.datetime,
vol.Required(RenaultServiceArgument.TEMPERATURE.value): cv.positive_float,
vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime,
}
)
SERVICE_CHARGE_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
{
vol.Optional(ATTR_WHEN): cv.datetime,
vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime,
}
)
SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema(
@@ -62,7 +68,7 @@ SERVICE_CHARGE_SET_SCHEDULE_SCHEMA = vol.Schema(
)
SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
{
vol.Required(ATTR_SCHEDULES): vol.All(
vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All(
cv.ensure_list, [SERVICE_CHARGE_SET_SCHEDULE_SCHEMA]
),
}
@@ -89,7 +95,7 @@ SERVICE_AC_SET_SCHEDULE_SCHEMA = vol.Schema(
)
SERVICE_AC_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
{
vol.Required(ATTR_SCHEDULES): vol.All(
vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All(
cv.ensure_list, [SERVICE_AC_SET_SCHEDULE_SCHEMA]
),
}
@@ -107,8 +113,8 @@ async def ac_cancel(service_call: ServiceCall) -> None:
async def ac_start(service_call: ServiceCall) -> None:
"""Start A/C."""
temperature: float = service_call.data[ATTR_TEMPERATURE]
when: datetime | None = service_call.data.get(ATTR_WHEN)
temperature: float = service_call.data[RenaultServiceArgument.TEMPERATURE]
when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN)
proxy = get_vehicle_proxy(service_call)
LOGGER.debug("A/C start attempt: %s / %s", temperature, when)
@@ -118,7 +124,7 @@ async def ac_start(service_call: ServiceCall) -> None:
async def charge_start(service_call: ServiceCall) -> None:
"""Start Charging with optional delay."""
when: datetime | None = service_call.data.get(ATTR_WHEN)
when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN)
proxy = get_vehicle_proxy(service_call)
LOGGER.debug("Charge start attempt, when: %s", when)
@@ -128,7 +134,9 @@ async def charge_start(service_call: ServiceCall) -> None:
async def charge_set_schedules(service_call: ServiceCall) -> None:
"""Set charge schedules."""
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
schedules: list[dict[str, Any]] = service_call.data[
RenaultServiceArgument.SCHEDULES
]
proxy = get_vehicle_proxy(service_call)
charge_schedules = await proxy.get_charging_settings()
for schedule in schedules:
@@ -147,7 +155,9 @@ async def charge_set_schedules(service_call: ServiceCall) -> None:
async def ac_set_schedules(service_call: ServiceCall) -> None:
"""Set A/C schedules."""
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
schedules: list[dict[str, Any]] = service_call.data[
RenaultServiceArgument.SCHEDULES
]
proxy = get_vehicle_proxy(service_call)
hvac_schedules = await proxy.get_hvac_settings()
@@ -168,7 +178,7 @@ async def ac_set_schedules(service_call: ServiceCall) -> None:
def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy:
"""Get vehicle from service_call data."""
device_registry = dr.async_get(service_call.hass)
device_id = service_call.data[ATTR_VEHICLE]
device_id = service_call.data[RenaultServiceArgument.VEHICLE]
device_entry = device_registry.async_get(device_id)
if device_entry is None:
raise ServiceValidationError(
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/risco",
"iot_class": "local_push",
"loggers": ["pyrisco"],
"requirements": ["pyrisco==0.7.0"]
"requirements": ["pyrisco==0.8.0"]
}
@@ -6,6 +6,7 @@ from datetime import timedelta
import logging
from typing import Any
import aiohttp
from roborock import (
RoborockException,
RoborockInvalidCredentials,
@@ -120,6 +121,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
translation_domain=DOMAIN,
translation_key="home_data_fail",
) from err
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.debug("Network error setting up Roborock: %s", err)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="network_error",
) from err
async def shutdown_roborock(_: Event | None = None) -> None:
await asyncio.gather(device_manager.close(), cache.flush())
@@ -677,6 +677,9 @@
"mqtt_unauthorized": {
"message": "Roborock MQTT servers rejected the connection due to rate limiting or invalid credentials. You may either attempt to reauthenticate or wait and reload the integration."
},
"network_error": {
"message": "Network error connecting to Roborock servers. Check your internet connection and the Roborock service status."
},
"no_coordinators": {
"message": "No devices were able to successfully setup"
},
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/thread",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.10.0", "pyroute2==0.7.5"],
"requirements": ["python-otbr-api==2.10.0", "pyroute2==0.9.6"],
"single_config_entry": true,
"zeroconf": ["_meshcop._udp.local."]
}
+4
View File
@@ -75,6 +75,10 @@ class VolvoLock(VolvoEntity, LockEntity):
def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None:
"""Update the state of the entity."""
if api_field is None:
self._attr_is_locked = None
return
assert isinstance(api_field, VolvoCarsValue)
self._attr_is_locked = api_field.value == "LOCKED"
+1 -1
View File
@@ -14,5 +14,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
}
@@ -6,8 +6,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
LocalOAuth2ImplementationWithPkce,
)
from .const import YOTO_AUDIENCE, YOTO_SCOPES
AUTHORIZE_URL = "https://login.yotoplay.com/authorize"
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
@@ -16,9 +14,9 @@ async def async_get_auth_implementation(
hass: HomeAssistant,
auth_domain: str,
credential: ClientCredential,
) -> YotoOAuth2Implementation:
"""Return a Yoto OAuth2 implementation backed by the user's credential."""
return YotoOAuth2Implementation(
) -> LocalOAuth2ImplementationWithPkce:
"""Return a Yoto OAuth2 implementation with PKCE."""
return LocalOAuth2ImplementationWithPkce(
hass,
auth_domain,
credential.client_id,
@@ -26,15 +24,3 @@ async def async_get_auth_implementation(
TOKEN_URL,
credential.client_secret,
)
class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Yoto OAuth2 implementation with PKCE, audience and scopes."""
@property
def extra_authorize_data(self) -> dict:
"""Append Yoto's audience and scopes to every authorize URL."""
return super().extra_authorize_data | {
"audience": YOTO_AUDIENCE,
"scope": " ".join(YOTO_SCOPES),
}
+9 -1
View File
@@ -8,7 +8,7 @@ from yoto_api import YotoError, get_account_id
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import _LOGGER, DOMAIN
from .const import _LOGGER, DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES
class YotoOAuth2FlowHandler(
@@ -23,6 +23,14 @@ class YotoOAuth2FlowHandler(
"""Return the logger used for the OAuth2 flow."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Append Yoto's audience and scopes to the authorize URL."""
return {
"audience": YOTO_AUDIENCE,
"scope": " ".join(YOTO_SCOPES),
}
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Identify the Yoto account from the access token."""
try:
@@ -8,7 +8,7 @@ import zeversolar
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -35,4 +35,7 @@ class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]):
async def _async_update_data(self) -> zeversolar.ZeverSolarData:
"""Fetch the latest data from the source."""
return await self.hass.async_add_executor_job(self._client.get_data)
try:
return await self.hass.async_add_executor_job(self._client.get_data)
except zeversolar.ZeverSolarError as err:
raise UpdateFailed(err) from err
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfPower
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -29,10 +29,8 @@ class ZeversolarEntityDescription(SensorEntityDescription):
SENSOR_TYPES = (
ZeversolarEntityDescription(
key="pac",
translation_key="pac",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.POWER,
value_fn=lambda data: data.pac,
),
+1 -1
View File
@@ -35,7 +35,7 @@ file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.7.9
habluetooth==6.8.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
+6 -6
View File
@@ -318,10 +318,10 @@ aiolichess==1.3.0
aiolifx-effects==0.3.2
# homeassistant.components.lifx
aiolifx-themes==1.0.2
aiolifx-themes==1.0.4
# homeassistant.components.lifx
aiolifx==1.2.1
aiolifx==1.2.2
# homeassistant.components.lookin
aiolookin==1.0.0
@@ -1213,7 +1213,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.7.9
habluetooth==6.8.0
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -2486,13 +2486,13 @@ pyrecswitch==1.0.2
pyrepetierng==0.1.0
# homeassistant.components.risco
pyrisco==0.7.0
pyrisco==0.8.0
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.7
# homeassistant.components.thread
pyroute2==0.7.5
pyroute2==0.9.6
# homeassistant.components.rympro
pyrympro==0.0.9
@@ -3403,7 +3403,7 @@ yalexs-ble==3.3.0
# homeassistant.components.august
# homeassistant.components.yale
yalexs==9.2.1
yalexs==9.2.7
# homeassistant.components.yeelight
yeelight==0.7.16
+26 -27
View File
@@ -6,11 +6,10 @@ from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
BASE_COMPONENT = "notify"
@pytest.fixture(autouse=True)
def reset_log_level():
@@ -26,25 +25,25 @@ async def test_apprise_config_load_fail01(hass: HomeAssistant) -> None:
"""Test apprise configuration failures 1."""
config = {
BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"}
NOTIFY_DOMAIN: {"name": "test", "platform": "apprise", "config": "/path/"}
}
with patch(
"homeassistant.components.apprise.notify.apprise.AppriseConfig.add",
return_value=False,
):
assert await async_setup_component(hass, BASE_COMPONENT, config)
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
await hass.async_block_till_done()
# Test that our service failed to load
assert not hass.services.has_service(BASE_COMPONENT, "test")
assert not hass.services.has_service(NOTIFY_DOMAIN, "test")
async def test_apprise_config_load_fail02(hass: HomeAssistant) -> None:
"""Test apprise configuration failures 2."""
config = {
BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"}
NOTIFY_DOMAIN: {"name": "test", "platform": "apprise", "config": "/path/"}
}
with (
@@ -57,11 +56,11 @@ async def test_apprise_config_load_fail02(hass: HomeAssistant) -> None:
return_value=True,
),
):
assert await async_setup_component(hass, BASE_COMPONENT, config)
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
await hass.async_block_till_done()
# Test that our service failed to load
assert not hass.services.has_service(BASE_COMPONENT, "test")
assert not hass.services.has_service(NOTIFY_DOMAIN, "test")
async def test_apprise_config_load_okay(hass: HomeAssistant, tmp_path: Path) -> None:
@@ -73,20 +72,20 @@ async def test_apprise_config_load_okay(hass: HomeAssistant, tmp_path: Path) ->
f = d / "apprise"
f.write_text("mailto://user:pass@example.com/")
config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}}
config = {NOTIFY_DOMAIN: {"name": "test", "platform": "apprise", "config": str(f)}}
assert await async_setup_component(hass, BASE_COMPONENT, config)
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
await hass.async_block_till_done()
# Valid configuration was loaded; our service is good
assert hass.services.has_service(BASE_COMPONENT, "test")
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
async def test_apprise_url_load_fail(hass: HomeAssistant) -> None:
"""Test apprise url failure."""
config = {
BASE_COMPONENT: {
NOTIFY_DOMAIN: {
"name": "test",
"platform": "apprise",
"url": "mailto://user:pass@example.com",
@@ -96,18 +95,18 @@ async def test_apprise_url_load_fail(hass: HomeAssistant) -> None:
"homeassistant.components.apprise.notify.apprise.Apprise.add",
return_value=False,
):
assert await async_setup_component(hass, BASE_COMPONENT, config)
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
await hass.async_block_till_done()
# Test that our service failed to load
assert not hass.services.has_service(BASE_COMPONENT, "test")
assert not hass.services.has_service(NOTIFY_DOMAIN, "test")
async def test_apprise_notification(hass: HomeAssistant) -> None:
"""Test apprise notification."""
config = {
BASE_COMPONENT: {
NOTIFY_DOMAIN: {
"name": "test",
"platform": "apprise",
"url": "mailto://user:pass@example.com",
@@ -124,18 +123,18 @@ async def test_apprise_notification(hass: HomeAssistant) -> None:
obj.add.return_value = True
obj.notify.return_value = True
mock_apprise.return_value = obj
assert await async_setup_component(hass, BASE_COMPONENT, config)
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
await hass.async_block_till_done()
# Test the existence of our service
assert hass.services.has_service(BASE_COMPONENT, "test")
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
# Test the call to our underlining notify() call
await hass.services.async_call(BASE_COMPONENT, "test", data)
await hass.services.async_call(NOTIFY_DOMAIN, "test", data)
await hass.async_block_till_done()
# Validate calls were made under the hood correctly
obj.add.assert_called_once_with(config[BASE_COMPONENT]["url"])
obj.add.assert_called_once_with(config[NOTIFY_DOMAIN]["url"])
obj.notify.assert_called_once_with(
body=data["message"], title=data["title"], tag=None
)
@@ -145,7 +144,7 @@ async def test_apprise_multiple_notification(hass: HomeAssistant) -> None:
"""Test apprise notification."""
config = {
BASE_COMPONENT: {
NOTIFY_DOMAIN: {
"name": "test",
"platform": "apprise",
"url": [
@@ -165,14 +164,14 @@ async def test_apprise_multiple_notification(hass: HomeAssistant) -> None:
obj.add.return_value = True
obj.notify.return_value = True
mock_apprise.return_value = obj
assert await async_setup_component(hass, BASE_COMPONENT, config)
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
await hass.async_block_till_done()
# Test the existence of our service
assert hass.services.has_service(BASE_COMPONENT, "test")
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
# Test the call to our underlining notify() call
await hass.services.async_call(BASE_COMPONENT, "test", data)
await hass.services.async_call(NOTIFY_DOMAIN, "test", data)
await hass.async_block_till_done()
# Validate 2 calls were made under the hood
@@ -196,7 +195,7 @@ async def test_apprise_notification_with_target(
f.write_text("devops=mailto://user:pass@example.com/\r\n")
f.write_text("system,alert=syslog://\r\n")
config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}}
config = {NOTIFY_DOMAIN: {"name": "test", "platform": "apprise", "config": str(f)}}
# Our Message, only notify the services tagged with "devops"
data = {"title": "Test Title", "message": "Test Message", "target": ["devops"]}
@@ -208,14 +207,14 @@ async def test_apprise_notification_with_target(
apprise_obj.add.return_value = True
apprise_obj.notify.return_value = True
mock_apprise.return_value = apprise_obj
assert await async_setup_component(hass, BASE_COMPONENT, config)
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
await hass.async_block_till_done()
# Test the existence of our service
assert hass.services.has_service(BASE_COMPONENT, "test")
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
# Test the call to our underlining notify() call
await hass.services.async_call(BASE_COMPONENT, "test", data)
await hass.services.async_call(NOTIFY_DOMAIN, "test", data)
await hass.async_block_till_done()
# Validate calls were made under the hood correctly
+69 -8
View File
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components.avea.const import DOMAIN
from homeassistant.components.avea.const import AVEA_SERVICE_UUID, DOMAIN
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant
@@ -12,7 +12,11 @@ from homeassistant.data_entry_flow import FlowResultType
from . import AVEA_DISCOVERY_INFO, NOT_AVEA_DISCOVERY_INFO
from tests.components.bluetooth import inject_bluetooth_service_info
from tests.components.bluetooth import (
generate_advertisement_data,
generate_ble_device,
inject_bluetooth_service_info,
)
pytestmark = pytest.mark.usefixtures("enable_bluetooth")
@@ -35,13 +39,22 @@ async def test_user_step_success(hass: HomeAssistant) -> None:
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"homeassistant.components.avea.config_flow.bluetooth.async_request_active_scan"
) as mock_request_active_scan:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
assert result["data_schema"].schema[CONF_ADDRESS].container == {
AVEA_DISCOVERY_INFO.address: (
f"{AVEA_DISCOVERY_INFO.name} ({AVEA_DISCOVERY_INFO.address})"
)
}
mock_request_active_scan.assert_awaited_once_with(hass)
with (
patch(
@@ -67,14 +80,62 @@ async def test_user_step_no_devices_found(hass: HomeAssistant) -> None:
inject_bluetooth_service_info(hass, NOT_AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"homeassistant.components.avea.config_flow.bluetooth.async_request_active_scan"
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_user_step_unnamed_device_label(hass: HomeAssistant) -> None:
"""Test unnamed discovered devices are shown without duplicating the address."""
discovery_info = type(AVEA_DISCOVERY_INFO)(
name=AVEA_DISCOVERY_INFO.address,
address=AVEA_DISCOVERY_INFO.address,
rssi=-60,
manufacturer_data={},
service_uuids=[AVEA_SERVICE_UUID],
service_data={},
source="local",
device=generate_ble_device(
address=AVEA_DISCOVERY_INFO.address, name=AVEA_DISCOVERY_INFO.address
),
advertisement=generate_advertisement_data(
local_name=AVEA_DISCOVERY_INFO.address,
manufacturer_data={},
service_data={},
service_uuids=[AVEA_SERVICE_UUID],
),
time=0,
connectable=True,
tx_power=-127,
)
with (
patch(
"homeassistant.components.avea.config_flow.async_discovered_service_info",
return_value=[discovery_info],
),
patch(
"homeassistant.components.avea.config_flow.bluetooth.async_request_active_scan"
) as mock_request_active_scan,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["data_schema"].schema[CONF_ADDRESS].container == {
AVEA_DISCOVERY_INFO.address: AVEA_DISCOVERY_INFO.address
}
mock_request_active_scan.assert_awaited_once_with(hass)
async def test_user_step_cannot_connect_recovers(hass: HomeAssistant) -> None:
"""Test the user step recovers after a cannot connect error."""
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
+53
View File
@@ -10,9 +10,11 @@ from homeassistant.components.bluetooth import (
MONOTONIC_TIME,
BaseHaRemoteScanner,
BluetoothChange,
BluetoothReachabilityIntent,
BluetoothScanningMode,
BluetoothServiceInfo,
HaBluetoothConnector,
async_address_reachability_diagnostics,
async_clear_advertisement_history,
async_request_active_scan,
async_scanner_by_source,
@@ -112,6 +114,57 @@ async def test_async_scanner_devices_by_address_connectable(
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_address_reachability_diagnostics(hass: HomeAssistant) -> None:
"""Test the address reachability diagnostics passthrough."""
# An address that was never seen reports as unknown.
assert "unknown" in async_address_reachability_diagnostics(
hass, "44:44:33:11:23:99", BluetoothReachabilityIntent.CONNECTION
)
manager = _get_manager()
class FakeInjectableScanner(BaseHaRemoteScanner):
def inject_advertisement(
self, device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Inject an advertisement."""
self._async_on_advertisement(
device.address,
advertisement_data.rssi,
device.name,
advertisement_data.service_uuids,
advertisement_data.service_data,
advertisement_data.manufacturer_data,
advertisement_data.tx_power,
{"scanner_specific_data": "test"},
MONOTONIC_TIME(),
)
connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True)
scanner = FakeInjectableScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {})
switchbot_device_adv = generate_advertisement_data(local_name="wohand", rssi=-80)
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
connection_diag = async_address_reachability_diagnostics(
hass, "44:44:33:11:23:45", BluetoothReachabilityIntent.CONNECTION
)
assert "in connectable history" in connection_diag
assert "esp32" in connection_diag
# An advertisement intent does not report connectable paths or slots.
advertisement_diag = async_address_reachability_diagnostics(
hass, "44:44:33:11:23:45", BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT
)
assert "advertising" in advertisement_diag
unsetup()
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_scanner_devices_by_address_non_connectable(
hass: HomeAssistant,
+17
View File
@@ -6,6 +6,23 @@ from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture(autouse=True)
def silent_ssdp_scanner() -> Generator[None]:
"""Start SSDP component and get Scanner, prevent actual SSDP traffic."""
with (
patch("homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"),
patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"),
patch("homeassistant.components.ssdp.Scanner.async_scan"),
patch(
"homeassistant.components.ssdp.Server._async_start_upnp_servers",
),
patch(
"homeassistant.components.ssdp.Server._async_stop_upnp_servers",
),
):
yield
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
+124
View File
@@ -5,6 +5,8 @@ from typing import Any
from unittest.mock import patch
from aioesphomeapi import APIClient, UpdateCommand, UpdateInfo, UpdateState
from awesomeversion import AwesomeVersion
from awesomeversion.exceptions import AwesomeVersionCompareException
import pytest
from homeassistant.components.esphome.dashboard import async_get_dashboard
@@ -547,6 +549,128 @@ async def test_generic_device_update_entity_has_update(
)
@pytest.mark.parametrize(
("current_version", "latest_version"),
[
("2025.11.5_c51f7548", "2025.11.6_aabbccdd"),
("2025.11.5_c51f7548", "2025.11.5_aabbccdd"),
("2025.11.6_aabbccdd", "2025.11.5_c51f7548"),
],
ids=["newer_base", "same_base_new_build", "older_base"],
)
def test_awesomeversion_cannot_compare_project_versions(
current_version: str, latest_version: str
) -> None:
"""Prove AwesomeVersion raises on ESPHome project versions.
ESPHome project versions carry a build suffix (e.g. 2025.11.5_c51f7548).
AwesomeVersion cannot parse these, so the base UpdateEntity comparison would
raise and force the entity on, which is why ESPHomeUpdateEntity mirrors the
device by comparing with a plain string inequality instead.
"""
with pytest.raises(AwesomeVersionCompareException):
assert AwesomeVersion(latest_version) > current_version
@pytest.mark.parametrize(
("current_version", "latest_version", "expected_state"),
[
("2025.11.5_c51f7548", "2025.11.6_aabbccdd", STATE_ON),
("2025.11.5_c51f7548", "2025.11.5_aabbccdd", STATE_OFF),
("2025.11.6_aabbccdd", "2025.11.5_c51f7548", STATE_OFF),
("2025.11.5_c51f7548", "2025.11.5_c51f7548", STATE_OFF),
],
ids=["newer_base", "same_base_new_build", "older_base", "identical"],
)
async def test_generic_device_update_entity_project_version(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
current_version: str,
latest_version: str,
expected_state: str,
) -> None:
"""Test version comparison for ESPHome project versions.
AwesomeVersion cannot parse the build suffix, so the entity strips it and
compares the real versions: only a genuinely newer base version is offered;
a different build of the same version or an older version is not.
"""
entity_info = [
UpdateInfo(
object_id="myupdate",
key=1,
name="my update",
)
]
states = [
UpdateState(
key=1,
current_version=current_version,
latest_version=latest_version,
title="ESPHome Project",
release_summary=RELEASE_SUMMARY,
release_url=RELEASE_URL,
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == expected_state
async def test_generic_device_update_entity_clears_after_ota(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test a project version update clears once the device runs the new build."""
entity_info = [
UpdateInfo(
object_id="myupdate",
key=1,
name="my update",
)
]
states = [
UpdateState(
key=1,
current_version="2025.11.5_c51f7548",
latest_version="2025.11.6_aabbccdd",
title="ESPHome Project",
release_summary=RELEASE_SUMMARY,
release_url=RELEASE_URL,
)
]
mock_device = await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_ON
mock_device.set_state(
UpdateState(
key=1,
current_version="2025.11.6_aabbccdd",
latest_version="2025.11.6_aabbccdd",
title="ESPHome Project",
release_summary=RELEASE_SUMMARY,
release_url=RELEASE_URL,
)
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
async def test_update_entity_release_notes(
hass: HomeAssistant,
mock_client: APIClient,
+43 -12
View File
@@ -5,7 +5,7 @@ import tempfile
from unittest.mock import Mock, patch
from aiohttp import web
from aioimmich.exceptions import ImmichError
from aioimmich.exceptions import ImmichError, ImmichForbiddenError
import pytest
from homeassistant.components.immich.const import DOMAIN
@@ -252,6 +252,12 @@ async def test_browse_media_collections_error(
with patch("homeassistant.components.immich.PLATFORMS", []):
await setup_integration(hass, mock_config_entry)
item = MediaSourceItem(
hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None
)
source = await async_get_media_source(hass)
# test generic ImmichError
getattr(
getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1]
).side_effect = ImmichError(
@@ -263,17 +269,26 @@ async def test_browse_media_collections_error(
}
)
source = await async_get_media_source(hass)
item = MediaSourceItem(
hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None
)
result = await source.async_browse_media(item)
assert result
assert result.identifier is None
assert len(result.children) == 0
# test specific ImmichForbiddenError
getattr(
getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1]
).side_effect = ImmichForbiddenError(
{
"message": "Missing required permission: asset.read",
"error": "Forbidden",
"statusCode": 403,
"correlationId": "e0hlizyl",
}
)
with pytest.raises(BrowseError, match="Missing API permission"):
await source.async_browse_media(item)
@pytest.mark.parametrize(
("collection", "mocked_get_fn"),
@@ -297,8 +312,15 @@ async def test_browse_media_collection_items_error(
with patch("homeassistant.components.immich.PLATFORMS", []):
await setup_integration(hass, mock_config_entry)
item = MediaSourceItem(
hass,
DOMAIN,
f"{mock_config_entry.unique_id}|{collection}|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
None,
)
source = await async_get_media_source(hass)
# test generic ImmichError
getattr(
getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1]
).side_effect = ImmichError(
@@ -309,18 +331,27 @@ async def test_browse_media_collection_items_error(
"correlationId": "e0hlizyl",
}
)
item = MediaSourceItem(
hass,
DOMAIN,
f"{mock_config_entry.unique_id}|{collection}|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
None,
)
result = await source.async_browse_media(item)
assert result
assert result.identifier is None
assert len(result.children) == 0
# test specific ImmichForbiddenError
getattr(
getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1]
).side_effect = ImmichForbiddenError(
{
"message": "Missing required permission: asset.read",
"error": "Forbidden",
"statusCode": 403,
"correlationId": "e0hlizyl",
}
)
with pytest.raises(BrowseError, match="Missing API permission"):
await source.async_browse_media(item)
@pytest.mark.parametrize(
("collection", "collection_id", "children"),
+28 -9
View File
@@ -1,5 +1,6 @@
"""Test the Immich services."""
import re
from unittest.mock import Mock, patch
from aioimmich.exceptions import ImmichError, ImmichNotFoundError
@@ -210,8 +211,30 @@ async def test_upload_file_album_not_found(
)
@pytest.mark.parametrize(
("side_effect", "expected_err_message"),
[
(
ImmichError(
{
"message": "Boom! Upload failed",
"error": "Bad Request",
"statusCode": 400,
"correlationId": "nyzxjkno",
}
),
"Boom! Upload failed (error: 'Bad Request' code: '400' correlation_id: 'nyzxjkno')",
),
(
FileNotFoundError(2, "No such file or directory", "/media/screenshot.jpg"),
"[Errno 2] No such file or directory: '/media/screenshot.jpg'",
),
],
)
async def test_upload_file_upload_failed(
hass: HomeAssistant,
side_effect: Exception,
expected_err_message: str,
mock_immich: Mock,
mock_config_entry: MockConfigEntry,
mock_media_source: Mock,
@@ -219,16 +242,12 @@ async def test_upload_file_upload_failed(
"""Test upload_file service raising upload_failed."""
await setup_integration(hass, mock_config_entry)
mock_immich.assets.async_upload_asset.side_effect = ImmichError(
{
"message": "Boom! Upload failed",
"error": "Bad Request",
"statusCode": 400,
"correlationId": "nyzxjkno",
}
)
mock_immich.assets.async_upload_asset.side_effect = side_effect
with pytest.raises(
ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed"
ServiceValidationError,
match=re.escape(
f"Upload of file `/media/screenshot.jpg` failed ({expected_err_message})"
),
):
await hass.services.async_call(
DOMAIN,
+3 -3
View File
@@ -9,7 +9,7 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.indevolt.coordinator import SCAN_INTERVAL
from homeassistant.components.number import SERVICE_SET_VALUE
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -87,7 +87,7 @@ async def test_number_set_values(
# Call the service to set the value
await hass.services.async_call(
Platform.NUMBER,
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{"entity_id": entity_id, "value": test_value},
blocking=True,
@@ -116,7 +116,7 @@ async def test_number_set_value_error(
# Attempt to set value
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.NUMBER,
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
"entity_id": "number.cms_sf2000_discharge_limit",
+6 -3
View File
@@ -9,7 +9,10 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.indevolt.coordinator import SCAN_INTERVAL
from homeassistant.components.select import SERVICE_SELECT_OPTION
from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -66,7 +69,7 @@ async def test_select_option(
# Attempt to change option
await hass.services.async_call(
Platform.SELECT,
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{"entity_id": "select.cms_sf2000_energy_mode", "option": option},
blocking=True,
@@ -97,7 +100,7 @@ async def test_select_set_option_error(
# Attempt to change option
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SELECT,
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
"entity_id": "select.cms_sf2000_energy_mode",
+8 -4
View File
@@ -9,7 +9,11 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.indevolt.coordinator import SCAN_INTERVAL
from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -84,7 +88,7 @@ async def test_switch_turn_on(
# Call the service to turn on
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": entity_id},
blocking=True,
@@ -143,7 +147,7 @@ async def test_switch_turn_off(
# Call the service to turn off
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{"entity_id": entity_id},
blocking=True,
@@ -172,7 +176,7 @@ async def test_switch_set_value_error(
# Attempt to switch on
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": "switch.cms_sf2000_allow_grid_charging"},
blocking=True,
@@ -1488,11 +1488,6 @@
'infrared': False,
'matrix': False,
'max_kelvin': 9000,
'min_ext_mz_firmware': 1532997580,
'min_ext_mz_firmware_components': list([
2,
77,
]),
'min_kelvin': 1500,
'multizone': True,
'relays': False,
+19 -1
View File
@@ -5,7 +5,7 @@ from collections.abc import AsyncGenerator, Generator
from pathlib import Path
from random import getrandbits
from typing import Any
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -33,6 +33,24 @@ def patch_hass_config(mock_hass_config: None) -> None:
"""Patch configuration.yaml."""
@pytest.fixture
def mock_v5_protocol_check() -> bool:
"""Fixture to mock a v5 protocol test result."""
return True
@pytest.fixture(autouse=True)
def mock_try_connection_protocol_check(
hass: HomeAssistant, mock_v5_protocol_check: bool
) -> Generator[MagicMock]:
"""Patch try_connection."""
with patch(
"homeassistant.components.mqtt.try_connection",
return_value=mock_v5_protocol_check,
) as mock_try_connection:
yield mock_try_connection
@pytest.fixture
def temp_dir_prefix() -> str:
"""Set an alternate temp dir prefix."""
+11 -4
View File
@@ -230,6 +230,7 @@ async def test_publish(
assert publish_mock.call_args[0][4].json() == {"MessageExpiryInterval": 60}
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
@pytest.mark.parametrize(
("mqtt_config_entry_options", "mqtt_config_entry_data", "protocol"),
[
@@ -1257,7 +1258,12 @@ async def test_restore_subscriptions_on_reconnect(
@pytest.mark.parametrize(
("mqtt_config_entry_data", "mqtt_config_entry_options"),
[({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})],
[
(
{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_PROTOCOL: "5"},
{mqtt.CONF_DISCOVERY: False},
)
],
)
async def test_restore_all_active_subscriptions_on_reconnect(
hass: HomeAssistant,
@@ -1277,7 +1283,7 @@ async def test_restore_all_active_subscriptions_on_reconnect(
# the subscription with the highest QoS should survive
expected = [
call([("test/state", 2)], properties=None),
call([("test/state", 2)], properties=ANY),
]
assert mqtt_client_mock.subscribe.mock_calls == expected
@@ -1291,7 +1297,7 @@ async def test_restore_all_active_subscriptions_on_reconnect(
# wait for cooldown
await mock_debouncer.wait()
expected.append(call([("test/state", 1)], properties=None))
expected.append(call([("test/state", 1)], properties=ANY))
for expected_call in expected:
assert mqtt_client_mock.subscribe.hass_call(expected_call)
@@ -1549,6 +1555,7 @@ async def test_handle_message_callback(
assert callbacks[0].payload == "test-payload"
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
@pytest.mark.parametrize(
("mqtt_config_entry_data", "protocol", "clean_session"),
[
@@ -1582,7 +1589,6 @@ async def test_handle_message_callback(
async def test_setup_mqtt_client_clean_session_and_protocol(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
mqtt_client_mock: MqttMockPahoClient,
protocol: int,
clean_session: bool | None,
) -> None:
@@ -1597,6 +1603,7 @@ async def test_setup_mqtt_client_clean_session_and_protocol(
assert mock_client.call_args[1]["protocol"] == protocol
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
@pytest.mark.parametrize(
("mqtt_config_entry_data", "connect_args"),
[
+3 -1
View File
@@ -130,6 +130,7 @@ MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n"
MOCK_ENTRY_DATA = {
mqtt.CONF_BROKER: "test-broker",
CONF_PROTOCOL: "5",
CONF_PORT: 1234,
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
@@ -273,7 +274,7 @@ def mock_try_connection_time_out() -> Generator[MagicMock]:
# Patch prevent waiting 5 sec for a timeout
with (
patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client,
patch("homeassistant.components.mqtt.config_flow.MQTT_TIMEOUT", 0),
patch("homeassistant.components.mqtt.client.MQTT_TIMEOUT", 0),
):
mock_client().loop_start = lambda *args: 1
yield mock_client()
@@ -1756,6 +1757,7 @@ async def test_step_hassio_reauth(
mock_try_connection.assert_called_once_with(
{
"broker": "core-mosquitto",
CONF_PROTOCOL: "5",
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
+125
View File
@@ -5,6 +5,7 @@ from copy import deepcopy
from datetime import datetime, timedelta
from functools import partial
import json
import logging
import time
from typing import Any, TypedDict
from unittest.mock import ANY, MagicMock, Mock, mock_open, patch
@@ -28,6 +29,7 @@ from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.const import (
ATTR_ASSUMED_STATE,
CONF_PORT,
CONF_PROTOCOL,
SERVICE_RELOAD,
STATE_UNAVAILABLE,
@@ -53,6 +55,7 @@ from tests.common import (
MockEntity,
MockEntityPlatform,
MockMqttReasonCode,
async_capture_events,
async_fire_mqtt_message,
async_fire_time_changed,
mock_restore_cache,
@@ -2470,3 +2473,125 @@ async def test_yaml_config_with_active_mqtt_config_entry(
state = hass.states.get("sensor.mqtt_sensor")
assert state is not None
assert issue is None
@pytest.mark.parametrize(
"mqtt_config_entry_data",
[
{
mqtt.CONF_BROKER: "mock-broker",
},
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PORT: 1883,
},
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1.1",
CONF_PORT: 1883,
},
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1",
CONF_PORT: 1883,
},
],
ids=[
"entry_without_protocol_without_port",
"entry_without_protocol_with_port",
"entry_with_protocol_3.1.1",
"entry_with_protocol_3.1",
],
)
async def test_mqtt_protocol_successful_migration_to_v5(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the silent MQTT protocol migration is successful."""
assert await async_setup_component(hass, "repairs", {})
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
with caplog.at_level(logging.INFO):
await mqtt_mock_entry()
assert len(events) == 0
assert (
"The MQTT protocol version was successfully updated to version 5"
in caplog.text
)
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
assert entry.data[mqtt.CONF_PROTOCOL] == mqtt.PROTOCOL_5
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
@pytest.mark.parametrize(
("mqtt_config_entry_data", "current_protocol"),
[
(
{
mqtt.CONF_BROKER: "mock-broker",
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1.1",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1",
CONF_PORT: 1883,
},
"3.1",
),
],
ids=[
"entry_without_protocol_without_port",
"entry_without_protocol_with_port",
"entry_with_protocol_3.1.1",
"entry_with_protocol_3.1",
],
)
async def test_mqtt_protocol_failed_migration_to_v5(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
current_protocol: str,
) -> None:
"""Test failed silent MQTT protocol migration creates a repair issue."""
assert await async_setup_component(hass, "repairs", {})
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
await mqtt_mock_entry()
assert len(events) == 1
assert events[0].data["issue_id"] == "protocol_5_migration"
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration")
assert issue is not None
assert issue.translation_key == "protocol_5_migration"
assert issue.translation_placeholders == {
"broker": "mock-broker",
"protocol": current_protocol,
}
assert (
"The MQTT protocol version was successfully updated to version 5"
not in caplog.text
)
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
assert entry.data.get(mqtt.CONF_PROTOCOL, mqtt.PROTOCOL_311) == current_protocol
+3 -191
View File
@@ -1,19 +1,18 @@
"""Test repairs for MQTT."""
from collections.abc import Coroutine, Generator
from collections.abc import Coroutine
from copy import deepcopy
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from homeassistant.components import mqtt
from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData
from homeassistant.const import CONF_PORT, CONF_PROTOCOL, SERVICE_RELOAD
from homeassistant.const import SERVICE_RELOAD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.setup import async_setup_component
from homeassistant.util.yaml import parse_yaml
from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message
@@ -28,13 +27,6 @@ from tests.conftest import ClientSessionGenerator
from tests.typing import MqttMockHAClientGenerator
@pytest.fixture
def mock_try_connection() -> Generator[MagicMock]:
"""Mock the try connection method."""
with patch("homeassistant.components.mqtt.repairs.try_connection") as mock_try:
yield mock_try
async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None:
"""Help to set up an exported MQTT device via YAML."""
with patch(
@@ -185,183 +177,3 @@ async def test_subentry_reconfigure_export_settings(
device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)})
assert device.config_entries_subentries[config_entry.entry_id] == {None}
assert device is not None
@pytest.mark.parametrize(
("mqtt_config_entry_data", "current_protocol"),
[
(
{
mqtt.CONF_BROKER: "mock-broker",
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1.1",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1",
CONF_PORT: 1883,
},
"3.1",
),
],
ids=[
"entry_without_protocol_without_port",
"entry_without_protocol_with_port",
"entry_with_protocol_3.1.1",
"entry_with_protocol_3.1",
],
)
async def test_mqtt_protocol_successful_migration_to_v5(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
hass_client: ClientSessionGenerator,
mqtt_config_entry_data: dict[str, Any],
current_protocol: str,
mock_try_connection: MagicMock,
) -> None:
"""Test the MQTT protocol migration repair flow is successful."""
assert await async_setup_component(hass, "repairs", {})
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
await mqtt_mock_entry()
assert len(events) == 1
assert events[0].data["issue_id"] == "protocol_5_migration"
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration")
assert issue is not None
assert issue.translation_key == "protocol_5_migration"
assert issue.translation_placeholders == {
"broker": "mock-broker",
"protocol": current_protocol,
}
await async_process_repairs_platforms(hass)
client = await hass_client()
data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration")
flow_id = data["flow_id"]
assert data["description_placeholders"] == {
"broker": "mock-broker",
"protocol": current_protocol,
}
assert data["step_id"] == "confirm"
mock_try_connection.side_effect = lambda x: True
data = await process_repair_fix_flow(client, flow_id)
assert data["type"] == "create_entry"
expected_entry_data: dict[str, Any] = mqtt_config_entry_data | {CONF_PROTOCOL: "5"}
mock_try_connection.assert_called_once_with(expected_entry_data | {CONF_PORT: 1883})
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
assert entry.data == expected_entry_data
await hass.async_block_till_done(wait_background_tasks=True)
@pytest.mark.parametrize(
("mqtt_config_entry_data", "current_protocol"),
[
(
{
mqtt.CONF_BROKER: "mock-broker",
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1.1",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1",
CONF_PORT: 1883,
},
"3.1",
),
],
ids=[
"entry_without_protocol_without_port",
"entry_without_protocol_with_port",
"entry_with_protocol_3.1.1",
"entry_with_protocol_3.1",
],
)
async def test_mqtt_protocol_failed_migration_to_v5(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
hass_client: ClientSessionGenerator,
current_protocol: str,
mock_try_connection: MagicMock,
) -> None:
"""Test the MQTT protocol migration repair flow fails."""
assert await async_setup_component(hass, "repairs", {})
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
await mqtt_mock_entry()
assert len(events) == 1
assert events[0].data["issue_id"] == "protocol_5_migration"
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration")
assert issue is not None
assert issue.translation_key == "protocol_5_migration"
assert issue.translation_placeholders == {
"broker": "mock-broker",
"protocol": current_protocol,
}
await async_process_repairs_platforms(hass)
client = await hass_client()
data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration")
flow_id = data["flow_id"]
assert data["description_placeholders"] == {
"broker": "mock-broker",
"protocol": current_protocol,
}
assert data["step_id"] == "confirm"
mock_try_connection.side_effect = lambda x: False
data = await process_repair_fix_flow(client, flow_id)
assert data["type"] == "abort"
assert data["reason"] == "mqtt_broker_migration_to_v5_failed"
assert data["description_placeholders"] == {
"broker": "mock-broker",
"protocol": current_protocol,
"url_mqtt_broker_configuration": "https://www.home-assistant.io/integrations/mqtt/#broker-configuration",
}
await hass.async_block_till_done(wait_background_tasks=True)
+2 -4
View File
@@ -274,9 +274,7 @@ async def test_yaml_import(
mocked_projector: MagicMock,
) -> None:
"""Test a YAML media player is imported and becomes an operational config entry."""
assert await async_setup_component(
hass, Platform.MEDIA_PLAYER, _EXAMPLE_YAML_CONFIG
)
assert await async_setup_component(hass, media_player.DOMAIN, _EXAMPLE_YAML_CONFIG)
await hass.async_block_till_done()
# Verify the config entry was created
@@ -308,7 +306,7 @@ async def test_failed_yaml_import(
with patch("pypjlink.Projector.from_address", side_effect=side_effect):
assert await async_setup_component(
hass, Platform.MEDIA_PLAYER, _EXAMPLE_YAML_CONFIG
hass, media_player.DOMAIN, _EXAMPLE_YAML_CONFIG
)
await hass.async_block_till_done()
@@ -367,6 +367,33 @@ async def test_form_no_nodes_exception(
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_form_no_nodes_empty_list(
hass: HomeAssistant,
mock_proxmox_client: MagicMock,
) -> None:
"""Test we handle no nodes found exception when empty list is returned."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
mock_proxmox_client.nodes.get.return_value = []
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_USER_STEP
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user_auth"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_USER_AUTH_STEP_PASSWORD
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_nodes_found"}
@pytest.mark.usefixtures("mock_setup_entry")
async def test_duplicate_entry(
hass: HomeAssistant,
+6 -11
View File
@@ -1,22 +1,17 @@
"""Constants for the Renault integration tests."""
from homeassistant.components.renault.const import (
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOCALE,
CONF_LOGIN_TOKEN,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.components.renault.const import RenaultConfigurationKeys
MOCK_ACCOUNT_ID = "account_id_1"
MOCK_LOGIN_TOKEN = "sample-login-token"
# Mock config data to be used across multiple tests
MOCK_CONFIG = {
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
CONF_LOGIN_TOKEN: MOCK_LOGIN_TOKEN,
CONF_KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
CONF_LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
RenaultConfigurationKeys.LOGIN_TOKEN: MOCK_LOGIN_TOKEN,
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
RenaultConfigurationKeys.LOCALE: "fr_FR",
}
MOCK_VEHICLES = {
+98 -67
View File
@@ -10,13 +10,7 @@ from renault_api.renault_account import RenaultAccount
from renault_api.renault_session import RenaultSession
from homeassistant import config_entries
from homeassistant.components.renault.const import (
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOCALE,
CONF_LOGIN_TOKEN,
DOMAIN,
)
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.components.renault.const import DOMAIN, RenaultConfigurationKeys
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import aiohttp_client
@@ -66,9 +60,9 @@ async def test_config_flow_single_account(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
@@ -77,9 +71,18 @@ async def test_config_flow_single_account(
assert result["errors"] == {"base": error}
data_schema = result["data_schema"].schema
assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR"
assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com"
assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test"
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.LOCALE)
== "fr_FR"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.USERNAME)
== "email@test.com"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.PASSWORD)
== "test"
)
renault_account = AsyncMock()
type(renault_account).account_id = PropertyMock(return_value="account_id_1")
@@ -104,19 +107,21 @@ async def test_config_flow_single_account(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "account_id_1"
assert result["data"][CONF_USERNAME] == "email@test.com"
assert result["data"][CONF_PASSWORD] == "test"
assert result["data"][CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1"
assert result["data"][CONF_LOCALE] == "fr_FR"
assert result["data"][RenaultConfigurationKeys.USERNAME] == "email@test.com"
assert result["data"][RenaultConfigurationKeys.PASSWORD] == "test"
assert result["data"][RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
result["data"][RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID] == "account_id_1"
)
assert result["data"][RenaultConfigurationKeys.LOCALE] == "fr_FR"
assert result["context"]["unique_id"] == "account_id_1"
assert len(mock_setup_entry.mock_calls) == 1
@@ -147,9 +152,9 @@ async def test_config_flow_no_account(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
@@ -200,9 +205,9 @@ async def test_config_flow_multiple_accounts(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
@@ -212,15 +217,17 @@ async def test_config_flow_multiple_accounts(
# Account selected
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"},
user_input={RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: "account_id_2"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "account_id_2"
assert result["data"][CONF_USERNAME] == "email@test.com"
assert result["data"][CONF_PASSWORD] == "test"
assert result["data"][CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2"
assert result["data"][CONF_LOCALE] == "fr_FR"
assert result["data"][RenaultConfigurationKeys.USERNAME] == "email@test.com"
assert result["data"][RenaultConfigurationKeys.PASSWORD] == "test"
assert result["data"][RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
result["data"][RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID] == "account_id_2"
)
assert result["data"][RenaultConfigurationKeys.LOCALE] == "fr_FR"
assert result["context"]["unique_id"] == "account_id_2"
assert len(mock_setup_entry.mock_calls) == 1
@@ -264,9 +271,9 @@ async def test_config_flow_duplicate(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
@@ -285,8 +292,8 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
assert result["type"] is FlowResultType.FORM
assert result["description_placeholders"] == {
CONF_NAME: "Mock Title",
CONF_USERNAME: "email@test.com",
"name": "Mock Title",
RenaultConfigurationKeys.USERNAME: "email@test.com",
}
assert result["errors"] == {}
@@ -297,13 +304,13 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "any"},
user_input={RenaultConfigurationKeys.PASSWORD: "any"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["description_placeholders"] == {
CONF_NAME: "Mock Title",
CONF_USERNAME: "email@test.com",
"name": "Mock Title",
RenaultConfigurationKeys.USERNAME: "email@test.com",
}
assert result2["errors"] == {"base": "invalid_credentials"}
@@ -315,15 +322,15 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
):
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "any"},
user_input={RenaultConfigurationKeys.PASSWORD: "any"},
)
assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"
assert config_entry.data[CONF_USERNAME] == "email@test.com"
assert config_entry.data[CONF_PASSWORD] == "any"
assert config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert config_entry.data[RenaultConfigurationKeys.USERNAME] == "email@test.com"
assert config_entry.data[RenaultConfigurationKeys.PASSWORD] == "any"
assert config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
async def test_reconfigure(
@@ -338,9 +345,18 @@ async def test_reconfigure(
assert not result["errors"]
data_schema = result["data_schema"].schema
assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR"
assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com"
assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test"
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.LOCALE)
== "fr_FR"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.USERNAME)
== "email@test.com"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.PASSWORD)
== "test"
)
renault_account = AsyncMock()
type(renault_account).account_id = PropertyMock(return_value="account_id_1")
@@ -365,20 +381,23 @@ async def test_reconfigure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email2@test.com",
CONF_PASSWORD: "test2",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email2@test.com",
RenaultConfigurationKeys.PASSWORD: "test2",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert config_entry.data[CONF_USERNAME] == "email2@test.com"
assert config_entry.data[CONF_PASSWORD] == "test2"
assert config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1"
assert config_entry.data[CONF_LOCALE] == "fr_FR"
assert config_entry.data[RenaultConfigurationKeys.USERNAME] == "email2@test.com"
assert config_entry.data[RenaultConfigurationKeys.PASSWORD] == "test2"
assert config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
config_entry.data[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
== "account_id_1"
)
assert config_entry.data[RenaultConfigurationKeys.LOCALE] == "fr_FR"
assert len(mock_setup_entry.mock_calls) == 1
@@ -395,9 +414,18 @@ async def test_reconfigure_mismatch(
assert not result["errors"]
data_schema = result["data_schema"].schema
assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR"
assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com"
assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test"
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.LOCALE)
== "fr_FR"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.USERNAME)
== "email@test.com"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.PASSWORD)
== "test"
)
renault_account = AsyncMock()
type(renault_account).account_id = PropertyMock(return_value="account_id_other")
@@ -422,9 +450,9 @@ async def test_reconfigure_mismatch(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email2@test.com",
CONF_PASSWORD: "test2",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email2@test.com",
RenaultConfigurationKeys.PASSWORD: "test2",
},
)
@@ -432,10 +460,13 @@ async def test_reconfigure_mismatch(
assert result["reason"] == "unique_id_mismatch"
# Unchanged values
assert config_entry.data[CONF_USERNAME] == "email@test.com"
assert config_entry.data[CONF_PASSWORD] == "test"
assert config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1"
assert config_entry.data[CONF_LOCALE] == "fr_FR"
assert config_entry.data[RenaultConfigurationKeys.USERNAME] == "email@test.com"
assert config_entry.data[RenaultConfigurationKeys.PASSWORD] == "test"
assert config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
config_entry.data[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
== "account_id_1"
)
assert config_entry.data[RenaultConfigurationKeys.LOCALE] == "fr_FR"
assert len(mock_setup_entry.mock_calls) == 0
+9 -12
View File
@@ -10,19 +10,13 @@ from renault_api.gigya.exceptions import GigyaException, InvalidCredentialsExcep
from renault_api.renault_session import RenaultSession
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.renault.const import (
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOCALE,
CONF_LOGIN_TOKEN,
DOMAIN,
)
from homeassistant.components.renault.const import DOMAIN, RenaultConfigurationKeys
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_USER,
ConfigEntry,
ConfigEntryState,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
@@ -34,10 +28,10 @@ from tests.typing import WebSocketGenerator
# Config data of an entry created before the login token was stored.
MOCK_CONFIG_NO_TOKEN = {
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
CONF_KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
CONF_LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
RenaultConfigurationKeys.LOCALE: "fr_FR",
}
@@ -102,7 +96,10 @@ async def test_setup_entry_password_login(
assert mock_login.called
assert legacy_config_entry.state is ConfigEntryState.LOADED
# The obtained login token is persisted so future setups skip the password.
assert legacy_config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
legacy_config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN]
== MOCK_LOGIN_TOKEN
)
async def test_setup_entry_bad_password(
+50 -38
View File
@@ -2,6 +2,7 @@
from collections.abc import Generator
from datetime import datetime
from enum import StrEnum
from unittest.mock import patch
import pytest
@@ -11,13 +12,8 @@ from renault_api.kamereon.models import ChargeSchedule, HvacSchedule
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.renault.const import DOMAIN
from homeassistant.components.renault.services import (
ATTR_SCHEDULES,
ATTR_VEHICLE,
ATTR_WHEN,
)
from homeassistant.components.renault.services import RenaultServiceArgument
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
@@ -27,6 +23,16 @@ from tests.common import async_load_fixture
pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles")
class RenaultService(StrEnum):
"""Renault service names."""
AC_CANCEL = "ac_cancel"
AC_SET_SCHEDULES = "ac_set_schedules"
AC_START = "ac_start"
CHARGE_SET_SCHEDULES = "charge_set_schedules"
CHARGE_START = "charge_start"
@pytest.fixture(autouse=True)
def override_platforms() -> Generator[None]:
"""Override PLATFORMS."""
@@ -56,7 +62,7 @@ async def test_service_set_ac_cancel(
await hass.async_block_till_done()
data = {
ATTR_VEHICLE: get_device_id(hass),
RenaultServiceArgument.VEHICLE: get_device_id(hass),
}
with patch(
@@ -68,7 +74,7 @@ async def test_service_set_ac_cancel(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "ac_cancel", service_data=data, blocking=True
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == ()
@@ -83,8 +89,8 @@ async def test_service_set_ac_start_simple(
temperature = 13.5
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_TEMPERATURE: temperature,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.TEMPERATURE: temperature,
}
with patch(
@@ -96,7 +102,7 @@ async def test_service_set_ac_start_simple(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "ac_start", service_data=data, blocking=True
DOMAIN, RenaultService.AC_START, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (temperature, None)
@@ -112,9 +118,9 @@ async def test_service_set_ac_start_with_date(
temperature = 13.5
when = datetime(2025, 8, 23, 17, 12, 45)
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_TEMPERATURE: temperature,
ATTR_WHEN: when,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.TEMPERATURE: temperature,
RenaultServiceArgument.WHEN: when,
}
with patch(
@@ -126,7 +132,7 @@ async def test_service_set_ac_start_with_date(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "ac_start", service_data=data, blocking=True
DOMAIN, RenaultService.AC_START, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (temperature, when)
@@ -140,7 +146,7 @@ async def test_service_charge_start_simple(
await hass.async_block_till_done()
data = {
ATTR_VEHICLE: get_device_id(hass),
RenaultServiceArgument.VEHICLE: get_device_id(hass),
}
with patch(
@@ -152,7 +158,7 @@ async def test_service_charge_start_simple(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "charge_start", service_data=data, blocking=True
DOMAIN, RenaultService.CHARGE_START, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (None,)
@@ -167,8 +173,8 @@ async def test_service_charge_start_with_date(
when = datetime(2025, 8, 23, 17, 12, 45)
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_WHEN: when,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.WHEN: when,
}
with patch(
@@ -180,7 +186,7 @@ async def test_service_charge_start_with_date(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "charge_start", service_data=data, blocking=True
DOMAIN, RenaultService.CHARGE_START, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (when,)
@@ -195,8 +201,8 @@ async def test_service_set_charge_schedule(
schedules = {"id": 2}
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_SCHEDULES: schedules,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.SCHEDULES: schedules,
}
with (
@@ -219,7 +225,10 @@ async def test_service_set_charge_schedule(
) as mock_action,
):
await hass.services.async_call(
DOMAIN, "charge_set_schedules", service_data=data, blocking=True
DOMAIN,
RenaultService.CHARGE_SET_SCHEDULES,
service_data=data,
blocking=True,
)
assert len(mock_action.mock_calls) == 1
mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0]
@@ -247,8 +256,8 @@ async def test_service_set_charge_schedule_multi(
{"id": 3},
]
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_SCHEDULES: schedules,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.SCHEDULES: schedules,
}
with (
@@ -271,7 +280,10 @@ async def test_service_set_charge_schedule_multi(
) as mock_action,
):
await hass.services.async_call(
DOMAIN, "charge_set_schedules", service_data=data, blocking=True
DOMAIN,
RenaultService.CHARGE_SET_SCHEDULES,
service_data=data,
blocking=True,
)
assert len(mock_action.mock_calls) == 1
mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0]
@@ -296,8 +308,8 @@ async def test_service_set_ac_schedule(
schedules = {"id": 2}
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_SCHEDULES: schedules,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.SCHEDULES: schedules,
}
with (
@@ -319,7 +331,7 @@ async def test_service_set_ac_schedule(
) as mock_action,
):
await hass.services.async_call(
DOMAIN, "ac_set_schedules", service_data=data, blocking=True
DOMAIN, RenaultService.AC_SET_SCHEDULES, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0]
@@ -347,8 +359,8 @@ async def test_service_set_ac_schedule_multi(
{"id": 4},
]
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_SCHEDULES: schedules,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.SCHEDULES: schedules,
}
with (
@@ -370,7 +382,7 @@ async def test_service_set_ac_schedule_multi(
) as mock_action,
):
await hass.services.async_call(
DOMAIN, "ac_set_schedules", service_data=data, blocking=True
DOMAIN, RenaultService.AC_SET_SCHEDULES, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
mock_call_data: list[HvacSchedule] = mock_action.mock_calls[0][1][0]
@@ -393,11 +405,11 @@ async def test_service_invalid_device_id(
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
data = {ATTR_VEHICLE: "some_random_id"}
data = {RenaultServiceArgument.VEHICLE: "some_random_id"}
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN, "ac_cancel", service_data=data, blocking=True
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
)
assert err.value.translation_key == "invalid_device_id"
assert err.value.translation_placeholders == {"device_id": "some_random_id"}
@@ -421,11 +433,11 @@ async def test_service_invalid_device_id2(
identifiers={(DOMAIN, "VF1AAAAA111222333")},
).id
data = {ATTR_VEHICLE: device_id}
data = {RenaultServiceArgument.VEHICLE: device_id}
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN, "ac_cancel", service_data=data, blocking=True
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
)
assert err.value.translation_key == "no_config_entry_for_device"
assert err.value.translation_placeholders == {"device_id": "REG-NUMBER"}
@@ -439,7 +451,7 @@ async def test_service_exception(
await hass.async_block_till_done()
data = {
ATTR_VEHICLE: get_device_id(hass),
RenaultServiceArgument.VEHICLE: get_device_id(hass),
}
with (
@@ -450,7 +462,7 @@ async def test_service_exception(
pytest.raises(HomeAssistantError, match="Didn't work"),
):
await hass.services.async_call(
DOMAIN, "ac_cancel", service_data=data, blocking=True
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == ()
+21
View File
@@ -5,6 +5,7 @@ import pathlib
from typing import Any
from unittest.mock import AsyncMock, patch
import aiohttp
from freezegun.api import FrozenDateTimeFactory
import pytest
from roborock import (
@@ -256,6 +257,26 @@ async def test_no_user_agreement(
assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement"
@pytest.mark.parametrize(
"side_effect",
[aiohttp.ClientError(), TimeoutError()],
ids=["client_error", "timeout"],
)
async def test_network_error_during_setup(
hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
side_effect: Exception,
) -> None:
"""Test that network errors during setup trigger retry, not terminal failure."""
with patch(
"homeassistant.components.roborock.create_device_manager",
side_effect=side_effect,
):
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_roborock_entry.error_reason_translation_key == "network_error"
@pytest.mark.parametrize("platforms", [[Platform.SENSOR]])
async def test_stale_device(
hass: HomeAssistant,
@@ -12,6 +12,7 @@ import pytest
from requests_mock.mocker import Mocker
import voluptuous as vol
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -25,13 +26,11 @@ from .conftest import (
SignalNotificationService,
)
BASE_COMPONENT = "notify"
async def test_signal_messenger_init(hass: HomeAssistant) -> None:
"""Test that service loads successfully."""
config = {
BASE_COMPONENT: {
NOTIFY_DOMAIN: {
"name": "test",
"platform": "signal_messenger",
"url": "http://127.0.0.1:8080",
@@ -41,10 +40,10 @@ async def test_signal_messenger_init(hass: HomeAssistant) -> None:
}
with patch("pysignalclirestapi.SignalCliRestApi.send_message", return_value=None):
assert await async_setup_component(hass, BASE_COMPONENT, config)
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
await hass.async_block_till_done()
assert hass.services.has_service(BASE_COMPONENT, "test")
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
def test_send_message(
+4 -3
View File
@@ -14,6 +14,7 @@ from unifi_access_api import (
UnifiAccessError,
)
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.components.unifi_access.const import DOMAIN
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
@@ -93,7 +94,7 @@ async def test_select_option_calls_api(
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
Platform.SELECT,
SELECT_DOMAIN,
"select_option",
{"entity_id": FRONT_DOOR_LOCK_RULE_SELECT_ENTITY, "option": "keep_lock"},
blocking=True,
@@ -116,7 +117,7 @@ async def test_select_schedule_option_does_not_call_api(
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
Platform.SELECT,
SELECT_DOMAIN,
"select_option",
{"entity_id": FRONT_DOOR_LOCK_RULE_SELECT_ENTITY, "option": "schedule"},
blocking=True,
@@ -229,7 +230,7 @@ async def test_select_option_raises_on_api_error(
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
Platform.SELECT,
SELECT_DOMAIN,
"select_option",
{
"entity_id": FRONT_DOOR_LOCK_RULE_SELECT_ENTITY,
+8 -8
View File
@@ -13,6 +13,7 @@ from uiprotect.data import (
from uiprotect.exceptions import ClientError, NotAuthorized
from uiprotect.websocket import WebsocketState
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
@@ -21,7 +22,6 @@ from homeassistant.const import (
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -167,7 +167,7 @@ async def test_relay_switch_turn_on_off(
await init_entry(hass, ufp, [])
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
@@ -176,7 +176,7 @@ async def test_relay_switch_turn_on_off(
relay.activate_output.reset_mock()
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
@@ -245,7 +245,7 @@ async def test_relay_switch_command_error_raises(
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
@@ -264,7 +264,7 @@ async def test_relay_switch_client_error_raises(
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
@@ -284,7 +284,7 @@ async def test_relay_switch_command_when_relay_gone(
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
@@ -303,7 +303,7 @@ async def test_relay_switch_command_when_bootstrap_unavailable(
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
@@ -456,7 +456,7 @@ async def test_relay_switch_command_when_output_gone(
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
+15 -11
View File
@@ -14,7 +14,11 @@ from uiprotect.data import (
from uiprotect.exceptions import ClientError, NotAuthorized
from uiprotect.websocket import WebsocketState
from homeassistant.components.siren import ATTR_DURATION, ATTR_VOLUME_LEVEL
from homeassistant.components.siren import (
ATTR_DURATION,
ATTR_VOLUME_LEVEL,
DOMAIN as SIREN_DOMAIN,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
@@ -155,7 +159,7 @@ async def test_siren_turn_on(
await init_entry(hass, ufp_with_siren, [])
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SIREN_ENTITY_ID},
blocking=True,
@@ -183,7 +187,7 @@ async def test_siren_turn_on_with_duration(
await init_entry(hass, ufp_with_siren, [])
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SIREN_ENTITY_ID, ATTR_DURATION: seconds},
blocking=True,
@@ -201,7 +205,7 @@ async def test_siren_turn_on_invalid_duration(
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SIREN_ENTITY_ID, ATTR_DURATION: 15},
blocking=True,
@@ -223,7 +227,7 @@ async def test_siren_turn_on_invalid_duration_does_not_set_volume(
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: SIREN_ENTITY_ID,
@@ -245,7 +249,7 @@ async def test_siren_turn_on_with_volume(
await init_entry(hass, ufp_with_siren, [])
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SIREN_ENTITY_ID, ATTR_VOLUME_LEVEL: 0.75},
blocking=True,
@@ -268,7 +272,7 @@ async def test_siren_turn_off(
assert state.state == STATE_ON
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: SIREN_ENTITY_ID},
blocking=True,
@@ -301,7 +305,7 @@ async def test_siren_turn_on_api_error(
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SIREN_ENTITY_ID},
blocking=True,
@@ -319,7 +323,7 @@ async def test_siren_turn_on_when_siren_gone(
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SIREN_ENTITY_ID},
blocking=True,
@@ -337,7 +341,7 @@ async def test_siren_turn_off_when_bootstrap_unavailable(
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: SIREN_ENTITY_ID},
blocking=True,
@@ -506,7 +510,7 @@ async def test_siren_turn_off_cancels_scheduled_timer(
# Manually turn off — must cancel the scheduled timer.
await hass.services.async_call(
Platform.SIREN,
SIREN_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: SIREN_ENTITY_ID},
blocking=True,
+30 -2
View File
@@ -1,8 +1,10 @@
"""Test Volvo locks."""
from collections.abc import Awaitable, Callable
from datetime import timedelta
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from volvocarsapi.api import VolvoCarsApi
@@ -14,7 +16,8 @@ from homeassistant.components.lock import (
SERVICE_UNLOCK,
LockState,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.components.volvo.coordinator import FAST_INTERVAL
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@@ -22,7 +25,7 @@ from homeassistant.helpers import entity_registry as er
from . import configure_mock
from .const import DEFAULT_VIN
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("mock_api", "full_model")
@@ -134,3 +137,28 @@ async def test_unlock_failure(
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == LockState.LOCKED
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
@pytest.mark.usefixtures("full_model")
async def test_lock_unavailable_when_api_field_missing(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test lock becomes unavailable when centralLock is missing from API response."""
with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]):
assert await setup_integration()
entity_id = "lock.volvo_xc40_lock"
assert hass.states.get(entity_id).state == LockState.LOCKED
# Simulate API returning doors data without centralLock
configure_mock(mock_api.async_get_doors_status, return_value={})
freezer.tick(timedelta(minutes=FAST_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
@@ -72,7 +72,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_category': None,
'entity_id': 'sensor.zeversolar_sensor_power',
'has_entity_name': True,
'hidden_by': None,
@@ -94,7 +94,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pac',
'translation_key': None,
'unique_id': '123456778_pac',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
+26 -3
View File
@@ -1,14 +1,17 @@
"""Test the sensor classes."""
from unittest.mock import patch
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from syrupy.assertion import SnapshotAssertion
from zeversolar.exceptions import ZeverSolarError
from homeassistant.const import Platform
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_sensors(
@@ -25,3 +28,23 @@ async def test_sensors(
await snapshot_platform(
hass, entity_registry, snapshot, init_integration.entry_id
)
async def test_sensor_update_failed(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_zeversolar_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test entities become unavailable after a failed coordinator update."""
assert hass.states.get("sensor.zeversolar_sensor_energy_today").state is not None
mock_zeversolar_client.get_data.side_effect = ZeverSolarError
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.zeversolar_sensor_energy_today").state
== STATE_UNAVAILABLE
)