mirror of
https://github.com/home-assistant/core.git
synced 2026-06-13 12:41:59 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbb6789031 |
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.8.0"]
|
||||
"requirements": ["hassil==3.7.0"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""The BleBox devices integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import ConnectionError, Error, HttpError, UnauthorizedRequest
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.session import ApiHost
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -12,16 +14,14 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DEFAULT_SETUP_TIMEOUT
|
||||
from .coordinator import BleBoxConfigEntry, BleBoxCoordinator
|
||||
from .helpers import get_maybe_authenticated_session
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
@@ -50,15 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo
|
||||
|
||||
try:
|
||||
product = await Box.async_from_host(api_host)
|
||||
except UnauthorizedRequest as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
except (
|
||||
ConnectionError,
|
||||
HttpError,
|
||||
) as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
except Error as ex:
|
||||
raise ConfigEntryError from ex
|
||||
_LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex)
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
coordinator = BleBoxCoordinator(hass, entry, product)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Config flow for BleBox devices integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -27,7 +26,6 @@ from .const import (
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_SETUP_TIMEOUT,
|
||||
DOMAIN,
|
||||
INVALID_AUTH,
|
||||
UNKNOWN,
|
||||
UNSUPPORTED_VERSION,
|
||||
)
|
||||
@@ -48,7 +46,6 @@ STEP_SCHEMA = vol.Schema(
|
||||
LOG_MSG = {
|
||||
UNSUPPORTED_VERSION: "Outdated firmware",
|
||||
CANNOT_CONNECT: "Failed to identify device",
|
||||
INVALID_AUTH: "Authentication failed",
|
||||
UNKNOWN: "Unknown error while identifying device",
|
||||
}
|
||||
|
||||
@@ -90,7 +87,7 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, INVALID_AUTH, _LOGGER.error, step_id
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
|
||||
)
|
||||
except Error as ex:
|
||||
return None, self.handle_step_exception(
|
||||
@@ -118,8 +115,6 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
product = await Box.async_from_host(api_host)
|
||||
except UnauthorizedRequest:
|
||||
return self.async_abort(reason="authorization_required")
|
||||
except UnsupportedBoxVersion:
|
||||
return self.async_abort(reason="unsupported_device_version")
|
||||
except UnsupportedBoxResponse:
|
||||
@@ -251,58 +246,3 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reconfigure_entry,
|
||||
data_updates=data_updates,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication upon an API authentication error."""
|
||||
self.context["title_placeholders"] = {
|
||||
"name": self._get_reauth_entry().title,
|
||||
"host": entry_data[CONF_HOST],
|
||||
}
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
host = reauth_entry.data[CONF_HOST]
|
||||
port = reauth_entry.data[CONF_PORT]
|
||||
|
||||
if user_input is not None:
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
websession = get_maybe_authenticated_session(self.hass, password, username)
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
|
||||
)
|
||||
try:
|
||||
await Box.async_from_host(api_host)
|
||||
except UnauthorizedRequest:
|
||||
errors["base"] = INVALID_AUTH
|
||||
except Error:
|
||||
errors["base"] = CANNOT_CONNECT
|
||||
except RuntimeError:
|
||||
errors["base"] = UNKNOWN
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"address": f"{host}:{port}"},
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ DEFAULT_SETUP_TIMEOUT = 10
|
||||
# translation strings
|
||||
ADDRESS_ALREADY_CONFIGURED = "address_already_configured"
|
||||
CANNOT_CONNECT = "cannot_connect"
|
||||
INVALID_AUTH = "invalid_auth"
|
||||
UNSUPPORTED_VERSION = "unsupported_version"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error, UnauthorizedRequest
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -41,8 +40,6 @@ class BleBoxCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Fetch data from the BleBox device."""
|
||||
try:
|
||||
await self.box.async_update_data()
|
||||
except UnauthorizedRequest as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except Error as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -24,7 +24,6 @@ from homeassistant.const import (
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfReactiveEnergy,
|
||||
UnitOfReactivePower,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
@@ -98,20 +97,6 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="forwardReactiveEnergy",
|
||||
translation_key="forward_reactive_energy",
|
||||
device_class=SensorDeviceClass.REACTIVE_ENERGY,
|
||||
native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="reverseReactiveEnergy",
|
||||
translation_key="reverse_reactive_energy",
|
||||
device_class=SensorDeviceClass.REACTIVE_ENERGY,
|
||||
native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="forwardActiveEnergy",
|
||||
translation_key="forward_active_energy",
|
||||
|
||||
@@ -3,33 +3,16 @@
|
||||
"abort": {
|
||||
"address_already_configured": "A BleBox device is already configured at {address}.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"authorization_required": "The BleBox device requires authentication.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device identifier does not match the previously configured device.",
|
||||
"unsupported_device_response": "The BleBox device returned an unrecognized response.",
|
||||
"unsupported_device_version": "[%key:component::blebox::config::error::unsupported_version%]"
|
||||
"unique_id_mismatch": "The device identifier does not match the previously configured device."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unsupported_version": "BleBox device has outdated firmware. Please upgrade it first."
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The password for your BleBox device.",
|
||||
"username": "The username for your BleBox device."
|
||||
},
|
||||
"description": "Enter credentials for the BleBox device at {address}.",
|
||||
"title": "Reauthenticate your BleBox device"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
@@ -81,10 +64,6 @@
|
||||
"current_n": { "name": "Current {index}" },
|
||||
"forward_active_energy": { "name": "Forward active energy" },
|
||||
"forward_active_energy_n": { "name": "Forward active energy {index}" },
|
||||
"forward_reactive_energy": { "name": "Forward reactive energy" },
|
||||
"forward_reactive_energy_n": {
|
||||
"name": "Forward reactive energy {index}"
|
||||
},
|
||||
"frequency": { "name": "Frequency" },
|
||||
"frequency_n": { "name": "Frequency {index}" },
|
||||
"open_status": {
|
||||
@@ -102,10 +81,6 @@
|
||||
"reactive_power_n": { "name": "Reactive power {index}" },
|
||||
"reverse_active_energy": { "name": "Reverse active energy" },
|
||||
"reverse_active_energy_n": { "name": "Reverse active energy {index}" },
|
||||
"reverse_reactive_energy": { "name": "Reverse reactive energy" },
|
||||
"reverse_reactive_energy_n": {
|
||||
"name": "Reverse reactive energy {index}"
|
||||
},
|
||||
"temperature": { "name": "Temperature" },
|
||||
"temperature_n": { "name": "Temperature {index}" },
|
||||
"voltage": { "name": "Voltage" },
|
||||
|
||||
@@ -31,24 +31,12 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
data["relayer_region"] = client.relayer_region
|
||||
data["remote_enabled"] = client.prefs.remote_enabled
|
||||
data["remote_connected"] = cloud.remote.is_connected
|
||||
data["remote_server"] = cloud.remote.snitun_server
|
||||
data["alexa_enabled"] = client.prefs.alexa_enabled
|
||||
data["google_enabled"] = client.prefs.google_enabled
|
||||
data["cloud_ice_servers_enabled"] = client.prefs.cloud_ice_servers_enabled
|
||||
data["remote_server"] = cloud.remote.snitun_server
|
||||
data["certificate_status"] = cloud.remote.certificate_status
|
||||
data["instance_id"] = client.prefs.instance_id
|
||||
data["iot_state"] = cloud.iot.state
|
||||
data["iot_tries"] = cloud.iot.tries
|
||||
|
||||
if (cert := cloud.remote.certificate) is not None:
|
||||
data["certificate_expire_date"] = cert.expire_date
|
||||
data["certificate_fingerprint"] = cert.fingerprint
|
||||
if cert.alternative_names:
|
||||
data["certificate_alternative_names"] = cert.alternative_names
|
||||
|
||||
if (disconnect := cloud.iot.last_disconnect_reason) is not None:
|
||||
data["iot_last_disconnect_clean"] = disconnect.clean
|
||||
data["iot_last_disconnect_reason"] = disconnect.reason
|
||||
|
||||
data["can_reach_cert_server"] = system_health.async_check_can_reach_url(
|
||||
hass, f"https://{cloud.acme_server}/directory"
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.8.0", "home-assistant-intents==2026.6.1"]
|
||||
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pydroplet==2.4.0"],
|
||||
"requirements": ["pydroplet==2.3.4"],
|
||||
"zeroconf": ["_droplet._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -9,12 +9,10 @@ import time
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import urlparse
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from aiohttp import ClientError, ClientResponse, ClientSession, web
|
||||
from aiohttp.hdrs import AUTHORIZATION
|
||||
import jwt
|
||||
from jwt.warnings import InsecureKeyLengthWarning
|
||||
from py_vapid import Vapid
|
||||
from pywebpush import WebPusher, WebPushException, webpush_async
|
||||
import voluptuous as vol
|
||||
@@ -327,8 +325,7 @@ class HTML5PushCallbackView(HomeAssistantView):
|
||||
if target_check.get(ATTR_TARGET) in self.registrations:
|
||||
possible_target = self.registrations[target_check[ATTR_TARGET]]
|
||||
key = possible_target["subscription"]["keys"]["auth"]
|
||||
with suppress(jwt.exceptions.DecodeError), warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", InsecureKeyLengthWarning)
|
||||
with suppress(jwt.exceptions.DecodeError):
|
||||
return jwt.decode(token, key, algorithms=["ES256", "HS256"])
|
||||
|
||||
return self.json_message(
|
||||
@@ -588,9 +585,7 @@ def add_jwt(timestamp: int, target: str, tag: str, jwt_secret: str) -> str:
|
||||
ATTR_TARGET: target,
|
||||
ATTR_TAG: tag,
|
||||
}
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", InsecureKeyLengthWarning)
|
||||
return jwt.encode(jwt_claims, jwt_secret)
|
||||
return jwt.encode(jwt_claims, jwt_secret)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -536,11 +536,6 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase):
|
||||
"""Return if the cover is closed."""
|
||||
return self.positions.tilt <= CLOSED_POSITION
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if shade position data is available."""
|
||||
return super().available and self.positions.tilt is not None
|
||||
|
||||
|
||||
class PowerViewShadeTopDown(PowerViewShadeBase):
|
||||
"""Representation of a shade that lowers from the roof to the floor.
|
||||
|
||||
@@ -248,10 +248,7 @@ def _generate_thumbnail_if_file_does_not_exist(
|
||||
if not target_file.is_file():
|
||||
image = ImageOps.exif_transpose(Image.open(original_path))
|
||||
image.thumbnail(target_size)
|
||||
save_format = content_type.partition("/")[-1]
|
||||
if save_format == "jpeg" and image.mode not in ("RGB", "L", "CMYK"):
|
||||
image = image.convert("RGB")
|
||||
image.save(target_path, format=save_format)
|
||||
image.save(target_path, format=content_type.partition("/")[-1])
|
||||
|
||||
|
||||
def _validate_size_from_filename(filename: str) -> tuple[int, int]:
|
||||
|
||||
@@ -119,7 +119,7 @@ class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
return ImmichData(
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["iometer==1.0.2"],
|
||||
"requirements": ["iometer==1.0.1"],
|
||||
"zeroconf": ["_iometer._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1123,7 +1123,7 @@
|
||||
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]",
|
||||
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]"
|
||||
},
|
||||
"description": "Telegrams for group addresses used in Home Assistant could not be decrypted because Data Secure keys are missing or invalid:\n\n{addresses}\n\nTo fix this, update the sending devices configurations via ETS and provide an updated ETS interface information export or Keyring file. Make sure that the group addresses used in Home Assistant are associated with the interface used by Home Assistant (`{interface}` when the issue last occurred).",
|
||||
"description": "Telegrams for group addresses used in Home Assistant could not be decrypted because Data Secure keys are missing or invalid:\n\n{addresses}\n\nTo fix this, update the sending devices configurations via ETS and provide an updated KNX Keyring file. Make sure that the group addresses used in Home Assistant are associated with the interface used by Home Assistant (`{interface}` when the issue last occurred).",
|
||||
"title": "Update KNX Keyring"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Support for LG TV running on NetCast 3 or 4."""
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -134,22 +133,13 @@ class LgTVDevice(MediaPlayerEntity):
|
||||
|
||||
channel_list = client.query_data("channel_list")
|
||||
if channel_list:
|
||||
channel_pairs = []
|
||||
channel_names = []
|
||||
for channel in channel_list:
|
||||
channel_name = channel.find("chname")
|
||||
if channel_name is not None:
|
||||
channel_pairs.append((str(channel_name.text), channel))
|
||||
|
||||
name_count = Counter(name for name, _ in channel_pairs)
|
||||
|
||||
self._sources = {}
|
||||
for name, channel in channel_pairs:
|
||||
if name_count[name] > 1:
|
||||
major = channel.find("major")
|
||||
if major is not None:
|
||||
name = f"{name} ({major.text})"
|
||||
self._sources[name] = channel
|
||||
|
||||
channel_names.append(str(channel_name.text))
|
||||
self._sources = dict(zip(channel_names, channel_list, strict=False))
|
||||
# sort source names by the major channel number
|
||||
source_tuples = [
|
||||
(k, source.find("major").text)
|
||||
for k, source in self._sources.items()
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"],
|
||||
"requirements": ["pyoverkiz[nexity]==2.0.1"],
|
||||
"requirements": ["pyoverkiz[nexity]==2.0.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "gateway*",
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["rf-protocols==4.2.0"]
|
||||
"requirements": ["rf-protocols==4.1.0"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==13.1.1"]
|
||||
"requirements": ["uiprotect==12.0.0"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Withings coordinator."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiowithings import (
|
||||
@@ -270,7 +270,7 @@ class WithingsActivityDataUpdateCoordinator(
|
||||
self._last_valid_update
|
||||
)
|
||||
|
||||
today = dt_util.now().date()
|
||||
today = date.today() # noqa: DTZ011
|
||||
for activity in activities:
|
||||
if activity.date == today:
|
||||
self._previous_data = activity
|
||||
|
||||
@@ -31,7 +31,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> boo
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
LOGGER.debug("Migrating from version %s", entry.version)
|
||||
|
||||
if entry.version == 1:
|
||||
new_options = entry.options.copy()
|
||||
@@ -55,19 +55,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bo
|
||||
del new_data[CONF_NAME]
|
||||
hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 2:
|
||||
entity_reg = er.async_get(hass)
|
||||
entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id)
|
||||
for entity in entries:
|
||||
if entity.unique_id == "yale_smart_alarm-panic":
|
||||
entity_reg.async_update_entity(
|
||||
entity.entity_id,
|
||||
new_unique_id=f"{entry.entry_id}-panic",
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=3)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s.%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
LOGGER.debug("Migration to version %s successful", entry.version)
|
||||
|
||||
return True
|
||||
|
||||
@@ -47,7 +47,7 @@ class YalePanicButton(YaleAlarmEntity, ButtonEntity):
|
||||
"""Initialize the plug switch."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}"
|
||||
self._attr_unique_id = f"yale_smart_alarm-{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
|
||||
@@ -64,7 +64,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Yale integration."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 2
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
from .const import DOMAIN
|
||||
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
|
||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.TIME]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Base entity for the Yoto integration."""
|
||||
|
||||
from yoto_api import YotoPlayer
|
||||
from typing import Any
|
||||
|
||||
from yoto_api import YotoError, YotoPlayer
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -44,3 +47,30 @@ class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
|
||||
def available(self) -> bool:
|
||||
"""Return if the entity is available."""
|
||||
return super().available and self._player_id in self.coordinator.data
|
||||
|
||||
|
||||
class YotoPlayerEntity(YotoEntity):
|
||||
"""Base class for entities reflecting live player state over MQTT."""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the entity is available."""
|
||||
return super().available and bool(self.player.is_online)
|
||||
|
||||
|
||||
class YotoConfigEntity(YotoEntity):
|
||||
"""Base class for entities that write player settings over REST."""
|
||||
|
||||
async def _async_set_config(self, **fields: Any) -> None:
|
||||
"""Write player config fields and refresh the local copy."""
|
||||
client = self.coordinator.client
|
||||
try:
|
||||
await client.set_player_config(self._player_id, **fields)
|
||||
await client.update_player_info(self._player_id)
|
||||
except YotoError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
self.coordinator.async_set_updated_data(client.players)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"time": {
|
||||
"day_mode_start": {
|
||||
"default": "mdi:weather-sunny"
|
||||
},
|
||||
"night_mode_start": {
|
||||
"default": "mdi:weather-night"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["yoto_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["yoto-api==4.2.0"]
|
||||
"requirements": ["yoto-api==4.1.0"]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
from .entity import YotoEntity
|
||||
from .entity import YotoPlayerEntity
|
||||
|
||||
URI_SCHEME = "yoto"
|
||||
URI_CARD = "card"
|
||||
@@ -53,7 +53,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
class YotoMediaPlayer(YotoPlayerEntity, MediaPlayerEntity):
|
||||
"""Representation of a Yoto Player."""
|
||||
|
||||
_attr_name = None
|
||||
@@ -82,11 +82,6 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
super().__init__(coordinator, player)
|
||||
self._attr_unique_id = player.id
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether the player is reachable through the Yoto cloud."""
|
||||
return super().available and bool(self.player.is_online)
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""Return the playback state."""
|
||||
|
||||
@@ -57,20 +57,14 @@ rules:
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: Only the media_player entity ships in this PR; no diagnostic entities yet.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Only the media_player entity ships in this PR; no entities are disabled by default.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: The media_player uses the device name; no translatable strings yet.
|
||||
comment: No noisy or less popular entities; nothing is disabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: No custom icon translations are needed yet.
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: Authorization is the only configuration; reauth covers re-linking the account.
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"time": {
|
||||
"day_mode_start": {
|
||||
"name": "Day mode start"
|
||||
},
|
||||
"night_mode_start": {
|
||||
"name": "Night mode start"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_failed": {
|
||||
"message": "Yoto credentials are no longer valid. Please reauthenticate your account."
|
||||
@@ -46,6 +56,9 @@
|
||||
"command_failed": {
|
||||
"message": "Yoto command failed: {error}"
|
||||
},
|
||||
"config_update_failed": {
|
||||
"message": "Failed to update Yoto player settings: {error}"
|
||||
},
|
||||
"invalid_media_id": {
|
||||
"message": "Not a Yoto media identifier: {media_id}"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Time platform for the Yoto integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import time
|
||||
|
||||
from yoto_api import PlayerConfig, YotoPlayer
|
||||
|
||||
from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
from .entity import YotoConfigEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class YotoTimeEntityDescription(TimeEntityDescription):
|
||||
"""Describes a Yoto time entity.
|
||||
|
||||
``config_field`` is the ``set_player_config`` kwarg written on change.
|
||||
"""
|
||||
|
||||
value_fn: Callable[[PlayerConfig], time | None]
|
||||
config_field: str
|
||||
|
||||
|
||||
TIME_ENTITIES: tuple[YotoTimeEntityDescription, ...] = (
|
||||
YotoTimeEntityDescription(
|
||||
key="day_mode_start",
|
||||
translation_key="day_mode_start",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda config: config.day_time,
|
||||
config_field="day_time",
|
||||
),
|
||||
YotoTimeEntityDescription(
|
||||
key="night_mode_start",
|
||||
translation_key="night_mode_start",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda config: config.night_time,
|
||||
config_field="night_time",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: YotoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Yoto time platform."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
YotoTime(coordinator, player, description)
|
||||
for player in coordinator.client.players.values()
|
||||
for description in TIME_ENTITIES
|
||||
)
|
||||
|
||||
|
||||
class YotoTime(YotoConfigEntity, TimeEntity):
|
||||
"""Representation of a Yoto player config time."""
|
||||
|
||||
entity_description: YotoTimeEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: YotoDataUpdateCoordinator,
|
||||
player: YotoPlayer,
|
||||
description: YotoTimeEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the time entity."""
|
||||
super().__init__(coordinator, player)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{player.id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> time | None:
|
||||
"""Return the configured time."""
|
||||
return self.entity_description.value_fn(self.player.info.config)
|
||||
|
||||
async def async_set_value(self, value: time) -> None:
|
||||
"""Update the configured time."""
|
||||
await self._async_set_config(**{self.entity_description.config_field: value})
|
||||
@@ -700,7 +700,7 @@ def _get_exposed_entities(
|
||||
):
|
||||
# Entity is in area
|
||||
area_names.append(area_entry.name)
|
||||
area_names.extend(sorted(area_entry.aliases))
|
||||
area_names.extend(area_entry.aliases)
|
||||
elif device_entry is not None:
|
||||
# Check device area
|
||||
if (
|
||||
@@ -711,7 +711,7 @@ def _get_exposed_entities(
|
||||
is not None
|
||||
):
|
||||
area_names.append(area_entry.name)
|
||||
area_names.extend(sorted(area_entry.aliases))
|
||||
area_names.extend(area_entry.aliases)
|
||||
|
||||
info: dict[str, Any] = {
|
||||
"names": ", ".join(names),
|
||||
@@ -962,9 +962,9 @@ def _get_cached_action_parameters(
|
||||
aliases = er.async_get_entity_aliases(hass, entity_entry)
|
||||
if aliases:
|
||||
if description:
|
||||
description = description + ". Aliases: " + str(sorted(aliases))
|
||||
description = description + ". Aliases: " + str(list(aliases))
|
||||
else:
|
||||
description = "Aliases: " + str(sorted(aliases))
|
||||
description = "Aliases: " + str(list(aliases))
|
||||
|
||||
parameters_cache.setdefault(domain, {})[action] = (description, parameters)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Config entry functions for Home Assistant templates."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.exceptions import TemplateError
|
||||
@@ -105,6 +104,4 @@ class ConfigEntryExtension(BaseTemplateExtension):
|
||||
if config_entry is None:
|
||||
return None
|
||||
|
||||
if isinstance(result := getattr(config_entry, attr_name), Enum):
|
||||
return result.value
|
||||
return result
|
||||
return getattr(config_entry, attr_name)
|
||||
|
||||
@@ -37,7 +37,7 @@ go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==6.8.3
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.8.0
|
||||
hassil==3.7.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
home-assistant-frontend==20260527.6
|
||||
home-assistant-intents==2026.6.1
|
||||
|
||||
Generated
+2
-2
@@ -25,7 +25,7 @@ cryptography==48.0.0
|
||||
fnv-hash-fast==2.0.3
|
||||
ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.8.0
|
||||
hassil==3.7.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
home-assistant-intents==2026.6.1
|
||||
httpx==0.28.1
|
||||
@@ -47,7 +47,7 @@ python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.3
|
||||
PyYAML==6.0.3
|
||||
requests==2.34.2
|
||||
rf-protocols==4.2.0
|
||||
rf-protocols==4.1.0
|
||||
securetar==2026.4.1
|
||||
SQLAlchemy==2.0.50
|
||||
standard-aifc==3.13.0
|
||||
|
||||
Generated
+7
-7
@@ -1232,7 +1232,7 @@ hass-splunk==0.1.4
|
||||
|
||||
# homeassistant.components.assist_satellite
|
||||
# homeassistant.components.conversation
|
||||
hassil==3.8.0
|
||||
hassil==3.7.0
|
||||
|
||||
# homeassistant.components.jewish_calendar
|
||||
hdate[astral]==1.2.1
|
||||
@@ -1377,7 +1377,7 @@ insteon-frontend-home-assistant==0.6.2
|
||||
intellifire4py==4.4.0
|
||||
|
||||
# homeassistant.components.iometer
|
||||
iometer==1.0.2
|
||||
iometer==1.0.1
|
||||
|
||||
# homeassistant.components.iotty
|
||||
iottycloud==0.3.0
|
||||
@@ -2120,7 +2120,7 @@ pydrawise==2026.4.0
|
||||
pydroid-ipcam==3.0.0
|
||||
|
||||
# homeassistant.components.droplet
|
||||
pydroplet==2.4.0
|
||||
pydroplet==2.3.4
|
||||
|
||||
# homeassistant.components.ebox
|
||||
pyebox==1.1.4
|
||||
@@ -2438,7 +2438,7 @@ pyotgw==2.2.3
|
||||
pyotp==2.9.0
|
||||
|
||||
# homeassistant.components.overkiz
|
||||
pyoverkiz[nexity]==2.0.1
|
||||
pyoverkiz[nexity]==2.0.0
|
||||
|
||||
# homeassistant.components.palazzetti
|
||||
pypalazzetti==0.1.20
|
||||
@@ -2902,7 +2902,7 @@ renson-endura-delta==1.7.2
|
||||
reolink-aio==0.21.0
|
||||
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==4.2.0
|
||||
rf-protocols==4.1.0
|
||||
|
||||
# homeassistant.components.idteck_prox
|
||||
rfk101py==0.0.1
|
||||
@@ -3252,7 +3252,7 @@ uasiren==0.0.1
|
||||
uhooapi==1.2.8
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==13.1.1
|
||||
uiprotect==12.0.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.6.1
|
||||
@@ -3436,7 +3436,7 @@ yeelightsunflower==0.0.10
|
||||
yolink-api==0.6.5
|
||||
|
||||
# homeassistant.components.yoto
|
||||
yoto-api==4.2.0
|
||||
yoto-api==4.1.0
|
||||
|
||||
# homeassistant.components.youless
|
||||
youless-api==2.2.0
|
||||
|
||||
@@ -27,20 +27,6 @@ from .conftest import (
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(name="zeroconf_data")
|
||||
def zeroconf_data_fixture() -> ZeroconfServiceInfo:
|
||||
"""Return ZeroconfServiceInfo for a BleBox device."""
|
||||
return ZeroconfServiceInfo(
|
||||
ip_address=ip_address("172.100.123.4"),
|
||||
ip_addresses=[ip_address("172.100.123.4")],
|
||||
port=80,
|
||||
hostname="bbx-bbtest123456.local.",
|
||||
type="_bbxsrv._tcp.local.",
|
||||
name="bbx-bbtest123456._bbxsrv._tcp.local.",
|
||||
properties={"_raw": {}},
|
||||
)
|
||||
|
||||
|
||||
def create_valid_feature_mock(path="homeassistant.components.blebox.Products"):
|
||||
"""Return a valid, complete BleBox feature mock."""
|
||||
feature = mock_only_feature(
|
||||
@@ -187,7 +173,7 @@ async def test_flow_with_auth_failure(hass: HomeAssistant, product_class_mock) -
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80},
|
||||
)
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_async_setup(hass: HomeAssistant) -> None:
|
||||
@@ -239,14 +225,20 @@ async def test_async_remove_entry(
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_flow_with_zeroconf(
|
||||
hass: HomeAssistant, zeroconf_data: ZeroconfServiceInfo
|
||||
) -> None:
|
||||
async def test_flow_with_zeroconf(hass: HomeAssistant) -> None:
|
||||
"""Test setup from zeroconf discovery."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=zeroconf_data,
|
||||
data=ZeroconfServiceInfo(
|
||||
ip_address=ip_address("172.100.123.4"),
|
||||
ip_addresses=[ip_address("172.100.123.4")],
|
||||
port=80,
|
||||
hostname="bbx-bbtest123456.local.",
|
||||
type="_bbxsrv._tcp.local.",
|
||||
name="bbx-bbtest123456._bbxsrv._tcp.local.",
|
||||
properties={"_raw": {}},
|
||||
),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
@@ -260,9 +252,7 @@ async def test_flow_with_zeroconf(
|
||||
|
||||
|
||||
async def test_flow_with_zeroconf_when_already_configured(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
zeroconf_data: ZeroconfServiceInfo,
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test behaviour if device already configured."""
|
||||
config_entry.add_to_hass(hass)
|
||||
@@ -277,16 +267,22 @@ async def test_flow_with_zeroconf_when_already_configured(
|
||||
result2 = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=zeroconf_data,
|
||||
data=ZeroconfServiceInfo(
|
||||
ip_address=ip_address("172.100.123.4"),
|
||||
ip_addresses=[ip_address("172.100.123.4")],
|
||||
port=80,
|
||||
hostname="bbx-bbtest123456.local.",
|
||||
type="_bbxsrv._tcp.local.",
|
||||
name="bbx-bbtest123456._bbxsrv._tcp.local.",
|
||||
properties={"_raw": {}},
|
||||
),
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.ABORT
|
||||
assert result2["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_flow_with_zeroconf_when_device_unsupported(
|
||||
hass: HomeAssistant, zeroconf_data: ZeroconfServiceInfo
|
||||
) -> None:
|
||||
async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) -> None:
|
||||
"""Test behaviour when device is not supported."""
|
||||
with patch(
|
||||
"homeassistant.components.blebox.config_flow.Box.async_from_host",
|
||||
@@ -295,16 +291,25 @@ async def test_flow_with_zeroconf_when_device_unsupported(
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=zeroconf_data,
|
||||
data=ZeroconfServiceInfo(
|
||||
ip_address=ip_address("172.100.123.4"),
|
||||
ip_addresses=[ip_address("172.100.123.4")],
|
||||
port=80,
|
||||
hostname="bbx-bbtest123456.local.",
|
||||
type="_bbxsrv._tcp.local.",
|
||||
name="bbx-bbtest123456._bbxsrv._tcp.local.",
|
||||
properties={"_raw": {}},
|
||||
),
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "unsupported_device_version"
|
||||
|
||||
|
||||
async def test_flow_with_zeroconf_when_device_response_unsupported(
|
||||
hass: HomeAssistant, zeroconf_data: ZeroconfServiceInfo
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test behaviour when device returned unsupported response."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.blebox.config_flow.Box.async_from_host",
|
||||
side_effect=blebox_uniapi.error.UnsupportedBoxResponse,
|
||||
@@ -312,29 +317,20 @@ async def test_flow_with_zeroconf_when_device_response_unsupported(
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=zeroconf_data,
|
||||
data=ZeroconfServiceInfo(
|
||||
ip_address=ip_address("172.100.123.4"),
|
||||
ip_addresses=[ip_address("172.100.123.4")],
|
||||
port=80,
|
||||
hostname="bbx-bbtest123456.local.",
|
||||
type="_bbxsrv._tcp.local.",
|
||||
name="bbx-bbtest123456._bbxsrv._tcp.local.",
|
||||
properties={"_raw": {}},
|
||||
),
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "unsupported_device_response"
|
||||
|
||||
|
||||
async def test_flow_with_zeroconf_when_unauthorized(
|
||||
hass: HomeAssistant, zeroconf_data: ZeroconfServiceInfo
|
||||
) -> None:
|
||||
"""Test behaviour when device requires authentication during zeroconf discovery."""
|
||||
with patch(
|
||||
"homeassistant.components.blebox.config_flow.Box.async_from_host",
|
||||
side_effect=blebox_uniapi.error.UnauthorizedRequest,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=zeroconf_data,
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "authorization_required"
|
||||
|
||||
|
||||
def create_product_mock(unique_id: str = "abcd0123ef5678"):
|
||||
"""Return a product mock with a given unique_id."""
|
||||
product = create_autospec(blebox_uniapi.box.Box, True, True)
|
||||
@@ -402,7 +398,7 @@ async def test_reconfigure_flow_unique_id_mismatch(
|
||||
[
|
||||
pytest.param(blebox_uniapi.error.Error, "cannot_connect", id="api_error"),
|
||||
pytest.param(
|
||||
blebox_uniapi.error.UnauthorizedRequest, "invalid_auth", id="auth_failure"
|
||||
blebox_uniapi.error.UnauthorizedRequest, "cannot_connect", id="auth_failure"
|
||||
),
|
||||
pytest.param(
|
||||
blebox_uniapi.error.UnsupportedBoxVersion,
|
||||
@@ -446,107 +442,3 @@ async def test_reconfigure_flow_recovers_after_error(
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
|
||||
async def test_reauth_flow_works(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry, product_class_mock
|
||||
) -> None:
|
||||
"""Test that reauth flow updates credentials and reloads."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
with product_class_mock as box_class:
|
||||
box_class.async_from_host = AsyncMock(
|
||||
return_value=create_product_mock("abcd0123ef5678")
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{config_flow.CONF_USERNAME: "admin", config_flow.CONF_PASSWORD: "secret"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert config_entry.data[config_flow.CONF_USERNAME] == "admin"
|
||||
assert config_entry.data[config_flow.CONF_PASSWORD] == "secret"
|
||||
|
||||
|
||||
async def test_reauth_flow_works_without_credentials(
|
||||
hass: HomeAssistant, product_class_mock
|
||||
) -> None:
|
||||
"""Test that reauth flow clears credentials when submitted without them."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
config_flow.CONF_HOST: "172.100.123.4",
|
||||
config_flow.CONF_PORT: 80,
|
||||
config_flow.CONF_USERNAME: "admin",
|
||||
config_flow.CONF_PASSWORD: "secret",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
with product_class_mock as box_class:
|
||||
box_class.async_from_host = AsyncMock(
|
||||
return_value=create_product_mock("abcd0123ef5678")
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert config_entry.data[config_flow.CONF_USERNAME] is None
|
||||
assert config_entry.data[config_flow.CONF_PASSWORD] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
[
|
||||
pytest.param(
|
||||
blebox_uniapi.error.UnauthorizedRequest, "invalid_auth", id="auth_failure"
|
||||
),
|
||||
pytest.param(blebox_uniapi.error.Error, "cannot_connect", id="api_error"),
|
||||
pytest.param(RuntimeError, "unknown", id="runtime_error"),
|
||||
],
|
||||
)
|
||||
async def test_reauth_flow_recovers_after_error(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
product_class_mock,
|
||||
exception: type[Exception],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test that reauth shows the correct error and allows a successful retry."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await config_entry.start_reauth_flow(hass)
|
||||
|
||||
with product_class_mock as box_class:
|
||||
box_class.async_from_host = AsyncMock(side_effect=exception)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{config_flow.CONF_USERNAME: "admin", config_flow.CONF_PASSWORD: "secret"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
with product_class_mock as box_class:
|
||||
box_class.async_from_host = AsyncMock(
|
||||
return_value=create_product_mock("abcd0123ef5678")
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{config_flow.CONF_USERNAME: "admin", config_flow.CONF_PASSWORD: "secret"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""BleBox devices setup tests."""
|
||||
|
||||
import logging
|
||||
|
||||
import blebox_uniapi
|
||||
import pytest
|
||||
|
||||
@@ -15,42 +17,36 @@ from .conftest import (
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_state"),
|
||||
[
|
||||
(blebox_uniapi.error.ConnectionError, ConfigEntryState.SETUP_RETRY),
|
||||
(blebox_uniapi.error.HttpError, ConfigEntryState.SETUP_RETRY),
|
||||
(blebox_uniapi.error.UnsupportedBoxVersion, ConfigEntryState.SETUP_ERROR),
|
||||
(blebox_uniapi.error.UnsupportedBoxResponse, ConfigEntryState.SETUP_ERROR),
|
||||
(blebox_uniapi.error.UnauthorizedRequest, ConfigEntryState.SETUP_ERROR),
|
||||
(blebox_uniapi.error.Error, ConfigEntryState.SETUP_ERROR),
|
||||
],
|
||||
)
|
||||
async def test_setup_failure(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
exception: type[Exception],
|
||||
expected_state: ConfigEntryState,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that setup failures map to the correct config entry state."""
|
||||
patch_product_identify(None, side_effect=exception)
|
||||
"""Test that setup failure is handled and logged."""
|
||||
|
||||
patch_product_identify(None, side_effect=blebox_uniapi.error.ClientError)
|
||||
|
||||
caplog.set_level(logging.ERROR)
|
||||
await async_setup_config_entry(hass, config_entry)
|
||||
assert config_entry.state is expected_state
|
||||
|
||||
assert "Identify failed at 172.100.123.4:80 ()" in caplog.text
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_auth_failure_triggers_reauth(
|
||||
async def test_setup_failure_on_connection(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that UnauthorizedRequest during setup triggers a reauth flow."""
|
||||
patch_product_identify(None, side_effect=blebox_uniapi.error.UnauthorizedRequest)
|
||||
"""Test that setup failure is handled and logged."""
|
||||
|
||||
patch_product_identify(None, side_effect=blebox_uniapi.error.ConnectionError)
|
||||
|
||||
caplog.set_level(logging.ERROR)
|
||||
await async_setup_config_entry(hass, config_entry)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert any(
|
||||
f["handler"] == "blebox" and f["context"]["source"] == "reauth" for f in flows
|
||||
)
|
||||
assert "Identify failed at 172.100.123.4:80 ()" in caplog.text
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_unload_config_entry(
|
||||
|
||||
@@ -70,7 +70,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]:
|
||||
)
|
||||
mock_cloud.auth = MagicMock(spec=CognitoAuth)
|
||||
mock_cloud.iot = MagicMock(
|
||||
spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED, tries=0
|
||||
spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED
|
||||
)
|
||||
mock_cloud.voice = MagicMock(spec=Voice)
|
||||
mock_cloud.files = MagicMock(spec=Files)
|
||||
|
||||
@@ -88,14 +88,12 @@
|
||||
relayer_region | xx-earth-616
|
||||
remote_enabled | True
|
||||
remote_connected | False
|
||||
remote_server | us-west-1
|
||||
alexa_enabled | True
|
||||
google_enabled | False
|
||||
cloud_ice_servers_enabled | True
|
||||
remote_server | us-west-1
|
||||
certificate_status | ready
|
||||
instance_id | 12345678901234567890
|
||||
iot_state | connected
|
||||
iot_tries | 0
|
||||
can_reach_cert_server | Exception: Unexpected exception
|
||||
can_reach_cloud_auth | Failed: unreachable
|
||||
can_reach_cloud | ok
|
||||
@@ -206,14 +204,12 @@
|
||||
relayer_region | xx-earth-616
|
||||
remote_enabled | True
|
||||
remote_connected | False
|
||||
remote_server | us-west-1
|
||||
alexa_enabled | True
|
||||
google_enabled | False
|
||||
cloud_ice_servers_enabled | True
|
||||
remote_server | us-west-1
|
||||
certificate_status | ready
|
||||
instance_id | 12345678901234567890
|
||||
iot_state | connected
|
||||
iot_tries | 0
|
||||
can_reach_cert_server | Exception: Unexpected exception
|
||||
can_reach_cloud_auth | Failed: unreachable
|
||||
can_reach_cloud | ok
|
||||
@@ -288,14 +284,12 @@
|
||||
relayer_region | xx-earth-616
|
||||
remote_enabled | True
|
||||
remote_connected | False
|
||||
remote_server | us-west-1
|
||||
alexa_enabled | True
|
||||
google_enabled | False
|
||||
cloud_ice_servers_enabled | True
|
||||
remote_server | us-west-1
|
||||
certificate_status | ready
|
||||
instance_id | 12345678901234567890
|
||||
iot_state | connected
|
||||
iot_tries | 0
|
||||
can_reach_cert_server | Exception: Unexpected exception
|
||||
can_reach_cloud_auth | Failed: unreachable
|
||||
can_reach_cloud | ok
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from aiohttp import ClientError
|
||||
from hass_nabucasa.iot_base import DisconnectReason
|
||||
from hass_nabucasa.remote import Certificate, CertificateStatus
|
||||
from hass_nabucasa.remote import CertificateStatus
|
||||
|
||||
from homeassistant.components.cloud.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.dt import UTC
|
||||
|
||||
from tests.common import get_system_health_info
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
@@ -50,11 +47,6 @@ async def test_cloud_system_health(
|
||||
|
||||
cloud.remote.snitun_server = "us-west-1"
|
||||
cloud.remote.certificate_status = CertificateStatus.READY
|
||||
cloud.remote.certificate = None
|
||||
cloud.remote.latency_by_location = {}
|
||||
cloud.iot.state = "connected"
|
||||
cloud.iot.tries = 0
|
||||
cloud.iot.last_disconnect_reason = None
|
||||
|
||||
await cloud.client.async_system_message({"region": "xx-earth-616"})
|
||||
await set_cloud_prefs(
|
||||
@@ -88,66 +80,4 @@ async def test_cloud_system_health(
|
||||
"can_reach_cloud_auth": {"type": "failed", "error": "unreachable"},
|
||||
"can_reach_cloud": "ok",
|
||||
"instance_id": cloud.client.prefs.instance_id,
|
||||
"iot_state": "connected",
|
||||
"iot_tries": 0,
|
||||
}
|
||||
|
||||
|
||||
async def test_cloud_system_health_with_cert_and_disconnect(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
cloud: MagicMock,
|
||||
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""Test cloud system health with certificate details and disconnect reason."""
|
||||
aioclient_mock.get("https://cloud.bla.com/status", text="")
|
||||
aioclient_mock.get("https://cert-server/directory", text="")
|
||||
aioclient_mock.get(
|
||||
"https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json",
|
||||
text="",
|
||||
)
|
||||
assert await async_setup_component(hass, "system_health", {})
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
DOMAIN,
|
||||
{
|
||||
DOMAIN: {
|
||||
"user_pool_id": "AAAA",
|
||||
"region": "us-east-1",
|
||||
"acme_server": "cert-server",
|
||||
"relayer_server": "cloud.bla.com",
|
||||
},
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
await cloud.login("test-user", "test-pass")
|
||||
|
||||
expire = datetime(2026, 8, 1, tzinfo=UTC)
|
||||
cloud.remote.snitun_server = "eu-central-1"
|
||||
cloud.remote.certificate_status = CertificateStatus.READY
|
||||
cloud.remote.certificate = Certificate(
|
||||
common_name="my-home.ui.nabu.casa",
|
||||
expire_date=expire,
|
||||
fingerprint="abc123def456",
|
||||
alternative_names=["custom.example.com"],
|
||||
)
|
||||
cloud.iot.state = "connected"
|
||||
cloud.iot.tries = 2
|
||||
cloud.iot.last_disconnect_reason = DisconnectReason(
|
||||
clean=False, reason="ping_timeout"
|
||||
)
|
||||
|
||||
await set_cloud_prefs({"remote_enabled": True})
|
||||
|
||||
info = await get_system_health_info(hass, "cloud")
|
||||
|
||||
for key, val in info.items():
|
||||
if asyncio.iscoroutine(val):
|
||||
info[key] = await val
|
||||
|
||||
assert info["certificate_expire_date"] == expire
|
||||
assert info["certificate_fingerprint"] == "abc123def456"
|
||||
assert info["certificate_alternative_names"] == ["custom.example.com"]
|
||||
assert info["iot_last_disconnect_clean"] is False
|
||||
assert info["iot_last_disconnect_reason"] == "ping_timeout"
|
||||
assert info["iot_tries"] == 2
|
||||
|
||||
@@ -4,15 +4,11 @@ from asyncio import Event
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from govee_local_api import GoveeDevice, GoveeLightCapabilities, GoveeLightFeatures
|
||||
from govee_local_api import GoveeLightCapabilities, GoveeLightFeatures
|
||||
from govee_local_api.light_capabilities import COMMON_FEATURES, SCENE_CODES
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.govee_light_local.const import DOMAIN
|
||||
from homeassistant.components.govee_light_local.coordinator import GoveeController
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_govee_api")
|
||||
@@ -60,37 +56,3 @@ SCENE_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities(
|
||||
segments=[],
|
||||
scenes=SCENE_CODES,
|
||||
)
|
||||
|
||||
|
||||
async def setup_light(
|
||||
hass: HomeAssistant,
|
||||
mock_govee_api: AsyncMock,
|
||||
capabilities: GoveeLightCapabilities = DEFAULT_CAPABILITIES,
|
||||
*,
|
||||
ip: str = "192.168.1.100",
|
||||
fingerprint: str = "asdawdqwdqwd",
|
||||
sku: str = "H615A",
|
||||
) -> tuple[MockConfigEntry, GoveeDevice]:
|
||||
"""Set up a single mocked Govee light device and return its entry and device.
|
||||
|
||||
The returned tuple lets tests that need to mutate the device after setup
|
||||
(e.g. ``device.update(...)`` in availability tests) access the underlying
|
||||
``GoveeDevice`` directly. Tests that only need the entry or neither can
|
||||
discard the unused half with ``_``.
|
||||
"""
|
||||
device = GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip=ip,
|
||||
fingerprint=fingerprint,
|
||||
sku=sku,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
mock_govee_api.devices = [device]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return entry, device
|
||||
|
||||
@@ -36,7 +36,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES, setup_light
|
||||
from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
@@ -45,7 +45,22 @@ async def test_light_known_device(
|
||||
hass: HomeAssistant, mock_govee_api: AsyncMock
|
||||
) -> None:
|
||||
"""Test adding a known device."""
|
||||
entry, _ = await setup_light(hass, mock_govee_api)
|
||||
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=DEFAULT_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -65,14 +80,22 @@ async def test_light_unknown_device(
|
||||
hass: HomeAssistant, mock_govee_api: AsyncMock
|
||||
) -> None:
|
||||
"""Test adding an unknown device."""
|
||||
await setup_light(
|
||||
hass,
|
||||
mock_govee_api,
|
||||
ON_OFF_CAPABILITIES,
|
||||
ip="192.168.1.101",
|
||||
fingerprint="unkown_device",
|
||||
sku="XYZK",
|
||||
)
|
||||
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.101",
|
||||
fingerprint="unkown_device",
|
||||
sku="XYZK",
|
||||
capabilities=ON_OFF_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -84,8 +107,22 @@ async def test_light_unknown_device(
|
||||
|
||||
async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None:
|
||||
"""Test remove device."""
|
||||
entry, _ = await setup_light(hass, mock_govee_api, fingerprint="asdawdqwdqwd1")
|
||||
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd1",
|
||||
sku="H615A",
|
||||
capabilities=DEFAULT_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("light.H615A") is not None
|
||||
|
||||
# Remove 1
|
||||
@@ -162,7 +199,22 @@ async def test_light_setup_error(
|
||||
|
||||
async def test_light_on_off(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None:
|
||||
"""Test light on and then off."""
|
||||
_, device = await setup_light(hass, mock_govee_api)
|
||||
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=DEFAULT_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -181,7 +233,7 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(device, True)
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
# Turn off
|
||||
await hass.services.async_call(
|
||||
@@ -195,7 +247,7 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "off"
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(device, False)
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -228,7 +280,21 @@ async def test_turn_on_call_order(
|
||||
mock_call_kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test that turn_on is called after set_brightness/set_color/set_preset."""
|
||||
_, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES)
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=SCENE_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -246,16 +312,32 @@ async def test_turn_on_call_order(
|
||||
|
||||
mock_govee_api.assert_has_calls(
|
||||
[
|
||||
call.set_brightness(device, 50),
|
||||
getattr(call, mock_call)(device, *mock_call_args, **mock_call_kwargs),
|
||||
call.turn_on_off(device, True),
|
||||
call.set_brightness(mock_govee_api.devices[0], 50),
|
||||
getattr(call, mock_call)(
|
||||
mock_govee_api.devices[0], *mock_call_args, **mock_call_kwargs
|
||||
),
|
||||
call.turn_on_off(mock_govee_api.devices[0], True),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def test_light_brightness(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None:
|
||||
"""Test changing brightness."""
|
||||
_, device = await setup_light(hass, mock_govee_api)
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=DEFAULT_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -274,7 +356,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: AsyncMock)
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
mock_govee_api.set_brightness.assert_awaited_with(device, 50)
|
||||
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50)
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 127
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -289,7 +371,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: AsyncMock)
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 255
|
||||
mock_govee_api.set_brightness.assert_awaited_with(device, 100)
|
||||
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
@@ -303,12 +385,26 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: AsyncMock)
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 255
|
||||
mock_govee_api.set_brightness.assert_awaited_with(device, 100)
|
||||
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100)
|
||||
|
||||
|
||||
async def test_light_color(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None:
|
||||
"""Test changing color."""
|
||||
_, device = await setup_light(hass, mock_govee_api)
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=DEFAULT_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -331,7 +427,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: AsyncMock) -> No
|
||||
assert light.attributes[ATTR_COLOR_MODE] == ColorMode.RGB
|
||||
|
||||
mock_govee_api.set_color.assert_awaited_with(
|
||||
device, rgb=(100, 255, 50), temperature=None
|
||||
mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -348,12 +444,29 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: AsyncMock) -> No
|
||||
assert light.attributes[ATTR_COLOR_TEMP_KELVIN] == 4400
|
||||
assert light.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP
|
||||
|
||||
mock_govee_api.set_color.assert_awaited_with(device, rgb=None, temperature=4400)
|
||||
mock_govee_api.set_color.assert_awaited_with(
|
||||
mock_govee_api.devices[0], rgb=None, temperature=4400
|
||||
)
|
||||
|
||||
|
||||
async def test_scene_on(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None:
|
||||
"""Test turning on scene."""
|
||||
_, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES)
|
||||
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=SCENE_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -373,14 +486,29 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None:
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes[ATTR_EFFECT] == "sunrise"
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(device, True)
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
|
||||
async def test_scene_restore_rgb(
|
||||
hass: HomeAssistant, mock_govee_api: AsyncMock
|
||||
) -> None:
|
||||
"""Test restore rgb color."""
|
||||
_, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES)
|
||||
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=SCENE_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -410,7 +538,7 @@ async def test_scene_restore_rgb(
|
||||
assert light.state == "on"
|
||||
assert light.attributes[ATTR_RGB_COLOR] == initial_color
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 255
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(device, True)
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
# Activate scene
|
||||
await hass.services.async_call(
|
||||
@@ -425,7 +553,7 @@ async def test_scene_restore_rgb(
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes[ATTR_EFFECT] == "sunrise"
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(device, True)
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
# Deactivate scene
|
||||
await hass.services.async_call(
|
||||
@@ -448,7 +576,22 @@ async def test_scene_restore_temperature(
|
||||
hass: HomeAssistant, mock_govee_api: AsyncMock
|
||||
) -> None:
|
||||
"""Test restore color temperature."""
|
||||
_, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES)
|
||||
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=SCENE_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -470,7 +613,7 @@ async def test_scene_restore_temperature(
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes[ATTR_COLOR_TEMP_KELVIN] == initial_color
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(device, True)
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
# Activate scene
|
||||
await hass.services.async_call(
|
||||
@@ -485,7 +628,7 @@ async def test_scene_restore_temperature(
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes[ATTR_EFFECT] == "sunrise"
|
||||
mock_govee_api.set_scene.assert_awaited_with(device, "sunrise")
|
||||
mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise")
|
||||
|
||||
# Deactivate scene
|
||||
await hass.services.async_call(
|
||||
@@ -507,7 +650,20 @@ async def test_update_callback_registered_and_triggers_state_update(
|
||||
hass: HomeAssistant, mock_govee_api: AsyncMock
|
||||
) -> None:
|
||||
"""Test that update callback is registered and triggers state update."""
|
||||
_, device = await setup_light(hass, mock_govee_api)
|
||||
device = GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=DEFAULT_CAPABILITIES,
|
||||
)
|
||||
mock_govee_api.devices = [device]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device.update_callback is not None
|
||||
|
||||
@@ -529,7 +685,20 @@ async def test_update_callback_cleared_on_remove(
|
||||
hass: HomeAssistant, mock_govee_api: AsyncMock
|
||||
) -> None:
|
||||
"""Test that update callback is cleared when entity is removed."""
|
||||
entry, device = await setup_light(hass, mock_govee_api)
|
||||
device = GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=DEFAULT_CAPABILITIES,
|
||||
)
|
||||
mock_govee_api.devices = [device]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device.update_callback is not None
|
||||
|
||||
@@ -541,7 +710,22 @@ async def test_update_callback_cleared_on_remove(
|
||||
|
||||
async def test_scene_none(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None:
|
||||
"""Test turn on 'none' scene."""
|
||||
_, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES)
|
||||
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=SCENE_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
@@ -571,7 +755,7 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: AsyncMock) -> Non
|
||||
assert light.state == "on"
|
||||
assert light.attributes[ATTR_RGB_COLOR] == initial_color
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 255
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(device, True)
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
# Activate scene
|
||||
await hass.services.async_call(
|
||||
@@ -624,7 +808,20 @@ async def test_device_availability(
|
||||
timeout, goes unavailable past it, and recovers when a status response
|
||||
refreshes ``lastseen``.
|
||||
"""
|
||||
_, device = await setup_light(hass, mock_govee_api)
|
||||
device = GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=DEFAULT_CAPABILITIES,
|
||||
)
|
||||
mock_govee_api.devices = [device]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("light.H615A")
|
||||
assert state is not None
|
||||
|
||||
@@ -5,11 +5,9 @@ from http import HTTPStatus
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
|
||||
import warnings
|
||||
|
||||
from aiohttp import ClientError
|
||||
from aiohttp.hdrs import AUTHORIZATION
|
||||
import jwt.warnings
|
||||
import pytest
|
||||
from pywebpush import WebPushException
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
@@ -1291,11 +1289,3 @@ async def test_html5_dismiss_message(
|
||||
"data": {"jwt": "JWT"},
|
||||
**expected_payload,
|
||||
}
|
||||
|
||||
|
||||
def test_add_jwt_no_insecure_key_warning() -> None:
|
||||
"""Test that add_jwt does not emit InsecureKeyLengthWarning for short keys."""
|
||||
short_key = "c2hvcnRfa2V5X2hlcmU="
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", jwt.warnings.InsecureKeyLengthWarning)
|
||||
html5.add_jwt(1234567890, "device", "tag", short_key)
|
||||
|
||||
@@ -6,8 +6,6 @@ from unittest.mock import patch
|
||||
|
||||
from aiohttp import ClientSession, ClientWebSocketResponse
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.image_upload import DOMAIN
|
||||
from homeassistant.components.websocket_api import TYPE_RESULT
|
||||
@@ -96,57 +94,3 @@ async def test_upload_image(
|
||||
|
||||
# Ensure removed from disk
|
||||
assert not item_folder.is_dir()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("image_mode", "content_type"),
|
||||
[
|
||||
("RGBA", "image/jpeg"),
|
||||
("LA", "image/jpeg"),
|
||||
("P", "image/jpeg"),
|
||||
],
|
||||
)
|
||||
async def test_upload_image_thumbnail_rgba_as_jpeg(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
hass_client: ClientSessionGenerator,
|
||||
image_mode: str,
|
||||
content_type: str,
|
||||
) -> None:
|
||||
"""Test thumbnail generation when image mode is incompatible with JPEG."""
|
||||
now = dt_util.utcnow()
|
||||
freezer.move_to(now)
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tempdir,
|
||||
patch.object(hass.config, "path", return_value=tempdir),
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
client: ClientSession = await hass_client()
|
||||
|
||||
with TEST_IMAGE.open("rb") as fp:
|
||||
res = await client.post("/api/image/upload", data={"file": fp})
|
||||
|
||||
assert res.status == 200
|
||||
item = await res.json()
|
||||
image_id = item["id"]
|
||||
|
||||
tempdir = pathlib.Path(tempdir)
|
||||
item_folder = tempdir / image_id
|
||||
|
||||
# Create an image file with the given mode to simulate the mismatch
|
||||
original_path = item_folder / "original"
|
||||
img = Image.new(image_mode, (300, 300))
|
||||
img.save(original_path, format="png")
|
||||
|
||||
# Change the stored content_type to simulate the mismatch
|
||||
hass.data[DOMAIN].data[image_id]["content_type"] = content_type
|
||||
|
||||
# Fetch the thumbnail; this should not raise an OSError
|
||||
res = await client.get(f"/api/image/serve/{image_id}/256x256")
|
||||
assert res.status == 200
|
||||
assert (item_folder / "256x256").is_file()
|
||||
|
||||
# Verify the generated thumbnail is a valid JPEG
|
||||
thumbnail = Image.open(item_folder / "256x256")
|
||||
assert thumbnail.mode == "RGB"
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from aiohttp import ContentTypeError, RequestInfo
|
||||
from multidict import CIMultiDict, CIMultiDictProxy
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -46,32 +43,3 @@ async def test_admin_sensors(
|
||||
assert hass.states.get("sensor.mock_title_videos_count") is None
|
||||
assert hass.states.get("sensor.mock_title_disk_used_by_photos") is None
|
||||
assert hass.states.get("sensor.mock_title_disk_used_by_videos") is None
|
||||
|
||||
|
||||
async def test_update_error_does_not_leak_api_key(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that API key is not leaked in error logs on connection failure."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
api_key = "SECRET_API_KEY_12345"
|
||||
headers = CIMultiDictProxy(
|
||||
CIMultiDict({"x-api-key": api_key, "Host": "example.com"})
|
||||
)
|
||||
request_info = RequestInfo(
|
||||
url=URL("https://example.com/api/server/about"),
|
||||
method="GET",
|
||||
headers=headers,
|
||||
real_url=URL("https://example.com/api/server/about"),
|
||||
)
|
||||
mock_immich.server.async_get_about_info.side_effect = ContentTypeError(
|
||||
request_info, (), status=503, message="Service Unavailable"
|
||||
)
|
||||
|
||||
await hass.config_entries.async_reload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert api_key not in caplog.text
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Tests for LG Netcast media player platform."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import MODEL_NAME, setup_lgnetcast
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
ENTITY_ID = f"{MP_DOMAIN}.{MODEL_NAME.lower()}"
|
||||
|
||||
|
||||
def _make_channel(name: str, major: str) -> ET.Element:
|
||||
"""Create a fake channel XML element."""
|
||||
channel = ET.Element("data")
|
||||
chname = ET.SubElement(channel, "chname")
|
||||
chname.text = name
|
||||
major_el = ET.SubElement(channel, "major")
|
||||
major_el.text = major
|
||||
return channel
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_lg_netcast() -> Generator[MagicMock]:
|
||||
"""Mock LG Netcast library."""
|
||||
with patch(
|
||||
"homeassistant.components.lg_netcast.LgNetCastClient"
|
||||
) as mock_client_class:
|
||||
yield mock_client_class
|
||||
|
||||
|
||||
async def test_source_list_duplicate_channel_names(
|
||||
hass: HomeAssistant,
|
||||
mock_lg_netcast: MagicMock,
|
||||
) -> None:
|
||||
"""Test that duplicate channel names are disambiguated in source list."""
|
||||
client = mock_lg_netcast.return_value
|
||||
client.get_volume.return_value = (20, False)
|
||||
context_client = client.__enter__.return_value
|
||||
channel_data = {
|
||||
"cur_channel": None,
|
||||
"channel_list": [
|
||||
_make_channel("BBC One", "1"),
|
||||
_make_channel("ITV", "3"),
|
||||
_make_channel("BBC One", "101"),
|
||||
],
|
||||
}
|
||||
context_client.query_data.side_effect = channel_data.get
|
||||
|
||||
await setup_lgnetcast(hass)
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60))
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
entity = hass.states.get(ENTITY_ID)
|
||||
assert entity is not None
|
||||
source_list = entity.attributes.get("source_list")
|
||||
assert source_list is not None
|
||||
assert len(source_list) == 3
|
||||
assert "ITV" in source_list
|
||||
assert "BBC One (1)" in source_list
|
||||
assert "BBC One (101)" in source_list
|
||||
@@ -107,7 +107,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'henk Active calories burnt today',
|
||||
'last_reset': '2023-10-21T00:00:00-07:00',
|
||||
'last_reset': '2023-10-20T00:00:00-07:00',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': 'calories',
|
||||
}),
|
||||
@@ -169,7 +169,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'duration',
|
||||
'friendly_name': 'henk Active time today',
|
||||
'last_reset': '2023-10-21T00:00:00-07:00',
|
||||
'last_reset': '2023-10-20T00:00:00-07:00',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
|
||||
}),
|
||||
@@ -733,7 +733,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'distance',
|
||||
'friendly_name': 'henk Distance travelled today',
|
||||
'last_reset': '2023-10-21T00:00:00-07:00',
|
||||
'last_reset': '2023-10-20T00:00:00-07:00',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
|
||||
}),
|
||||
@@ -1000,7 +1000,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'distance',
|
||||
'friendly_name': 'henk Elevation change today',
|
||||
'last_reset': '2023-10-21T00:00:00-07:00',
|
||||
'last_reset': '2023-10-20T00:00:00-07:00',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
|
||||
}),
|
||||
@@ -2043,7 +2043,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'duration',
|
||||
'friendly_name': 'henk Intense activity today',
|
||||
'last_reset': '2023-10-21T00:00:00-07:00',
|
||||
'last_reset': '2023-10-20T00:00:00-07:00',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
@@ -2702,7 +2702,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'duration',
|
||||
'friendly_name': 'henk Moderate activity today',
|
||||
'last_reset': '2023-10-21T00:00:00-07:00',
|
||||
'last_reset': '2023-10-20T00:00:00-07:00',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
@@ -3576,7 +3576,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'duration',
|
||||
'friendly_name': 'henk Soft activity today',
|
||||
'last_reset': '2023-10-21T00:00:00-07:00',
|
||||
'last_reset': '2023-10-20T00:00:00-07:00',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
@@ -3739,7 +3739,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'henk Steps today',
|
||||
'last_reset': '2023-10-21T00:00:00-07:00',
|
||||
'last_reset': '2023-10-20T00:00:00-07:00',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': 'steps',
|
||||
}),
|
||||
@@ -4035,7 +4035,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'henk Total calories burnt today',
|
||||
'last_reset': '2023-10-21T00:00:00-07:00',
|
||||
'last_reset': '2023-10-20T00:00:00-07:00',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': 'calories',
|
||||
}),
|
||||
|
||||
@@ -26,7 +26,7 @@ from . import (
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-10-21 12:00:00")
|
||||
@pytest.mark.freeze_time("2023-10-21")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_all_entities(
|
||||
hass: HomeAssistant,
|
||||
@@ -160,7 +160,7 @@ async def test_activity_sensors_unknown_next_day(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test activity sensors will return unknown the next day."""
|
||||
freezer.move_to("2023-10-21 12:00:00")
|
||||
freezer.move_to("2023-10-21")
|
||||
await setup_integration(hass, polling_config_entry, False)
|
||||
|
||||
assert hass.states.get("sensor.henk_steps_today")
|
||||
@@ -181,7 +181,7 @@ async def test_activity_sensors_same_result_same_day(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test activity sensors will return the same result if old data is updated."""
|
||||
freezer.move_to("2023-10-21 12:00:00")
|
||||
freezer.move_to("2023-10-21")
|
||||
await setup_integration(hass, polling_config_entry, False)
|
||||
|
||||
assert hass.states.get("sensor.henk_steps_today").state == "1155"
|
||||
@@ -202,7 +202,7 @@ async def test_activity_sensors_created_when_existed(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test activity sensors will be added if they existed before."""
|
||||
freezer.move_to("2023-10-21 12:00:00")
|
||||
freezer.move_to("2023-10-21")
|
||||
await setup_integration(hass, polling_config_entry, False)
|
||||
|
||||
assert hass.states.get("sensor.henk_steps_today")
|
||||
@@ -223,7 +223,7 @@ async def test_activity_sensors_created_when_receive_activity_data(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test activity sensors will be added if we receive activity data."""
|
||||
freezer.move_to("2023-10-21 12:00:00")
|
||||
freezer.move_to("2023-10-21")
|
||||
withings.get_activities_in_period.return_value = []
|
||||
await setup_integration(hass, polling_config_entry, False)
|
||||
|
||||
@@ -244,22 +244,6 @@ async def test_activity_sensors_created_when_receive_activity_data(
|
||||
assert hass.states.get("sensor.henk_steps_today")
|
||||
|
||||
|
||||
async def test_activity_sensors_respect_timezone(
|
||||
hass: HomeAssistant,
|
||||
withings: AsyncMock,
|
||||
polling_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test activity sensors use HA timezone instead of system timezone."""
|
||||
await hass.config.async_set_time_zone("Asia/Tokyo")
|
||||
freezer.move_to("2023-10-20T20:00:00+00:00")
|
||||
await setup_integration(hass, polling_config_entry, False)
|
||||
|
||||
state = hass.states.get("sensor.henk_steps_today")
|
||||
assert state is not None
|
||||
assert state.state == "1155"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_sleep_sensors_created_when_existed(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -46,7 +46,7 @@ async def load_config_entry(
|
||||
entry_id="1",
|
||||
unique_id="username",
|
||||
version=2,
|
||||
minor_version=3,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'panic',
|
||||
'unique_id': '1-panic',
|
||||
'unique_id': 'yale_smart_alarm-panic',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -27,7 +27,7 @@ async def test_setup_entry(
|
||||
entry_id="1",
|
||||
unique_id="username",
|
||||
version=2,
|
||||
minor_version=3,
|
||||
minor_version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
@@ -87,7 +87,7 @@ async def test_migrate_entry(
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 3
|
||||
assert entry.minor_version == 2
|
||||
assert entry.data == ENTRY_CONFIG
|
||||
assert entry.options == OPTIONS_CONFIG
|
||||
|
||||
@@ -95,47 +95,3 @@ async def test_migrate_entry(
|
||||
lock = entity_registry.async_get(lock_entity_id)
|
||||
|
||||
assert lock.options == {"lock": {"default_code": "123456"}}
|
||||
|
||||
|
||||
async def test_migrate_panic_button_unique_id(
|
||||
hass: HomeAssistant,
|
||||
get_client: Mock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migration of panic button unique_id from v2.2 to v2.3."""
|
||||
entry = MockConfigEntry(
|
||||
title=ENTRY_CONFIG["username"],
|
||||
domain=DOMAIN,
|
||||
source=SOURCE_USER,
|
||||
data=ENTRY_CONFIG,
|
||||
options=OPTIONS_CONFIG,
|
||||
entry_id="1",
|
||||
unique_id="username",
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
entity_registry.async_get_or_create(
|
||||
"button",
|
||||
DOMAIN,
|
||||
"yale_smart_alarm-panic",
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient",
|
||||
return_value=get_client,
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 3
|
||||
|
||||
migrated = entity_registry.async_get_entity_id("button", DOMAIN, "1-panic")
|
||||
assert migrated is not None
|
||||
old = entity_registry.async_get_entity_id(
|
||||
"button", DOMAIN, "yale_smart_alarm-panic"
|
||||
)
|
||||
assert old is None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Fixtures for the Yoto integration tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, time as dt_time
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -14,6 +14,7 @@ from yoto_api import (
|
||||
Group,
|
||||
PlaybackEvent,
|
||||
PlaybackStatus,
|
||||
PlayerConfig,
|
||||
PlayerInfo,
|
||||
Track,
|
||||
YotoPlayer,
|
||||
@@ -87,6 +88,10 @@ def _build_player() -> YotoPlayer:
|
||||
player.info = PlayerInfo(
|
||||
firmware_version="v2.17.5",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
config=PlayerConfig(
|
||||
day_time=dt_time(7, 0),
|
||||
night_time=dt_time(19, 0),
|
||||
),
|
||||
)
|
||||
player.last_event = PlaybackEvent(
|
||||
player_id=PLAYER_ID,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_entities[time.nursery_yoto_day_mode_start-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'time',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'time.nursery_yoto_day_mode_start',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Day mode start',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Day mode start',
|
||||
'platform': 'yoto',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'day_mode_start',
|
||||
'unique_id': 'player-test_day_mode_start',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[time.nursery_yoto_day_mode_start-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Nursery Yoto Day mode start',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'time.nursery_yoto_day_mode_start',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '07:00:00',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[time.nursery_yoto_night_mode_start-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'time',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'time.nursery_yoto_night_mode_start',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Night mode start',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Night mode start',
|
||||
'platform': 'yoto',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'night_mode_start',
|
||||
'unique_id': 'player-test_night_mode_start',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[time.nursery_yoto_night_mode_start-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Nursery Yoto Night mode start',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'time.nursery_yoto_night_mode_start',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '19:00:00',
|
||||
})
|
||||
# ---
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the Yoto media player platform."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
@@ -22,7 +22,7 @@ from homeassistant.components.media_player import (
|
||||
SERVICE_VOLUME_SET,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -68,7 +68,8 @@ async def test_entity_state(
|
||||
) -> None:
|
||||
"""Snapshot the media player entity state."""
|
||||
freezer.move_to("2026-05-08T12:00:00+00:00")
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.MEDIA_PLAYER]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for the Yoto time platform."""
|
||||
|
||||
from datetime import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from yoto_api import YotoError
|
||||
|
||||
from homeassistant.components.time import (
|
||||
ATTR_TIME,
|
||||
DOMAIN as TIME_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import PLAYER_ID
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("setup_credentials")
|
||||
|
||||
|
||||
async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
|
||||
"""Set up the integration with only the time platform."""
|
||||
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.TIME]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_yoto_client")
|
||||
async def test_all_entities(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Snapshot every Yoto time entity."""
|
||||
await _setup(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_available_when_offline(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Config is written over REST, so entities stay available when offline."""
|
||||
player = next(iter(mock_yoto_client.players.values()))
|
||||
player.is_online = False
|
||||
|
||||
await _setup(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("time.nursery_yoto_day_mode_start")
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "expected_fields"),
|
||||
[
|
||||
pytest.param(
|
||||
"time.nursery_yoto_day_mode_start",
|
||||
{"day_time": time(8, 30)},
|
||||
id="day-mode-start",
|
||||
),
|
||||
pytest.param(
|
||||
"time.nursery_yoto_night_mode_start",
|
||||
{"night_time": time(8, 30)},
|
||||
id="night-mode-start",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_set_value(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_id: str,
|
||||
expected_fields: dict[str, time],
|
||||
) -> None:
|
||||
"""Setting a time writes the matching player config field."""
|
||||
await _setup(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TIME_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_TIME: time(8, 30)},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_yoto_client.set_player_config.assert_awaited_once_with(
|
||||
PLAYER_ID, **expected_fields
|
||||
)
|
||||
mock_yoto_client.update_player_info.assert_awaited_once_with(PLAYER_ID)
|
||||
|
||||
|
||||
async def test_set_value_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""A failed config write raises a Home Assistant error."""
|
||||
await _setup(hass, mock_config_entry)
|
||||
mock_yoto_client.set_player_config.side_effect = YotoError("MQTT timeout")
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="Failed to update Yoto player settings"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
TIME_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{ATTR_ENTITY_ID: "time.nursery_yoto_day_mode_start", ATTR_TIME: time(7, 0)},
|
||||
blocking=True,
|
||||
)
|
||||
@@ -110,37 +110,24 @@ async def test_config_entry_id(
|
||||
|
||||
async def test_config_entry_attr(hass: HomeAssistant) -> None:
|
||||
"""Test config entry attr."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain="mock_light",
|
||||
title="mock title",
|
||||
source=config_entries.SOURCE_BLUETOOTH,
|
||||
disabled_by=config_entries.ConfigEntryDisabler.USER,
|
||||
pref_disable_polling=True,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
expected = {
|
||||
info = {
|
||||
"domain": "mock_light",
|
||||
"title": "mock title",
|
||||
"source": config_entries.SOURCE_BLUETOOTH,
|
||||
"disabled_by": "user",
|
||||
"pref_disable_polling": "True",
|
||||
"state": "not_loaded",
|
||||
"disabled_by": config_entries.ConfigEntryDisabler.USER,
|
||||
"pref_disable_polling": True,
|
||||
}
|
||||
config_entry = MockConfigEntry(**info)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
for key, value in expected.items():
|
||||
assert (
|
||||
render(
|
||||
hass,
|
||||
"{{ config_entry_attr('"
|
||||
+ config_entry.entry_id
|
||||
+ "', '"
|
||||
+ key
|
||||
+ "') }}",
|
||||
parse_result=False,
|
||||
)
|
||||
== value
|
||||
)
|
||||
info["state"] = config_entries.ConfigEntryState.NOT_LOADED
|
||||
|
||||
for key, value in info.items():
|
||||
assert render(
|
||||
hass,
|
||||
"{{ config_entry_attr('" + config_entry.entry_id + "', '" + key + "') }}",
|
||||
parse_result=False,
|
||||
) == str(value)
|
||||
|
||||
for config_entry_id, key in (
|
||||
(config_entry.entry_id, "invalid_key"),
|
||||
|
||||
@@ -1234,7 +1234,7 @@ async def test_script_tool(
|
||||
assert tool.name == "test_script"
|
||||
assert (
|
||||
tool.description
|
||||
== "This is a test script. Aliases: ['script alias', 'script name']"
|
||||
== "This is a test script. Aliases: ['script name', 'script alias']"
|
||||
)
|
||||
schema = {
|
||||
vol.Required("beer", description="Number of beers"): cv.string,
|
||||
@@ -1249,7 +1249,7 @@ async def test_script_tool(
|
||||
|
||||
assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == {
|
||||
"test_script": (
|
||||
"This is a test script. Aliases: ['script alias', 'script name']",
|
||||
"This is a test script. Aliases: ['script name', 'script alias']",
|
||||
vol.Schema(schema),
|
||||
),
|
||||
"script_with_no_fields": (
|
||||
@@ -1358,14 +1358,14 @@ async def test_script_tool(
|
||||
assert tool.name == "test_script"
|
||||
assert (
|
||||
tool.description
|
||||
== "This is a new test script. Aliases: ['script alias', 'script name']"
|
||||
== "This is a new test script. Aliases: ['script name', 'script alias']"
|
||||
)
|
||||
schema = {vol.Required("beer", description="Number of beers"): cv.string}
|
||||
assert tool.parameters.schema == schema
|
||||
|
||||
assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == {
|
||||
"test_script": (
|
||||
"This is a new test script. Aliases: ['script alias', 'script name']",
|
||||
"This is a new test script. Aliases: ['script name', 'script alias']",
|
||||
vol.Schema(schema),
|
||||
),
|
||||
"script_with_no_fields": (
|
||||
|
||||
Reference in New Issue
Block a user