Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Bottein
aa3c7578fc Add deprecation 2025-12-05 16:11:31 +01:00
Paul Bottein
4b619a5904 Migrate lovelace panel to dashboard 2025-12-05 16:07:46 +01:00
86 changed files with 911 additions and 2061 deletions

View File

@@ -13,7 +13,6 @@ core: &core
# Our base platforms, that are used by other integrations
base_platforms: &base_platforms
- homeassistant/components/ai_task/**
- homeassistant/components/air_quality/**
- homeassistant/components/alarm_control_panel/**
- homeassistant/components/assist_satellite/**

4
CODEOWNERS generated
View File

@@ -1358,8 +1358,8 @@ build.json @home-assistant/supervisor
/tests/components/ring/ @sdb9696
/homeassistant/components/risco/ @OnFreund
/tests/components/risco/ @OnFreund
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm
/tests/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck
/tests/components/rituals_perfume_genie/ @milanmeu @frenck
/homeassistant/components/rmvtransport/ @cgtobi
/tests/components/rmvtransport/ @cgtobi
/homeassistant/components/roborock/ @Lash-L @allenporter

View File

@@ -101,8 +101,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Schema({str: STRUCTURE_FIELD_SCHEMA}),
_validate_structure_fields,
),
vol.Optional(ATTR_ATTACHMENTS): selector.MediaSelector(
{"accept": ["*/*"], "multiple": True}
vol.Optional(ATTR_ATTACHMENTS): vol.All(
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
),
}
),
@@ -118,8 +118,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Required(ATTR_TASK_NAME): cv.string,
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_INSTRUCTIONS): cv.string,
vol.Optional(ATTR_ATTACHMENTS): selector.MediaSelector(
{"accept": ["*/*"], "multiple": True}
vol.Optional(ATTR_ATTACHMENTS): vol.All(
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
),
}
),

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:

View File

@@ -44,7 +44,7 @@ rules:
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
@@ -54,7 +54,7 @@ rules:
comment: Single device integration, no dynamic device discovery needed.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: done
icon-translations: todo

View File

@@ -1,134 +0,0 @@
"""Sensor platform for Airobot thermostat."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pyairobotrest.models import ThermostatStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
EntityCategory,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AirobotConfigEntry
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotSensorEntityDescription(SensorEntityDescription):
"""Describes Airobot sensor entity."""
value_fn: Callable[[ThermostatStatus], StateType]
supported_fn: Callable[[ThermostatStatus], bool] = lambda _: True
SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
AirobotSensorEntityDescription(
key="air_temperature",
translation_key="air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.temp_air,
),
AirobotSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.hum_air,
),
AirobotSensorEntityDescription(
key="floor_temperature",
translation_key="floor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.temp_floor,
supported_fn=lambda status: status.has_floor_sensor,
),
AirobotSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.co2,
supported_fn=lambda status: status.has_co2_sensor,
),
AirobotSensorEntityDescription(
key="air_quality_index",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.aqi,
supported_fn=lambda status: status.has_co2_sensor,
),
AirobotSensorEntityDescription(
key="heating_uptime",
translation_key="heating_uptime",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.heating_uptime,
entity_registry_enabled_default=False,
),
AirobotSensorEntityDescription(
key="errors",
translation_key="errors",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.errors,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot sensor platform."""
coordinator = entry.runtime_data
async_add_entities(
AirobotSensor(coordinator, description)
for description in SENSOR_TYPES
if description.supported_fn(coordinator.data.status)
)
class AirobotSensor(AirobotEntity, SensorEntity):
"""Representation of an Airobot sensor."""
entity_description: AirobotSensorEntityDescription
def __init__(
self,
coordinator,
description: AirobotSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data.status)

View File

@@ -43,25 +43,6 @@
}
}
},
"entity": {
"sensor": {
"air_temperature": {
"name": "Air temperature"
},
"device_uptime": {
"name": "Device uptime"
},
"errors": {
"name": "Error count"
},
"floor_temperature": {
"name": "Floor temperature"
},
"heating_uptime": {
"name": "Heating uptime"
}
}
},
"exceptions": {
"authentication_failed": {
"message": "Authentication failed, please reauthenticate."

View File

@@ -42,25 +42,14 @@ async def get_remotes(client: MozartClient) -> list[PairedRemote]:
def get_device_buttons(model: BeoModel) -> list[str]:
"""Get supported buttons for a given model."""
# Beoconnect Core does not have any buttons
if model == BeoModel.BEOCONNECT_CORE:
return []
buttons = DEVICE_BUTTONS.copy()
# Models that don't have a microphone button
if model in (
BeoModel.BEOSOUND_A5,
BeoModel.BEOSOUND_A9,
BeoModel.BEOSOUND_PREMIERE,
):
buttons.remove(BeoButtons.MICROPHONE)
# Models that don't have a Bluetooth button
if model in (
BeoModel.BEOSOUND_A9,
BeoModel.BEOSOUND_PREMIERE,
):
# Beosound Premiere does not have a bluetooth button
if model == BeoModel.BEOSOUND_PREMIERE:
buttons.remove(BeoButtons.BLUETOOTH)
# Beoconnect Core does not have any buttons
elif model == BeoModel.BEOCONNECT_CORE:
buttons = []
return buttons

View File

@@ -56,6 +56,7 @@ class DeviceAutomationConditionProtocol(Protocol):
class DeviceCondition(Condition):
"""Device condition."""
_hass: HomeAssistant
_config: ConfigType
@classmethod
@@ -86,7 +87,7 @@ class DeviceCondition(Condition):
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config)
self._hass = hass
assert config.options is not None
self._config = config.options

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dnsip",
"iot_class": "cloud_polling",
"requirements": ["aiodns==3.6.0"]
"requirements": ["aiodns==3.5.0"]
}

View File

@@ -102,12 +102,6 @@ class ConfiguredDoorBird:
"""Get token for device."""
return self._token
def _get_hass_url(self) -> str:
"""Get the Home Assistant URL for this device."""
if custom_url := self.custom_url:
return custom_url
return get_url(self._hass, prefer_external=False)
async def async_register_events(self) -> None:
"""Register events on device."""
if not self.door_station_events:
@@ -152,7 +146,13 @@ class ConfiguredDoorBird:
async def _async_register_events(self) -> dict[str, Any]:
"""Register events on device."""
hass_url = self._get_hass_url()
# Override url if another is specified in the configuration
if custom_url := self.custom_url:
hass_url = custom_url
else:
# Get the URL of this server
hass_url = get_url(self._hass, prefer_external=False)
http_fav = await self._async_get_http_favorites()
if any(
# Note that a list comp is used here to ensure all
@@ -191,14 +191,10 @@ class ConfiguredDoorBird:
self._get_event_name(event): event_type
for event, event_type in DEFAULT_EVENT_TYPES
}
hass_url = self._get_hass_url()
for identifier, data in http_fav.items():
title: str | None = data.get("title")
if not title or not title.startswith("Home Assistant"):
continue
value: str | None = data.get("value")
if not value or not value.startswith(hass_url):
continue # Not our favorite - different HA instance or stale
event = title.partition("(")[2].strip(")")
if input_type := favorite_input_type.get(identifier):
events.append(DoorbirdEvent(event, input_type))

View File

