Compare commits

..

1 Commits

Author SHA1 Message Date
mib1185
05917a9fcd add tests for the switch platform 2025-11-23 13:51:58 +00:00
114 changed files with 3052 additions and 5684 deletions

2
CODEOWNERS generated
View File

@@ -391,8 +391,6 @@ build.json @home-assistant/supervisor
/tests/components/dsmr/ @Robbie1221
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duckdns/ @tr4nt0r
/tests/components/duckdns/ @tr4nt0r
/homeassistant/components/duke_energy/ @hunterjm
/tests/components/duke_energy/ @hunterjm
/homeassistant/components/duotecno/ @cereal2nd

View File

@@ -71,26 +71,9 @@ class BangOlufsenModel(StrEnum):
BEOSOUND_BALANCE = "Beosound Balance"
BEOSOUND_EMERGE = "Beosound Emerge"
BEOSOUND_LEVEL = "Beosound Level"
BEOSOUND_PREMIERE = "Beosound Premiere"
BEOSOUND_THEATRE = "Beosound Theatre"
# Physical "buttons" on devices
class BangOlufsenButtons(StrEnum):
"""Enum for device buttons."""
BLUETOOTH = "Bluetooth"
MICROPHONE = "Microphone"
NEXT = "Next"
PLAY_PAUSE = "PlayPause"
PRESET_1 = "Preset1"
PRESET_2 = "Preset2"
PRESET_3 = "Preset3"
PRESET_4 = "Preset4"
PREVIOUS = "Previous"
VOLUME = "Volume"
# Dispatcher events
class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types."""
@@ -221,6 +204,23 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
),
]
)
# Map for storing compatibility of devices.
MODEL_SUPPORT_DEVICE_BUTTONS: Final[str] = "device_buttons"
MODEL_SUPPORT_MAP = {
MODEL_SUPPORT_DEVICE_BUTTONS: (
BangOlufsenModel.BEOLAB_8,
BangOlufsenModel.BEOLAB_28,
BangOlufsenModel.BEOSOUND_2,
BangOlufsenModel.BEOSOUND_A5,
BangOlufsenModel.BEOSOUND_A9,
BangOlufsenModel.BEOSOUND_BALANCE,
BangOlufsenModel.BEOSOUND_EMERGE,
BangOlufsenModel.BEOSOUND_LEVEL,
BangOlufsenModel.BEOSOUND_THEATRE,
)
}
# Device events
BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
@@ -236,7 +236,18 @@ EVENT_TRANSLATION_MAP: dict[str, str] = {
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BangOlufsenButtons]
DEVICE_BUTTONS: Final[list[str]] = [
"Bluetooth",
"Microphone",
"Next",
"PlayPause",
"Preset1",
"Preset2",
"Preset3",
"Preset4",
"Previous",
"Volume",
]
DEVICE_BUTTON_EVENTS: Final[list[str]] = [

View File

@@ -6,13 +6,11 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import BangOlufsenConfigEntry
from .const import DOMAIN
from .util import get_device_buttons
from .const import DEVICE_BUTTONS, DOMAIN
async def async_get_config_entry_diagnostics(
@@ -42,7 +40,7 @@ async def async_get_config_entry_diagnostics(
data["media_player"] = state_dict
# Add button Event entity states (if enabled)
for device_button in get_device_buttons(config_entry.data[CONF_MODEL]):
for device_button in DEVICE_BUTTONS:
if entity_id := entity_registry.async_get_entity_id(
EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}"
):

View File

@@ -9,9 +9,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BangOlufsenConfigEntry
from .const import CONNECTION_STATUS, DEVICE_BUTTON_EVENTS, WebsocketNotification
from .const import (
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DEVICE_BUTTONS,
MODEL_SUPPORT_DEVICE_BUTTONS,
MODEL_SUPPORT_MAP,
WebsocketNotification,
)
from .entity import BangOlufsenEntity
from .util import get_device_buttons
PARALLEL_UPDATES = 0
@@ -23,10 +29,11 @@ async def async_setup_entry(
) -> None:
"""Set up Sensor entities from config entry."""
async_add_entities(
BangOlufsenButtonEvent(config_entry, button_type)
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
)
if config_entry.data[CONF_MODEL] in MODEL_SUPPORT_MAP[MODEL_SUPPORT_DEVICE_BUTTONS]:
async_add_entities(
BangOlufsenButtonEvent(config_entry, button_type)
for button_type in DEVICE_BUTTONS
)
class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity):

View File

@@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DEVICE_BUTTONS, DOMAIN, BangOlufsenButtons, BangOlufsenModel
from .const import DOMAIN
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
@@ -21,18 +21,3 @@ def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
def get_serial_number_from_jid(jid: str) -> str:
"""Get serial number from Beolink JID."""
return jid.split(".")[2].split("@")[0]
def get_device_buttons(model: BangOlufsenModel) -> list[str]:
"""Get supported buttons for a given model."""
buttons = DEVICE_BUTTONS.copy()
# Beosound Premiere does not have a bluetooth button
if model == BangOlufsenModel.BEOSOUND_PREMIERE:
buttons.remove(BangOlufsenButtons.BLUETOOTH)
# Beoconnect Core does not have any buttons
elif model == BangOlufsenModel.BEOCONNECT_CORE:
buttons = []
return buttons

View File

@@ -10,7 +10,6 @@ from typing import Any, cast
from aiohttp import ClientSession
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import (
CALLBACK_TYPE,
@@ -19,17 +18,13 @@ from homeassistant.core import (
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
_LOGGER = logging.getLogger(__name__)
ATTR_TXT = "txt"
@@ -37,13 +32,7 @@ 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"
@@ -60,112 +49,39 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SERVICE_TXT_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector(
{
"integration": DOMAIN,
}
),
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
}
)
type DuckDnsConfigEntry = ConfigEntry
SERVICE_TXT_SCHEMA = vol.Schema({vol.Required(ATTR_TXT): vol.Any(None, cv.string)})
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the DuckDNS component."""
hass.services.async_register(
DOMAIN,
SERVICE_SET_TXT,
update_domain_service,
schema=SERVICE_TXT_SCHEMA,
)
if DOMAIN not in config:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
"""Set up Duck DNS from a config entry."""
domain: str = config[DOMAIN][CONF_DOMAIN]
token: str = config[DOMAIN][CONF_ACCESS_TOKEN]
session = async_get_clientsession(hass)
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],
)
return await _update_duckdns(session, domain, token)
entry.async_on_unload(
async_track_time_interval_backoff(
hass, update_domain_interval, BACKOFF_INTERVALS
)
intervals = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
async_track_time_interval_backoff(hass, update_domain_interval, intervals)
async def update_domain_service(call: ServiceCall) -> None:
"""Update the DuckDNS entry."""
await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT])
hass.services.async_register(
DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA
)
return True
def get_config_entry(
hass: HomeAssistant, entry_id: str | None = None
) -> DuckDnsConfigEntry:
"""Return config entry or raise if not found or not loaded."""
if entry_id is None:
if not (config_entries := hass.config_entries.async_entries(DOMAIN)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if len(config_entries) != 1:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_selected",
)
return config_entries[0]
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
return entry
async def update_domain_service(call: ServiceCall) -> None:
"""Update the DuckDNS entry."""
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
session = async_get_clientsession(call.hass)
await _update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
txt=call.data.get(ATTR_TXT),
)
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
"""Unload a config entry."""
return True
_SENTINEL = object()

View File

@@ -1,81 +0,0 @@
"""Config flow for the Duck DNS integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from . import _update_duckdns
from .const import DOMAIN
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_DOMAIN): TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT, suffix=".duckdns.org")
),
vol.Required(CONF_ACCESS_TOKEN): str,
}
)
class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Duck DNS."""
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:
self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]})
session = async_get_clientsession(self.hass)
try:
if not await _update_duckdns(
session,
user_input[CONF_DOMAIN],
user_input[CONF_ACCESS_TOKEN],
):
errors["base"] = "update_failed"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title=f"{user_input[CONF_DOMAIN]}.duckdns.org", data=user_input
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
description_placeholders={"url": "https://www.duckdns.org/"},
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
self._async_abort_entries_match({CONF_DOMAIN: import_info[CONF_DOMAIN]})
result = await self.async_step_user(import_info)
if errors := result.get("errors"):
deprecate_yaml_issue(self.hass, import_success=False)
return self.async_abort(reason=errors["base"])
deprecate_yaml_issue(self.hass, import_success=True)
return result

View File

@@ -1,7 +0,0 @@
"""Constants for the Duck DNS integration."""
from typing import Final
DOMAIN = "duckdns"
ATTR_CONFIG_ENTRY: Final = "config_entry_id"

View File

@@ -1,40 +0,0 @@
"""Issues for Duck DNS integration."""
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
@callback
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
"""Deprecate yaml issue."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.6.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Duck DNS",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_error",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_error",
translation_placeholders={
"url": "/config/integrations/dashboard/add?domain=duckdns"
},
)

View File

@@ -1,8 +1,8 @@
{
"domain": "duckdns",
"name": "Duck DNS",
"codeowners": ["@tr4nt0r"],
"config_flow": true,
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/duckdns",
"iot_class": "cloud_polling"
"iot_class": "cloud_polling",
"quality_scale": "legacy"
}

View File

@@ -1,10 +1,7 @@
set_txt:
fields:
config_entry_id:
selector:
config_entry:
integration: duckdns
txt:
required: true
example: "This domain name is reserved for use in documentation"
selector:
text:

View File

@@ -1,48 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
"update_failed": "Updating Duck DNS failed"
},
"step": {
"user": {
"data": {
"access_token": "Token",
"domain": "Subdomain"
},
"data_description": {
"access_token": "Your Duck DNS account token",
"domain": "The Duck DNS subdomain to update"
},
"description": "Enter your Duck DNS subdomain and token below to configure dynamic DNS updates. You can find your token on the [Duck DNS]({url}) homepage after logging into your account."
}
}
},
"exceptions": {
"entry_not_found": {
"message": "Duck DNS integration entry not found"
},
"entry_not_selected": {
"message": "Duck DNS integration entry not selected"
}
},
"issues": {
"deprecated_yaml_import_issue_error": {
"description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "The Duck DNS YAML configuration import failed"
}
},
"services": {
"set_txt": {
"description": "Sets the TXT record of your Duck DNS subdomain.",
"description": "Sets the TXT record of your DuckDNS subdomain.",
"fields": {
"config_entry_id": {
"description": "The Duck DNS integration ID.",
"name": "Integration ID"
},
"txt": {
"description": "Payload for the TXT record.",
"name": "TXT"

View File

@@ -20,7 +20,7 @@ from .coordinator import (
GoogleWeatherSubEntryRuntimeData,
)
_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER]
_PLATFORMS: list[Platform] = [Platform.WEATHER]
async def async_setup_entry(

View File

@@ -16,15 +16,10 @@ class GoogleWeatherBaseEntity(Entity):
_attr_has_entity_name = True
def __init__(
self,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
unique_id_suffix: str | None = None,
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
) -> None:
"""Initialize base entity."""
self._attr_unique_id = subentry.subentry_id
if unique_id_suffix is not None:
self._attr_unique_id += f"_{unique_id_suffix.lower()}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,

View File

@@ -1,27 +0,0 @@
{
"entity": {
"sensor": {
"cloud_coverage": {
"default": "mdi:weather-cloudy"
},
"precipitation_probability": {
"default": "mdi:weather-rainy"
},
"precipitation_qpf": {
"default": "mdi:cup-water"
},
"thunderstorm_probability": {
"default": "mdi:weather-lightning"
},
"uv_index": {
"default": "mdi:weather-sunny-alert"
},
"visibility": {
"default": "mdi:eye"
},
"weather_condition": {
"default": "mdi:card-text-outline"
}
}
}
}

View File

@@ -1,233 +0,0 @@
"""Support for Google Weather sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from google_weather_api import CurrentConditionsResponse
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import (
DEGREE,
PERCENTAGE,
UV_INDEX,
UnitOfLength,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
)
from .entity import GoogleWeatherBaseEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class GoogleWeatherSensorDescription(SensorEntityDescription):
"""Class describing Google Weather sensor entities."""
value_fn: Callable[[CurrentConditionsResponse], str | int | float | None]
SENSOR_TYPES: tuple[GoogleWeatherSensorDescription, ...] = (
GoogleWeatherSensorDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.temperature.degrees,
),
GoogleWeatherSensorDescription(
key="feelsLikeTemperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.feels_like_temperature.degrees,
translation_key="apparent_temperature",
),
GoogleWeatherSensorDescription(
key="dewPoint",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.dew_point.degrees,
translation_key="dew_point",
),
GoogleWeatherSensorDescription(
key="heatIndex",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.heat_index.degrees,
translation_key="heat_index",
),
GoogleWeatherSensorDescription(
key="windChill",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.wind_chill.degrees,
translation_key="wind_chill",
),
GoogleWeatherSensorDescription(
key="relativeHumidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.relative_humidity,
),
GoogleWeatherSensorDescription(
key="uvIndex",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data: data.uv_index,
translation_key="uv_index",
),
GoogleWeatherSensorDescription(
key="precipitation_probability",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.precipitation.probability.percent,
translation_key="precipitation_probability",
),
GoogleWeatherSensorDescription(
key="precipitation_qpf",
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
value_fn=lambda data: data.precipitation.qpf.quantity,
),
GoogleWeatherSensorDescription(
key="thunderstormProbability",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.thunderstorm_probability,
translation_key="thunderstorm_probability",
),
GoogleWeatherSensorDescription(
key="airPressure",
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
native_unit_of_measurement=UnitOfPressure.HPA,
value_fn=lambda data: data.air_pressure.mean_sea_level_millibars,
),
GoogleWeatherSensorDescription(
key="wind_direction",
device_class=SensorDeviceClass.WIND_DIRECTION,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
native_unit_of_measurement=DEGREE,
value_fn=lambda data: data.wind.direction.degrees,
),
GoogleWeatherSensorDescription(
key="wind_speed",
device_class=SensorDeviceClass.WIND_SPEED,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: data.wind.speed.value,
),
GoogleWeatherSensorDescription(
key="wind_gust",
device_class=SensorDeviceClass.WIND_SPEED,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: data.wind.gust.value,
translation_key="wind_gust_speed",
),
GoogleWeatherSensorDescription(
key="visibility",
device_class=SensorDeviceClass.DISTANCE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
value_fn=lambda data: data.visibility.distance,
translation_key="visibility",
),
GoogleWeatherSensorDescription(
key="cloudCover",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.cloud_cover,
translation_key="cloud_coverage",
),
GoogleWeatherSensorDescription(
key="weatherCondition",
entity_registry_enabled_default=False,
value_fn=lambda data: data.weather_condition.description.text,
translation_key="weather_condition",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GoogleWeatherConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Google Weather entities from a config_entry."""
for subentry in entry.subentries.values():
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
subentry.subentry_id
]
coordinator = subentry_runtime_data.coordinator_observation
async_add_entities(
(
GoogleWeatherSensor(coordinator, subentry, description)
for description in SENSOR_TYPES
if description.value_fn(coordinator.data) is not None
),
config_subentry_id=subentry.subentry_id,
)
class GoogleWeatherSensor(
CoordinatorEntity[GoogleWeatherCurrentConditionsCoordinator],
GoogleWeatherBaseEntity,
SensorEntity,
):
"""Define a Google Weather entity."""
entity_description: GoogleWeatherSensorDescription
def __init__(
self,
coordinator: GoogleWeatherCurrentConditionsCoordinator,
subentry: ConfigSubentry,
description: GoogleWeatherSensorDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
GoogleWeatherBaseEntity.__init__(
self, coordinator.config_entry, subentry, description.key
)
self.entity_description = description
@property
def native_value(self) -> str | int | float | None:
"""Return the state."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -61,42 +61,5 @@
}
}
}
},
"entity": {
"sensor": {
"apparent_temperature": {
"name": "Apparent temperature"
},
"cloud_coverage": {
"name": "Cloud coverage"
},
"dew_point": {
"name": "Dew point"
},
"heat_index": {
"name": "Heat index temperature"
},
"precipitation_probability": {
"name": "Precipitation probability"
},
"thunderstorm_probability": {
"name": "Thunderstorm probability"
},
"uv_index": {
"name": "UV index"
},
"visibility": {
"name": "Visibility"
},
"weather_condition": {
"name": "Weather condition"
},
"wind_chill": {
"name": "Wind chill temperature"
},
"wind_gust_speed": {
"name": "Wind gust speed"
}
}
}
}

View File

@@ -128,8 +128,6 @@ ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed"
CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"

View File