@@ -2,22 +2,33 @@
from __future__ import annotations
from collections.abc import Callable, Coroutine, Sequence
from datetime import datetime, timedelta
import logging
from typing import Any, cast
from aiohttp import ClientSession
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import (
CALLBACK_TYPE,
HassJob,
HomeAssistant,
ServiceCall,
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
from .const import ATTR_CONFIG_ENTRY
from .coordinator import DuckDnsConfigEntry, DuckDnsUpdateCoordinator
from .helpers import update_duckdns
_LOGGER = logging.getLogger(__name__)
@@ -25,8 +36,17 @@ ATTR_TXT = "txt"
DOMAIN = "duckdns"
INTERVAL = timedelta(minutes=5)
BACKOFF_INTERVALS = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
SERVICE_SET_TXT = "set_txt"
UPDATE_URL = "https://www.duckdns.org/update"
CONFIG_SCHEMA = vol.Schema(
{
@@ -51,6 +71,8 @@ SERVICE_TXT_SCHEMA = vol.Schema(
}
)
type DuckDnsConfigEntry = ConfigEntry
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the DuckDNS component."""
@@ -77,12 +99,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
"""Set up Duck DNS from a config entry."""
coordinator = DuckDnsUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
session = async_get_clientsession(hass)
# Add a dummy listener as we do not have regular entities
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
async def update_domain_interval(_now: datetime) -> bool:
"""Update the DuckDNS entry."""
return await _update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
)
entry.async_on_unload(
async_track_time_interval_backoff(
hass, update_domain_interval, BACKOFF_INTERVALS
)
)
return True
@@ -122,7 +153,7 @@ async def update_domain_service(call: ServiceCall) -> None:
session = async_get_clientsession(call.hass)
await update_duckdns(
await _update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
@@ -133,3 +164,73 @@ async def update_domain_service(call: ServiceCall) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
"""Unload a config entry."""
return True
_SENTINEL = object()
async def _update_duckdns(
session: ClientSession,
domain: str,
token: str,
*,
txt: str | None | object = _SENTINEL,
clear: bool = False,
) -> bool:
"""Update DuckDNS."""
params = {"domains": domain, "token": token}
if txt is not _SENTINEL:
if txt is None:
# Pass in empty txt value to indicate it's clearing txt record
params["txt"] = ""
clear = True
else:
params["txt"] = cast(str, txt)
if clear:
params["clear"] = "true"
resp = await session.get(UPDATE_URL, params=params)
body = await resp.text()
if body != "OK":
_LOGGER.warning("Updating DuckDNS domain failed: %s", domain)
return False
return True
@callback
@bind_hass
def async_track_time_interval_backoff(
hass: HomeAssistant,
action: Callable[[datetime], Coroutine[Any, Any, bool]],
intervals: Sequence[timedelta],
) -> CALLBACK_TYPE:
"""Add a listener that fires repetitively at every timedelta interval."""
remove: CALLBACK_TYPE | None = None
failed = 0
async def interval_listener(now: datetime) -> None:
"""Handle elapsed intervals with backoff."""
nonlocal failed, remove
try:
failed += 1
if await action(now):
failed = 0
finally:
delay = intervals[failed] if failed < len(intervals) else intervals[-1]
remove = async_call_later(
hass, delay.total_seconds(), interval_listener_job
)
interval_listener_job = HassJob(interval_listener, cancel_on_shutdown=True)
hass.async_run_hass_job(interval_listener_job, dt_util.utcnow())
def remove_listener() -> None:
"""Remove interval listener."""
if remove:
remove()
return remove_listener

View File

@@ -16,8 +16,8 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
from . import _update_duckdns
from .const import DOMAIN
from .helpers import update_duckdns
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
@@ -46,7 +46,7 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]})
session = async_get_clientsession(self.hass)
try:
if not await update_duckdns(
if not await _update_duckdns(
session,
user_input[CONF_DOMAIN],
user_input[CONF_ACCESS_TOKEN],
@@ -93,7 +93,7 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
session = async_get_clientsession(self.hass)
try:
if not await update_duckdns(
if not await _update_duckdns(
session,
entry.data[CONF_DOMAIN],
user_input[CONF_ACCESS_TOKEN],

View File

@@ -1,83 +0,0 @@
"""Coordinator for the Duck DNS integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from aiohttp import ClientError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .helpers import update_duckdns
_LOGGER = logging.getLogger(__name__)
type DuckDnsConfigEntry = ConfigEntry[DuckDnsUpdateCoordinator]
INTERVAL = timedelta(minutes=5)
BACKOFF_INTERVALS = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
"""Duck DNS update coordinator."""
config_entry: DuckDnsConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: DuckDnsConfigEntry) -> None:
"""Initialize the Duck DNS update coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=INTERVAL,
)
self.session = async_get_clientsession(hass)
self.failed = 0
async def _async_update_data(self) -> None:
"""Update Duck DNS."""
retry_after = BACKOFF_INTERVALS[
min(self.failed, len(BACKOFF_INTERVALS))
].total_seconds()
try:
if not await update_duckdns(
self.session,
self.config_entry.data[CONF_DOMAIN],
self.config_entry.data[CONF_ACCESS_TOKEN],
):
self.failed += 1
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={
CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN],
},
retry_after=retry_after,
)
except ClientError as e:
self.failed += 1
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN],
},
retry_after=retry_after,
) from e
self.failed = 0

View File

@@ -1,35 +0,0 @@
"""Helpers for Duck DNS integration."""
from aiohttp import ClientSession
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
UPDATE_URL = "https://www.duckdns.org/update"
async def update_duckdns(
session: ClientSession,
domain: str,
token: str,
*,
txt: str | None | UndefinedType = UNDEFINED,
clear: bool = False,
) -> bool:
"""Update DuckDNS."""
params = {"domains": domain, "token": token}
if txt is not UNDEFINED:
if txt is None:
# Pass in empty txt value to indicate it's clearing txt record
params["txt"] = ""
clear = True
else:
params["txt"] = txt
if clear:
params["clear"] = "true"
resp = await session.get(UPDATE_URL, params=params)
body = await resp.text()
return body == "OK"

View File

@@ -32,17 +32,11 @@
}
},
"exceptions": {
"connection_error": {
"message": "Updating Duck DNS domain {domain} failed due to a connection error"
},
"entry_not_found": {
"message": "Duck DNS integration entry not found"
},
"entry_not_selected": {
"message": "Duck DNS integration entry not selected"
},
"update_failed": {
"message": "Updating Duck DNS domain {domain} failed"
}
},
"issues": {

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251203.1"]
"requirements": ["home-assistant-frontend==20251203.0"]
}

View File

@@ -149,7 +149,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
}
),
supports_response=SupportsResponse.ONLY,
description_placeholders={"example_image_path": "/config/www/image.jpg"},
)
return True

View File

@@ -23,7 +23,7 @@ CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image-preview"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p"

View File

@@ -162,7 +162,7 @@
"fields": {
"filenames": {
"description": "Attachments to add to the prompt (images, PDFs, etc)",
"example": "{example_image_path}",
"example": "/config/www/image.jpg",
"name": "Attachment filenames"
},
"prompt": {

View File

@@ -159,5 +159,4 @@ def async_setup_services(hass: HomeAssistant) -> None:
_async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
description_placeholders={"example_image_path": "/config/www/image.jpg"},
)

View File

@@ -92,7 +92,7 @@
},
"filename": {
"description": "Path to the image or video to upload.",
"example": "{example_image_path}",
"example": "/config/www/image.jpg",
"name": "Filename"
}
},

View File

@@ -7,10 +7,11 @@ in the Home Assistant Labs UI for users to enable or disable.
from __future__ import annotations
from collections.abc import Callable
import logging
from homeassistant.const import EVENT_LABS_UPDATED
from homeassistant.core import HomeAssistant
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.generated.labs import LABS_PREVIEW_FEATURES
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.storage import Store
@@ -18,7 +19,6 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_custom_components
from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
from .helpers import async_is_preview_feature_enabled, async_listen
from .models import (
EventLabsUpdatedData,
LabPreviewFeature,
@@ -135,3 +135,55 @@ async def _async_scan_all_preview_features(
_LOGGER.debug("Loaded %d total lab preview features", len(preview_features))
return preview_features
@callback
def async_is_preview_feature_enabled(
hass: HomeAssistant, domain: str, preview_feature: str
) -> bool:
"""Check if a lab preview feature is enabled.
Args:
hass: HomeAssistant instance
domain: Integration domain
preview_feature: Preview feature name
Returns:
True if the preview feature is enabled, False otherwise
"""
if LABS_DATA not in hass.data:
return False
labs_data = hass.data[LABS_DATA]
return (domain, preview_feature) in labs_data.data.preview_feature_status
@callback
def async_listen(
hass: HomeAssistant,
domain: str,
preview_feature: str,
listener: Callable[[], None],
) -> Callable[[], None]:
"""Listen for changes to a specific preview feature.
Args:
hass: HomeAssistant instance
domain: Integration domain
preview_feature: Preview feature name
listener: Callback to invoke when the preview feature is toggled
Returns:
Callable to unsubscribe from the listener
"""
@callback
def _async_feature_updated(event: Event[EventLabsUpdatedData]) -> None:
"""Handle labs feature update event."""
if (
event.data["domain"] == domain
and event.data["preview_feature"] == preview_feature
):
listener()
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)

View File

@@ -1,63 +0,0 @@
"""Helper functions for the Home Assistant Labs integration."""
from __future__ import annotations
from collections.abc import Callable
from homeassistant.const import EVENT_LABS_UPDATED
from homeassistant.core import Event, HomeAssistant, callback
from .const import LABS_DATA
from .models import EventLabsUpdatedData
@callback
def async_is_preview_feature_enabled(
hass: HomeAssistant, domain: str, preview_feature: str
) -> bool:
"""Check if a lab preview feature is enabled.
Args:
hass: HomeAssistant instance
domain: Integration domain
preview_feature: Preview feature name
Returns:
True if the preview feature is enabled, False otherwise
"""
if LABS_DATA not in hass.data:
return False
labs_data = hass.data[LABS_DATA]
return (domain, preview_feature) in labs_data.data.preview_feature_status
@callback
def async_listen(
hass: HomeAssistant,
domain: str,
preview_feature: str,
listener: Callable[[], None],
) -> Callable[[], None]:
"""Listen for changes to a specific preview feature.
Args:
hass: HomeAssistant instance
domain: Integration domain
preview_feature: Preview feature name
listener: Callback to invoke when the preview feature is toggled
Returns:
Callable to unsubscribe from the listener
"""
@callback
def _async_feature_updated(event: Event[EventLabsUpdatedData]) -> None:
"""Handle labs feature update event."""
if (
event.data["domain"] == domain
and event.data["preview_feature"] == preview_feature
):
listener()
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)

View File

@@ -12,7 +12,6 @@ from homeassistant.const import EVENT_LABS_UPDATED
from homeassistant.core import HomeAssistant, callback
from .const import LABS_DATA
from .helpers import async_is_preview_feature_enabled, async_listen
from .models import EventLabsUpdatedData
@@ -21,7 +20,6 @@ def async_setup(hass: HomeAssistant) -> None:
"""Set up the number websocket API."""
websocket_api.async_register_command(hass, websocket_list_preview_features)
websocket_api.async_register_command(hass, websocket_update_preview_feature)
websocket_api.async_register_command(hass, websocket_subscribe_feature)
@callback
@@ -110,52 +108,3 @@ async def websocket_update_preview_feature(
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
connection.send_result(msg["id"])
@callback
@websocket_api.websocket_command(
{
vol.Required("type"): "labs/subscribe",
vol.Required("domain"): str,
vol.Required("preview_feature"): str,
}
)
def websocket_subscribe_feature(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to a specific lab preview feature updates."""
domain = msg["domain"]
preview_feature_key = msg["preview_feature"]
labs_data = hass.data[LABS_DATA]
preview_feature_id = f"{domain}.{preview_feature_key}"
if preview_feature_id not in labs_data.preview_features:
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_FOUND,
f"Preview feature {preview_feature_id} not found",
)
return
preview_feature = labs_data.preview_features[preview_feature_id]
@callback
def send_event() -> None:
"""Send feature state to client."""
enabled = async_is_preview_feature_enabled(hass, domain, preview_feature_key)
connection.send_message(
websocket_api.event_message(
msg["id"],
preview_feature.to_dict(enabled=enabled),
)
)
connection.subscriptions[msg["id"]] = async_listen(
hass, domain, preview_feature_key, send_event
)
connection.send_result(msg["id"])
send_event()

View File

@@ -108,7 +108,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_MESSAGE,
_async_service_message,
schema=SERVICE_MESSAGE_SCHEMA,
description_placeholders={"icons_url": "https://developer.lametric.com/icons"},
)

View File

@@ -211,7 +211,7 @@
"name": "[%key:common::config_flow::data::device%]"
},
"icon": {
"description": "The ID number of the icon or animation to display. List of all icons and their IDs can be found at: {icons_url}.",
"description": "The ID number of the icon or animation to display. List of all icons and their IDs can be found at: https://developer.lametric.com/icons.",
"name": "Icon ID"
},
"icon_type": {

View File

@@ -52,7 +52,7 @@ class StateConditionBase(Condition):
self, hass: HomeAssistant, config: ConditionConfig, state: str
) -> None:
"""Initialize condition."""
super().__init__(hass, config)
self._hass = hass
if TYPE_CHECKING:
assert config.target
assert config.options

View File

@@ -14,9 +14,14 @@ from homeassistant.config import (
from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers import (
collection,
config_validation as cv,
issue_registry as ir,
)
from homeassistant.helpers.frame import report_usage
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.storage import Store
from homeassistant.helpers.translation import async_get_translations
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration
@@ -34,6 +39,7 @@ from .const import ( # noqa: F401
DEFAULT_ICON,
DOMAIN,
EVENT_LOVELACE_UPDATED,
LOVELACE_CONFIG_FILE,
LOVELACE_DATA,
MODE_STORAGE,
MODE_YAML,
@@ -135,14 +141,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
mode = config[DOMAIN][CONF_MODE]
yaml_resources = config[DOMAIN].get(CONF_RESOURCES)
frontend.async_register_built_in_panel(
hass,
DOMAIN,
config={"mode": mode},
sidebar_title="overview",
sidebar_icon="mdi:view-dashboard",
sidebar_default_visible=False,
)
# Deprecated - Remove in 2026.6
# For YAML mode, register the default panel (temporary until user migrates)
if mode == MODE_YAML:
frontend.async_register_built_in_panel(
hass,
DOMAIN,
config={"mode": mode},
sidebar_title="overview",
sidebar_icon="mdi:view-dashboard",
sidebar_default_visible=False,
)
_async_create_yaml_mode_repair(hass)
# End deprecation
async def reload_resources_service_handler(service_call: ServiceCall) -> None:
"""Reload yaml resources."""
@@ -282,6 +293,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
dashboards_collection.async_add_listener(storage_dashboard_changed)
await dashboards_collection.async_load()
# Migrate default lovelace panel to dashboard entry (storage mode only)
if mode == MODE_STORAGE:
await _async_migrate_default_config(hass, dashboards_collection)
dashboard.DashboardsCollectionWebSocket(
dashboards_collection,
"lovelace/dashboards",
@@ -360,3 +375,94 @@ async def _create_map_dashboard(
map_store = hass.data[LOVELACE_DATA].dashboards["map"]
await map_store.async_save({"strategy": {"type": "map"}})
async def _async_migrate_default_config(
hass: HomeAssistant, dashboards_collection: dashboard.DashboardsCollection
) -> None:
"""Migrate default lovelace storage config to a named dashboard entry.
This migration:
1. Checks if .storage/lovelace exists with data
2. Checks if a dashboard with url_path "lovelace" already exists (skip if so)
3. Checks if .storage/lovelace.lovelace already exists (skip if so - incomplete migration)
4. Creates a new dashboard entry with url_path "lovelace"
5. Copies data to .storage/lovelace.lovelace
6. Removes old .storage/lovelace file
"""
# Check if already migrated (dashboard with url_path "lovelace" exists)
for item in dashboards_collection.async_items():
if item.get(CONF_URL_PATH) == DOMAIN:
return
# Check if old storage data exists
old_store = Store[dict[str, Any]](
hass, dashboard.CONFIG_STORAGE_VERSION, dashboard.CONFIG_STORAGE_KEY_DEFAULT
)
old_data = await old_store.async_load()
if old_data is None or old_data.get("config") is None:
return
# Check if new storage data already exists (incomplete previous migration)
new_store = Store[dict[str, Any]](
hass,
dashboard.CONFIG_STORAGE_VERSION,
dashboard.CONFIG_STORAGE_KEY.format(DOMAIN),
)
new_data = await new_store.async_load()
if new_data is not None:
_LOGGER.warning(
"Both old and new lovelace storage files exist, skipping migration"
)
return
_LOGGER.info("Migrating default lovelace config to dashboard entry")
# Get translated title for the dashboard
translations = await async_get_translations(
hass, hass.config.language, "dashboard", {onboarding.DOMAIN}
)
title = translations.get(
"component.onboarding.dashboard.overview.title", "Overview"
)
# Create dashboard entry
try:
await dashboards_collection.async_create_item(
{
CONF_ALLOW_SINGLE_WORD: True,
CONF_ICON: DEFAULT_ICON,
CONF_TITLE: title,
CONF_URL_PATH: DOMAIN,
}
)
except Exception:
_LOGGER.exception("Failed to create dashboard entry during migration")
return
# Save data to new location
await new_store.async_save(old_data)
# Remove old file
await old_store.async_remove()
_LOGGER.info("Successfully migrated default lovelace config to dashboard entry")
# Deprecated - Remove in 2026.6
@callback
def _async_create_yaml_mode_repair(hass: HomeAssistant) -> None:
"""Create repair issue for YAML mode migration."""
ir.async_create_issue(
hass,
DOMAIN,
"yaml_mode_deprecated",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="yaml_mode_deprecated",
translation_placeholders={"config_file": LOVELACE_CONFIG_FILE},
)
# End deprecation

View File

@@ -286,7 +286,7 @@ class DashboardsCollection(collection.DictStorageCollection):
if not allow_single_word and "-" not in url_path:
raise vol.Invalid("Url path needs to contain a hyphen (-)")
if url_path in self.hass.data[DATA_PANELS]:
if DATA_PANELS in self.hass.data and url_path in self.hass.data[DATA_PANELS]:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="url_already_exists",

View File

@@ -4,6 +4,12 @@
"message": "The URL \"{url}\" is already in use. Please choose a different one."
}
},
"issues": {
"yaml_mode_deprecated": {
"description": "Starting with Home Assistant 2026.6, the default Lovelace dashboard will no longer support YAML mode. To migrate:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. Rename `{config_file}` to a new filename (e.g., `my-dashboard.yaml`)\n3. Add a dashboard entry in your `configuration.yaml`:\n\n```yaml\nlovelace:\n dashboards:\n lovelace:\n mode: yaml\n filename: my-dashboard.yaml\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n```\n\n4. Restart Home Assistant",
"title": "Lovelace YAML mode migration required"
}
},
"services": {
"reload_resources": {
"description": "Reloads dashboard resources from the YAML-configuration.",

View File

@@ -30,7 +30,9 @@ rules:
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: done
reauthentication-flow:
status: exempt
comment: Devices don't require authentication
test-coverage: todo
# Gold

View File

@@ -5,6 +5,7 @@
"living_room": "Living Room"
},
"dashboard": {
"map": { "title": "Map" }
"map": { "title": "Map" },
"overview": { "title": "Overview" }
}
}

View File

@@ -129,5 +129,4 @@ def async_setup_services(hass: HomeAssistant) -> None:
async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
description_placeholders={"example_image_path": "/config/www/image.jpg"},
)

View File

@@ -156,7 +156,7 @@
},
"filename": {
"description": "Path to the file to upload.",
"example": "{example_image_path}",
"example": "/config/www/image.jpg",
"name": "Filename"
}
},

View File

@@ -1,23 +1,20 @@
"""The Rituals Perfume Genie integration."""
import asyncio
import logging
from aiohttp import ClientError, ClientResponseError
from pyrituals import Account, AuthenticationException, Diffuser
import aiohttp
from pyrituals import Account, Diffuser
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ACCOUNT_HASH, DOMAIN, UPDATE_INTERVAL
from .coordinator import RitualsDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.NUMBER,
@@ -29,38 +26,12 @@ PLATFORMS = [
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Rituals Perfume Genie from a config entry."""
# Initiate reauth for old config entries which don't have username / password in the entry data
if CONF_EMAIL not in entry.data or CONF_PASSWORD not in entry.data:
raise ConfigEntryAuthFailed("Missing credentials")
session = async_get_clientsession(hass)
account = Account(
email=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
session=session,
)
account = Account(session=session, account_hash=entry.data[ACCOUNT_HASH])
try:
# Authenticate first so API token/cookies are available for subsequent calls
await account.authenticate()
account_devices = await account.get_devices()
except AuthenticationException as err:
# Credentials invalid/expired -> raise AuthFailed to trigger reauth flow
raise ConfigEntryAuthFailed(err) from err
except ClientResponseError as err:
_LOGGER.debug(
"HTTP error during Rituals setup: status=%s, url=%s, headers=%s",
err.status,
err.request_info,
dict(err.headers or {}),
)
raise ConfigEntryNotReady from err
except ClientError as err:
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
# Migrate old unique_ids to the new format
@@ -74,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Create a coordinator for each diffuser
coordinators = {
diffuser.hublot: RitualsDataUpdateCoordinator(
hass, entry, account, diffuser, update_interval
hass, entry, diffuser, update_interval
)
for diffuser in account_devices
}
@@ -135,14 +106,3 @@ def async_migrate_entities_unique_ids(
registry_entry.entity_id,
new_unique_id=f"{diffuser.hublot}-{new_unique_id}",
)
# Migration helpers for API v2
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate config entry to version 2: drop legacy ACCOUNT_HASH and bump version."""
if entry.version < 2:
data = dict(entry.data)
data.pop(ACCOUNT_HASH, None)
hass.config_entries.async_update_entry(entry, data=data, version=2)
return True
return True

View File

@@ -2,10 +2,10 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
import logging
from typing import Any
from aiohttp import ClientError
from aiohttp import ClientResponseError
from pyrituals import Account, AuthenticationException
import voluptuous as vol
@@ -13,7 +13,9 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .const import ACCOUNT_HASH, DOMAIN
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema(
{
@@ -26,88 +28,39 @@ DATA_SCHEMA = vol.Schema(
class RitualsPerfumeGenieConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Rituals Perfume Genie."""
VERSION = 2
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
session = async_get_clientsession(self.hass)
account = Account(
email=user_input[CONF_EMAIL],
password=user_input[CONF_PASSWORD],
session=session,
)
if user_input is None:
return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)
try:
await account.authenticate()
except AuthenticationException:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(user_input[CONF_EMAIL])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_EMAIL],
data=user_input,
)
errors = {}
session = async_get_clientsession(self.hass)
account = Account(user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session)
try:
await account.authenticate()
except ClientResponseError:
_LOGGER.exception("Unexpected response")
errors["base"] = "cannot_connect"
except AuthenticationException:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(account.email)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=account.email,
data={ACCOUNT_HASH: account.account_hash},
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication with Rituals."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Form to log in again."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if TYPE_CHECKING:
assert reauth_entry.unique_id is not None
if user_input:
session = async_get_clientsession(self.hass)
account = Account(
email=reauth_entry.unique_id,
password=user_input[CONF_PASSWORD],
session=session,
)
try:
await account.authenticate()
except AuthenticationException:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data={
CONF_EMAIL: reauth_entry.unique_id,
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
reauth_entry.data,
),
errors=errors,
)

View File

@@ -4,7 +4,6 @@ from datetime import timedelta
DOMAIN = "rituals_perfume_genie"
# Old (API V1)
ACCOUNT_HASH = "account_hash"
# The API provided by Rituals is currently rate limited to 30 requests

View File

@@ -3,13 +3,11 @@
from datetime import timedelta
import logging
from aiohttp import ClientError, ClientResponseError
from pyrituals import Account, AuthenticationException, Diffuser
from pyrituals import Diffuser
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
@@ -25,12 +23,10 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]):
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
account: Account,
diffuser: Diffuser,
update_interval: timedelta,
) -> None:
"""Initialize global Rituals Perfume Genie data updater."""
self.account = account
self.diffuser = diffuser
super().__init__(
hass,
@@ -41,36 +37,5 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]):
)
async def _async_update_data(self) -> None:
"""Fetch data from Rituals, with one silent re-auth on 401.
If silent re-auth also fails, raise ConfigEntryAuthFailed to trigger reauth flow.
Other HTTP/network errors are wrapped in UpdateFailed so HA can retry.
"""
try:
await self.diffuser.update_data()
except (AuthenticationException, ClientResponseError) as err:
# Treat 401/403 like AuthenticationException → one silent re-auth, single retry
if isinstance(err, ClientResponseError) and (status := err.status) not in (
401,
403,
):
# Non-auth HTTP error → let HA retry
raise UpdateFailed(f"HTTP {status}") from err
self.logger.debug(
"Auth issue detected (%r). Attempting silent re-auth.", err
)
try:
await self.account.authenticate()
await self.diffuser.update_data()
except AuthenticationException as err2:
# Credentials invalid → trigger HA reauth
raise ConfigEntryAuthFailed from err2
except ClientResponseError as err2:
# Still HTTP auth errors after refresh → trigger HA reauth
if err2.status in (401, 403):
raise ConfigEntryAuthFailed from err2
raise UpdateFailed(f"HTTP {err2.status}") from err2
except ClientError as err:
# Network issues (timeouts, DNS, etc.)
raise UpdateFailed(f"Network error: {err!r}") from err
"""Fetch data from Rituals."""
await self.diffuser.update_data()

View File

@@ -1,10 +1,10 @@
{
"domain": "rituals_perfume_genie",
"name": "Rituals Perfume Genie",
"codeowners": ["@milanmeu", "@frenck", "@quebulm"],
"codeowners": ["@milanmeu", "@frenck"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie",
"iot_class": "cloud_polling",
"loggers": ["pyrituals"],
"requirements": ["pyrituals==0.0.7"]
"requirements": ["pyrituals==0.0.6"]
}

View File

@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "Re-authentication was successful"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -10,12 +9,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"description": "Please enter the correct password."
},
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",

View File

@@ -65,9 +65,11 @@ rules:
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues: done
repair-issues:
status: todo
comment: The Cloud vs Local API warning should probably be a repair issue.
stale-devices: done
# Platinum
async-dependency: done
async-dependency: todo
inject-websession: done
strict-typing: done

View File

@@ -44,7 +44,6 @@ from .entity import (
)
from .utils import (
async_remove_orphaned_entities,
async_remove_shelly_entity,
format_ble_addr,
get_blu_trv_device_info,
get_device_entry_gen,
@@ -81,7 +80,6 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_action="trigger_reboot",
supported=lambda coordinator: coordinator.sleep_period == 0,
),
ShellyButtonDescription[ShellyBlockCoordinator](
key="self_test",
@@ -199,8 +197,7 @@ async def async_setup_entry(
"""Set up button entities."""
entry_data = config_entry.runtime_data
coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None
device_gen = get_device_entry_gen(config_entry)
if device_gen in RPC_GENERATIONS:
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
coordinator = entry_data.rpc
else:
coordinator = entry_data.block
@@ -213,12 +210,6 @@ async def async_setup_entry(
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
)
# Remove the 'restart' button for sleeping devices as it was mistakenly
# added in https://github.com/home-assistant/core/pull/154673
entry_sleep_period = config_entry.data[CONF_SLEEP_PERIOD]
if device_gen in RPC_GENERATIONS and entry_sleep_period:
async_remove_shelly_entity(hass, BUTTON_PLATFORM, f"{coordinator.mac}-reboot")
entities: list[ShellyButton] = []
entities.extend(
@@ -233,7 +224,7 @@ async def async_setup_entry(
return
# add RPC buttons
if entry_sleep_period:
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc(
hass,
config_entry,

View File

@@ -150,7 +150,6 @@ class SunCondition(Condition):
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config)
assert config.options is not None
self._options = config.options

View File

@@ -64,12 +64,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
DESCRIPTION_PLACEHOLDERS: dict[str, str] = {
"botfather_username": "@BotFather",
"botfather_url": "https://t.me/botfather",
"socks_url": "socks5://username:password@proxy_ip:proxy_port",
}
STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
{
vol.Required(CONF_PLATFORM): SelectSelector(
@@ -316,7 +310,10 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a flow to create a new config entry for a Telegram bot."""
description_placeholders: dict[str, str] = DESCRIPTION_PLACEHOLDERS.copy()
description_placeholders: dict[str, str] = {
"botfather_username": "@BotFather",
"botfather_url": "https://t.me/botfather",
}
if not user_input:
return self.async_show_form(
step_id="user",
@@ -555,14 +552,13 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
},
},
),
description_placeholders=DESCRIPTION_PLACEHOLDERS,
)
user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get(
CONF_PROXY_URL
)
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = DESCRIPTION_PLACEHOLDERS.copy()
description_placeholders: dict[str, str] = {}
user_input[CONF_API_KEY] = api_key
bot_name = await self._validate_bot(

View File

@@ -60,7 +60,7 @@
"proxy_url": "Proxy URL"
},
"data_description": {
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n({socks_url})"
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)"
},
"name": "Advanced settings"
}

View File