@@ -27,7 +27,6 @@ from homeassistant.helpers.issue_registry import (
)
from .const import (
ADDONS_COORDINATOR,
ATTR_DATA,
ATTR_HEALTHY,
ATTR_STARTUP,
@@ -50,7 +49,6 @@ from .const import (
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_MOUNT_MOUNT_FAILED,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_ADDON_URL,
PLACEHOLDER_KEY_FREE_SPACE,
@@ -59,7 +57,7 @@ from .const import (
STARTUP_COMPLETE,
UPDATE_KEY_SUPERVISOR,
)
from .coordinator import HassioDataUpdateCoordinator, get_addons_info, get_host_info
from .coordinator import get_addons_info, get_host_info
from .handler import HassIO, get_supervisor_client
ISSUE_KEY_UNHEALTHY = "unhealthy"
@@ -79,7 +77,7 @@ UNSUPPORTED_SKIP_REPAIR = {"privileged"}
# Keys (type + context) of issues that when found should be made into a repair
ISSUE_KEYS_FOR_REPAIRS = {
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_MOUNT_MOUNT_FAILED,
"issue_mount_mount_failed",
"issue_system_multiple_data_disks",
"issue_system_reboot_required",
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
@@ -286,9 +284,6 @@ class SupervisorIssues:
else:
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
if issue.key == ISSUE_MOUNT_MOUNT_FAILED:
self._async_coordinator_refresh()
async_create_issue(
self._hass,
DOMAIN,
@@ -341,9 +336,6 @@ class SupervisorIssues:
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
async_delete_issue(self._hass, DOMAIN, issue.uuid.hex)
if issue.key == ISSUE_MOUNT_MOUNT_FAILED:
self._async_coordinator_refresh()
del self._issues[issue.uuid]
def get_issue(self, issue_id: str) -> Issue | None:
@@ -414,11 +406,3 @@ class SupervisorIssues:
elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED:
self.remove_issue(Issue.from_dict(event[ATTR_DATA]))
def _async_coordinator_refresh(self) -> None:
"""Refresh coordinator to update latest data in entities."""
coordinator: HassioDataUpdateCoordinator | None
if coordinator := self._hass.data.get(ADDONS_COORDINATOR):
coordinator.config_entry.async_create_task(
self._hass, coordinator.async_refresh()
)

View File

@@ -13,13 +13,11 @@ DOMAIN = "home_connect"
API_DEFAULT_RETRY_AFTER = 60
APPLIANCES_WITH_PROGRAMS = (
"AirConditioner",
"CleaningRobot",
"CoffeeMaker",
"Dishwasher",
"Dryer",
"Hood",
"Microwave",
"Oven",
"WarmingDrawer",
"Washer",
@@ -85,14 +83,6 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
}
FAN_SPEED_MODE_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
)
}
AVAILABLE_MAPS_ENUM = {
bsh_key_to_translation_key(option): option
for option in (
@@ -325,10 +315,6 @@ PROGRAM_ENUM_OPTIONS = {
options,
)
for option_key, options in (
(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
FAN_SPEED_MODE_OPTIONS,
),
(
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
AVAILABLE_MAPS_ENUM,

View File

@@ -82,12 +82,6 @@ set_program_and_options:
- dishcare_dishwasher_program_maximum_cleaning
- dishcare_dishwasher_program_mixed_load
- dishcare_dishwasher_program_learning_dishwasher
- heating_ventilation_air_conditioning_air_conditioner_program_active_clean
- heating_ventilation_air_conditioning_air_conditioner_program_auto
- heating_ventilation_air_conditioning_air_conditioner_program_cool
- heating_ventilation_air_conditioning_air_conditioner_program_dry
- heating_ventilation_air_conditioning_air_conditioner_program_fan
- heating_ventilation_air_conditioning_air_conditioner_program_heat
- laundry_care_dryer_program_cotton
- laundry_care_dryer_program_synthetic
- laundry_care_dryer_program_mix
@@ -142,7 +136,6 @@ set_program_and_options:
- cooking_oven_program_microwave_90_watt
- cooking_oven_program_microwave_180_watt
- cooking_oven_program_microwave_360_watt
- cooking_oven_program_microwave_450_watt
- cooking_oven_program_microwave_600_watt
- cooking_oven_program_microwave_900_watt
- cooking_oven_program_microwave_1000_watt
@@ -184,28 +177,6 @@ set_program_and_options:
- laundry_care_washer_dryer_program_easy_care
- laundry_care_washer_dryer_program_wash_and_dry_60
- laundry_care_washer_dryer_program_wash_and_dry_90
air_conditioner_options:
collapsed: true
fields:
heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_percentage:
example: 50
required: false
selector:
number:
min: 1
max: 100
step: 1
mode: box
unit_of_measurement: "%"
heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode:
required: false
selector:
select:
mode: dropdown
translation_key: fan_speed_mode
options:
- heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic
- heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual
cleaning_robot_options:
collapsed: true
fields:

View File

@@ -252,7 +252,6 @@
"cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
"cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
"cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
"cooking_oven_program_microwave_450_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_450_watt%]",
"cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
@@ -282,12 +281,6 @@
"dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
"dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
"dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
"heating_ventilation_air_conditioning_air_conditioner_program_active_clean": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_active_clean%]",
"heating_ventilation_air_conditioning_air_conditioner_program_auto": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_auto%]",
"heating_ventilation_air_conditioning_air_conditioner_program_cool": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_cool%]",
"heating_ventilation_air_conditioning_air_conditioner_program_dry": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_dry%]",
"heating_ventilation_air_conditioning_air_conditioner_program_fan": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_fan%]",
"heating_ventilation_air_conditioning_air_conditioner_program_heat": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_heat%]",
"laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
"laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
"laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
@@ -450,13 +443,6 @@
"laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]"
}
},
"fan_speed_mode": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode::name%]",
"state": {
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic": "[%key:component::home_connect::selector::fan_speed_mode::options::heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic%]",
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual": "[%key:component::home_connect::selector::fan_speed_mode::options::heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual%]"
}
},
"flow_rate": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]",
"state": {
@@ -589,7 +575,6 @@
"cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
"cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
"cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
"cooking_oven_program_microwave_450_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_450_watt%]",
"cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
@@ -619,12 +604,6 @@
"dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
"dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
"dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
"heating_ventilation_air_conditioning_air_conditioner_program_active_clean": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_active_clean%]",
"heating_ventilation_air_conditioning_air_conditioner_program_auto": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_auto%]",
"heating_ventilation_air_conditioning_air_conditioner_program_cool": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_cool%]",
"heating_ventilation_air_conditioning_air_conditioner_program_dry": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_dry%]",
"heating_ventilation_air_conditioning_air_conditioner_program_fan": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_fan%]",
"heating_ventilation_air_conditioning_air_conditioner_program_heat": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_heat%]",
"laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
"laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
"laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
@@ -1439,12 +1418,6 @@
"laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry"
}
},
"fan_speed_mode": {
"options": {
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic": "Auto",
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual": "Manual"
}
},
"flow_rate": {
"options": {
"consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense",
@@ -1553,7 +1526,6 @@
"cooking_oven_program_microwave_1000_watt": "1000 Watt",
"cooking_oven_program_microwave_180_watt": "180 Watt",
"cooking_oven_program_microwave_360_watt": "360 Watt",
"cooking_oven_program_microwave_450_watt": "450 Watt",
"cooking_oven_program_microwave_600_watt": "600 Watt",
"cooking_oven_program_microwave_900_watt": "900 Watt",
"cooking_oven_program_microwave_90_watt": "90 Watt",
@@ -1583,12 +1555,6 @@
"dishcare_dishwasher_program_quick_65": "Quick 65ºC",
"dishcare_dishwasher_program_steam_fresh": "Steam fresh",
"dishcare_dishwasher_program_super_60": "Super 60ºC",
"heating_ventilation_air_conditioning_air_conditioner_program_active_clean": "Active clean",
"heating_ventilation_air_conditioning_air_conditioner_program_auto": "Auto",
"heating_ventilation_air_conditioning_air_conditioner_program_cool": "Cool",
"heating_ventilation_air_conditioning_air_conditioner_program_dry": "Dry",
"heating_ventilation_air_conditioning_air_conditioner_program_fan": "Fan",
"heating_ventilation_air_conditioning_air_conditioner_program_heat": "Heat",
"laundry_care_dryer_program_anti_shrink": "Anti shrink",
"laundry_care_dryer_program_blankets": "Blankets",
"laundry_care_dryer_program_business_shirts": "Business shirts",
@@ -1857,14 +1823,6 @@
"description": "Defines if the program sequence is optimized with special drying cycle ensures improved drying for glasses, plates and plasticware.",
"name": "Zeolite dry"
},
"heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode": {
"description": "Setting to adjust the fan speed mode to Manual or Auto.",
"name": "Fan speed mode"
},
"heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_percentage": {
"description": "Setting to adjust the venting level of the air conditioner as a percentage.",
"name": "Fan speed percentage"
},
"laundry_care_dryer_option_drying_target": {
"description": "Describes the drying target for a dryer program.",
"name": "Drying target"
@@ -1896,10 +1854,6 @@
},
"name": "Set program and options",
"sections": {
"air_conditioner_options": {
"description": "Specific settings for air conditioners.",
"name": "Air conditioner options"
},
"cleaning_robot_options": {
"description": "Options for cleaning robots.",
"name": "Cleaning robot options"

View File

@@ -62,10 +62,6 @@
},
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_otbr_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
},
@@ -208,10 +204,6 @@
"reconfigure_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
},

View File

@@ -35,5 +35,3 @@ ZIGBEE_FLASHER_ADDON_SLUG = "core_silabs_flasher"
SILABS_MULTIPROTOCOL_ADDON_SLUG = "core_silabs_multiprotocol"
SILABS_FLASHER_ADDON_SLUG = "core_silabs_flasher"
Z2M_EMBER_DOCS_URL = "https://www.zigbee2mqtt.io/guide/adapters/emberznet.html"

View File

@@ -33,7 +33,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from .const import OTBR_DOMAIN, Z2M_EMBER_DOCS_URL, ZHA_DOMAIN
from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import (
ApplicationType,
FirmwareInfo,
@@ -456,7 +456,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
assert self._hardware_name is not None
if self._zigbee_integration == ZigbeeIntegration.OTHER:
return await self.async_step_show_z2m_docs_url()
return self._async_flow_finished()
result = await self.hass.config_entries.flow.async_init(
ZHA_DOMAIN,
@@ -475,21 +475,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
)
return self._continue_zha_flow(result)
async def async_step_show_z2m_docs_url(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show Zigbee2MQTT documentation link."""
if user_input is not None:
return self._async_flow_finished()
return self.async_show_form(
step_id="show_z2m_docs_url",
description_placeholders={
**self._get_translation_placeholders(),
"z2m_docs_url": Z2M_EMBER_DOCS_URL,
},
)
@callback
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
"""Continue the ZHA flow."""

View File

@@ -53,10 +53,6 @@
},
"title": "Pick your protocol"
},
"show_z2m_docs_url": {
"description": "Your {model} is now running the latest Zigbee firmware.\nPlease read the Zigbee2MQTT documentation for EmberZNet adapters and copy the config for your {model}: {z2m_docs_url}",
"title": "Set up Zigbee2MQTT"
},
"start_otbr_addon": {
"title": "Configuring Thread"
},

View File

@@ -62,10 +62,6 @@
},
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_otbr_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
},
@@ -208,10 +204,6 @@
"reconfigure_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
},

View File

@@ -138,10 +138,6 @@
"reconfigure_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
},

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Any
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
from homematicip.base.functionalChannels import MultiModeInputChannel
from homematicip.device import (
AccelerationSensor,
ContactInterface,
@@ -88,11 +87,8 @@ async def async_setup_entry(
entities.append(HomematicipTiltVibrationSensor(hap, device))
if isinstance(device, WiredInput32):
entities.extend(
HomematicipMultiContactInterface(
hap, device, channel_real_index=channel.index
)
for channel in device.functionalChannels
if isinstance(channel, MultiModeInputChannel)
HomematicipMultiContactInterface(hap, device, channel=channel)
for channel in range(1, 33)
)
elif isinstance(device, FullFlushContactInterface6):
entities.extend(
@@ -231,24 +227,21 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt
device,
channel=1,
is_multi_channel=True,
channel_real_index=None,
) -> None:
"""Initialize the multi contact entity."""
super().__init__(
hap,
device,
channel=channel,
is_multi_channel=is_multi_channel,
channel_real_index=channel_real_index,
hap, device, channel=channel, is_multi_channel=is_multi_channel
)
@property
def is_on(self) -> bool | None:
"""Return true if the contact interface is on/open."""
channel = self.get_channel_or_raise()
if channel.windowState is None:
if self._device.functionalChannels[self._channel].windowState is None:
return None
return channel.windowState != WindowState.CLOSED
return (
self._device.functionalChannels[self._channel].windowState
!= WindowState.CLOSED
)
class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensorEntity):

View File

@@ -283,23 +283,19 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
channel = self.get_channel_or_raise()
return channel.doorState == DoorState.CLOSED
return self.functional_channel.doorState == DoorState.CLOSED
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
channel = self.get_channel_or_raise()
await channel.async_send_door_command(DoorCommand.OPEN)
await self.functional_channel.async_send_door_command(DoorCommand.OPEN)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
channel = self.get_channel_or_raise()
await channel.async_send_door_command(DoorCommand.CLOSE)
await self.functional_channel.async_send_door_command(DoorCommand.CLOSE)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
channel = self.get_channel_or_raise()
await channel.async_send_door_command(DoorCommand.STOP)
await self.functional_channel.async_send_door_command(DoorCommand.STOP)
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import contextlib
import logging
from typing import Any
@@ -85,7 +84,6 @@ class HomematicipGenericEntity(Entity):
post: str | None = None,
channel: int | None = None,
is_multi_channel: bool | None = False,
channel_real_index: int | None = None,
) -> None:
"""Initialize the generic entity."""
self._hap = hap
@@ -93,19 +91,8 @@ class HomematicipGenericEntity(Entity):
self._device = device
self._post = post
self._channel = channel
# channel_real_index represents the actual index of the devices channel.
# Accessing a functionalChannel by the channel parameter or array index is unreliable,
# because the functionalChannels array is sorted as strings, not numbers.
# For example, channels are ordered as: 1, 10, 11, 12, 2, 3, ...
# Using channel_real_index ensures you reference the correct channel.
self._channel_real_index: int | None = channel_real_index
self._is_multi_channel = is_multi_channel
self.functional_channel = None
with contextlib.suppress(ValueError):
self.functional_channel = self.get_current_channel()
self.functional_channel = self.get_current_channel()
# Marker showing that the HmIP device hase been removed.
self.hmip_device_removed = False
@@ -114,20 +101,17 @@ class HomematicipGenericEntity(Entity):
"""Return device specific attributes."""
# Only physical devices should be HA devices.
if isinstance(self._device, Device):
device_id = str(self._device.id)
home_id = str(self._device.homeId)
return DeviceInfo(
identifiers={
# Serial numbers of Homematic IP device
(DOMAIN, device_id)
(DOMAIN, self._device.id)
},
manufacturer=self._device.oem,
model=self._device.modelType,
name=self._device.label,
sw_version=self._device.firmwareVersion,
# Link to the homematic ip access point.
via_device=(DOMAIN, home_id),
via_device=(DOMAIN, self._device.homeId),
)
return None
@@ -201,31 +185,25 @@ class HomematicipGenericEntity(Entity):
def name(self) -> str:
"""Return the name of the generic entity."""
name = ""
name = None
# Try to get a label from a channel.
functional_channels = getattr(self._device, "functionalChannels", None)
if functional_channels and self.functional_channel:
if hasattr(self._device, "functionalChannels"):
if self._is_multi_channel:
label = getattr(self.functional_channel, "label", None)
if label:
name = str(label)
elif len(functional_channels) > 1:
label = getattr(functional_channels[1], "label", None)
if label:
name = str(label)
name = self._device.functionalChannels[self._channel].label
elif len(self._device.functionalChannels) > 1:
name = self._device.functionalChannels[1].label
# Use device label, if name is not defined by channel label.
if not name:
name = self._device.label or ""
name = self._device.label
if self._post:
name = f"{name} {self._post}"
elif self._is_multi_channel:
name = f"{name} Channel{self.get_channel_index()}"
name = f"{name} Channel{self._channel}"
# Add a prefix to the name if the homematic ip home has a name.
home_name = getattr(self._home, "name", None)
if name and home_name:
name = f"{home_name} {name}"
if name and self._home.name:
name = f"{self._home.name} {name}"
return name
@@ -239,7 +217,9 @@ class HomematicipGenericEntity(Entity):
"""Return a unique ID."""
unique_id = f"{self.__class__.__name__}_{self._device.id}"
if self._is_multi_channel:
unique_id = f"{self.__class__.__name__}_Channel{self.get_channel_index()}_{self._device.id}"
unique_id = (
f"{self.__class__.__name__}_Channel{self._channel}_{self._device.id}"
)
return unique_id
@@ -274,65 +254,12 @@ class HomematicipGenericEntity(Entity):
return state_attr
def get_current_channel(self) -> FunctionalChannel:
"""Return the FunctionalChannel for the device.
"""Return the FunctionalChannel for device."""
if hasattr(self._device, "functionalChannels"):
if self._is_multi_channel:
return self._device.functionalChannels[self._channel]
Resolution priority:
1. For multi-channel entities with a real index, find channel by index match.
2. For multi-channel entities without a real index, use the provided channel position.
3. For non multi-channel entities with >1 channels, use channel at position 1
(index 0 is often a meta/service channel in HmIP).
Raises ValueError if no suitable channel can be resolved.
"""
functional_channels = getattr(self._device, "functionalChannels", None)
if not functional_channels:
raise ValueError(
f"Device {getattr(self._device, 'id', 'unknown')} has no functionalChannels"
)
if len(self._device.functionalChannels) > 1:
return self._device.functionalChannels[1]
# Multi-channel handling
if self._is_multi_channel:
# Prefer real index mapping when provided to avoid ordering issues.
if self._channel_real_index is not None:
for channel in functional_channels:
if channel.index == self._channel_real_index:
return channel
raise ValueError(
f"Real channel index {self._channel_real_index} not found for device "
f"{getattr(self._device, 'id', 'unknown')}"
)
# Fallback: positional channel (already sorted as strings upstream).
if self._channel is not None and 0 <= self._channel < len(
functional_channels
):
return functional_channels[self._channel]
raise ValueError(
f"Channel position {self._channel} invalid for device "
f"{getattr(self._device, 'id', 'unknown')} (len={len(functional_channels)})"
)
# Single-channel / non multi-channel entity: choose second element if available
if len(functional_channels) > 1:
return functional_channels[1]
return functional_channels[0]
def get_channel_index(self) -> int:
"""Return the correct channel index for this entity.
Prefers channel_real_index if set, otherwise returns channel.
This ensures the correct channel is used even if the functionalChannels list is not numerically ordered.
"""
if self._channel_real_index is not None:
return self._channel_real_index
if self._channel is not None:
return self._channel
return 1
def get_channel_or_raise(self) -> FunctionalChannel:
"""Return the FunctionalChannel or raise an error if not found."""
if not self.functional_channel:
raise ValueError(
f"No functional channel found for device {getattr(self._device, 'id', 'unknown')}"
)
return self.functional_channel
return None

View File

@@ -92,9 +92,7 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
channel = self.get_channel_or_raise()
channel.add_on_channel_event_handler(self._async_handle_event)
self.functional_channel.add_on_channel_event_handler(self._async_handle_event)
@callback
def _async_handle_event(self, *args, **kwargs) -> None:

View File

@@ -134,49 +134,49 @@ class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
@property
def is_on(self) -> bool:
"""Return true if light is on."""
channel = self.get_channel_or_raise()
return channel.on
return self.functional_channel.on
@property
def brightness(self) -> int | None:
"""Return the current brightness."""
channel = self.get_channel_or_raise()
return int(channel.dimLevel * 255.0)
return int(self.functional_channel.dimLevel * 255.0)
@property
def hs_color(self) -> tuple[float, float] | None:
"""Return the hue and saturation color value [float, float]."""
channel = self.get_channel_or_raise()
if channel.hue is None or channel.saturationLevel is None:
if (
self.functional_channel.hue is None
or self.functional_channel.saturationLevel is None
):
return None
return (
channel.hue,
channel.saturationLevel * 100.0,
self.functional_channel.hue,
self.functional_channel.saturationLevel * 100.0,
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
channel = self.get_channel_or_raise()
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
hue = hs_color[0] % 360.0
saturation = hs_color[1] / 100.0
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
if ATTR_HS_COLOR not in kwargs:
hue = channel.hue
saturation = channel.saturationLevel
hue = self.functional_channel.hue
saturation = self.functional_channel.saturationLevel
if ATTR_BRIGHTNESS not in kwargs:
# If no brightness is set, use the current brightness
dim_level = channel.dimLevel or 1.0
await channel.set_hue_saturation_dim_level_async(
dim_level = self.functional_channel.dimLevel or 1.0
await self.functional_channel.set_hue_saturation_dim_level_async(
hue=hue, saturation_level=saturation, dim_level=dim_level
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
channel = self.get_channel_or_raise()
await channel.set_switch_state_async(on=False)
await self.functional_channel.set_switch_state_async(on=False)
class HomematicipLightMeasuring(HomematicipLight):

View File

@@ -307,8 +307,7 @@ class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity):
@property
def native_value(self) -> float | None:
"""Return the state."""
channel = self.get_channel_or_raise()
return channel.waterFlow
return self.functional_channel.waterFlow
class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity):

View File

@@ -113,18 +113,15 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""Return true if switch is on."""
channel = self.get_channel_or_raise()
return channel.on
return self.functional_channel.on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
channel = self.get_channel_or_raise()
await channel.async_turn_on()
await self.functional_channel.async_turn_on()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
channel = self.get_channel_or_raise()
await channel.async_turn_off()
await self.functional_channel.async_turn_off()
class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity):

View File

@@ -47,16 +47,13 @@ class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity):
async def async_open_valve(self) -> None:
"""Open the valve."""
channel = self.get_channel_or_raise()
await channel.set_watering_switch_state_async(True)
await self.functional_channel.set_watering_switch_state_async(True)
async def async_close_valve(self) -> None:
"""Close valve."""
channel = self.get_channel_or_raise()
await channel.set_watering_switch_state_async(False)
await self.functional_channel.set_watering_switch_state_async(False)
@property
def is_closed(self) -> bool:
"""Return if the valve is closed."""
channel = self.get_channel_or_raise()
return channel.wateringActive is False
return self.functional_channel.wateringActive is False

View File

@@ -287,11 +287,7 @@ class DashboardsCollection(collection.DictStorageCollection):
raise vol.Invalid("Url path needs to contain a hyphen (-)")
if url_path in self.hass.data[DATA_PANELS]:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="url_already_exists",
translation_placeholders={"url": url_path},
)
raise vol.Invalid("Panel url path needs to be unique")
return self.CREATE_SCHEMA(data) # type: ignore[no-any-return]

View File

@@ -1,9 +1,4 @@
{
"exceptions": {
"url_already_exists": {
"message": "The URL \"{url}\" is already in use. Please choose a different one."
}
},
"services": {
"reload_resources": {
"description": "Reloads dashboard resources from the YAML-configuration.",

View File

@@ -25,7 +25,6 @@ async def async_get_config_entry_diagnostics(
"scenes": bridge.scenes,
"occupancy_groups": bridge.occupancy_groups,
"areas": bridge.areas,
"smart_away_state": bridge.smart_away_state,
},
"integration_data": {
"keypad_button_names_to_leap": data.keypad_data.button_names_to_leap,

View File

@@ -5,12 +5,9 @@ from typing import Any
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import LutronCasetaEntity, LutronCasetaUpdatableEntity
from .models import LutronCasetaData
from .entity import LutronCasetaUpdatableEntity
async def async_setup_entry(
@@ -26,14 +23,9 @@ async def async_setup_entry(
data = config_entry.runtime_data
bridge = data.bridge
switch_devices = bridge.get_devices_by_domain(SWITCH_DOMAIN)
entities: list[LutronCasetaLight | LutronCasetaSmartAwaySwitch] = [
async_add_entities(
LutronCasetaLight(switch_device, data) for switch_device in switch_devices
]
if bridge.smart_away_state != "":
entities.append(LutronCasetaSmartAwaySwitch(data))
async_add_entities(entities)
)
class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity):
@@ -69,46 +61,3 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity):
def is_on(self) -> bool:
"""Return true if device is on."""
return self._device["current_state"] > 0
class LutronCasetaSmartAwaySwitch(LutronCasetaEntity, SwitchEntity):
"""Representation of Lutron Caseta Smart Away."""
def __init__(self, data: LutronCasetaData) -> None:
"""Init a switch entity."""
device = {
"device_id": "smart_away",
"name": "Smart Away",
"type": "SmartAway",
"model": "Smart Away",
"area": data.bridge_device["area"],
"serial": data.bridge_device["serial"],
}
super().__init__(device, data)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.bridge_device["serial"])},
)
self._smart_away_unique_id = f"{self._bridge_unique_id}_smart_away"
@property
def unique_id(self) -> str:
"""Return the unique ID of the smart away switch."""
return self._smart_away_unique_id
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
self._smartbridge.add_smart_away_subscriber(self._handle_bridge_update)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn Smart Away on."""
await self._smartbridge.activate_smart_away()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn Smart Away off."""
await self._smartbridge.deactivate_smart_away()
@property
def is_on(self) -> bool:
"""Return true if Smart Away is on."""
return self._smartbridge.smart_away_state == "Enabled"

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
"quality_scale": "legacy",
"requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0", "aiofiles==24.1.0"]
"requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0"]
}

View File