@@ -635,14 +635,14 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
# Support legacy mireds in template light.
temperature = int(render)
if (min_kelvin := self._attr_min_color_temp_kelvin) is not None:
max_mireds = color_util.color_temperature_kelvin_to_mired(min_kelvin)
else:
max_mireds = DEFAULT_MAX_MIREDS
if (max_kelvin := self._attr_max_color_temp_kelvin) is not None:
min_mireds = color_util.color_temperature_kelvin_to_mired(max_kelvin)
min_mireds = color_util.color_temperature_kelvin_to_mired(min_kelvin)
else:
min_mireds = DEFAULT_MIN_MIREDS
if (max_kelvin := self._attr_max_color_temp_kelvin) is not None:
max_mireds = color_util.color_temperature_kelvin_to_mired(max_kelvin)
else:
max_mireds = DEFAULT_MAX_MIREDS
if min_mireds <= temperature <= max_mireds:
self._attr_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(temperature)
@@ -856,36 +856,42 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
try:
if render in (None, "None", ""):
self._attr_min_color_temp_kelvin = None
self._attr_max_mireds = DEFAULT_MAX_MIREDS
self._attr_max_color_temp_kelvin = None
return
self._attr_min_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(int(render))
self._attr_max_mireds = max_mireds = int(render)
self._attr_max_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(max_mireds)
)
except ValueError:
_LOGGER.exception(
"Template must supply an integer temperature within the range for"
" this light, or 'None'"
)
self._attr_min_color_temp_kelvin = None
self._attr_max_mireds = DEFAULT_MAX_MIREDS
self._attr_max_color_temp_kelvin = None
@callback
def _update_min_mireds(self, render):
"""Update the min mireds from the template."""
try:
if render in (None, "None", ""):
self._attr_max_color_temp_kelvin = None
self._attr_min_mireds = DEFAULT_MIN_MIREDS
self._attr_min_color_temp_kelvin = None
return
self._attr_max_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(int(render))
self._attr_min_mireds = min_mireds = int(render)
self._attr_min_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(min_mireds)
)
except ValueError:
_LOGGER.exception(
"Template must supply an integer temperature within the range for"
" this light, or 'None'"
)
self._attr_max_color_temp_kelvin = None
self._attr_min_mireds = DEFAULT_MIN_MIREDS
self._attr_min_color_temp_kelvin = None
@callback
def _update_supports_transition(self, render):

View File

@@ -19,7 +19,6 @@ from homeassistant.components.light import (
LightEntityDescription,
LightEntityFeature,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import rgb_hex_to_rgb_list
@@ -118,7 +117,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiLightEntityDescription, ...] = (
UnifiLightEntityDescription[Devices, Device](
key="LED control",
translation_key="led_control",
entity_category=EntityCategory.CONFIG,
allowed_fn=lambda hub, obj_id: True,
api_handler_fn=lambda api: api.devices,
available_fn=async_device_available_fn,

View File

@@ -40,7 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"requirements": ["uiprotect==7.33.2", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==7.31.0", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -1914,11 +1914,11 @@
"title": "Zigbee network settings have changed"
},
"wrong_silabs_firmware_installed_nabucasa": {
"description": "Your Zigbee adapter is currently in an incorrect state: {firmware_type}.\n\nThe device may have Thread or multiprotocol firmware installed, or it may be stuck in the bootloader. To resolve this, try to unplug the adapter temporarily.\n\nIf the issue persists and you need to install Zigbee firmware:\n - Go to Settings > System > Hardware, select the device and select Configure.\n - Select the 'Migrate Zigbee to a new adapter' option and follow the instructions.",
"title": "Zigbee adapter in incorrect state"
"description": "Your Zigbee adapter was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}).\n\nTo run your adapter exclusively with ZHA, you need to install the Zigbee firmware:\n - Go to Settings > System > Hardware, select the device and select Configure.\n - Select the 'Migrate Zigbee to a new adapter' option and follow the instructions.",
"title": "Zigbee adapter with multiprotocol firmware detected"
},
"wrong_silabs_firmware_installed_other": {
"description": "Your Zigbee adapter is currently in an incorrect state: {firmware_type}.\n\nThe device may have Thread or multiprotocol firmware installed, or it may be stuck in the bootloader. To resolve this, try to unplug the adapter temporarily.\n\nIf the issue persists and you need to install Zigbee firmware, follow your Zigbee adapter manufacturer's instructions for how to do this.",
"description": "Your Zigbee adapter was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}).\n\nTo run your adapter exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee adapter manufacturer's instructions for how to do this.",
"title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]"
}
},

View File

@@ -114,7 +114,6 @@ class ZoneCondition(Condition):
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config)
assert config.options is not None
self._options = config.options

View File

@@ -259,8 +259,6 @@ _CONDITION_SCHEMA = _CONDITION_BASE_SCHEMA.extend(
class Condition(abc.ABC):
"""Condition class."""
_hass: HomeAssistant
@classmethod
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
@@ -295,7 +293,6 @@ class Condition(abc.ABC):
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
self._hass = hass
@abc.abstractmethod
async def async_get_checker(self) -> ConditionCheckerType:

View File

@@ -124,12 +124,6 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = {
# Added in 2025.10.0 because of
# https://github.com/frenck/spook/issues/1066
"spook": BlockedIntegration(AwesomeVersion("4.0.0"), "breaks the template engine"),
# Added in 2025.12.1 because of
# https://github.com/JaccoR/hass-entso-e/issues/263
"entsoe": BlockedIntegration(
AwesomeVersion("0.7.1"),
"crashes Home Assistant when it can't connect to the API",
),
}
DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey(

View File

@@ -2,7 +2,7 @@
aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
aiodns==3.6.0
aiodns==3.5.0
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
@@ -39,7 +39,7 @@ habluetooth==5.8.0
hass-nabucasa==1.7.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20251203.1
home-assistant-frontend==20251203.0
home-assistant-intents==2025.12.2
httpx==0.28.1
ifaddr==0.2.0

View File

@@ -24,7 +24,7 @@ classifiers = [
]
requires-python = ">=3.13.2"
dependencies = [
"aiodns==3.6.0",
"aiodns==3.5.0",
# Integrations may depend on hassio integration without listing it to
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11

2
requirements.txt generated
View File

@@ -3,7 +3,7 @@
-c homeassistant/package_constraints.txt
# Home Assistant Core
aiodns==3.6.0
aiodns==3.5.0
aiohasupervisor==0.3.3
aiohttp==3.13.2
aiohttp_cors==0.8.1

8
requirements_all.txt generated
View File

@@ -231,7 +231,7 @@ aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
# homeassistant.components.dnsip
aiodns==3.6.0
aiodns==3.5.0
# homeassistant.components.duke_energy
aiodukeenergy==0.3.0
@@ -1204,7 +1204,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251203.1
home-assistant-frontend==20251203.0
# homeassistant.components.conversation
home-assistant-intents==2025.12.2
@@ -2351,7 +2351,7 @@ pyrepetierng==0.1.0
pyrisco==0.6.7
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.7
pyrituals==0.0.6
# homeassistant.components.thread
pyroute2==0.7.5
@@ -3059,7 +3059,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==7.33.2
uiprotect==7.31.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7

View File

@@ -222,7 +222,7 @@ aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
# homeassistant.components.dnsip
aiodns==3.6.0
aiodns==3.5.0
# homeassistant.components.duke_energy
aiodukeenergy==0.3.0
@@ -1062,7 +1062,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251203.1
home-assistant-frontend==20251203.0
# homeassistant.components.conversation
home-assistant-intents==2025.12.2
@@ -1980,7 +1980,7 @@ pyrate-limiter==3.9.0
pyrisco==0.6.7
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.7
pyrituals==0.0.6
# homeassistant.components.thread
pyroute2==0.7.5
@@ -2547,7 +2547,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==7.33.2
uiprotect==7.31.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7

View File

@@ -12,13 +12,7 @@ from pyairobotrest.models import (
import pytest
from homeassistant.components.airobot.const import DOMAIN
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
CONF_PASSWORD,
CONF_USERNAME,
Platform,
)
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -111,24 +105,16 @@ def mock_config_entry() -> MockConfigEntry:
)
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.CLIMATE, Platform.SENSOR]
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_airobot_client: AsyncMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the Airobot integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.airobot.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -1,220 +0,0 @@
# serializer version: 1
# name: test_sensors[sensor.test_thermostat_air_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_thermostat_air_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Air temperature',
'platform': 'airobot',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'air_temperature',
'unique_id': 'T01A1B2C3_air_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.test_thermostat_air_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Test Thermostat Air temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_thermostat_air_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '22.0',
})
# ---
# name: test_sensors[sensor.test_thermostat_error_count-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_thermostat_error_count',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Error count',
'platform': 'airobot',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'errors',
'unique_id': 'T01A1B2C3_errors',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.test_thermostat_error_count-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Thermostat Error count',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_thermostat_error_count',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.test_thermostat_heating_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_thermostat_heating_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Heating uptime',
'platform': 'airobot',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'heating_uptime',
'unique_id': 'T01A1B2C3_heating_uptime',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
})
# ---
# name: test_sensors[sensor.test_thermostat_heating_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'Test Thermostat Heating uptime',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_thermostat_heating_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.38888888888889',
})
# ---
# name: test_sensors[sensor.test_thermostat_humidity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_thermostat_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'Humidity',
'platform': 'airobot',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'T01A1B2C3_humidity',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[sensor.test_thermostat_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'Test Thermostat Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_thermostat_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '45.0',
})
# ---

View File

@@ -17,7 +17,7 @@ from homeassistant.components.climate import (
SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.entity_registry as er
@@ -25,19 +25,12 @@ import homeassistant.helpers.entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.CLIMATE]
@pytest.mark.usefixtures("init_integration")
async def test_climate_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
platforms: list[Platform],
) -> None:
"""Test climate entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -1,38 +0,0 @@
"""Tests for the Airobot sensor platform."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.SENSOR]
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_sensor_availability_without_optional_sensors(
hass: HomeAssistant,
) -> None:
"""Test sensors are not created when optional hardware is not present."""
# Default mock has no floor sensor, CO2, or AQI - they should not be created
assert hass.states.get("sensor.test_thermostat_floor_temperature") is None
assert hass.states.get("sensor.test_thermostat_carbon_dioxide") is None
assert hass.states.get("sensor.test_thermostat_air_quality_index") is None

View File

@@ -37,7 +37,6 @@ from .const import (
TEST_DATA_CREATE_ENTRY,
TEST_DATA_CREATE_ENTRY_2,
TEST_DATA_CREATE_ENTRY_3,
TEST_DATA_CREATE_ENTRY_4,
TEST_FRIENDLY_NAME,
TEST_FRIENDLY_NAME_3,
TEST_FRIENDLY_NAME_4,
@@ -49,12 +48,10 @@ from .const import (
TEST_NAME,
TEST_NAME_2,
TEST_NAME_3,
TEST_NAME_4,
TEST_REMOTE_SERIAL,
TEST_SERIAL_NUMBER,
TEST_SERIAL_NUMBER_2,
TEST_SERIAL_NUMBER_3,
TEST_SERIAL_NUMBER_4,
TEST_SOUND_MODE,
TEST_SOUND_MODE_2,
TEST_SOUND_MODE_NAME,
@@ -96,17 +93,6 @@ def mock_config_entry_premiere() -> MockConfigEntry:
)
@pytest.fixture
def mock_config_entry_a5() -> MockConfigEntry:
"""Mock config entry for Beosound A5."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_SERIAL_NUMBER_4,
data=TEST_DATA_CREATE_ENTRY_4,
title=TEST_NAME_4,
)
async def mock_websocket_connection(
hass: HomeAssistant, mock_mozart_client: AsyncMock
) -> None:

View File

@@ -42,7 +42,6 @@ TEST_MODEL_CORE = "Beoconnect Core"
TEST_MODEL_PREMIERE = "Beosound Premiere"
TEST_MODEL_THEATRE = "Beosound Theatre"
TEST_MODEL_LEVEL = "Beosound Level"
TEST_MODEL_A5 = "Beosound A5"
TEST_SERIAL_NUMBER = "11111111"
TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}"
TEST_FRIENDLY_NAME = "Living room Balance"
@@ -65,11 +64,9 @@ TEST_JID_3 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_3}@prod
TEST_MEDIA_PLAYER_ENTITY_ID_3 = f"media_player.beosound_premiere_{TEST_SERIAL_NUMBER_3}"
TEST_HOST_3 = "192.168.0.3"
TEST_FRIENDLY_NAME_4 = "Lounge room A5"
TEST_SERIAL_NUMBER_4 = "44444444"
TEST_NAME_4 = f"{TEST_MODEL_A5}-{TEST_SERIAL_NUMBER_4}"
TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_4}@products.bang-olufsen.com"
TEST_MEDIA_PLAYER_ENTITY_ID_4 = f"media_player.beosound_a5_{TEST_SERIAL_NUMBER_4}"
TEST_FRIENDLY_NAME_4 = "Lounge room Balance"
TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.44444444@products.bang-olufsen.com"
TEST_MEDIA_PLAYER_ENTITY_ID_4 = "media_player.beosound_balance_44444444"
TEST_HOST_4 = "192.168.0.4"
# Beoremote One
@@ -108,13 +105,6 @@ TEST_DATA_CREATE_ENTRY_3 = {
CONF_NAME: TEST_NAME_3,
}
TEST_DATA_CREATE_ENTRY_4 = {
CONF_HOST: TEST_HOST_4,
CONF_MODEL: TEST_MODEL_A5,
CONF_BEOLINK_JID: TEST_JID_4,
CONF_NAME: TEST_NAME_4,
}
TEST_DATA_ZEROCONF = ZeroconfServiceInfo(
ip_address=IPv4Address(TEST_HOST),
ip_addresses=[IPv4Address(TEST_HOST)],

View File

@@ -45,11 +45,11 @@
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'peers': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',

View File

@@ -1,108 +1,4 @@
# serializer version: 1
# name: test_button_event_creation_a5
list([
'event.beosound_a5_44444444_bluetooth',
'event.beosound_a5_44444444_next',
'event.beosound_a5_44444444_play_pause',
'event.beosound_a5_44444444_favorite_1',
'event.beosound_a5_44444444_favorite_2',
'event.beosound_a5_44444444_favorite_3',
'event.beosound_a5_44444444_favorite_4',
'event.beosound_a5_44444444_previous',
'event.beosound_a5_44444444_volume',
'event.beoremote_one_55555555_44444444_light_blue',
'event.beoremote_one_55555555_44444444_light_digit_0',
'event.beoremote_one_55555555_44444444_light_digit_1',
'event.beoremote_one_55555555_44444444_light_digit_2',
'event.beoremote_one_55555555_44444444_light_digit_3',
'event.beoremote_one_55555555_44444444_light_digit_4',
'event.beoremote_one_55555555_44444444_light_digit_5',
'event.beoremote_one_55555555_44444444_light_digit_6',
'event.beoremote_one_55555555_44444444_light_digit_7',
'event.beoremote_one_55555555_44444444_light_digit_8',
'event.beoremote_one_55555555_44444444_light_digit_9',
'event.beoremote_one_55555555_44444444_light_down',
'event.beoremote_one_55555555_44444444_light_green',
'event.beoremote_one_55555555_44444444_light_left',
'event.beoremote_one_55555555_44444444_light_play',
'event.beoremote_one_55555555_44444444_light_red',
'event.beoremote_one_55555555_44444444_light_rewind',
'event.beoremote_one_55555555_44444444_light_right',
'event.beoremote_one_55555555_44444444_light_select',
'event.beoremote_one_55555555_44444444_light_stop',
'event.beoremote_one_55555555_44444444_light_up',
'event.beoremote_one_55555555_44444444_light_wind',
'event.beoremote_one_55555555_44444444_light_yellow',
'event.beoremote_one_55555555_44444444_light_function_1',
'event.beoremote_one_55555555_44444444_light_function_2',
'event.beoremote_one_55555555_44444444_light_function_3',
'event.beoremote_one_55555555_44444444_light_function_4',
'event.beoremote_one_55555555_44444444_light_function_5',
'event.beoremote_one_55555555_44444444_light_function_6',
'event.beoremote_one_55555555_44444444_light_function_7',
'event.beoremote_one_55555555_44444444_light_function_8',
'event.beoremote_one_55555555_44444444_light_function_9',
'event.beoremote_one_55555555_44444444_light_function_10',
'event.beoremote_one_55555555_44444444_light_function_11',
'event.beoremote_one_55555555_44444444_light_function_12',
'event.beoremote_one_55555555_44444444_light_function_13',
'event.beoremote_one_55555555_44444444_light_function_14',
'event.beoremote_one_55555555_44444444_light_function_15',
'event.beoremote_one_55555555_44444444_light_function_16',
'event.beoremote_one_55555555_44444444_light_function_17',
'event.beoremote_one_55555555_44444444_control_blue',
'event.beoremote_one_55555555_44444444_control_digit_0',
'event.beoremote_one_55555555_44444444_control_digit_1',
'event.beoremote_one_55555555_44444444_control_digit_2',
'event.beoremote_one_55555555_44444444_control_digit_3',
'event.beoremote_one_55555555_44444444_control_digit_4',
'event.beoremote_one_55555555_44444444_control_digit_5',
'event.beoremote_one_55555555_44444444_control_digit_6',
'event.beoremote_one_55555555_44444444_control_digit_7',
'event.beoremote_one_55555555_44444444_control_digit_8',
'event.beoremote_one_55555555_44444444_control_digit_9',
'event.beoremote_one_55555555_44444444_control_down',
'event.beoremote_one_55555555_44444444_control_green',
'event.beoremote_one_55555555_44444444_control_left',
'event.beoremote_one_55555555_44444444_control_play',
'event.beoremote_one_55555555_44444444_control_red',
'event.beoremote_one_55555555_44444444_control_rewind',
'event.beoremote_one_55555555_44444444_control_right',
'event.beoremote_one_55555555_44444444_control_select',
'event.beoremote_one_55555555_44444444_control_stop',
'event.beoremote_one_55555555_44444444_control_up',
'event.beoremote_one_55555555_44444444_control_wind',
'event.beoremote_one_55555555_44444444_control_yellow',
'event.beoremote_one_55555555_44444444_control_function_1',
'event.beoremote_one_55555555_44444444_control_function_2',
'event.beoremote_one_55555555_44444444_control_function_3',
'event.beoremote_one_55555555_44444444_control_function_4',
'event.beoremote_one_55555555_44444444_control_function_5',
'event.beoremote_one_55555555_44444444_control_function_6',
'event.beoremote_one_55555555_44444444_control_function_7',
'event.beoremote_one_55555555_44444444_control_function_8',
'event.beoremote_one_55555555_44444444_control_function_9',
'event.beoremote_one_55555555_44444444_control_function_10',
'event.beoremote_one_55555555_44444444_control_function_11',
'event.beoremote_one_55555555_44444444_control_function_12',
'event.beoremote_one_55555555_44444444_control_function_13',
'event.beoremote_one_55555555_44444444_control_function_14',
'event.beoremote_one_55555555_44444444_control_function_15',
'event.beoremote_one_55555555_44444444_control_function_16',
'event.beoremote_one_55555555_44444444_control_function_17',
'event.beoremote_one_55555555_44444444_control_function_18',
'event.beoremote_one_55555555_44444444_control_function_19',
'event.beoremote_one_55555555_44444444_control_function_20',
'event.beoremote_one_55555555_44444444_control_function_21',
'event.beoremote_one_55555555_44444444_control_function_22',
'event.beoremote_one_55555555_44444444_control_function_23',
'event.beoremote_one_55555555_44444444_control_function_24',
'event.beoremote_one_55555555_44444444_control_function_25',
'event.beoremote_one_55555555_44444444_control_function_26',
'event.beoremote_one_55555555_44444444_control_function_27',
'media_player.beosound_a5_44444444',
])
# ---
# name: test_button_event_creation_balance
list([
'event.beosound_balance_11111111_bluetooth',
@@ -208,8 +104,9 @@
'media_player.beosound_balance_11111111',
])
# ---
# name: test_button_event_creation_premiere
# name: test_button_event_creation_beosound_premiere
list([
'event.beosound_premiere_33333333_microphone',
'event.beosound_premiere_33333333_next',
'event.beosound_premiere_33333333_play_pause',
'event.beosound_premiere_33333333_favorite_1',
@@ -311,7 +208,7 @@
'media_player.beosound_premiere_33333333',
])
# ---
# name: test_no_button_and_remote_key_event_creation_core
# name: test_no_button_and_remote_key_event_creation
list([
'media_player.beoconnect_core_22222222',
])

View File

@@ -5,11 +5,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -53,11 +53,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -102,11 +102,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -151,11 +151,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -200,11 +200,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -249,11 +249,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -297,11 +297,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -345,11 +345,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -393,11 +393,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -441,11 +441,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -489,11 +489,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -537,11 +537,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -585,11 +585,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -634,11 +634,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com',
@@ -683,11 +683,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -732,11 +732,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com',
@@ -781,11 +781,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -831,11 +831,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com',
@@ -880,11 +880,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -929,11 +929,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com',
@@ -978,11 +978,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -1029,7 +1029,7 @@
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
@@ -1072,11 +1072,11 @@
<BeoAttribute.BEOLINK: 'beolink'>: dict({
<BeoAttribute.BEOLINK_LISTENERS: 'listeners'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_PEERS: 'peers'>: dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
<BeoAttribute.BEOLINK_SELF: 'self'>: dict({
'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com',

View File

@@ -3,7 +3,6 @@
from unittest.mock import AsyncMock
from mozart_api.models import BeoRemoteButton, ButtonEvent, PairedRemoteResponse
from pytest_unordered import unordered
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.bang_olufsen.const import (
@@ -21,64 +20,37 @@ from .const import (
TEST_BUTTON_EVENT_ENTITY_ID,
TEST_REMOTE_KEY_EVENT_ENTITY_ID,
TEST_SERIAL_NUMBER_3,
TEST_SERIAL_NUMBER_4,
)
from .util import (
get_a5_entity_ids,
get_balance_entity_ids,
get_core_entity_ids,
get_premiere_entity_ids,
get_remote_entity_ids,
)
from .util import get_button_entity_ids, get_remote_entity_ids
from tests.common import MockConfigEntry
async def _check_button_event_creation(
async def test_button_event_creation_balance(
hass: HomeAssistant,
integration: None,
entity_registry: EntityRegistry,
snapshot: SnapshotAssertion,
config_entry: MockConfigEntry,
client: AsyncMock,
entity_ids: list[str],
) -> None:
"""Test body for entity creation tests."""
# Load entry
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await mock_websocket_connection(hass, client)
"""Test button event entities are created when using a Balance (Most devices support all buttons like the Balance)."""
# Add Button Event entity ids
entity_ids: list[str] = [*get_button_entity_ids(), *get_remote_entity_ids()]
# Check that the entities are available
for entity_id in entity_ids:
assert entity_registry.async_get(entity_id)
# Check that no entities other than the expected have been created
# Check number of entities
# The media_player entity and all of the button event entities should be the only available
entity_ids_available = list(entity_registry.entities.keys())
assert len(entity_ids_available) == 1 + len(entity_ids)
assert entity_ids_available == unordered(entity_ids)
# Check snapshot
assert entity_ids_available == snapshot
async def test_button_event_creation_balance(
hass: HomeAssistant,
entity_registry: EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
mock_mozart_client: AsyncMock,
) -> None:
"""Test button event entities are created when using a Balance (Most devices support all buttons like the Balance)."""
await _check_button_event_creation(
hass,
entity_registry,
snapshot,
mock_config_entry,
mock_mozart_client,
[*get_balance_entity_ids(), *get_remote_entity_ids()],
)
async def test_no_button_and_remote_key_event_creation_core(
async def test_no_button_and_remote_key_event_creation(
hass: HomeAssistant,
mock_config_entry_core: MockConfigEntry,
mock_mozart_client: AsyncMock,
@@ -90,58 +62,51 @@ async def test_no_button_and_remote_key_event_creation_core(
items=[]
)
await _check_button_event_creation(
hass,
entity_registry,
snapshot,
mock_config_entry_core,
mock_mozart_client,
get_core_entity_ids(),
)
# Load entry
mock_config_entry_core.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_core.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
# Check number of entities
# The media_player entity should be the only available
entity_ids_available = list(entity_registry.entities.keys())
assert len(entity_ids_available) == 1
# Check snapshot
assert entity_ids_available == snapshot
async def test_button_event_creation_premiere(
async def test_button_event_creation_beosound_premiere(
hass: HomeAssistant,
mock_config_entry_premiere: MockConfigEntry,
mock_mozart_client: AsyncMock,
entity_registry: EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test Bluetooth and Microphone button event entities are not created when using a Beosound Premiere."""
"""Test Bluetooth button event entity is not created when using a Beosound Premiere."""
await _check_button_event_creation(
hass,
entity_registry,
snapshot,
mock_config_entry_premiere,
mock_mozart_client,
[
*get_premiere_entity_ids(),
*get_remote_entity_ids(device_serial=TEST_SERIAL_NUMBER_3),
],
)
# Load entry
mock_config_entry_premiere.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_premiere.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
# Add Button Event entity ids
entity_ids = [
*get_button_entity_ids("beosound_premiere_33333333"),
*get_remote_entity_ids(device_serial=TEST_SERIAL_NUMBER_3),
]
entity_ids.remove("event.beosound_premiere_33333333_bluetooth")
async def test_button_event_creation_a5(
hass: HomeAssistant,
mock_config_entry_a5: MockConfigEntry,
mock_mozart_client: AsyncMock,
entity_registry: EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test Microphone button event entity is not created when using a Beosound A5."""
# Check that the entities are available
for entity_id in entity_ids:
assert entity_registry.async_get(entity_id)
await _check_button_event_creation(
hass,
entity_registry,
snapshot,
mock_config_entry_a5,
mock_mozart_client,
[
*get_a5_entity_ids(),
*get_remote_entity_ids(device_serial=TEST_SERIAL_NUMBER_4),
],
)
# Check number of entities
# The media_player entity and all of the button event entities (except Bluetooth) should be the only available
entity_ids_available = list(entity_registry.entities.keys())
assert len(entity_ids_available) == 1 + len(entity_ids)
assert entity_ids_available == snapshot
async def test_button(

View File

@@ -10,7 +10,6 @@ from mozart_api.models import (
WebsocketNotificationTag,
)
import pytest
from pytest_unordered import unordered
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.bang_olufsen.const import (
@@ -30,7 +29,7 @@ from .const import (
TEST_REMOTE_SERIAL_PAIRED,
TEST_SERIAL_NUMBER,
)
from .util import get_balance_entity_ids, get_remote_entity_ids
from .util import get_button_entity_ids, get_remote_entity_ids
from tests.common import MockConfigEntry
@@ -134,8 +133,9 @@ async def test_on_remote_control_already_added(
assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)})
# Check number of entities (remote and button events and media_player)
assert list(entity_registry.entities.keys()) == unordered(
[*get_balance_entity_ids(), *get_remote_entity_ids()]
assert (
len(list(entity_registry.entities.keys()))
== len(get_remote_entity_ids()) + len(get_button_entity_ids()) + 1
)
remote_callback = mock_mozart_client.get_notification_notifications.call_args[0][0]
@@ -152,11 +152,12 @@ async def test_on_remote_control_already_added(
assert mock_mozart_client.get_bluetooth_remotes.call_count == 2
assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)})
# Check number of entities (remote and button events and media_player)
# Check number of entities
entity_ids_available = list(entity_registry.entities.keys())
assert list(entity_registry.entities.keys()) == unordered(
[*get_balance_entity_ids(), *get_remote_entity_ids()]
assert (
len(entity_ids_available)
== len(get_remote_entity_ids()) + len(get_button_entity_ids()) + 1
)
assert entity_ids_available == snapshot
@@ -179,9 +180,10 @@ async def test_on_remote_control_paired(
assert mock_mozart_client.get_bluetooth_remotes.call_count == 1
assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)})
# Check number of entities (button and remote events and media_player)
assert list(entity_registry.entities.keys()) == unordered(
[*get_balance_entity_ids(), *get_remote_entity_ids()]
# Check number of entities (button events and media_player)
assert (
len(list(entity_registry.entities.keys()))
== len(get_remote_entity_ids()) + len(get_button_entity_ids()) + 1
)
# "Pair" a new remote
mock_mozart_client.get_bluetooth_remotes.return_value = PairedRemoteResponse(
@@ -232,12 +234,12 @@ async def test_on_remote_control_paired(
# Check number of entities (remote and button events and media_player)
entity_ids_available = list(entity_registry.entities.keys())
assert entity_ids_available == unordered(
[
*get_balance_entity_ids(),
*get_remote_entity_ids(),
*get_remote_entity_ids("66666666"),
]
assert (
len(entity_ids_available)
== len(get_remote_entity_ids())
+ len(get_remote_entity_ids())
+ len(get_button_entity_ids())
+ 1
)
assert entity_ids_available == snapshot
@@ -260,11 +262,11 @@ async def test_on_remote_control_unpaired(
assert mock_mozart_client.get_bluetooth_remotes.call_count == 1
assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)})
# Check number of entities (button and remote events and media_player)
assert list(entity_registry.entities.keys()) == unordered(
[*get_balance_entity_ids(), *get_remote_entity_ids()]
# Check number of entities (button events and media_player)
assert (
len(list(entity_registry.entities.keys()))
== len(get_remote_entity_ids()) + len(get_button_entity_ids()) + 1
)
# "Unpair" the remote
mock_mozart_client.get_bluetooth_remotes.return_value = PairedRemoteResponse(
items=[]
@@ -294,7 +296,7 @@ async def test_on_remote_control_unpaired(
# Check number of entities (button events and media_player)
entity_ids_available = list(entity_registry.entities.keys())
assert entity_ids_available == unordered(get_balance_entity_ids())
assert len(entity_ids_available) == +len(get_button_entity_ids()) + 1
assert entity_ids_available == snapshot

View File

@@ -10,58 +10,17 @@ from homeassistant.components.bang_olufsen.const import (
DEVICE_BUTTONS,
)
from .const import (
TEST_MEDIA_PLAYER_ENTITY_ID,
TEST_MEDIA_PLAYER_ENTITY_ID_2,
TEST_MEDIA_PLAYER_ENTITY_ID_3,
TEST_MEDIA_PLAYER_ENTITY_ID_4,
TEST_REMOTE_SERIAL,
TEST_SERIAL_NUMBER,
)
from .const import TEST_REMOTE_SERIAL, TEST_SERIAL_NUMBER
def _get_button_entity_ids(id_prefix: str = "beosound_balance_11111111") -> list[str]:
"""Return a list of button entity_ids that Mozart devices provide.
Beoconnect Core, Beosound A5, Beosound A9 and Beosound Premiere do not have (all of the) physical buttons and need filtering.
"""
def get_button_entity_ids(id_prefix: str = "beosound_balance_11111111") -> list[str]:
"""Return a list of button entity_ids that Mozart devices (except Beoconnect Core and Beosound Premiere) provides."""
return [
f"event.{id_prefix}_{underscore(button_type)}".replace("preset", "favorite_")
for button_type in DEVICE_BUTTONS
]
def get_balance_entity_ids() -> list[str]:
"""Return a list of entity_ids that a Beosound Balance provides."""
return [TEST_MEDIA_PLAYER_ENTITY_ID, *_get_button_entity_ids()]
def get_premiere_entity_ids() -> list[str]:
"""Return a list of entity_ids that a Beosound Premiere provides."""
buttons = [
TEST_MEDIA_PLAYER_ENTITY_ID_3,
*_get_button_entity_ids("beosound_premiere_33333333"),
]
buttons.remove("event.beosound_premiere_33333333_bluetooth")
buttons.remove("event.beosound_premiere_33333333_microphone")
return buttons
def get_a5_entity_ids() -> list[str]:
"""Return a list of entity_ids that a Beosound A5 provides."""
buttons = [
TEST_MEDIA_PLAYER_ENTITY_ID_4,
*_get_button_entity_ids("beosound_a5_44444444"),
]
buttons.remove("event.beosound_a5_44444444_microphone")
return buttons
def get_core_entity_ids() -> list[str]:
"""Return a list of entity_ids that a Beoconnect core provides."""
return [TEST_MEDIA_PLAYER_ENTITY_ID_2]
def get_remote_entity_ids(
remote_serial: str = TEST_REMOTE_SERIAL, device_serial: str = TEST_SERIAL_NUMBER
) -> list[str]:

View File

@@ -82,10 +82,6 @@ def patch_doorbird_api_entry_points(api: MagicMock) -> Generator[DoorBird]:
"homeassistant.components.doorbird.config_flow.DoorBird",
return_value=api,
),
patch(
"homeassistant.components.doorbird.device.get_url",
return_value="http://127.0.0.1:8123",
),
):
yield api

View File

@@ -2,141 +2,15 @@
from copy import deepcopy
from http import HTTPStatus
from typing import Any
from doorbirdpy import DoorBirdScheduleEntry
import pytest
from homeassistant.components.doorbird.const import (
CONF_EVENTS,
DEFAULT_DOORBELL_EVENT,
DEFAULT_MOTION_EVENT,
DOMAIN,
)
from homeassistant.components.doorbird.const import CONF_EVENTS
from homeassistant.core import HomeAssistant
from . import VALID_CONFIG
from .conftest import DoorbirdMockerType
from tests.common import MockConfigEntry
@pytest.fixture
def doorbird_favorites_with_stale() -> dict[str, dict[str, Any]]:
"""Return favorites fixture with stale favorites from another HA instance.
Creates favorites where identifier "2" has the same event name as "0"
(mydoorbird_doorbell) but points to a different HA instance URL.
These stale favorites should be filtered out.
"""
return {
"http": {
"0": {
"title": "Home Assistant (mydoorbird_doorbell)",
"value": "http://127.0.0.1:8123/api/doorbird/mydoorbird_doorbell?token=test-token",
},
# Stale favorite from a different HA instance - should be filtered out
"2": {
"title": "Home Assistant (mydoorbird_doorbell)",
"value": "http://old-ha-instance:8123/api/doorbird/mydoorbird_doorbell?token=old-token",
},
"5": {
"title": "Home Assistant (mydoorbird_motion)",
"value": "http://127.0.0.1:8123/api/doorbird/mydoorbird_motion?token=test-token",
},
}
}
@pytest.fixture
def doorbird_schedule_with_stale() -> list[DoorBirdScheduleEntry]:
"""Return schedule fixture with outputs referencing stale favorites.
Both param "0" and "2" map to doorbell input, but "2" is a stale favorite.
"""
schedule_data = [
{
"input": "doorbell",
"param": "1",
"output": [
{
"event": "http",
"param": "0",
"schedule": {"weekdays": [{"to": "107999", "from": "108000"}]},
},
{
"event": "http",
"param": "2",
"schedule": {"weekdays": [{"to": "107999", "from": "108000"}]},
},
],
},
{
"input": "motion",
"param": "",
"output": [
{
"event": "http",
"param": "5",
"schedule": {"weekdays": [{"to": "107999", "from": "108000"}]},
},
],
},
]
return DoorBirdScheduleEntry.parse_all(schedule_data)
async def test_stale_favorites_filtered_by_url(
hass: HomeAssistant,
doorbird_mocker: DoorbirdMockerType,
doorbird_favorites_with_stale: dict[str, dict[str, Any]],
doorbird_schedule_with_stale: list[DoorBirdScheduleEntry],
) -> None:
"""Test that stale favorites from other HA instances are filtered out."""
await doorbird_mocker(
favorites=doorbird_favorites_with_stale,
schedule=doorbird_schedule_with_stale,
)
# Should have 2 event entities - stale favorite "2" is filtered out
# because its URL doesn't match the current HA instance
event_entities = hass.states.async_all("event")
assert len(event_entities) == 2
async def test_custom_url_used_for_favorites(
hass: HomeAssistant,
doorbird_mocker: DoorbirdMockerType,
) -> None:
"""Test that custom URL override is used instead of get_url."""
custom_url = "https://my-custom-url.example.com:8443"
favorites = {
"http": {
"1": {
"title": "Home Assistant (mydoorbird_doorbell)",
"value": f"{custom_url}/api/doorbird/mydoorbird_doorbell?token=test-token",
},
"2": {
"title": "Home Assistant (mydoorbird_motion)",
"value": f"{custom_url}/api/doorbird/mydoorbird_motion?token=test-token",
},
}
}
config_with_custom_url = {
**VALID_CONFIG,
"hass_url_override": custom_url,
}
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="1CCAE3AAAAAA",
data=config_with_custom_url,
options={CONF_EVENTS: [DEFAULT_DOORBELL_EVENT, DEFAULT_MOTION_EVENT]},
)
await doorbird_mocker(entry=entry, favorites=favorites)
# Should have 2 event entities using the custom URL
event_entities = hass.states.async_all("event")
assert len(event_entities) == 2
async def test_no_configured_events(
hass: HomeAssistant,

View File

@@ -43,7 +43,7 @@ def mock_update_duckdns() -> Generator[AsyncMock]:
"""Mock _update_duckdns."""
with patch(
"homeassistant.components.duckdns.config_flow.update_duckdns",
"homeassistant.components.duckdns.config_flow._update_duckdns",
return_value=True,
) as mock:
yield mock

View File

@@ -5,9 +5,15 @@ import logging
import pytest
from homeassistant.components.duckdns import ATTR_TXT, DOMAIN, SERVICE_SET_TXT
from homeassistant.components.duckdns.coordinator import BACKOFF_INTERVALS
from homeassistant.components.duckdns.helpers import UPDATE_URL
from homeassistant.components.duckdns import (
ATTR_TXT,
BACKOFF_INTERVALS,
DOMAIN,
INTERVAL,
SERVICE_SET_TXT,
UPDATE_URL,
async_track_time_interval_backoff,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
@@ -67,13 +73,12 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -
assert aioclient_mock.call_count == 2
@pytest.mark.freeze_time
async def test_setup_backoff(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
) -> None:
"""Test update fails with backoffs and recovers."""
"""Test setup fails if first update fails."""
aioclient_mock.get(
UPDATE_URL,
params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN},
@@ -81,10 +86,10 @@ async def test_setup_backoff(
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
assert config_entry.state is ConfigEntryState.LOADED
assert aioclient_mock.call_count == 1
tme = utcnow()
@@ -98,17 +103,6 @@ async def test_setup_backoff(
assert aioclient_mock.call_count == idx + 1
aioclient_mock.clear_requests()
aioclient_mock.get(
UPDATE_URL,
params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN},
text="OK",
)
async_fire_time_changed(hass, tme)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
@pytest.mark.usefixtures("setup_duckdns")
async def test_service_set_txt(
@@ -153,6 +147,67 @@ async def test_service_clear_txt(
assert aioclient_mock.call_count == 1
async def test_async_track_time_interval_backoff(hass: HomeAssistant) -> None:
"""Test setup fails if first update fails."""
ret_val = False
call_count = 0
tme = None
async def _return(now):
nonlocal call_count, ret_val, tme
if tme is None:
tme = now
call_count += 1
return ret_val
intervals = (
INTERVAL,
INTERVAL * 2,
INTERVAL * 5,
INTERVAL * 9,
INTERVAL * 10,
INTERVAL * 11,
INTERVAL * 12,
)
async_track_time_interval_backoff(hass, _return, intervals)
await hass.async_block_till_done()
assert call_count == 1
_LOGGER.debug("Backoff")
for idx in range(1, len(intervals)):
tme += intervals[idx]
async_fire_time_changed(hass, tme + timedelta(seconds=0.1))
await hass.async_block_till_done()
assert call_count == idx + 1
_LOGGER.debug("Max backoff reached - intervals[-1]")
for _idx in range(1, 10):
tme += intervals[-1]
async_fire_time_changed(hass, tme + timedelta(seconds=0.1))
await hass.async_block_till_done()
assert call_count == idx + 1 + _idx
_LOGGER.debug("Reset backoff")
call_count = 0
ret_val = True
tme += intervals[-1]
async_fire_time_changed(hass, tme + timedelta(seconds=0.1))
await hass.async_block_till_done()
assert call_count == 1
_LOGGER.debug("No backoff - intervals[0]")
for _idx in range(2, 10):
tme += intervals[0]
async_fire_time_changed(hass, tme + timedelta(seconds=0.1))
await hass.async_block_till_done()
assert call_count == _idx
async def test_load_unload(
hass: HomeAssistant,
config_entry: MockConfigEntry,

View File

@@ -695,199 +695,3 @@ async def test_websocket_backup_timeout_handling(
assert not msg["success"]
assert msg["error"]["code"] == "unknown_error"
async def test_websocket_subscribe_feature(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test subscribing to a specific preview feature."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "labs/subscribe",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
# Initial state is sent as event
event_msg = await client.receive_json()
assert event_msg["type"] == "event"
assert event_msg["event"] == {
"preview_feature": "special_repair",
"domain": "kitchen_sink",
"enabled": False,
"is_built_in": True,
"feedback_url": ANY,
"learn_more_url": ANY,
"report_issue_url": ANY,
}
async def test_websocket_subscribe_feature_receives_updates(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that subscription receives updates when feature is toggled."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "labs/subscribe",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
}
)
subscribe_msg = await client.receive_json()
assert subscribe_msg["success"]
subscription_id = subscribe_msg["id"]
# Initial state event
initial_event_msg = await client.receive_json()
assert initial_event_msg["id"] == subscription_id
assert initial_event_msg["type"] == "event"
assert initial_event_msg["event"]["enabled"] is False
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
# Update event arrives before the update result
event_msg = await client.receive_json()
assert event_msg["id"] == subscription_id
assert event_msg["type"] == "event"
assert event_msg["event"] == {
"preview_feature": "special_repair",
"domain": "kitchen_sink",
"enabled": True,
"is_built_in": True,
"feedback_url": ANY,
"learn_more_url": ANY,
"report_issue_url": ANY,
}
update_msg = await client.receive_json()
assert update_msg["success"]
async def test_websocket_subscribe_nonexistent_feature(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test subscribing to a preview feature that doesn't exist."""
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "labs/subscribe",
"domain": "nonexistent",
"preview_feature": "feature",
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "not_found"
assert "not found" in msg["error"]["message"].lower()
async def test_websocket_subscribe_does_not_require_admin(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
) -> None:
"""Test that subscribe does not require admin privileges."""
hass_admin_user.groups = []
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "labs/subscribe",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
}
)
msg = await client.receive_json()
assert msg["success"]
# Consume initial state event
await client.receive_json()
async def test_websocket_subscribe_only_receives_subscribed_feature_updates(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that subscription only receives updates for the subscribed feature."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "labs/subscribe",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
}
)
subscribe_msg = await client.receive_json()
assert subscribe_msg["success"]
# Consume initial state event
await client.receive_json()
# Fire an event for a different feature
hass.bus.async_fire(
EVENT_LABS_UPDATED,
{"domain": "other_domain", "preview_feature": "other_feature", "enabled": True},
)
await hass.async_block_till_done()
await client.send_json_auto_id(
{
"type": "labs/update",
"domain": "kitchen_sink",
"preview_feature": "special_repair",
"enabled": True,
}
)
# Event message arrives before the update result
# Should only receive event for subscribed feature, not the other one
event_msg = await client.receive_json()
assert event_msg["type"] == "event"
assert event_msg["event"]["domain"] == "kitchen_sink"
assert event_msg["event"]["preview_feature"] == "special_repair"
update_msg = await client.receive_json()
assert update_msg["success"]