@@ -40,7 +40,6 @@ from homeassistant.util.async_ import create_eager_task
from . import debug_info, discovery
from .client import (
MQTT,
async_on_subscribe_done,
async_publish,
async_subscribe,
async_subscribe_internal,
@@ -164,7 +163,6 @@ __all__ = [
"async_create_certificate_temp_files",
"async_forward_entry_setup_and_setup_discovery",
"async_migrate_entry",
"async_on_subscribe_done",
"async_prepare_subscribe_topics",
"async_publish",
"async_remove_config_entry_device",

View File

@@ -38,10 +38,7 @@ from homeassistant.core import (
get_hassjob_callable_job_type,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.importlib import async_import_module
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
@@ -74,7 +71,6 @@ from .const import (
DEFAULT_WS_PATH,
DOMAIN,
MQTT_CONNECTION_STATE,
MQTT_PROCESSED_SUBSCRIPTIONS,
PROTOCOL_5,
PROTOCOL_31,
TRANSPORT_WEBSOCKETS,
@@ -113,7 +109,6 @@ INITIAL_SUBSCRIBE_COOLDOWN = 0.5
SUBSCRIBE_COOLDOWN = 0.1
UNSUBSCRIBE_COOLDOWN = 0.1
TIMEOUT_ACK = 10
SUBSCRIBE_TIMEOUT = 10
RECONNECT_INTERVAL_SECONDS = 10
MAX_WILDCARD_SUBSCRIBES_PER_CALL = 1
@@ -189,38 +184,6 @@ async def async_publish(
)
@callback
def async_on_subscribe_done(
hass: HomeAssistant,
topic: str,
qos: int,
on_subscribe_status: CALLBACK_TYPE,
) -> CALLBACK_TYPE:
"""Call on_subscribe_done when the matched subscription was completed.
If a subscription is already present the callback will call
on_subscribe_status directly.
Call the returned callback to stop and cleanup status monitoring.
"""
async def _sync_mqtt_subscribe(subscriptions: list[tuple[str, int]]) -> None:
if (topic, qos) not in subscriptions:
return
hass.loop.call_soon(on_subscribe_status)
mqtt_data = hass.data[DATA_MQTT]
if (
mqtt_data.client.connected
and mqtt_data.client.is_active_subscription(topic)
and not mqtt_data.client.is_pending_subscription(topic)
):
hass.loop.call_soon(on_subscribe_status)
return async_dispatcher_connect(
hass, MQTT_PROCESSED_SUBSCRIPTIONS, _sync_mqtt_subscribe
)
@bind_hass
async def async_subscribe(
hass: HomeAssistant,
@@ -228,32 +191,12 @@ async def async_subscribe(
msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None],
qos: int = DEFAULT_QOS,
encoding: str | None = DEFAULT_ENCODING,
on_subscribe: CALLBACK_TYPE | None = None,
) -> CALLBACK_TYPE:
"""Subscribe to an MQTT topic.
If the on_subcribe callback hook is set, it will be called once
when the subscription has been completed.
Call the return value to unsubscribe.
"""
handler: CALLBACK_TYPE | None = None
def _on_subscribe_done() -> None:
"""Call once when the subscription was completed."""
if TYPE_CHECKING:
assert on_subscribe is not None and handler is not None
handler()
on_subscribe()
subscription_handler = async_subscribe_internal(
hass, topic, msg_callback, qos, encoding
)
if on_subscribe is not None:
handler = async_on_subscribe_done(hass, topic, qos, _on_subscribe_done)
return subscription_handler
return async_subscribe_internal(hass, topic, msg_callback, qos, encoding)
@callback
@@ -697,16 +640,12 @@ class MQTT:
if fileno > -1:
self.loop.remove_writer(sock)
def is_active_subscription(self, topic: str) -> bool:
def _is_active_subscription(self, topic: str) -> bool:
"""Check if a topic has an active subscription."""
return topic in self._simple_subscriptions or any(
other.topic == topic for other in self._wildcard_subscriptions
)
def is_pending_subscription(self, topic: str) -> bool:
"""Check if a topic has a pending subscription."""
return topic in self._pending_subscriptions
async def async_publish(
self, topic: str, payload: PublishPayloadType, qos: int, retain: bool
) -> None:
@@ -960,7 +899,7 @@ class MQTT:
@callback
def _async_unsubscribe(self, topic: str) -> None:
"""Unsubscribe from a topic."""
if self.is_active_subscription(topic):
if self._is_active_subscription(topic):
if self._max_qos[topic] == 0:
return
subs = self._matching_subscriptions(topic)
@@ -1024,7 +963,6 @@ class MQTT:
self._last_subscribe = time.monotonic()
await self._async_wait_for_mid_or_raise(mid, result)
async_dispatcher_send(self.hass, MQTT_PROCESSED_SUBSCRIPTIONS, chunk_list)
async def _async_perform_unsubscribes(self) -> None:
"""Perform pending MQTT client unsubscribes."""

View File

@@ -375,7 +375,6 @@ DOMAIN = "mqtt"
LOGGER = logging.getLogger(__package__)
MQTT_CONNECTION_STATE = "mqtt_connection_state"
MQTT_PROCESSED_SUBSCRIPTIONS = "mqtt_processed_subscriptions"
PAYLOAD_EMPTY_JSON = "{}"
PAYLOAD_NONE = "None"

View File

@@ -306,14 +306,6 @@ class OllamaSubentryFlowHandler(ConfigSubentryFlow):
async_step_reconfigure = async_step_set_options
def filter_invalid_llm_apis(hass: HomeAssistant, selected_apis: list[str]) -> list[str]:
"""Accepts a list of LLM API IDs and filters this against those currently available."""
valid_llm_apis = [api.id for api in llm.async_get_apis(hass)]
return [api for api in selected_apis if api in valid_llm_apis]
def ollama_config_option_schema(
hass: HomeAssistant,
is_new: bool,
@@ -334,10 +326,6 @@ def ollama_config_option_schema(
else:
schema = {}
selected_llm_apis = filter_invalid_llm_apis(
hass, options.get(CONF_LLM_HASS_API, [])
)
schema.update(
{
vol.Required(
@@ -361,7 +349,7 @@ def ollama_config_option_schema(
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": selected_llm_apis},
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
): SelectSelector(
SelectSelectorConfig(
options=[

View File

@@ -60,7 +60,6 @@ from .coordinator import (
from .repairs import (
async_manage_ble_scanner_firmware_unsupported_issue,
async_manage_deprecated_firmware_issue,
async_manage_open_wifi_ap_issue,
async_manage_outbound_websocket_incorrectly_enabled_issue,
)
from .utils import (
@@ -348,7 +347,6 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
hass,
entry,
)
async_manage_open_wifi_ap_issue(hass, entry)
remove_empty_sub_devices(hass, entry)
elif (
sleep_period is None

View File

@@ -5,18 +5,14 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, Final, cast
from typing import TYPE_CHECKING, Any, Final
from aioshelly.ble import get_name_from_model_id
from aioshelly.ble.manufacturer_data import (
has_rpc_over_ble,
parse_shelly_manufacturer_data,
)
from aioshelly.ble.provisioning import (
async_provision_wifi,
async_scan_wifi_networks,
ble_rpc_device,
)
from aioshelly.ble.provisioning import async_provision_wifi, async_scan_wifi_networks
from aioshelly.block_device import BlockDevice
from aioshelly.common import ConnectionOptions, get_info
from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS
@@ -37,7 +33,6 @@ from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_ble_device_from_address,
async_clear_address_from_match_history,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
@@ -103,32 +98,6 @@ BLE_SCANNER_OPTIONS = [
INTERNAL_WIFI_AP_IP = "192.168.33.1"
async def async_get_ip_from_ble(ble_device: BLEDevice) -> str | None:
"""Get device IP address via BLE after WiFi provisioning.
Args:
ble_device: BLE device to query
Returns:
IP address string if available, None otherwise
"""
try:
async with ble_rpc_device(ble_device) as device:
await device.update_status()
if (
(wifi := device.status.get("wifi"))
and isinstance(wifi, dict)
and (ip := wifi.get("sta_ip"))
):
return cast(str, ip)
return None
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to get IP via BLE: %s", err)
return None
# BLE provisioning flow steps that are in the finishing state
# Used to determine if a BLE flow should be aborted when zeroconf discovers the device
BLUETOOTH_FINISHING_STEPS = {"do_provision", "provision_done"}
@@ -426,14 +395,6 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
if not mac:
return self.async_abort(reason="invalid_discovery_info")
# Clear match history at the start of discovery flow.
# This ensures that if the user never provisions the device and it
# disappears (powers down), the discovery flow gets cleaned up,
# and then the device comes back later, it can be rediscovered.
# Also handles factory reset scenarios where the device may reappear
# with different advertisement content (RPC-over-BLE re-enabled).
async_clear_address_from_match_history(self.hass, discovery_info.address)
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
if not has_rpc_over_ble(discovery_info.manufacturer_data):
LOGGER.debug(
@@ -678,21 +639,13 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
aiozc = await zeroconf.async_get_async_instance(self.hass)
result = await async_lookup_device_by_name(aiozc, self.device_name)
# If we still don't have a host, try BLE fallback for alternate subnets
# If we still don't have a host, provisioning failed
if not result:
LOGGER.debug(
"Active lookup failed, trying to get IP address via BLE as fallback"
)
if ip := await async_get_ip_from_ble(self.ble_device):
LOGGER.debug("Got IP %s from BLE, using it", ip)
state.host = ip
state.port = DEFAULT_HTTP_PORT
else:
LOGGER.debug("BLE fallback also failed - provisioning unsuccessful")
# Store failure info and return None - provision_done will handle redirect
return None
else:
state.host, state.port = result
LOGGER.debug("Active lookup failed - provisioning unsuccessful")
# Store failure info and return None - provision_done will handle redirect
return None
state.host, state.port = result
else:
LOGGER.debug(
"Zeroconf discovery received for device after WiFi provisioning at %s",
@@ -732,13 +685,6 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
# Secure device after provisioning if requested (disable AP/BLE)
await self._async_secure_device_after_provision(self.host, self.port)
# Clear match history so device can be rediscovered if factory reset
# This ensures that if the device is factory reset in the future
# (re-enabling BLE provisioning), it will trigger a new discovery flow
if TYPE_CHECKING:
assert self.ble_device is not None
async_clear_address_from_match_history(self.hass, self.ble_device.address)
# User just provisioned this device - create entry directly without confirmation
return self.async_create_entry(
title=device_info["title"],

View File

@@ -254,7 +254,6 @@ OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = (
"outbound_websocket_incorrectly_enabled_{unique}"
)
DEPRECATED_FIRMWARE_ISSUE_ID = "deprecated_firmware_{unique}"
OPEN_WIFI_AP_ISSUE_ID = "open_wifi_ap_{unique}"
class DeprecatedFirmwareInfo(TypedDict):

View File

@@ -20,14 +20,14 @@ from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, ROLE_GENERIC
from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .utils import (
async_remove_shelly_entity,
get_block_device_info,
get_rpc_channel_name,
get_block_entity_name,
get_rpc_device_info,
get_rpc_key,
get_rpc_entity_name,
get_rpc_key_instances,
get_rpc_role_by_key,
)
@@ -371,7 +371,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]):
"""Initialize Shelly entity."""
super().__init__(coordinator)
self.block = block
self._attr_name = get_block_entity_name(coordinator.device, block)
self._attr_device_info = get_entity_block_device_info(coordinator, block)
self._attr_unique_id = f"{coordinator.mac}-{block.description}"
@@ -413,9 +413,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]):
"""Initialize Shelly entity."""
super().__init__(coordinator)
self.key = key
self._attr_device_info = get_entity_rpc_device_info(coordinator, key)
self._attr_unique_id = f"{coordinator.mac}-{key}"
self._attr_name = get_rpc_entity_name(coordinator.device, key)
@property
def available(self) -> bool:
@@ -467,6 +467,9 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity):
self.entity_description = description
self._attr_unique_id: str = f"{super().unique_id}-{self.attribute}"
self._attr_name = get_block_entity_name(
coordinator.device, block, description.name
)
@property
def attribute_value(self) -> StateType:
@@ -504,7 +507,9 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]):
self.block_coordinator = coordinator
self.attribute = attribute
self.entity_description = description
self._attr_name = get_block_entity_name(
coordinator.device, None, description.name
)
self._attr_unique_id = f"{coordinator.mac}-{attribute}"
self._attr_device_info = get_entity_block_device_info(coordinator)
self._last_value = None
@@ -541,13 +546,13 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, Entity):
self.attribute = attribute
self.entity_description = description
if description.role == ROLE_GENERIC:
self._attr_name = get_rpc_channel_name(coordinator.device, key)
self._attr_unique_id = f"{super().unique_id}-{attribute}"
self._attr_name = get_rpc_entity_name(
coordinator.device, key, description.name, description.role
)
self._last_value = None
has_id, _, component_id = get_rpc_key(key)
self._id = int(component_id) if has_id and component_id.isnumeric() else None
id_key = key.split(":")[-1]
self._id = int(id_key) if id_key.isnumeric() else None
if description.unit is not None:
self._attr_native_unit_of_measurement = description.unit(
@@ -621,6 +626,9 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
self._attr_unique_id = (
f"{self.coordinator.mac}-{block.description}-{attribute}"
)
self._attr_name = get_block_entity_name(
coordinator.device, block, description.name
)
elif entry is not None:
self._attr_unique_id = entry.unique_id
@@ -681,7 +689,11 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity):
self._attr_unique_id = f"{coordinator.mac}-{key}-{attribute}"
self._last_value = None
if not coordinator.device.initialized and entry is not None:
if coordinator.device.initialized:
self._attr_name = get_rpc_entity_name(
coordinator.device, key, description.name
)
elif entry is not None:
self._attr_name = cast(str, entry.original_name)
async def async_update(self) -> None:

View File

@@ -17,7 +17,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
"requirements": ["aioshelly==13.21.0"],
"requirements": ["aioshelly==13.20.0"],
"zeroconf": [
{
"name": "shelly*",

View File

@@ -22,7 +22,6 @@ from .const import (
DEPRECATED_FIRMWARE_ISSUE_ID,
DEPRECATED_FIRMWARES,
DOMAIN,
OPEN_WIFI_AP_ISSUE_ID,
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
BLEScannerMode,
)
@@ -150,45 +149,6 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
ir.async_delete_issue(hass, DOMAIN, issue_id)
@callback
def async_manage_open_wifi_ap_issue(
hass: HomeAssistant,
entry: ShellyConfigEntry,
) -> None:
"""Manage the open WiFi AP issue."""
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=entry.unique_id)
if TYPE_CHECKING:
assert entry.runtime_data.rpc is not None
device = entry.runtime_data.rpc.device
# Check if WiFi AP is enabled and is open (no password)
if (
(wifi_config := device.config.get("wifi"))
and (ap_config := wifi_config.get("ap"))
and ap_config.get("enable")
and ap_config.get("is_open")
):
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=True,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="open_wifi_ap",
translation_placeholders={
"device_name": device.name,
"ip_address": device.ip_address,
},
data={"entry_id": entry.entry_id},
)
return
ir.async_delete_issue(hass, DOMAIN, issue_id)
class ShellyRpcRepairsFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
@@ -269,49 +229,6 @@ class DisableOutboundWebSocketFlow(ShellyRpcRepairsFlow):
return self.async_create_entry(title="", data={})
class DisableOpenWiFiApFlow(RepairsFlow):
"""Handler for Disable Open WiFi AP flow."""
def __init__(self, device: RpcDevice, issue_id: str) -> None:
"""Initialize."""
self._device = device
self.issue_id = issue_id
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
issue_registry = ir.async_get(self.hass)
description_placeholders = None
if issue := issue_registry.async_get_issue(DOMAIN, self.issue_id):
description_placeholders = issue.translation_placeholders
return self.async_show_menu(
menu_options=["confirm", "ignore"],
description_placeholders=description_placeholders,
)
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
try:
result = await self._device.wifi_setconfig(ap_enable=False)
if result.get("restart_required"):
await self._device.trigger_reboot()
except (DeviceConnectionError, RpcCallError):
return self.async_abort(reason="cannot_connect")
return self.async_create_entry(title="", data={})
async def async_step_ignore(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the ignore step of a fix flow."""
ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True)
return self.async_abort(reason="issue_ignored")
async def async_create_fix_flow(
hass: HomeAssistant, issue_id: str, data: dict[str, str] | None
) -> RepairsFlow:
@@ -336,7 +253,4 @@ async def async_create_fix_flow(
if "outbound_websocket_incorrectly_enabled" in issue_id:
return DisableOutboundWebSocketFlow(device)
if "open_wifi_ap" in issue_id:
return DisableOpenWiFiApFlow(device, issue_id)
return ConfirmRepairFlow()

View File

@@ -59,6 +59,9 @@ class RpcSelect(ShellyRpcAttributeEntity, SelectEntity):
if self.option_map:
self._attr_options = list(self.option_map.values())
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
delattr(self, "_attr_name")
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""

View File

@@ -664,25 +664,6 @@
"description": "Shelly device {device_name} with IP address {ip_address} requires calibration. To calibrate the device, it must be rebooted after proper installation on the valve. You can reboot the device in its web panel, go to 'Settings' > 'Device Reboot'.",
"title": "Shelly device {device_name} is not calibrated"
},
"open_wifi_ap": {
"fix_flow": {
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"issue_ignored": "Issue ignored"
},
"step": {
"init": {
"description": "Your Shelly device {device_name} with IP address {ip_address} has an open WiFi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.",
"menu_options": {
"confirm": "Disable WiFi access point",
"ignore": "Ignore"
},
"title": "[%key:component::shelly::issues::open_wifi_ap::title%]"
}
}
},
"title": "Open WiFi access point on {device_name}"
},
"outbound_websocket_incorrectly_enabled": {
"fix_flow": {
"abort": {

View File

@@ -49,6 +49,7 @@ from homeassistant.helpers.device_registry import (
DeviceInfo,
)
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util.dt import utcnow
from .const import (
@@ -116,6 +117,20 @@ def get_block_number_of_channels(device: BlockDevice, block: Block) -> int:
return channels or 1
def get_block_entity_name(
device: BlockDevice,
block: Block | None,
name: str | UndefinedType | None = None,
) -> str | None:
"""Naming for block based switch and sensors."""
channel_name = get_block_channel_name(device, block)
if name is not UNDEFINED and name:
return f"{channel_name} {name.lower()}" if channel_name else name
return channel_name
def get_block_custom_name(device: BlockDevice, block: Block | None) -> str | None:
"""Get custom name from device settings."""
if block and (key := cast(str, block.type) + "s") and key in device.settings:
@@ -459,6 +474,23 @@ def get_rpc_sub_device_name(
return f"{device.name} {component.title()} {component_id}"
def get_rpc_entity_name(
device: RpcDevice,
key: str,
name: str | UndefinedType | None = None,
role: str | None = None,
) -> str | None:
"""Naming for RPC based switch and sensors."""
channel_name = get_rpc_channel_name(device, key)
if name is not UNDEFINED and name:
if role and role != ROLE_GENERIC:
return name
return f"{channel_name} {name.lower()}" if channel_name else name
return channel_name
def get_entity_translation_attributes(
channel_name: str | None,
translation_key: str | None,
@@ -794,9 +826,11 @@ async def get_rpc_scripts_event_types(
device: RpcDevice, ignore_scripts: list[str]
) -> dict[int, list[str]]:
"""Return a dict of all scripts and their event types."""
script_instances = get_rpc_key_instances(device.status, "script")
script_events = {}
for script in get_rpc_key_instances(device.status, "script"):
if get_rpc_channel_name(device, script) in ignore_scripts:
for script in script_instances:
script_name = get_rpc_entity_name(device, script)
if script_name in ignore_scripts:
continue
script_id = get_rpc_key_id(script)

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["slack"],
"requirements": ["slack_sdk==3.33.4", "aiofiles==24.1.0"]
"requirements": ["slack_sdk==3.33.4"]
}

View File

@@ -16,7 +16,6 @@ from homeassistant.const import (
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
@@ -165,15 +164,6 @@ WALL_CONNECTOR_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
WallConnectorSensorDescription(
key="total_power_w",
translation_key="total_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].total_power_w,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
WallConnectorSensorDescription(
key="session_energy_wh",
translation_key="session_energy_wh",

View File

@@ -75,9 +75,6 @@
"status_code": {
"name": "Status code"
},
"total_power_w": {
"name": "Total power"
},
"voltage_a_v": {
"name": "Phase A voltage"
},

View File

@@ -123,9 +123,7 @@ async def async_setup_entry(
ThermoProBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class ThermoProBluetoothSensorEntity(

View File

@@ -22,7 +22,6 @@ ATTR_FOLLOW_SINCE = "following_since"
ATTR_FOLLOWING = "followers"
ATTR_VIEWERS = "viewers"
ATTR_STARTED_AT = "started_at"
ATTR_CHANNEL_PICTURE = "channel_picture"
STATE_OFFLINE = "offline"
STATE_STREAMING = "streaming"
@@ -83,7 +82,6 @@ class TwitchSensor(CoordinatorEntity[TwitchCoordinator], SensorEntity):
ATTR_STARTED_AT: channel.started_at,
ATTR_VIEWERS: channel.viewers,
ATTR_SUBSCRIPTION: False,
ATTR_CHANNEL_PICTURE: channel.picture,
}
if channel.subscribed is not None:
resp[ATTR_SUBSCRIPTION] = channel.subscribed

View File

@@ -14,6 +14,7 @@ from homeassistant.core import (
ServiceResponse,
SupportsResponse,
)
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.location import find_coordinates
from homeassistant.helpers.selector import (
BooleanSelector,
@@ -46,7 +47,6 @@ from .const import (
VEHICLE_TYPES,
)
from .coordinator import WazeTravelTimeCoordinator, async_get_travel_times
from .httpx_client import create_httpx_client
PLATFORMS = [Platform.SENSOR]
@@ -106,8 +106,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}):
hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1)
httpx_client = await create_httpx_client(hass)
httpx_client = get_async_client(hass)
client = WazeRouteCalculator(
region=config_entry.data[CONF_REGION].upper(), client=httpx_client
)
@@ -120,7 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async def async_get_travel_times_service(service: ServiceCall) -> ServiceResponse:
httpx_client = await create_httpx_client(hass)
httpx_client = get_async_client(hass)
client = WazeRouteCalculator(
region=service.data[CONF_REGION].upper(), client=httpx_client
)

View File

@@ -1,26 +0,0 @@
"""Special httpx client for Waze Travel Time integration."""
import httpx
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import create_async_httpx_client
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
DATA_HTTPX_ASYNC_CLIENT: HassKey[httpx.AsyncClient] = HassKey("httpx_async_client")
def create_transport() -> httpx.AsyncHTTPTransport:
"""Create a httpx transport which enforces the use of IPv4."""
return httpx.AsyncHTTPTransport(local_address="0.0.0.0")
async def create_httpx_client(hass: HomeAssistant) -> httpx.AsyncClient:
"""Create a httpx client which enforces the use of IPv4."""
if (client := hass.data[DOMAIN].get(DATA_HTTPX_ASYNC_CLIENT)) is None:
transport = await hass.async_add_executor_job(create_transport)
client = hass.data[DOMAIN][DATA_HTTPX_ASYNC_CLIENT] = create_async_httpx_client(
hass, transport=transport
)
return client

View File

@@ -99,13 +99,10 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
def _async_update_attrs(self) -> None:
"""Handle updating _attr values."""
state = self._device.state
if (brightness := state.get_brightness()) is not None:
self._attr_brightness = max(0, min(255, brightness))
color_modes = self.supported_color_modes
assert color_modes is not None
if (brightness := state.get_brightness()) is not None:
self._attr_brightness = max(0, min(255, brightness))
if ColorMode.COLOR_TEMP in color_modes and (
color_temp := state.get_colortemp()
):
@@ -114,19 +111,12 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
elif (
ColorMode.RGBWW in color_modes and (rgbww := state.get_rgbww()) is not None
):
self._attr_color_mode = ColorMode.RGBWW
self._attr_rgbww_color = rgbww
self._attr_color_mode = ColorMode.RGBWW
elif ColorMode.RGBW in color_modes and (rgbw := state.get_rgbw()) is not None:
self._attr_color_mode = ColorMode.RGBW
self._attr_rgbw_color = rgbw
self._attr_effect = effect = state.get_scene()
if effect is not None:
if brightness is not None:
self._attr_color_mode = ColorMode.BRIGHTNESS
else:
self._attr_color_mode = ColorMode.ONOFF
self._attr_color_mode = ColorMode.RGBW
self._attr_effect = state.get_scene()
super()._async_update_attrs()
async def async_turn_on(self, **kwargs: Any) -> None:

View File

@@ -157,7 +157,6 @@ FLOWS = {
"droplet",
"dsmr",
"dsmr_reader",
"duckdns",
"duke_energy",
"dunehd",
"duotecno",

View File

@@ -1467,7 +1467,7 @@
"duckdns": {
"name": "Duck DNS",
"integration_type": "hub",
"config_flow": true,
"config_flow": false,
"iot_class": "cloud_polling"
},
"duke_energy": {

View File

@@ -55,7 +55,11 @@ from homeassistant.core import (
valid_entity_id,
)
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import entity_registry as er, location as loc_helper
from homeassistant.helpers import (
entity_registry as er,
issue_registry as ir,
location as loc_helper,
)
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.translation import async_translate_state
from homeassistant.helpers.typing import TemplateVarsType
@@ -1219,6 +1223,25 @@ def config_entry_attr(
return getattr(config_entry, attr_name)
def issues(hass: HomeAssistant) -> dict[tuple[str, str], dict[str, Any]]:
"""Return all open issues."""
current_issues = ir.async_get(hass).issues
# Use JSON for safe representation
return {
key: issue_entry.to_json()
for (key, issue_entry) in current_issues.items()
if issue_entry.active
}
def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | None:
"""Get issue by domain and issue_id."""
result = ir.async_get(hass).async_get_issue(domain, issue_id)
if result:
return result.to_json()
return None
def closest(hass: HomeAssistant, *args: Any) -> State | None:
"""Find closest entity.
@@ -1873,7 +1896,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
)
self.add_extension("homeassistant.helpers.template.extensions.DeviceExtension")
self.add_extension("homeassistant.helpers.template.extensions.FloorExtension")
self.add_extension("homeassistant.helpers.template.extensions.IssuesExtension")
self.add_extension("homeassistant.helpers.template.extensions.LabelExtension")
self.add_extension("homeassistant.helpers.template.extensions.MathExtension")
self.add_extension("homeassistant.helpers.template.extensions.RegexExtension")
@@ -1960,6 +1982,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["config_entry_id"] = hassfunction(config_entry_id)
self.filters["config_entry_id"] = self.globals["config_entry_id"]
# Issue extensions
self.globals["issues"] = hassfunction(issues)
self.globals["issue"] = hassfunction(issue)
self.filters["issue"] = self.globals["issue"]
if limited:
# Only device_entities is available to limited templates, mark other
# functions and filters as unsupported.

View File

@@ -7,7 +7,6 @@ from .crypto import CryptoExtension
from .datetime import DateTimeExtension
from .devices import DeviceExtension
from .floors import FloorExtension
from .issues import IssuesExtension
from .labels import LabelExtension
from .math import MathExtension
from .regex import RegexExtension
@@ -21,7 +20,6 @@ __all__ = [
"DateTimeExtension",
"DeviceExtension",
"FloorExtension",
"IssuesExtension",
"LabelExtension",
"MathExtension",
"RegexExtension",

View File

@@ -1,54 +0,0 @@
"""Issue functions for Home Assistant templates."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.helpers import issue_registry as ir
from .base import BaseTemplateExtension, TemplateFunction
if TYPE_CHECKING:
from homeassistant.helpers.template import TemplateEnvironment
class IssuesExtension(BaseTemplateExtension):
"""Extension for issue-related template functions."""
def __init__(self, environment: TemplateEnvironment) -> None:
"""Initialize the issues extension."""
super().__init__(
environment,
functions=[
TemplateFunction(
"issues",
self.issues,
as_global=True,
requires_hass=True,
),
TemplateFunction(
"issue",
self.issue,
as_global=True,
as_filter=True,
requires_hass=True,
),
],
)
def issues(self) -> dict[tuple[str, str], dict[str, Any]]:
"""Return all open issues."""
current_issues = ir.async_get(self.hass).issues
# Use JSON for safe representation
return {
key: issue_entry.to_json()
for (key, issue_entry) in current_issues.items()
if issue_entry.active
}
def issue(self, domain: str, issue_id: str) -> dict[str, Any] | None:
"""Get issue by domain and issue_id."""
result = ir.async_get(self.hass).async_get_issue(domain, issue_id)
if result:
return result.to_json()
return None

6
requirements_all.txt generated
View File

@@ -254,10 +254,6 @@ aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==42.7.0
# homeassistant.components.matrix
# homeassistant.components.slack
aiofiles==24.1.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -393,7 +389,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.21.0
aioshelly==13.20.0
# homeassistant.components.skybell
aioskybell==22.7.0

View File

@@ -242,10 +242,6 @@ aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==42.7.0
# homeassistant.components.matrix
# homeassistant.components.slack
aiofiles==24.1.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -375,7 +371,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.21.0
aioshelly==13.20.0
# homeassistant.components.skybell
aioskybell==22.7.0

View File

@@ -48,6 +48,18 @@ async def mock_adguard() -> AsyncMock:
# async method mocks
adguard_mock.version = AsyncMock(return_value="v0.107.50")
adguard_mock.protection_enabled = AsyncMock(return_value=True)
adguard_mock.enable_protection = AsyncMock()
adguard_mock.disable_protection = AsyncMock()
adguard_mock.parental.enabled = AsyncMock(return_value=True)
adguard_mock.parental.enable = AsyncMock()
adguard_mock.parental.disable = AsyncMock()
adguard_mock.safesearch.enabled = AsyncMock(return_value=True)
adguard_mock.safesearch.enable = AsyncMock()
adguard_mock.safesearch.disable = AsyncMock()
adguard_mock.safebrowsing.enabled = AsyncMock(return_value=True)
adguard_mock.safebrowsing.enable = AsyncMock()
adguard_mock.safebrowsing.disable = AsyncMock()
adguard_mock.stats.dns_queries = AsyncMock(return_value=666)
adguard_mock.stats.blocked_filtering = AsyncMock(return_value=1337)
adguard_mock.stats.blocked_percentage = AsyncMock(return_value=200.75)
@@ -61,6 +73,12 @@ async def mock_adguard() -> AsyncMock:
adguard_mock.filtering.enable_url = AsyncMock()
adguard_mock.filtering.disable_url = AsyncMock()
adguard_mock.filtering.refresh = AsyncMock()
adguard_mock.filtering.enabled = AsyncMock(return_value=True)
adguard_mock.filtering.enable = AsyncMock()
adguard_mock.filtering.disable = AsyncMock()
adguard_mock.querylog.enabled = AsyncMock(return_value=True)
adguard_mock.querylog.enable = AsyncMock()
adguard_mock.querylog.disable = AsyncMock()
adguard_mock.update.update_available = AsyncMock(
return_value=AdGuardHomeAvailableUpdate(
new_version="v0.107.59",

View File

@@ -0,0 +1,289 @@
# serializer version: 1
# name: test_switch[switch.adguard_home_filtering-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.adguard_home_filtering',
'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': 'Filtering',
'platform': 'adguard',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'filtering',
'unique_id': 'adguard_127.0.0.1_3000_switch_filtering',
'unit_of_measurement': None,
})
# ---
# name: test_switch[switch.adguard_home_filtering-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'AdGuard Home Filtering',
}),
'context': <ANY>,
'entity_id': 'switch.adguard_home_filtering',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switch[switch.adguard_home_parental_control-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.adguard_home_parental_control',
'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': 'Parental control',
'platform': 'adguard',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'parental',
'unique_id': 'adguard_127.0.0.1_3000_switch_parental',
'unit_of_measurement': None,
})
# ---
# name: test_switch[switch.adguard_home_parental_control-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'AdGuard Home Parental control',
}),
'context': <ANY>,
'entity_id': 'switch.adguard_home_parental_control',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switch[switch.adguard_home_protection-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.adguard_home_protection',
'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': 'Protection',
'platform': 'adguard',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'protection',
'unique_id': 'adguard_127.0.0.1_3000_switch_protection',
'unit_of_measurement': None,
})
# ---
# name: test_switch[switch.adguard_home_protection-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'AdGuard Home Protection',
}),
'context': <ANY>,
'entity_id': 'switch.adguard_home_protection',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switch[switch.adguard_home_query_log-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.adguard_home_query_log',
'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': 'Query log',
'platform': 'adguard',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'query_log',
'unique_id': 'adguard_127.0.0.1_3000_switch_querylog',
'unit_of_measurement': None,
})
# ---
# name: test_switch[switch.adguard_home_query_log-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'AdGuard Home Query log',
}),
'context': <ANY>,
'entity_id': 'switch.adguard_home_query_log',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switch[switch.adguard_home_safe_browsing-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.adguard_home_safe_browsing',
'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': 'Safe browsing',
'platform': 'adguard',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'safe_browsing',
'unique_id': 'adguard_127.0.0.1_3000_switch_safebrowsing',
'unit_of_measurement': None,
})
# ---
# name: test_switch[switch.adguard_home_safe_browsing-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'AdGuard Home Safe browsing',
}),
'context': <ANY>,
'entity_id': 'switch.adguard_home_safe_browsing',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switch[switch.adguard_home_safe_search-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.adguard_home_safe_search',
'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': 'Safe search',
'platform': 'adguard',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'safe_search',
'unique_id': 'adguard_127.0.0.1_3000_switch_safesearch',
'unit_of_measurement': None,
})
# ---
# name: test_switch[switch.adguard_home_safe_search-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'AdGuard Home Safe search',
}),
'context': <ANY>,
'entity_id': 'switch.adguard_home_safe_search',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -0,0 +1,161 @@
"""Tests for the AdGuard Home switch entity."""
from collections.abc import Callable
import logging
from typing import Any
from unittest.mock import AsyncMock, patch
from adguardhome import AdGuardHomeError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_switch(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_adguard: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the adguard switch platform."""
with patch("homeassistant.components.adguard.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry, mock_adguard)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
("switch_name", "service", "call_assertion"),
[
(
"protection",
SERVICE_TURN_ON,
lambda mock: mock.enable_protection.assert_called_once(),
),
(
"protection",
SERVICE_TURN_OFF,
lambda mock: mock.disable_protection.assert_called_once(),
),
(
"parental_control",
SERVICE_TURN_ON,
lambda mock: mock.parental.enable.assert_called_once(),
),
(
"parental_control",
SERVICE_TURN_OFF,
lambda mock: mock.parental.disable.assert_called_once(),
),
(
"safe_search",
SERVICE_TURN_ON,
lambda mock: mock.safesearch.enable.assert_called_once(),
),
(
"safe_search",
SERVICE_TURN_OFF,
lambda mock: mock.safesearch.disable.assert_called_once(),
),
(
"safe_browsing",
SERVICE_TURN_ON,
lambda mock: mock.safebrowsing.enable.assert_called_once(),
),
(
"safe_browsing",
SERVICE_TURN_OFF,
lambda mock: mock.safebrowsing.disable.assert_called_once(),
),
(
"filtering",
SERVICE_TURN_ON,
lambda mock: mock.filtering.enable.assert_called_once(),
),
(
"filtering",
SERVICE_TURN_OFF,
lambda mock: mock.filtering.disable.assert_called_once(),
),
(
"query_log",
SERVICE_TURN_ON,
lambda mock: mock.querylog.enable.assert_called_once(),
),
(
"query_log",
SERVICE_TURN_OFF,
lambda mock: mock.querylog.disable.assert_called_once(),
),
],
)
async def test_switch_actions(
hass: HomeAssistant,
mock_adguard: AsyncMock,
mock_config_entry: MockConfigEntry,
switch_name: str,
service: str,
call_assertion: Callable[[AsyncMock], Any],
) -> None:
"""Test the adguard switch actions."""
with patch("homeassistant.components.adguard.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry, mock_adguard)
await hass.services.async_call(
"switch",
service,
{ATTR_ENTITY_ID: f"switch.adguard_home_{switch_name}"},
blocking=True,
)
call_assertion(mock_adguard)
@pytest.mark.parametrize(
("service", "expected_message"),
[
(
SERVICE_TURN_ON,
"An error occurred while turning on AdGuard Home switch",
),
(
SERVICE_TURN_OFF,
"An error occurred while turning off AdGuard Home switch",
),
],
)
async def test_switch_action_failed(
hass: HomeAssistant,
mock_adguard: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
service: str,
expected_message: str,
) -> None:
"""Test the adguard switch actions."""
caplog.set_level(logging.ERROR)
with patch("homeassistant.components.adguard.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry, mock_adguard)
mock_adguard.enable_protection.side_effect = AdGuardHomeError("Boom")
mock_adguard.disable_protection.side_effect = AdGuardHomeError("Boom")
await hass.services.async_call(
"switch",
service,
{ATTR_ENTITY_ID: "switch.adguard_home_protection"},
blocking=True,
)
assert expected_message in caplog.text

View File

@@ -34,7 +34,6 @@ from homeassistant.core import HomeAssistant
from .const import (
TEST_DATA_CREATE_ENTRY,
TEST_DATA_CREATE_ENTRY_2,
TEST_DATA_CREATE_ENTRY_3,
TEST_FRIENDLY_NAME,
TEST_FRIENDLY_NAME_3,
TEST_FRIENDLY_NAME_4,
@@ -45,10 +44,8 @@ from .const import (
TEST_JID_4,
TEST_NAME,
TEST_NAME_2,
TEST_NAME_3,
TEST_SERIAL_NUMBER,
TEST_SERIAL_NUMBER_2,
TEST_SERIAL_NUMBER_3,
TEST_SOUND_MODE,
TEST_SOUND_MODE_2,
TEST_SOUND_MODE_NAME,
@@ -79,17 +76,6 @@ def mock_config_entry_core() -> MockConfigEntry:
)
@pytest.fixture
def mock_config_entry_premiere() -> MockConfigEntry:
"""Mock config entry for Beosound Premiere."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_SERIAL_NUMBER_3,
data=TEST_DATA_CREATE_ENTRY_3,
title=TEST_NAME_3,
)
async def mock_websocket_connection(
hass: HomeAssistant, mock_mozart_client: AsyncMock
) -> None:

View File

@@ -39,7 +39,6 @@ TEST_HOST_INVALID = "192.168.0"
TEST_HOST_IPV6 = "1111:2222:3333:4444:5555:6666:7777:8888"
TEST_MODEL_BALANCE = "Beosound Balance"
TEST_MODEL_CORE = "Beoconnect Core"
TEST_MODEL_PREMIERE = "Beosound Premiere"
TEST_MODEL_THEATRE = "Beosound Theatre"
TEST_MODEL_LEVEL = "Beosound Level"
TEST_SERIAL_NUMBER = "11111111"
@@ -57,11 +56,9 @@ TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_2}@prod
TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beoconnect_core_22222222"
TEST_HOST_2 = "192.168.0.2"
TEST_FRIENDLY_NAME_3 = "Bedroom Premiere"
TEST_SERIAL_NUMBER_3 = "33333333"
TEST_NAME_3 = f"{TEST_MODEL_PREMIERE}-{TEST_SERIAL_NUMBER_3}"
TEST_JID_3 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_3}@products.bang-olufsen.com"
TEST_MEDIA_PLAYER_ENTITY_ID_3 = f"media_player.beosound_premiere_{TEST_SERIAL_NUMBER_3}"
TEST_FRIENDLY_NAME_3 = "Lego room Balance"
TEST_JID_3 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.33333333@products.bang-olufsen.com"
TEST_MEDIA_PLAYER_ENTITY_ID_3 = "media_player.beosound_balance_33333333"
TEST_HOST_3 = "192.168.0.3"
TEST_FRIENDLY_NAME_4 = "Lounge room Balance"
@@ -93,13 +90,6 @@ TEST_DATA_CREATE_ENTRY_2 = {
CONF_NAME: TEST_NAME_2,
}
TEST_DATA_CREATE_ENTRY_3 = {
CONF_HOST: TEST_HOST_3,
CONF_MODEL: TEST_MODEL_PREMIERE,
CONF_BEOLINK_JID: TEST_JID_3,
CONF_NAME: TEST_NAME_3,
}
TEST_DATA_ZEROCONF = ZeroconfServiceInfo(
ip_address=IPv4Address(TEST_HOST),
ip_addresses=[IPv4Address(TEST_HOST)],

View File

@@ -44,11 +44,11 @@
'attributes': dict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({

View File

@@ -1,5 +1,5 @@
# serializer version: 1
# name: test_button_event_creation_balance
# name: test_button_event_creation
list([
'event.beosound_balance_11111111_bluetooth',
'event.beosound_balance_11111111_microphone',
@@ -19,17 +19,3 @@
'media_player.beoconnect_core_22222222',
])
# ---
# 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',
'event.beosound_premiere_33333333_favorite_2',
'event.beosound_premiere_33333333_favorite_3',
'event.beosound_premiere_33333333_favorite_4',
'event.beosound_premiere_33333333_previous',
'event.beosound_premiere_33333333_volume',
'media_player.beosound_premiere_33333333',
])
# ---

View File

@@ -4,11 +4,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -52,11 +52,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -101,11 +101,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -150,11 +150,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -199,11 +199,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -248,11 +248,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -296,11 +296,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -344,11 +344,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -392,11 +392,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -440,11 +440,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -488,11 +488,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -536,11 +536,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -584,11 +584,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -633,11 +633,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -682,11 +682,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -731,11 +731,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -780,11 +780,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -830,11 +830,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -879,11 +879,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -928,11 +928,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -977,11 +977,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -1028,7 +1028,7 @@
'Laundry room Core': '1111.1111111.22222222@products.bang-olufsen.com',
}),
'peers': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
@@ -1071,11 +1071,11 @@
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lego room Balance': '1111.1111111.33333333@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',
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({

View File

@@ -10,7 +10,6 @@ from homeassistant.components.bang_olufsen.const import (
DEVICE_BUTTON_EVENTS,
DEVICE_BUTTONS,
EVENT_TRANSLATION_MAP,
BangOlufsenButtons,
)
from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES
from homeassistant.const import STATE_UNKNOWN
@@ -23,13 +22,13 @@ from .const import TEST_BUTTON_EVENT_ENTITY_ID
from tests.common import MockConfigEntry
async def test_button_event_creation_balance(
async def test_button_event_creation(
hass: HomeAssistant,
integration: None,
entity_registry: EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test button event entities are created when using a Balance (Most devices support all buttons like the Balance)."""
"""Test button event entities are created."""
# Add Button Event entity ids
entity_ids = [
@@ -73,43 +72,6 @@ async def test_button_event_creation_beoconnect_core(
assert entity_ids_available == snapshot
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 button event entity is not created when using a Beosound Premiere."""
# 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
premiere_buttons = DEVICE_BUTTONS.copy()
premiere_buttons.remove(BangOlufsenButtons.BLUETOOTH.value)
entity_ids = [
f"event.beosound_premiere_33333333_{underscore(button_type)}".replace(
"preset", "favorite_"
)
for button_type in premiere_buttons
]
# Check that the entities are available
for entity_id in entity_ids:
assert entity_registry.async_get(entity_id)
# 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(
hass: HomeAssistant,
integration: None,

View File

@@ -1,48 +0,0 @@
"""Common fixtures for the Duck DNS tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.duckdns.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from tests.common import MockConfigEntry
TEST_SUBDOMAIN = "homeassistant"
TEST_TOKEN = "123e4567-e89b-12d3-a456-426614174000"
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.duckdns.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture(name="config_entry")
def mock_config_entry() -> MockConfigEntry:
"""Mock Duck DNS configuration entry."""
return MockConfigEntry(
domain=DOMAIN,
title=f"{TEST_SUBDOMAIN}.duckdns.org",
data={
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
},
entry_id="12345",
)
@pytest.fixture
def mock_update_duckdns() -> Generator[AsyncMock]:
"""Mock _update_duckdns."""
with patch(
"homeassistant.components.duckdns.config_flow._update_duckdns",
return_value=True,
) as mock:
yield mock

View File

@@ -1,255 +0,0 @@
"""Test the Duck DNS config flow."""
from unittest.mock import AsyncMock
import pytest
from homeassistant.components.duckdns.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from .conftest import TEST_SUBDOMAIN, TEST_TOKEN
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_update_duckdns")
async def test_form(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: "123e4567-e89b-12d3-a456-426614174000",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{TEST_SUBDOMAIN}.duckdns.org"
assert result["data"] == {
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_update_duckdns")
async def test_form_already_configured(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test we abort if already configured."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: "123e4567-e89b-12d3-a456-426614174000",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_form_unknown_exception(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_update_duckdns: AsyncMock,
) -> None:
"""Test we handle unknown exception."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_update_duckdns.side_effect = ValueError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unknown"}
mock_update_duckdns.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{TEST_SUBDOMAIN}.duckdns.org"
assert result["data"] == {
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_update_failed(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_update_duckdns: AsyncMock,
) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_update_duckdns.return_value = False
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "update_failed"}
mock_update_duckdns.return_value = True
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{TEST_SUBDOMAIN}.duckdns.org"
assert result["data"] == {
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_update_duckdns")
async def test_import(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test import flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{TEST_SUBDOMAIN}.duckdns.org"
assert result["data"] == {
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
}
assert len(mock_setup_entry.mock_calls) == 1
assert issue_registry.async_get_issue(
domain=HOMEASSISTANT_DOMAIN,
issue_id=f"deprecated_yaml_{DOMAIN}",
)
async def test_import_failed(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
issue_registry: ir.IssueRegistry,
mock_update_duckdns: AsyncMock,
) -> None:
"""Test import flow failed."""
mock_update_duckdns.return_value = False
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "update_failed"
assert len(mock_setup_entry.mock_calls) == 0
assert issue_registry.async_get_issue(
domain=DOMAIN,
issue_id="deprecated_yaml_import_issue_error",
)
async def test_import_exception(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
issue_registry: ir.IssueRegistry,
mock_update_duckdns: AsyncMock,
) -> None:
"""Test import flow failed unknown."""
mock_update_duckdns.side_effect = ValueError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_DOMAIN: TEST_SUBDOMAIN,
CONF_ACCESS_TOKEN: TEST_TOKEN,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unknown"
assert len(mock_setup_entry.mock_calls) == 0
assert issue_registry.async_get_issue(
domain=DOMAIN,
issue_id="deprecated_yaml_import_issue_error",
)
@pytest.mark.usefixtures("mock_update_duckdns")
async def test_init_import_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test yaml triggers import flow."""
await async_setup_component(
hass,
DOMAIN,
{"duckdns": {CONF_DOMAIN: TEST_SUBDOMAIN, CONF_ACCESS_TOKEN: TEST_TOKEN}},
)
assert len(mock_setup_entry.mock_calls) == 1
assert len(hass.config_entries.async_entries(DOMAIN)) == 1

View File

@@ -5,25 +5,19 @@ import logging
import pytest
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.components import duckdns
from homeassistant.components.duckdns import async_track_time_interval_backoff
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
from .conftest import TEST_SUBDOMAIN, TEST_TOKEN
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.common import async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
DOMAIN = "bla"
TOKEN = "abcdefgh"
_LOGGER = logging.getLogger(__name__)
INTERVAL = duckdns.INTERVAL
async def async_set_txt(hass: HomeAssistant, txt: str | None) -> None:
@@ -32,40 +26,37 @@ async def async_set_txt(hass: HomeAssistant, txt: str | None) -> None:
This is a legacy helper method. Do not use it for new tests.
"""
await hass.services.async_call(
DOMAIN, SERVICE_SET_TXT, {ATTR_TXT: txt}, blocking=True
duckdns.DOMAIN, duckdns.SERVICE_SET_TXT, {duckdns.ATTR_TXT: txt}, blocking=True
)
@pytest.fixture
async def setup_duckdns(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Fixture that sets up DuckDNS."""
aioclient_mock.get(
UPDATE_URL,
params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN},
text="OK",
duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK"
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
await async_setup_component(
hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}}
)
@pytest.mark.usefixtures("setup_duckdns")
async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None:
"""Test setup works if update passes."""
aioclient_mock.get(
UPDATE_URL,
params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN},
text="OK",
duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK"
)
result = await async_setup_component(
hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}}
)
await hass.async_block_till_done()
assert result
assert aioclient_mock.call_count == 1
async_fire_time_changed(hass, utcnow() + timedelta(minutes=5))
@@ -74,47 +65,50 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -
async def test_setup_backoff(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test setup fails if first update fails."""
aioclient_mock.get(
UPDATE_URL,
params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN},
text="KO",
duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="KO"
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
result = await async_setup_component(
hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}}
)
assert result
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert aioclient_mock.call_count == 1
# Copy of the DuckDNS intervals from duckdns/__init__.py
intervals = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
tme = utcnow()
await hass.async_block_till_done()
_LOGGER.debug("Backoff")
for idx in range(1, len(BACKOFF_INTERVALS)):
tme += BACKOFF_INTERVALS[idx]
for idx in range(1, len(intervals)):
tme += intervals[idx]
async_fire_time_changed(hass, tme)
await hass.async_block_till_done()
assert aioclient_mock.call_count == idx + 1
@pytest.mark.usefixtures("setup_duckdns")
async def test_service_set_txt(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_duckdns
) -> None:
"""Test set txt service call."""
# Empty the fixture mock requests
aioclient_mock.clear_requests()
aioclient_mock.get(
UPDATE_URL,
params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN, "txt": "some-txt"},
duckdns.UPDATE_URL,
params={"domains": DOMAIN, "token": TOKEN, "txt": "some-txt"},
text="OK",
)
@@ -123,22 +117,16 @@ async def test_service_set_txt(
assert aioclient_mock.call_count == 1
@pytest.mark.usefixtures("setup_duckdns")
async def test_service_clear_txt(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_duckdns
) -> None:
"""Test clear txt service call."""
# Empty the fixture mock requests
aioclient_mock.clear_requests()
aioclient_mock.get(
UPDATE_URL,
params={
"domains": TEST_SUBDOMAIN,
"token": TEST_TOKEN,
"txt": "",
"clear": "true",
},
duckdns.UPDATE_URL,
params={"domains": DOMAIN, "token": TOKEN, "txt": "", "clear": "true"},
text="OK",
)
@@ -206,28 +194,3 @@ async def test_async_track_time_interval_backoff(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert call_count == _idx
async def test_load_unload(
hass: HomeAssistant,
config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test loading and unloading of the config entry."""
aioclient_mock.get(
UPDATE_URL,
params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN},
text="OK",
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.NOT_LOADED

View File

@@ -1,923 +0,0 @@
# serializer version: 1
# name: test_sensor[sensor.home_apparent_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.home_apparent_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': 'Apparent temperature',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'apparent_temperature',
'unique_id': 'home-subentry-id_feelsliketemperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor[sensor.home_apparent_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Home Apparent temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_apparent_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '13.1',
})
# ---
# name: test_sensor[sensor.home_atmospheric_pressure-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.home_atmospheric_pressure',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.ATMOSPHERIC_PRESSURE: 'atmospheric_pressure'>,
'original_icon': None,
'original_name': 'Atmospheric pressure',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'home-subentry-id_airpressure',
'unit_of_measurement': <UnitOfPressure.HPA: 'hPa'>,
})
# ---
# name: test_sensor[sensor.home_atmospheric_pressure-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'atmospheric_pressure',
'friendly_name': 'Home Atmospheric pressure',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPressure.HPA: 'hPa'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_atmospheric_pressure',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1019.16',
})
# ---
# name: test_sensor[sensor.home_cloud_coverage-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.home_cloud_coverage',
'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': 'Cloud coverage',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cloud_coverage',
'unique_id': 'home-subentry-id_cloudcover',
'unit_of_measurement': '%',
})
# ---
# name: test_sensor[sensor.home_cloud_coverage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Home Cloud coverage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.home_cloud_coverage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensor[sensor.home_dew_point-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.home_dew_point',
'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': 'Dew point',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dew_point',
'unique_id': 'home-subentry-id_dewpoint',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor[sensor.home_dew_point-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Home Dew point',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_dew_point',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.1',
})
# ---
# name: test_sensor[sensor.home_heat_index_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.home_heat_index_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': 'Heat index temperature',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'heat_index',
'unique_id': 'home-subentry-id_heatindex',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor[sensor.home_heat_index_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Home Heat index temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_heat_index_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '13.7',
})
# ---
# name: test_sensor[sensor.home_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.home_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': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'home-subentry-id_relativehumidity',
'unit_of_measurement': '%',
})
# ---
# name: test_sensor[sensor.home_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'Home Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.home_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '42',
})
# ---
# name: test_sensor[sensor.home_precipitation_intensity-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.home_precipitation_intensity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.PRECIPITATION_INTENSITY: 'precipitation_intensity'>,
'original_icon': None,
'original_name': 'Precipitation intensity',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'home-subentry-id_precipitation_qpf',
'unit_of_measurement': <UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: 'mm/h'>,
})
# ---
# name: test_sensor[sensor.home_precipitation_intensity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'precipitation_intensity',
'friendly_name': 'Home Precipitation intensity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: 'mm/h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_precipitation_intensity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_sensor[sensor.home_precipitation_probability-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.home_precipitation_probability',
'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': 'Precipitation probability',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'precipitation_probability',
'unique_id': 'home-subentry-id_precipitation_probability',
'unit_of_measurement': '%',
})
# ---
# name: test_sensor[sensor.home_precipitation_probability-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Home Precipitation probability',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.home_precipitation_probability',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensor[sensor.home_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.home_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': 'Temperature',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'home-subentry-id_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor[sensor.home_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Home Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '13.7',
})
# ---
# name: test_sensor[sensor.home_thunderstorm_probability-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.home_thunderstorm_probability',
'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': 'Thunderstorm probability',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'thunderstorm_probability',
'unique_id': 'home-subentry-id_thunderstormprobability',
'unit_of_measurement': '%',
})
# ---
# name: test_sensor[sensor.home_thunderstorm_probability-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Home Thunderstorm probability',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.home_thunderstorm_probability',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensor[sensor.home_uv_index-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.home_uv_index',
'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': 'UV index',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'uv_index',
'unique_id': 'home-subentry-id_uvindex',
'unit_of_measurement': 'UV index',
})
# ---
# name: test_sensor[sensor.home_uv_index-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Home UV index',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'UV index',
}),
'context': <ANY>,
'entity_id': 'sensor.home_uv_index',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_sensor[sensor.home_visibility-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.home_visibility',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DISTANCE: 'distance'>,
'original_icon': None,
'original_name': 'Visibility',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'visibility',
'unique_id': 'home-subentry-id_visibility',
'unit_of_measurement': <UnitOfLength.KILOMETERS: 'km'>,
})
# ---
# name: test_sensor[sensor.home_visibility-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'distance',
'friendly_name': 'Home Visibility',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfLength.KILOMETERS: 'km'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_visibility',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '16.0',
})
# ---
# name: test_sensor[sensor.home_weather_condition-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'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.home_weather_condition',
'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': 'Weather condition',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'weather_condition',
'unique_id': 'home-subentry-id_weathercondition',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.home_weather_condition-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Home Weather condition',
}),
'context': <ANY>,
'entity_id': 'sensor.home_weather_condition',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Sunny',
})
# ---
# name: test_sensor[sensor.home_wind_chill_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.home_wind_chill_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': 'Wind chill temperature',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wind_chill',
'unique_id': 'home-subentry-id_windchill',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor[sensor.home_wind_chill_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Home Wind chill temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_wind_chill_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '13.1',
})
# ---
# name: test_sensor[sensor.home_wind_direction-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT_ANGLE: 'measurement_angle'>,
}),
'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.home_wind_direction',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.WIND_DIRECTION: 'wind_direction'>,
'original_icon': None,
'original_name': 'Wind direction',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'home-subentry-id_wind_direction',
'unit_of_measurement': '°',
})
# ---
# name: test_sensor[sensor.home_wind_direction-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'wind_direction',
'friendly_name': 'Home Wind direction',
'state_class': <SensorStateClass.MEASUREMENT_ANGLE: 'measurement_angle'>,
'unit_of_measurement': '°',
}),
'context': <ANY>,
'entity_id': 'sensor.home_wind_direction',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '335',
})
# ---
# name: test_sensor[sensor.home_wind_gust_speed-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.home_wind_gust_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.WIND_SPEED: 'wind_speed'>,
'original_icon': None,
'original_name': 'Wind gust speed',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wind_gust_speed',
'unique_id': 'home-subentry-id_wind_gust',
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
})
# ---
# name: test_sensor[sensor.home_wind_gust_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'wind_speed',
'friendly_name': 'Home Wind gust speed',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_wind_gust_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '18.0',
})
# ---
# name: test_sensor[sensor.home_wind_speed-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.home_wind_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.WIND_SPEED: 'wind_speed'>,
'original_icon': None,
'original_name': 'Wind speed',
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'home-subentry-id_wind_speed',
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
})
# ---
# name: test_sensor[sensor.home_wind_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'wind_speed',
'friendly_name': 'Home Wind speed',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_wind_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '8.0',
})
# ---

View File

@@ -1,168 +0,0 @@
"""Test sensor of Google Weather integration."""
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from google_weather_api import GoogleWeatherApiError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
Platform,
UnitOfLength,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_google_weather_api: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test states of the sensor."""
with patch("homeassistant.components.google_weather._PLATFORMS", [Platform.SENSOR]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_availability(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure that we mark the entities unavailable correctly when service is offline."""
entity_id = "sensor.home_temperature"
await hass.config_entries.async_setup(mock_config_entry.entry_id)
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
assert state.state == "13.7"
mock_google_weather_api.async_get_current_conditions.side_effect = (
GoogleWeatherApiError()
)
freezer.tick(timedelta(minutes=15))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNAVAILABLE
mock_google_weather_api.async_get_current_conditions.side_effect = None
freezer.tick(timedelta(minutes=15))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
assert state.state == "13.7"
mock_google_weather_api.async_get_current_conditions.assert_called_with(
latitude=10.1, longitude=20.1
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_manual_update_entity(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test manual update entity via service homeassistant/update_entity."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, "homeassistant", {})
mock_google_weather_api.async_get_current_conditions.assert_called_once_with(
latitude=10.1, longitude=20.1
)
await hass.services.async_call(
"homeassistant",
"update_entity",
{ATTR_ENTITY_ID: ["sensor.home_temperature"]},
blocking=True,
)
assert mock_google_weather_api.async_get_current_conditions.call_count == 2
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_imperial_units(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test states of the sensor with imperial units."""
hass.config.units = US_CUSTOMARY_SYSTEM
await hass.config_entries.async_setup(mock_config_entry.entry_id)
state = hass.states.get("sensor.home_temperature")
assert state
assert state.state == "56.66"
assert (
state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT
)
state = hass.states.get("sensor.home_wind_speed")
assert state
assert float(state.state) == pytest.approx(4.97097)
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfSpeed.MILES_PER_HOUR
state = hass.states.get("sensor.home_visibility")
assert state
assert float(state.state) == pytest.approx(9.94194)
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.MILES
state = hass.states.get("sensor.home_atmospheric_pressure")
assert state
assert float(state.state) == pytest.approx(30.09578)
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.INHG
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_state_update(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure the sensor state changes after updating the data."""
entity_id = "sensor.home_temperature"
await hass.config_entries.async_setup(mock_config_entry.entry_id)
state = hass.states.get(entity_id)
assert state
assert state.state == "13.7"
mock_google_weather_api.async_get_current_conditions.return_value.temperature.degrees = 15.0
freezer.tick(timedelta(minutes=15))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == "15.0"

View File

@@ -3,18 +3,9 @@
from dataclasses import replace
from datetime import timedelta
import os
from pathlib import PurePath
from unittest.mock import AsyncMock, patch
from uuid import uuid4
from aiohasupervisor.models.mounts import (
CIFSMountResponse,
MountsInfo,
MountState,
MountType,
MountUsage,
NFSMountResponse,
)
from aiohasupervisor.models.mounts import CIFSMountResponse, MountsInfo, MountState
import pytest
from homeassistant.components.hassio import DOMAIN
@@ -27,7 +18,6 @@ from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import WebSocketGenerator
MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"}
@@ -240,16 +230,16 @@ async def test_mount_binary_sensor(
assert hass.states.get(entity_id) is None
# Add a mount.
mock_mounts: list[CIFSMountResponse | NFSMountResponse] = [
mock_mounts = [
CIFSMountResponse(
share="files",
server="1.2.3.4",
name="NAS",
type=MountType.CIFS,
usage=MountUsage.SHARE,
type="cifs",
usage="share",
read_only=False,
state=MountState.ACTIVE,
user_path=PurePath("/share/nas"),
user_path="/share/nas",
)
]
supervisor_client.mounts.info = AsyncMock(
@@ -292,115 +282,3 @@ async def test_mount_binary_sensor(
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1000))
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id) is not None
async def test_mount_refresh_after_issue(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
supervisor_client: AsyncMock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test hassio mount state is refreshed after an issue was send by the supervisor."""
# Add a mount.
mock_mounts: list[CIFSMountResponse | NFSMountResponse] = [
CIFSMountResponse(
share="files",
server="1.2.3.4",
name="NAS",
type=MountType.CIFS,
usage=MountUsage.SHARE,
read_only=False,
state=MountState.ACTIVE,
user_path=PurePath("/share/nas"),
)
]
supervisor_client.mounts.info = AsyncMock(
return_value=MountsInfo(default_backup_mount=None, mounts=mock_mounts)
)
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
config_entry.add_to_hass(hass)
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(
hass,
"hassio",
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
)
assert result
await hass.async_block_till_done()
# Enable the entity.
entity_id = "binary_sensor.nas_connected"
entity_registry.async_update_entity(entity_id, disabled_by=None)
await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
# Test new entity.
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == "on"
# Change mount state to failed, issue a repair, and verify entity's state.
mock_mounts[0] = replace(mock_mounts[0], state=MountState.FAILED)
client = await hass_ws_client(hass)
issue_uuid = uuid4().hex
await client.send_json(
{
"id": 1,
"type": "supervisor/event",
"data": {
"event": "issue_changed",
"data": {
"uuid": issue_uuid,
"type": "mount_failed",
"context": "mount",
"reference": "nas",
"suggestions": [
{
"uuid": uuid4().hex,
"type": "execute_reload",
"context": "mount",
"reference": "nas",
},
{
"uuid": uuid4().hex,
"type": "execute_remove",
"context": "mount",
"reference": "nas",
},
],
},
},
}
)
msg = await client.receive_json()
assert msg["success"]
await hass.async_block_till_done(wait_background_tasks=True)
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == "off"
# Change mount state to active, issue a repair, and verify entity's state.
mock_mounts[0] = replace(mock_mounts[0], state=MountState.ACTIVE)
await client.send_json(
{
"id": 2,
"type": "supervisor/event",
"data": {
"event": "issue_removed",
"data": {
"uuid": issue_uuid,
"type": "mount_failed",
"context": "mount",
"reference": "nas",
},
},
}
)
msg = await client.receive_json()
assert msg["success"]
await hass.async_block_till_done(wait_background_tasks=True)
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == "on"

View File

@@ -108,24 +108,6 @@
"enumber": "HCS000000/06",
"haId": "123456789012345678"
},
{
"name": "AirConditioner",
"brand": "BOSCH",
"vib": "HCS000006",
"connected": true,
"type": "AirConditioner",
"enumber": "HCS000000/07",
"haId": "8765432109876543210"
},
{
"name": "Microwave",
"brand": "BOSCH",
"vib": "HCS000006",
"connected": true,
"type": "Microwave",
"enumber": "HCS000000/08",
"haId": "541513213246313789"
},
{
"name": "DNE",
"brand": "BOSCH",

View File

@@ -205,47 +205,5 @@
}
]
}
},
"AirConditioner": {
"data": {
"programs": [
{
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean",
"constraints": {
"execution": "startonly"
}
},
{
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.Auto",
"constraints": {
"execution": "startonly"
}
},
{
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.Cool",
"constraints": {
"execution": "startonly"
}
},
{
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.Dry",
"constraints": {
"execution": "startonly"
}
},
{
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.Fan",
"constraints": {
"execution": "startonly"
}
},
{
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.Heat",
"constraints": {
"execution": "startonly"
}
}
]
}
}
}

View File

@@ -31,72 +31,6 @@
'type': 'CookProcessor',
'vib': 'HCS000006',
}),
'541513213246313789': dict({
'brand': 'BOSCH',
'connected': True,
'e_number': 'HCS000000/08',
'ha_id': '541513213246313789',
'name': 'Microwave',
'programs': list([
]),
'settings': dict({
}),
'status': dict({
'BSH.Common.Status.DoorState': dict({
'value': 'BSH.Common.EnumType.DoorState.Closed',
}),
'BSH.Common.Status.OperationState': dict({
'value': 'BSH.Common.EnumType.OperationState.Ready',
}),
'BSH.Common.Status.RemoteControlActive': dict({
'value': True,
}),
'BSH.Common.Status.RemoteControlStartAllowed': dict({
'value': True,
}),
'Refrigeration.Common.Status.Door.Refrigerator': dict({
'value': 'BSH.Common.EnumType.DoorState.Open',
}),
}),
'type': 'Microwave',
'vib': 'HCS000006',
}),
'8765432109876543210': dict({
'brand': 'BOSCH',
'connected': True,
'e_number': 'HCS000000/07',
'ha_id': '8765432109876543210',
'name': 'AirConditioner',
'programs': list([
'HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean',
'HeatingVentilationAirConditioning.AirConditioner.Program.Auto',
'HeatingVentilationAirConditioning.AirConditioner.Program.Cool',
'HeatingVentilationAirConditioning.AirConditioner.Program.Dry',
'HeatingVentilationAirConditioning.AirConditioner.Program.Fan',
'HeatingVentilationAirConditioning.AirConditioner.Program.Heat',
]),
'settings': dict({
}),
'status': dict({
'BSH.Common.Status.DoorState': dict({
'value': 'BSH.Common.EnumType.DoorState.Closed',
}),
'BSH.Common.Status.OperationState': dict({
'value': 'BSH.Common.EnumType.OperationState.Ready',
}),
'BSH.Common.Status.RemoteControlActive': dict({
'value': True,
}),
'BSH.Common.Status.RemoteControlStartAllowed': dict({
'value': True,
}),
'Refrigeration.Common.Status.Door.Refrigerator': dict({
'value': 'BSH.Common.EnumType.DoorState.Open',
}),
}),
'type': 'AirConditioner',
'vib': 'HCS000006',
}),
'BOSCH-000000000-000000000000': dict({
'brand': 'BOSCH',
'connected': True,

View File

@@ -16,7 +16,6 @@ from ha_silabs_firmware_client import (
import pytest
from yarl import URL
from homeassistant.components.homeassistant_hardware.const import Z2M_EMBER_DOCS_URL
from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
STEP_PICK_FIRMWARE_THREAD,
STEP_PICK_FIRMWARE_ZIGBEE,
@@ -578,26 +577,12 @@ async def test_config_flow_zigbee_custom_other(hass: HomeAssistant) -> None:
assert pick_result["progress_action"] == "install_firmware"
assert pick_result["step_id"] == "install_zigbee_firmware"
show_z2m_result = await consume_progress_flow(
create_result = await consume_progress_flow(
hass,
flow_id=pick_result["flow_id"],
valid_step_ids=("install_zigbee_firmware",),
)
# After firmware installation, Z2M docs link is shown
assert show_z2m_result["type"] is FlowResultType.FORM
assert show_z2m_result["step_id"] == "show_z2m_docs_url"
assert (
show_z2m_result["description_placeholders"]["z2m_docs_url"]
== Z2M_EMBER_DOCS_URL
)
# Submit the form to complete the flow
create_result = await hass.config_entries.flow.async_configure(
show_z2m_result["flow_id"],
user_input={},
)
assert create_result["type"] is FlowResultType.CREATE_ENTRY
config_entry = create_result["result"]

View File

@@ -63,25 +63,12 @@ async def async_manipulate_test_data(
new_value: Any,
channel: int = 1,
fire_device: HomeMaticIPObject | None = None,
channel_real_index: int | None = None,
):
"""Set new value on hmip device."""
if channel == 1:
setattr(hmip_device, attribute, new_value)
channels = getattr(hmip_device, "functionalChannels", None)
if channels:
if channel_real_index is not None:
functional_channel = next(
(ch for ch in channels if ch.index == channel_real_index),
None,
)
assert functional_channel is not None, (
f"No functional channel with index {channel_real_index} found in hmip_device.functionalChannels"
)
else:
functional_channel = channels[channel]
if hasattr(hmip_device, "functionalChannels"):
functional_channel = hmip_device.functionalChannels[channel]
setattr(functional_channel, attribute, new_value)
fire_target = hmip_device if fire_device is None else fire_device

View File

@@ -565,8 +565,8 @@ async def test_hmip_multi_contact_interface(
hass: HomeAssistant, default_mock_hap_factory: HomeFactory
) -> None:
"""Test HomematicipMultiContactInterface."""
entity_id = "binary_sensor.wired_eingangsmodul_32_fach_channel10"
entity_name = "Wired Eingangsmodul 32-fach Channel10"
entity_id = "binary_sensor.wired_eingangsmodul_32_fach_channel5"
entity_name = "Wired Eingangsmodul 32-fach Channel5"
device_model = "HmIPW-DRI32"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=["Wired Eingangsmodul 32-fach", "Licht Flur"]
@@ -578,25 +578,15 @@ async def test_hmip_multi_contact_interface(
assert ha_state.state == STATE_OFF
await async_manipulate_test_data(
hass, hmip_device, "windowState", WindowState.OPEN, channel_real_index=10
hass, hmip_device, "windowState", WindowState.OPEN, channel=5
)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_ON
await async_manipulate_test_data(
hass, hmip_device, "windowState", None, channel_real_index=10
)
await async_manipulate_test_data(hass, hmip_device, "windowState", None, channel=5)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_UNKNOWN
# Test channel 32 of device
entity_id = "binary_sensor.wired_eingangsmodul_32_fach_channel32"
entity_name = "Wired Eingangsmodul 32-fach Channel32"
ha_state, hmip_device = get_and_check_entity_basics(
hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
ha_state, hmip_device = get_and_check_entity_basics(
hass,
mock_hap,

View File

@@ -374,7 +374,7 @@ async def test_storage_dashboards(
assert response["success"]
assert response["result"] == []
# Add a wrong dashboard (no hyphen)
# Add a wrong dashboard
await client.send_json(
{
"id": 6,
@@ -484,36 +484,16 @@ async def test_storage_dashboards(
assert response["result"][0]["show_in_sidebar"] is False
assert response["result"][0]["require_admin"] is False
# Add a wrong dashboard (missing title)
await client.send_json(
{
"id": 14,
"type": "lovelace/dashboards/create",
"url_path": "path",
}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "invalid_format"
# Add dashboard with existing url path
await client.send_json(
{
"id": 15,
"type": "lovelace/dashboards/create",
"url_path": "created-url-path",
"title": "Another title",
}
{"id": 14, "type": "lovelace/dashboards/create", "url_path": "created-url-path"}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
assert response["error"]["translation_key"] == "url_already_exists"
assert response["error"]["translation_placeholders"]["url"] == "created-url-path"
# Delete dashboards
await client.send_json(
{"id": 16, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id}
{"id": 15, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id}
)
response = await client.receive_json()
assert response["success"]

View File

@@ -3,7 +3,7 @@
import asyncio
from collections.abc import Callable
from typing import Any
from unittest.mock import AsyncMock, patch
from unittest.mock import patch
from homeassistant.components.lutron_caseta import DOMAIN
from homeassistant.components.lutron_caseta.const import (
@@ -90,9 +90,7 @@ _LEAP_DEVICE_TYPES = {
class MockBridge:
"""Mock Lutron bridge that emulates configured connected status."""
def __init__(
self, can_connect=True, timeout_on_connect=False, smart_away_state=""
) -> None:
def __init__(self, can_connect=True, timeout_on_connect=False) -> None:
"""Initialize MockBridge instance with configured mock connectivity."""
self.timeout_on_connect = timeout_on_connect
self.can_connect = can_connect
@@ -103,23 +101,6 @@ class MockBridge:
self.devices = self.load_devices()
self.buttons = self.load_buttons()
self._subscribers: dict[str, list] = {}
self.smart_away_state = smart_away_state
self._smart_away_subscribers = []
self.activate_smart_away = AsyncMock(side_effect=self._activate)
self.deactivate_smart_away = AsyncMock(side_effect=self._deactivate)
async def _activate(self):
"""Activate smart away."""
self.smart_away_state = "Enabled"
for callback in self._smart_away_subscribers:
callback()
async def _deactivate(self):
"""Deactivate smart away."""
self.smart_away_state = "Disabled"
for callback in self._smart_away_subscribers:
callback()
async def connect(self):
"""Connect the mock bridge."""
@@ -134,10 +115,6 @@ class MockBridge:
self._subscribers[device_id] = []
self._subscribers[device_id].append(callback_)
def add_smart_away_subscriber(self, callback_):
"""Add a smart away subscriber."""
self._smart_away_subscribers.append(callback_)
def add_button_subscriber(self, button_id: str, callback_):
"""Mock a listener for button presses."""
@@ -377,7 +354,6 @@ async def async_setup_integration(
can_connect: bool = True,
timeout_during_connect: bool = False,
timeout_during_configure: bool = False,
smart_away_state: str = "",
) -> MockConfigEntry:
"""Set up a mock bridge."""
if config_entry_id is None:
@@ -394,9 +370,7 @@ async def async_setup_integration(
if not timeout_during_connect:
on_connect_callback()
return mock_bridge(
can_connect=can_connect,
timeout_on_connect=timeout_during_configure,
smart_away_state=smart_away_state,
can_connect=can_connect, timeout_on_connect=timeout_during_configure
)
with patch(

View File

@@ -180,7 +180,6 @@ async def test_diagnostics(
},
"occupancy_groups": {},
"scenes": {},
"smart_away_state": "",
},
"entry": {
"data": {"ca_certs": "", "certfile": "", "host": "1.1.1.1", "keyfile": ""},

View File

@@ -1,13 +1,5 @@
"""Tests for the Lutron Caseta integration."""
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -24,88 +16,3 @@ async def test_switch_unique_id(
# Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID
assert entity_registry.async_get(switch_entity_id).unique_id == "000004d2_803"
async def test_smart_away_switch_setup(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test smart away switch is created when bridge supports it."""
await async_setup_integration(hass, MockBridge, smart_away_state="Disabled")
smart_away_entity_id = "switch.hallway_smart_away"
# Verify entity is registered
entity_entry = entity_registry.async_get(smart_away_entity_id)
assert entity_entry is not None
assert entity_entry.unique_id == "000004d2_smart_away"
# Verify initial state is off
state = hass.states.get(smart_away_entity_id)
assert state is not None
assert state.state == STATE_OFF
async def test_smart_away_switch_not_created_when_not_supported(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test smart away switch is not created when bridge doesn't support it."""
await async_setup_integration(hass, MockBridge)
smart_away_entity_id = "switch.hallway_smart_away"
# Verify entity is not registered
entity_entry = entity_registry.async_get(smart_away_entity_id)
assert entity_entry is None
# Verify state doesn't exist
state = hass.states.get(smart_away_entity_id)
assert state is None
async def test_smart_away_turn_on(hass: HomeAssistant) -> None:
"""Test turning on smart away."""
await async_setup_integration(hass, MockBridge, smart_away_state="Disabled")
smart_away_entity_id = "switch.hallway_smart_away"
# Verify initial state is off
state = hass.states.get(smart_away_entity_id)
assert state.state == STATE_OFF
# Turn on smart away
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: smart_away_entity_id},
blocking=True,
)
# Verify state is on
state = hass.states.get(smart_away_entity_id)
assert state.state == STATE_ON
async def test_smart_away_turn_off(hass: HomeAssistant) -> None:
"""Test turning off smart away."""
await async_setup_integration(hass, MockBridge, smart_away_state="Enabled")
smart_away_entity_id = "switch.hallway_smart_away"
# Verify initial state is off
state = hass.states.get(smart_away_entity_id)
assert state.state == STATE_ON
# Turn on smart away
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: smart_away_entity_id},
blocking=True,
)
# Verify state is on
state = hass.states.get(smart_away_entity_id)
assert state.state == STATE_OFF

View File

@@ -282,100 +282,6 @@ async def test_subscribe_topic(
unsub()
async def test_status_subscription_done(
hass: HomeAssistant,
mqtt_client_mock: MqttMockPahoClient,
mqtt_mock_entry: MqttMockHAClientGenerator,
recorded_calls: list[ReceiveMessage],
record_calls: MessageCallbackType,
) -> None:
"""Test the on subscription status."""
await mqtt_mock_entry()
on_status = asyncio.Event()
on_status_calls: list[bool] = []
def _on_subscribe_status() -> None:
on_status.set()
on_status_calls.append(True)
subscribe_callback = await mqtt.async_subscribe(
hass, "test-topic", record_calls, qos=0
)
handler = mqtt.async_on_subscribe_done(
hass, "test-topic", 0, on_subscribe_status=_on_subscribe_status
)
await on_status.wait()
assert ("test-topic", 0) in help_all_subscribe_calls(mqtt_client_mock)
await mqtt.async_publish(hass, "test-topic", "beer ready", 0)
handler()
assert len(recorded_calls) == 1
assert recorded_calls[0].topic == "test-topic"
assert recorded_calls[0].payload == "beer ready"
assert recorded_calls[0].qos == 0
# Test as we have an existing subscription, test we get a callback
recorded_calls.clear()
on_status.clear()
handler = mqtt.async_on_subscribe_done(
hass, "test-topic", 0, on_subscribe_status=_on_subscribe_status
)
assert len(on_status_calls) == 1
await on_status.wait()
assert len(on_status_calls) == 2
# cleanup
handler()
subscribe_callback()
async def test_subscribe_topic_with_subscribe_done(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
recorded_calls: list[ReceiveMessage],
record_calls: MessageCallbackType,
) -> None:
"""Test the subscription of a topic."""
await mqtt_mock_entry()
on_status = asyncio.Event()
def _on_subscribe() -> None:
hass.async_create_task(mqtt.async_publish(hass, "test-topic", "beer ready", 0))
on_status.set()
# Start a first subscription
unsub1 = await mqtt.async_subscribe(
hass, "test-topic", record_calls, on_subscribe=_on_subscribe
)
await on_status.wait()
await hass.async_block_till_done()
assert len(recorded_calls) == 1
assert recorded_calls[0].topic == "test-topic"
assert recorded_calls[0].payload == "beer ready"
assert recorded_calls[0].qos == 0
recorded_calls.clear()
# Start a second subscription to the same topic
on_status.clear()
unsub2 = await mqtt.async_subscribe(
hass, "test-topic", record_calls, on_subscribe=_on_subscribe
)
await on_status.wait()
await hass.async_block_till_done()
assert len(recorded_calls) == 2
assert recorded_calls[0].topic == "test-topic"
assert recorded_calls[0].payload == "beer ready"
assert recorded_calls[0].qos == 0
assert recorded_calls[1].topic == "test-topic"
assert recorded_calls[1].payload == "beer ready"
assert recorded_calls[1].qos == 0
unsub1()
unsub2()
@pytest.mark.usefixtures("mqtt_mock_entry")
async def test_subscribe_topic_not_initialize(
hass: HomeAssistant, record_calls: MessageCallbackType
@@ -386,16 +292,6 @@ async def test_subscribe_topic_not_initialize(
):
await mqtt.async_subscribe(hass, "test-topic", record_calls)
def _on_subscribe_callback() -> None:
pass
with pytest.raises(
HomeAssistantError, match=r".*make sure MQTT is set up correctly"
):
await mqtt.async_subscribe(
hass, "test-topic", record_calls, on_subscribe=_on_subscribe_callback
)
async def test_subscribe_mqtt_config_entry_disabled(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, record_calls: MessageCallbackType

View File

@@ -68,23 +68,6 @@ def mock_config_entry_with_assist(
return mock_config_entry
@pytest.fixture
def mock_config_entry_with_assist_invalid_api(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Mock a config entry with assist."""
subentry = next(iter(mock_config_entry.subentries.values()))
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={
**subentry.data,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST, "invalid_api"],
},
)
return mock_config_entry
@pytest.fixture
async def mock_init_component(hass: HomeAssistant, mock_config_entry: MockConfigEntry):
"""Initialize integration."""

View File

@@ -8,7 +8,7 @@ import pytest
from homeassistant import config_entries
from homeassistant.components import ollama
from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -463,27 +463,6 @@ async def test_subentry_reconfigure_with_download(
}
async def test_filter_invalid_llms(
hass: HomeAssistant,
mock_init_component,
mock_config_entry_with_assist_invalid_api: MockConfigEntry,
) -> None:
"""Test reconfiguring subentry when one of the configured LLM APIs has been removed."""
subentry = next(iter(mock_config_entry_with_assist_invalid_api.subentries.values()))
assert len(subentry.data.get(CONF_LLM_HASS_API)) == 2
assert "invalid_api" in subentry.data.get(CONF_LLM_HASS_API)
assert "assist" in subentry.data.get(CONF_LLM_HASS_API)
valid_apis = ollama.config_flow.filter_invalid_llm_apis(
hass, subentry.data[CONF_LLM_HASS_API]
)
assert len(valid_apis) == 1
assert "invalid_api" not in valid_apis
assert "assist" in valid_apis
async def test_creating_ai_task_subentry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,

View File

@@ -576,7 +576,7 @@ def _mock_rpc_device(version: str | None = None):
zigbee_enabled=False,
zigbee_firmware=False,
ip_address="10.10.10.10",
wifi_setconfig=AsyncMock(return_value={"restart_required": True}),
wifi_setconfig=AsyncMock(return_value={}),
ble_setconfig=AsyncMock(return_value={"restart_required": False}),
shutdown=AsyncMock(),
)

View File

@@ -101,11 +101,6 @@ BLE_MANUFACTURER_DATA_WITH_MAC_UNKNOWN_MODEL = {
0x0BA9: bytes.fromhex("0105000b99990a70d6c297bacc")
} # Flags (0x01, 0x05, 0x00), Model (0x0b, 0x99, 0x99) - unknown model ID, MAC (0x0a, 0x70, 0xd6, 0xc2, 0x97, 0xba, 0xcc)
BLE_MANUFACTURER_DATA_FOR_CLEAR_TEST = {
0x0BA9: bytes.fromhex("0105000b30100a00eeddccbbaa")
} # Flags (0x01, 0x05, 0x00), Model (0x0b, 0x30, 0x10), MAC (0x0a, 0x00, 0xee, 0xdd, 0xcc, 0xbb, 0xaa)
# Device WiFi MAC: 00eeddccbbaa (little-endian) -> AABBCCDDEE00 (reversed to big-endian)
BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
name="ShellyPlus2PM-C049EF8873E8",
address="AA:BB:CC:DD:EE:FF",
@@ -126,26 +121,6 @@ BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
tx_power=-127,
)
BLE_DISCOVERY_INFO_FOR_CLEAR_TEST = BluetoothServiceInfoBleak(
name="ShellyPlus2PM-AABBCCDDEE00",
address="AA:BB:CC:DD:EE:00",
rssi=-60,
manufacturer_data=BLE_MANUFACTURER_DATA_FOR_CLEAR_TEST,
service_uuids=[],
service_data={},
source="local",
device=generate_ble_device(
address="AA:BB:CC:DD:EE:00",
name="ShellyPlus2PM-AABBCCDDEE00",
),
advertisement=generate_advertisement_data(
manufacturer_data=BLE_MANUFACTURER_DATA_FOR_CLEAR_TEST,
),
time=0,
connectable=True,
tx_power=-127,
)
BLE_DISCOVERY_INFO_NO_RPC = BluetoothServiceInfoBleak(
name="ShellyPlus2PM-C049EF8873E8",
address="AA:BB:CC:DD:EE:FF",
@@ -2102,87 +2077,6 @@ async def test_bluetooth_discovery(
assert len(mock_setup_entry.mock_calls) == 1
async def test_bluetooth_provisioning_clears_match_history(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_setup: AsyncMock,
) -> None:
"""Test bluetooth provisioning clears match history at discovery start and after successful provisioning."""
# Inject BLE device so it's available in the bluetooth scanner
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO_FOR_CLEAR_TEST)
with patch(
"homeassistant.components.shelly.config_flow.async_clear_address_from_match_history",
) as mock_clear:
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=BLE_DISCOVERY_INFO_FOR_CLEAR_TEST,
context={"source": config_entries.SOURCE_BLUETOOTH},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
# Confirm
with patch(
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
return_value=[{"ssid": "MyNetwork", "rssi": -50, "auth": 2}],
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {}
)
# Select network
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SSID: "MyNetwork"},
)
# Reset mock to only count calls during provisioning
mock_clear.reset_mock()
# Enter password and provision
with (
patch(
"homeassistant.components.shelly.config_flow.async_provision_wifi",
),
patch(
"homeassistant.components.shelly.config_flow.async_lookup_device_by_name",
return_value=("1.1.1.1", 80),
),
patch(
"homeassistant.components.shelly.config_flow.get_info",
return_value=MOCK_DEVICE_INFO,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "my_password"},
)
# Provisioning happens in background, shows progress
assert result["type"] is FlowResultType.SHOW_PROGRESS
await hass.async_block_till_done()
# Complete provisioning by configuring the progress step
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# Provisioning should complete and create entry
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "AABBCCDDEE00"
# Verify match history was cleared once during provisioning
# Only count calls with our test device's address to avoid interference from other tests
our_device_calls = [
call
for call in mock_clear.call_args_list
if len(call.args) > 1
and call.args[1] == BLE_DISCOVERY_INFO_FOR_CLEAR_TEST.address
]
assert our_device_calls
mock_clear.assert_called_with(hass, BLE_DISCOVERY_INFO_FOR_CLEAR_TEST.address)
@pytest.mark.usefixtures("mock_zeroconf")
async def test_bluetooth_discovery_no_rpc_over_ble(
hass: HomeAssistant,
@@ -2198,88 +2092,6 @@ async def test_bluetooth_discovery_no_rpc_over_ble(
assert result["reason"] == "invalid_discovery_info"
async def test_bluetooth_factory_reset_rediscovery(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_setup: AsyncMock,
) -> None:
"""Test device can be rediscovered after factory reset when RPC-over-BLE is re-enabled."""
# First discovery: device is already provisioned (no RPC-over-BLE)
# Inject the device without RPC so it's in the bluetooth scanner
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO_NO_RPC)
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=BLE_DISCOVERY_INFO_NO_RPC,
context={"source": config_entries.SOURCE_BLUETOOTH},
)
# Should abort because RPC-over-BLE is not enabled
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_discovery_info"
# Simulate factory reset: device now advertises with RPC-over-BLE enabled
# Inject the updated advertisement
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO)
# Second discovery: device after factory reset (RPC-over-BLE now enabled)
# Wait for automatic discovery to happen
await hass.async_block_till_done()
# Find the flow that was automatically created
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
# Should successfully start config flow since match history was cleared
assert result["step_id"] == "bluetooth_confirm"
assert (
result["context"]["title_placeholders"]["name"] == "ShellyPlus2PM-C049EF8873E8"
)
with patch(
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
return_value=[{"ssid": "MyNetwork", "rssi": -50, "auth": 2}],
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# Select network
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SSID: "MyNetwork"},
)
# Enter password and provision
with (
patch(
"homeassistant.components.shelly.config_flow.async_provision_wifi",
),
patch(
"homeassistant.components.shelly.config_flow.async_lookup_device_by_name",
return_value=("1.1.1.1", 80),
),
patch(
"homeassistant.components.shelly.config_flow.get_info",
return_value=MOCK_DEVICE_INFO,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "my_password"},
)
# Provisioning happens in background
assert result["type"] is FlowResultType.SHOW_PROGRESS
await hass.async_block_till_done()
# Complete provisioning
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# Provisioning should complete and create entry
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "C049EF8873E8"
@pytest.mark.usefixtures("mock_zeroconf")
async def test_bluetooth_discovery_invalid_name(
hass: HomeAssistant,
@@ -2372,41 +2184,6 @@ async def test_bluetooth_discovery_already_configured(
assert result["reason"] == "already_configured"
async def test_bluetooth_discovery_already_configured_clears_match_history(
hass: HomeAssistant,
) -> None:
"""Test bluetooth discovery clears match history when device already configured."""
# Inject BLE device so it's available in the bluetooth scanner
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO)
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="C049EF8873E8", # MAC from device name - uppercase no colons
data={
CONF_HOST: "1.1.1.1",
CONF_MODEL: MODEL_PLUS_2PM,
CONF_SLEEP_PERIOD: 0,
CONF_GEN: 2,
},
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.shelly.config_flow.async_clear_address_from_match_history"
) as mock_clear:
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=BLE_DISCOVERY_INFO,
context={"source": config_entries.SOURCE_BLUETOOTH},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
# Verify match history was cleared to allow rediscovery if factory reset
mock_clear.assert_called_once_with(hass, BLE_DISCOVERY_INFO.address)
@pytest.mark.usefixtures("mock_zeroconf")
async def test_bluetooth_discovery_no_ble_device(
hass: HomeAssistant,
@@ -3369,163 +3146,6 @@ async def test_bluetooth_provision_timeout_active_lookup_fails(
assert result["reason"] == "unknown"
async def test_bluetooth_provision_timeout_ble_fallback_succeeds(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_setup: AsyncMock,
) -> None:
"""Test WiFi provisioning times out, active lookup fails, but BLE fallback succeeds."""
# Inject BLE device
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO)
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=BLE_DISCOVERY_INFO,
context={"source": config_entries.SOURCE_BLUETOOTH},
)
# Confirm and scan
with patch(
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
return_value=[{"ssid": "MyNetwork", "rssi": -50, "auth": 2}],
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# Select network
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SSID: "MyNetwork"},
)
# Mock device for BLE status query
mock_ble_status_device = AsyncMock()
mock_ble_status_device.status = {"wifi": {"sta_ip": "192.168.1.100"}}
# Mock device for secure device feature
mock_device = AsyncMock()
mock_device.initialize = AsyncMock()
mock_device.name = "Test name"
mock_device.status = {"sys": {}}
mock_device.xmod_info = {}
mock_device.shelly = {"model": MODEL_PLUS_2PM}
mock_device.wifi_setconfig = AsyncMock(return_value={})
mock_device.ble_setconfig = AsyncMock(return_value={"restart_required": False})
mock_device.shutdown = AsyncMock()
# Provision WiFi but no zeroconf discovery arrives, active lookup fails, BLE fallback succeeds
with (
patch(
"homeassistant.components.shelly.config_flow.PROVISIONING_TIMEOUT",
0.01, # Short timeout to trigger timeout path
),
patch("homeassistant.components.shelly.config_flow.async_provision_wifi"),
patch(
"homeassistant.components.shelly.config_flow.async_lookup_device_by_name",
return_value=None, # Active lookup fails
),
patch(
"homeassistant.components.shelly.config_flow.ble_rpc_device",
) as mock_ble_rpc,
patch(
"homeassistant.components.shelly.config_flow.get_info",
return_value=MOCK_DEVICE_INFO,
),
patch(
"homeassistant.components.shelly.config_flow.RpcDevice.create",
return_value=mock_device,
),
):
# Configure BLE RPC mock to return device with IP
mock_ble_rpc.return_value.__aenter__.return_value = mock_ble_status_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "my_password"},
)
# Provisioning shows progress
assert result["type"] is FlowResultType.SHOW_PROGRESS
await hass.async_block_till_done()
# Timeout occurs, active lookup fails, but BLE fallback gets IP
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# Should create entry successfully with IP from BLE
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Test name"
assert result["data"][CONF_HOST] == "192.168.1.100"
assert result["data"][CONF_PORT] == DEFAULT_HTTP_PORT
async def test_bluetooth_provision_timeout_ble_fallback_fails(
hass: HomeAssistant,
) -> None:
"""Test WiFi provisioning times out, active lookup fails, and BLE fallback also fails."""
# Inject BLE device
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO)
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=BLE_DISCOVERY_INFO,
context={"source": config_entries.SOURCE_BLUETOOTH},
)
# Confirm and scan
with patch(
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
return_value=[{"ssid": "MyNetwork", "rssi": -50, "auth": 2}],
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# Select network
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SSID: "MyNetwork"},
)
# Provision WiFi but no zeroconf discovery, active lookup fails, BLE fallback fails
with (
patch(
"homeassistant.components.shelly.config_flow.PROVISIONING_TIMEOUT",
0.01, # Short timeout to trigger timeout path
),
patch("homeassistant.components.shelly.config_flow.async_provision_wifi"),
patch(
"homeassistant.components.shelly.config_flow.async_lookup_device_by_name",
return_value=None, # Active lookup fails
),
patch(
"homeassistant.components.shelly.config_flow.async_get_ip_from_ble",
return_value=None, # BLE fallback also fails
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "my_password"},
)
# Provisioning shows progress
assert result["type"] is FlowResultType.SHOW_PROGRESS
await hass.async_block_till_done()
# Timeout occurs, both active lookup and BLE fallback fail
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# Should show provision_failed form
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "provision_failed"
# User aborts after failure
with patch(
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
side_effect=RuntimeError("BLE device unavailable"),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unknown"
async def test_bluetooth_provision_secure_device_both_enabled(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,

View File

@@ -11,7 +11,6 @@ from homeassistant.components.shelly.const import (
CONF_BLE_SCANNER_MODE,
DEPRECATED_FIRMWARE_ISSUE_ID,
DOMAIN,
OPEN_WIFI_AP_ISSUE_ID,
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
BLEScannerMode,
DeprecatedFirmwareInfo,
@@ -255,207 +254,3 @@ async def test_deprecated_firmware_issue(
# Assert the issue is no longer present
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_open_wifi_ap_issue(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test repair issues handling for open WiFi AP."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "is_open": True}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 2)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
await async_process_repairs_platforms(hass)
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
flow_id = result["flow_id"]
assert result["step_id"] == "init"
assert result["type"] == "menu"
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "confirm"})
assert result["type"] == "create_entry"
assert mock_rpc_device.wifi_setconfig.call_count == 1
assert mock_rpc_device.wifi_setconfig.call_args[1] == {"ap_enable": False}
assert mock_rpc_device.trigger_reboot.call_count == 1
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_open_wifi_ap_issue_no_restart(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test repair issues handling for open WiFi AP when restart not required."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "is_open": True}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 2)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
await async_process_repairs_platforms(hass)
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
flow_id = result["flow_id"]
assert result["step_id"] == "init"
assert result["type"] == "menu"
mock_rpc_device.wifi_setconfig.return_value = {"restart_required": False}
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "confirm"})
assert result["type"] == "create_entry"
assert mock_rpc_device.wifi_setconfig.call_count == 1
assert mock_rpc_device.wifi_setconfig.call_args[1] == {"ap_enable": False}
assert mock_rpc_device.trigger_reboot.call_count == 0
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
@pytest.mark.parametrize(
"exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")]
)
async def test_open_wifi_ap_issue_exc(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
exception: Exception,
) -> None:
"""Test repair issues handling when wifi_setconfig ends with an exception."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "is_open": True}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 2)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
await async_process_repairs_platforms(hass)
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
flow_id = result["flow_id"]
assert result["step_id"] == "init"
assert result["type"] == "menu"
mock_rpc_device.wifi_setconfig.side_effect = exception
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "confirm"})
assert result["type"] == "abort"
assert result["reason"] == "cannot_connect"
assert mock_rpc_device.wifi_setconfig.call_count == 1
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
async def test_no_open_wifi_ap_issue_with_password(
hass: HomeAssistant,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test no repair issue is created when WiFi AP has a password."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "is_open": False}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
await init_integration(hass, 2)
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_no_open_wifi_ap_issue_when_disabled(
hass: HomeAssistant,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test no repair issue is created when WiFi AP is disabled."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": False, "is_open": True}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
await init_integration(hass, 2)
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_open_wifi_ap_issue_ignore(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test ignoring the open WiFi AP issue."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "is_open": True}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 2)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
await async_process_repairs_platforms(hass)
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
flow_id = result["flow_id"]
assert result["step_id"] == "init"
assert result["type"] == "menu"
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "ignore"})
assert result["type"] == "abort"
assert result["reason"] == "issue_ignored"
assert mock_rpc_device.wifi_setconfig.call_count == 0
assert issue_registry.async_get_issue(DOMAIN, issue_id).dismissed_version

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