View File

@@ -10,6 +10,7 @@ import pytest
from homeassistant.components import frontend
from homeassistant.components.lovelace import const, dashboard
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from tests.common import assert_setup_component, async_capture_events
@@ -29,111 +30,190 @@ def mock_onboarding_done() -> Generator[MagicMock]:
yield mock_onboarding
async def test_lovelace_from_storage(
async def test_lovelace_from_storage_new_installation(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test we load lovelace config from storage."""
"""Test new installation has no default lovelace panel."""
assert await async_setup_component(hass, "lovelace", {})
# No default lovelace panel for new installations
assert "lovelace" not in hass.data.get(frontend.DATA_PANELS, {})
client = await hass_ws_client(hass)
# Dashboards list should be empty
await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
response = await client.receive_json()
assert response["success"]
assert response["result"] == []
async def test_lovelace_from_storage_migration(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test we migrate existing lovelace config from storage to dashboard."""
# Pre-populate storage with existing lovelace config
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
"version": 1,
"key": dashboard.CONFIG_STORAGE_KEY_DEFAULT,
"data": {"config": {"views": [{"title": "Home"}]}},
}
assert await async_setup_component(hass, "lovelace", {})
# After migration, lovelace panel should be registered as a dashboard
assert "lovelace" in hass.data[frontend.DATA_PANELS]
assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"}
client = await hass_ws_client(hass)
# Fetch data
await client.send_json({"id": 5, "type": "lovelace/config"})
# Dashboard should be in the list
await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "config_not_found"
assert response["success"]
assert len(response["result"]) == 1
assert response["result"][0]["url_path"] == "lovelace"
assert response["result"][0]["title"] == "Overview"
# Fetch migrated config
await client.send_json({"id": 6, "type": "lovelace/config", "url_path": "lovelace"})
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"views": [{"title": "Home"}]}
# Old storage key should be gone, new one should exist
assert dashboard.CONFIG_STORAGE_KEY_DEFAULT not in hass_storage
assert dashboard.CONFIG_STORAGE_KEY.format("lovelace") in hass_storage
# Store new config
events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
await client.send_json(
{"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
{
"id": 7,
"type": "lovelace/config/save",
"url_path": "lovelace",
"config": {"yo": "hello"},
}
)
response = await client.receive_json()
assert response["success"]
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format("lovelace")]["data"] == {
"config": {"yo": "hello"}
}
assert len(events) == 1
# Load new config
await client.send_json({"id": 7, "type": "lovelace/config"})
await client.send_json({"id": 8, "type": "lovelace/config", "url_path": "lovelace"})
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"yo": "hello"}
# Test with recovery mode
hass.config.recovery_mode = True
await client.send_json({"id": 8, "type": "lovelace/config"})
await client.send_json({"id": 9, "type": "lovelace/config", "url_path": "lovelace"})
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "config_not_found"
await client.send_json(
{"id": 9, "type": "lovelace/config/save", "config": {"yo": "hello"}}
{
"id": 10,
"type": "lovelace/config/save",
"url_path": "lovelace",
"config": {"yo": "hello"},
}
)
response = await client.receive_json()
assert not response["success"]
await client.send_json({"id": 10, "type": "lovelace/config/delete"})
await client.send_json(
{"id": 11, "type": "lovelace/config/delete", "url_path": "lovelace"}
)
response = await client.receive_json()
assert not response["success"]
async def test_lovelace_from_storage_save_before_load(
async def test_lovelace_migration_skipped_when_both_files_exist(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test we can load lovelace config from storage."""
assert await async_setup_component(hass, "lovelace", {})
client = await hass_ws_client(hass)
# Store new config
await client.send_json(
{"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
)
response = await client.receive_json()
assert response["success"]
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
"config": {"yo": "hello"}
"""Test migration is skipped when both old and new storage files exist."""
# Pre-populate both old and new storage (simulating incomplete migration)
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
"version": 1,
"key": dashboard.CONFIG_STORAGE_KEY_DEFAULT,
"data": {"config": {"views": [{"title": "Old"}]}},
}
hass_storage[dashboard.CONFIG_STORAGE_KEY.format("lovelace")] = {
"version": 1,
"key": dashboard.CONFIG_STORAGE_KEY.format("lovelace"),
"data": {"config": {"views": [{"title": "New"}]}},
}
assert await async_setup_component(hass, "lovelace", {})
async def test_lovelace_from_storage_delete(
# No dashboard should be created (migration skipped)
client = await hass_ws_client(hass)
await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
response = await client.receive_json()
assert response["success"]
assert response["result"] == []
async def test_lovelace_migration_skipped_when_already_migrated(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test we delete lovelace config from storage."""
assert await async_setup_component(hass, "lovelace", {})
client = await hass_ws_client(hass)
# Store new config
await client.send_json(
{"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
)
response = await client.receive_json()
assert response["success"]
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
"config": {"yo": "hello"}
"""Test migration is skipped when dashboard already exists."""
# Pre-populate dashboards with existing lovelace dashboard
hass_storage[dashboard.DASHBOARDS_STORAGE_KEY] = {
"version": 1,
"key": dashboard.DASHBOARDS_STORAGE_KEY,
"data": {
"items": [
{
"id": "lovelace",
"url_path": "lovelace",
"title": "Overview",
"icon": "mdi:view-dashboard",
"show_in_sidebar": True,
"require_admin": False,
"mode": "storage",
}
]
},
}
hass_storage[dashboard.CONFIG_STORAGE_KEY.format("lovelace")] = {
"version": 1,
"key": dashboard.CONFIG_STORAGE_KEY.format("lovelace"),
"data": {"config": {"views": [{"title": "Home"}]}},
}
# Also have old file (should be ignored since dashboard exists)
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
"version": 1,
"key": dashboard.CONFIG_STORAGE_KEY_DEFAULT,
"data": {"config": {"views": [{"title": "Old"}]}},
}
# Delete config
await client.send_json({"id": 7, "type": "lovelace/config/delete"})
assert await async_setup_component(hass, "lovelace", {})
client = await hass_ws_client(hass)
await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
response = await client.receive_json()
assert response["success"]
assert dashboard.CONFIG_STORAGE_KEY_DEFAULT not in hass_storage
# Only the pre-existing dashboard, no duplicate
assert len(response["result"]) == 1
assert response["result"][0]["url_path"] == "lovelace"
# Fetch data
await client.send_json({"id": 8, "type": "lovelace/config"})
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "config_not_found"
# Old storage should still exist (not touched)
assert dashboard.CONFIG_STORAGE_KEY_DEFAULT in hass_storage
async def test_lovelace_from_yaml(
@@ -226,6 +306,24 @@ async def test_lovelace_from_yaml(
assert len(events) == 2
async def test_lovelace_from_yaml_creates_repair_issue(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test YAML mode creates a repair issue."""
assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
# Panel should still be registered for backwards compatibility
assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "yaml"}
# Repair issue should be created with 6-month deadline
issue_registry = ir.async_get(hass)
issue = issue_registry.async_get_issue("lovelace", "yaml_mode_deprecated")
assert issue is not None
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.is_fixable is False
assert issue.breaks_in_ha_version == "2026.6.0"
@pytest.mark.parametrize("url_path", ["test-panel", "test-panel-no-sidebar"])
async def test_dashboard_from_yaml(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, url_path
@@ -364,7 +462,9 @@ async def test_storage_dashboards(
) -> None:
"""Test we load lovelace config from storage."""
assert await async_setup_component(hass, "lovelace", {})
assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"}
# New installations don't have default lovelace panel
assert "lovelace" not in hass.data.get(frontend.DATA_PANELS, {})
client = await hass_ws_client(hass)

View File

@@ -34,11 +34,12 @@ async def test_system_health_info_autogen(hass: HomeAssistant) -> None:
assert info == {"dashboards": 1, "mode": "auto-gen", "resources": 0}
async def test_system_health_info_storage(
async def test_system_health_info_storage_migration(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test system health info endpoint."""
"""Test system health info endpoint after migration from old storage."""
assert await async_setup_component(hass, "system_health", {})
# Pre-populate old storage format (triggers migration)
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
"key": "lovelace",
"version": 1,
@@ -47,7 +48,8 @@ async def test_system_health_info_storage(
assert await async_setup_component(hass, "lovelace", {})
await hass.async_block_till_done()
info = await get_system_health_info(hass, "lovelace")
assert info == {"dashboards": 1, "mode": "storage", "resources": 0, "views": 0}
# After migration: default dashboard (auto-gen) + migrated "lovelace" dashboard (storage with data)
assert info == {"dashboards": 2, "mode": "storage", "resources": 0, "views": 0}
async def test_system_health_info_yaml(hass: HomeAssistant) -> None:

View File

@@ -4,9 +4,8 @@ from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from homeassistant.components.rituals_perfume_genie.const import DOMAIN
from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_json_object_fixture
@@ -18,11 +17,7 @@ def mock_config_entry(unique_id: str, entry_id: str = "an_entry_id") -> MockConf
domain=DOMAIN,
title="name@example.com",
unique_id=unique_id,
data={
CONF_EMAIL: "test@rituals.com",
CONF_PASSWORD: "test-password",
},
version=2,
data={ACCOUNT_HASH: "an_account_hash"},
entry_id=entry_id,
)
@@ -95,15 +90,13 @@ async def init_integration(
"""Initialize the Rituals Perfume Genie integration with the given Config Entry and Diffuser list."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.rituals_perfume_genie.Account"
) as mock_account_cls:
mock_account = mock_account_cls.return_value
mock_account.authenticate = AsyncMock()
mock_account.get_devices = AsyncMock(return_value=mock_diffusers)
"homeassistant.components.rituals_perfume_genie.Account.get_devices",
return_value=mock_diffusers,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_config_entry.entry_id in hass.data[DOMAIN]
assert hass.data[DOMAIN]
await hass.async_block_till_done()

View File

@@ -1,64 +0,0 @@
"""Fixtures for Rituals Perfume Genie tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.rituals_perfume_genie import ACCOUNT_HASH, DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from .const import TEST_EMAIL, TEST_PASSWORD
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.rituals_perfume_genie.async_setup_entry",
return_value=True,
) as mock:
yield mock
@pytest.fixture
def mock_rituals_account() -> Generator[AsyncMock]:
"""Mock Rituals Account."""
with (
patch(
"homeassistant.components.rituals_perfume_genie.config_flow.Account",
autospec=True,
) as mock_account_cls,
patch(
"homeassistant.components.rituals_perfume_genie.Account",
new=mock_account_cls,
),
):
mock_account = mock_account_cls.return_value
yield mock_account
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock Rituals Account."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_EMAIL,
data={CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD},
title=TEST_EMAIL,
version=2,
)
@pytest.fixture
def old_mock_config_entry() -> MockConfigEntry:
"""Mock Rituals Account."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_EMAIL,
data={ACCOUNT_HASH: "old_hash_should_be_removed"},
title=TEST_EMAIL,
version=1,
)

View File

@@ -1,4 +0,0 @@
"""Constants for rituals_perfume_genie tests."""
TEST_EMAIL = "test@rituals.com"
TEST_PASSWORD = "test-password"

View File

@@ -1,213 +1,126 @@
"""Test the Rituals Perfume Genie config flow."""
from unittest.mock import AsyncMock
from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientError
from aiohttp import ClientResponseError
from pyrituals import AuthenticationException
import pytest
from homeassistant.components.rituals_perfume_genie.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant import config_entries
from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import TEST_EMAIL, TEST_PASSWORD
from tests.common import MockConfigEntry
TEST_EMAIL = "rituals@example.com"
VALID_PASSWORD = "passw0rd"
WRONG_PASSWORD = "wrong-passw0rd"
async def test_user_flow_success(
hass: HomeAssistant, mock_rituals_account: AsyncMock, mock_setup_entry: AsyncMock
) -> None:
"""Test successful user flow setup."""
def _mock_account(*_):
account = MagicMock()
account.authenticate = AsyncMock()
account.account_hash = "any"
account.email = TEST_EMAIL
return account
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
assert result["errors"] is None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: TEST_PASSWORD,
},
)
with (
patch(
"homeassistant.components.rituals_perfume_genie.config_flow.Account",
side_effect=_mock_account,
),
patch(
"homeassistant.components.rituals_perfume_genie.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: VALID_PASSWORD,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_EMAIL
assert result["data"] == {
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: TEST_PASSWORD,
}
assert result["result"].unique_id == TEST_EMAIL
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == TEST_EMAIL
assert isinstance(result2["data"][ACCOUNT_HASH], str)
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(AuthenticationException, "invalid_auth"),
(ClientError, "cannot_connect"),
],
)
async def test_user_flow_errors(
hass: HomeAssistant,
mock_rituals_account: AsyncMock,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test user flow with different errors."""
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_rituals_account.authenticate.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: TEST_PASSWORD,
},
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
with patch(
"homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate",
side_effect=AuthenticationException,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: WRONG_PASSWORD,
},
)
mock_rituals_account.authenticate.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
async def test_duplicate_entry(
hass: HomeAssistant,
mock_rituals_account: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test user flow with invalid credentials."""
mock_config_entry.add_to_hass(hass)
async def test_form_auth_exception(hass: HomeAssistant) -> None:
"""Test we handle auth exception."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: TEST_PASSWORD,
},
with patch(
"homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: VALID_PASSWORD,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
with patch(
"homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate",
side_effect=ClientResponseError(
None, None, status=HTTPStatus.INTERNAL_SERVER_ERROR
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: VALID_PASSWORD,
},
)
async def test_reauth_flow_success(
hass: HomeAssistant,
mock_rituals_account: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful reauth flow (updating credentials)."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new_correct_password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == "new_correct_password"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(AuthenticationException, "invalid_auth"),
(ClientError, "cannot_connect"),
],
)
async def test_reauth_flow_errors(
hass: HomeAssistant,
mock_rituals_account: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
error: str,
) -> None:
"""Test reauth flow with different errors."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
mock_rituals_account.authenticate.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new_correct_password"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_rituals_account.authenticate.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PASSWORD: "new_correct_password",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == "new_correct_password"
async def test_reauth_migrated_entry(
hass: HomeAssistant, mock_rituals_account: AsyncMock, mock_setup_entry: AsyncMock
) -> None:
"""Test successful reauth flow (updating credentials)."""
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_EMAIL,
data={},
title=TEST_EMAIL,
version=2,
)
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new_correct_password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data == {
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: "new_correct_password",
}
assert len(mock_setup_entry.mock_calls) == 1
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}

View File

@@ -1,10 +1,10 @@
"""Tests for the Rituals Perfume Genie integration."""
from unittest.mock import AsyncMock
from unittest.mock import patch
import aiohttp
from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN
from homeassistant.components.rituals_perfume_genie.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -16,39 +16,17 @@ from .common import (
mock_diffuser_v1_battery_cartridge,
)
from tests.common import MockConfigEntry
async def test_migration_v1_to_v2(
hass: HomeAssistant,
mock_rituals_account: AsyncMock,
old_mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from V1 (account_hash) to V2 (credentials)."""
old_mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(old_mock_config_entry.entry_id)
await hass.async_block_till_done()
assert old_mock_config_entry.version == 2
assert ACCOUNT_HASH not in old_mock_config_entry.data
assert old_mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert len(hass.config_entries.flow.async_progress()) == 1
async def test_config_entry_not_ready(
hass: HomeAssistant,
mock_rituals_account: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
async def test_config_entry_not_ready(hass: HomeAssistant) -> None:
"""Test the Rituals configuration entry setup if connection to Rituals is missing."""
mock_config_entry.add_to_hass(hass)
mock_rituals_account.get_devices.side_effect = aiohttp.ClientError
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
config_entry = mock_config_entry(unique_id="id_123_not_ready")
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.rituals_perfume_genie.Account.get_devices",
side_effect=aiohttp.ClientError,
):
await hass.config_entries.async_setup(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_config_entry_unload(hass: HomeAssistant) -> None:

View File

@@ -554,30 +554,3 @@ async def test_wall_display_screen_buttons(
blocking=True,
)
mock_rpc_device.wall_display_set_screen.assert_called_once_with(value=value)
async def test_rpc_remove_restart_button_for_sleeping_devices(
hass: HomeAssistant,
mock_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
device_registry: DeviceRegistry,
entity_registry: EntityRegistry,
) -> None:
"""Test RPC remove restart button for sleeping devices."""
config_entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True)
device_entry = register_device(device_registry, config_entry)
entity_id = register_entity(
hass,
BUTTON_DOMAIN,
"test_name_restart",
"reboot",
config_entry,
device_id=device_entry.id,
)
assert entity_registry.async_get(entity_id) is not None
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert entity_registry.async_get(entity_id) is None

View File

@@ -2345,20 +2345,19 @@ async def test_effect_template(
],
)
@pytest.mark.parametrize(
("expected_min_mireds", "expected_max_kelvin", "attribute_template"),
("expected_min_mireds", "attribute_template"),
[
(118, 8474, "{{118}}"),
(153, 6535, "{{x - 12}}"),
(153, 6535, "None"),
(153, 6535, "{{ none }}"),
(153, 6535, ""),
(153, 6535, "{{ 'a' }}"),
(118, "{{118}}"),
(153, "{{x - 12}}"),
(153, "None"),
(153, "{{ none }}"),
(153, ""),
(153, "{{ 'a' }}"),
],
)
async def test_min_mireds_template(
hass: HomeAssistant,
expected_min_mireds: int,
expected_max_kelvin: int,
expected_min_mireds,
style: ConfigurationStyle,
setup_light_with_mireds,
) -> None:
@@ -2370,7 +2369,6 @@ async def test_min_mireds_template(
state = hass.states.get("light.test_template_light")
assert state is not None
assert state.attributes.get("min_mireds") == expected_min_mireds
assert state.attributes.get("max_color_temp_kelvin") == expected_max_kelvin
@pytest.mark.parametrize("count", [1])
@@ -2383,20 +2381,19 @@ async def test_min_mireds_template(
],
)
@pytest.mark.parametrize(
("expected_max_mireds", "expected_min_kelvin", "attribute_template"),
("expected_max_mireds", "attribute_template"),
[
(488, 2049, "{{488}}"),
(500, 2000, "{{x - 12}}"),
(500, 2000, "None"),
(500, 2000, "{{ none }}"),
(500, 2000, ""),
(500, 2000, "{{ 'a' }}"),
(488, "{{488}}"),
(500, "{{x - 12}}"),
(500, "None"),
(500, "{{ none }}"),
(500, ""),
(500, "{{ 'a' }}"),
],
)
async def test_max_mireds_template(
hass: HomeAssistant,
expected_max_mireds: int,
expected_min_kelvin: int,
expected_max_mireds,
style: ConfigurationStyle,
setup_light_with_mireds,
) -> None:
@@ -2408,7 +2405,6 @@ async def test_max_mireds_template(
state = hass.states.get("light.test_template_light")
assert state is not None
assert state.attributes.get("max_mireds") == expected_max_mireds
assert state.attributes.get("min_color_temp_kelvin") == expected_min_kelvin
@pytest.mark.parametrize(

View File

@@ -15,7 +15,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_category': None,
'entity_id': 'light.device_with_led_led',
'has_entity_name': True,
'hidden_by': None,

View File

@@ -37,6 +37,7 @@ from homeassistant.helpers.automation import move_top_level_schema_fields_to_opt
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
ConditionConfig,
async_validate_condition_config,
)
from homeassistant.helpers.template import Template
@@ -2123,6 +2124,9 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None:
"""Validate config."""
return config
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
class MockCondition1(MockCondition):
"""Mock condition 1."""