This commit is contained in:
Paulus Schoutsen
2023-07-13 12:26:19 -04:00
committed by GitHub
59 changed files with 971 additions and 225 deletions

View File

@@ -182,7 +182,6 @@ omit =
homeassistant/components/crownstone/listeners.py
homeassistant/components/cups/sensor.py
homeassistant/components/currencylayer/sensor.py
homeassistant/components/daikin/__init__.py
homeassistant/components/daikin/climate.py
homeassistant/components/daikin/sensor.py
homeassistant/components/daikin/switch.py

View File

@@ -42,6 +42,7 @@ class BlinkSyncModule(AlarmControlPanelEntity):
_attr_icon = ICON
_attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY
_attr_name = None
_attr_has_entity_name = True
def __init__(self, data, name, sync):
"""Initialize the alarm control panel."""

View File

@@ -58,13 +58,14 @@ async def async_setup_entry(
class BlinkBinarySensor(BinarySensorEntity):
"""Representation of a Blink binary sensor."""
_attr_has_entity_name = True
def __init__(
self, data, camera, description: BinarySensorEntityDescription
) -> None:
"""Initialize the sensor."""
self.data = data
self.entity_description = description
self._attr_name = f"{DOMAIN} {camera} {description.name}"
self._camera = data.cameras[camera]
self._attr_unique_id = f"{self._camera.serial}-{description.key}"
self._attr_device_info = DeviceInfo(

View File

@@ -38,6 +38,7 @@ async def async_setup_entry(
class BlinkCamera(Camera):
"""An implementation of a Blink Camera."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, data, name, camera):

View File

@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==2.12.0"]
"requirements": ["bthome-ble==2.12.1"]
}

View File

@@ -301,55 +301,55 @@
"name": "Condition 1d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
"cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
"snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
"lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
"lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
}
},
"condition_2d": {
"name": "Condition 2d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
"cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
"snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
"lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
"lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
}
},
"condition_3d": {
"name": "Condition 3d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
"cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
"snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
"lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
"lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
}
},
"condition_4d": {
"name": "Condition 4d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
"cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
"snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
"lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
"lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
}
},
"condition_5d": {
"name": "Condition 5d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
"cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
"snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
"lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
"lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
}
},
"conditioncode_1d": {
@@ -371,76 +371,76 @@
"name": "Detailed condition 1d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
"partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
"partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]",
"partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
"partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
"partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
"cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
"light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
"partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
"partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]",
"partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]",
"snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]",
"snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]",
"lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]"
"snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
"snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]",
"lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
}
},
"conditiondetailed_2d": {
"name": "Detailed condition 2d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
"partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
"partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]",
"partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
"partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
"partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
"cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
"light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
"partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
"partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]",
"partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]",
"snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]",
"snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]",
"lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]"
"snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
"snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]",
"lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
}
},
"conditiondetailed_3d": {
"name": "Detailed condition 3d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
"partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
"partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]",
"partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
"partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
"partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
"cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
"light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
"partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
"partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]",
"partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]",
"snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]",
"snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]",
"lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]"
"snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
"snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]",
"lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
}
},
"conditiondetailed_4d": {
"name": "Detailed condition 4d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
"partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
"partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]",
"partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
"partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
"partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
"cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
"light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
"partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
@@ -455,21 +455,21 @@
"name": "Detailed condition 5d",
"state": {
"clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
"partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
"partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]",
"partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
"partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
"partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
"cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
"fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
"rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
"cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
"fog": "[%key:component::weather::entity_component::_::state::fog%]",
"rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
"light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
"light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
"partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
"partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]",
"partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]",
"snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]",
"snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]",
"lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]"
"snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
"snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]",
"lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
}
},
"conditionexact_1d": {

View File

@@ -15,8 +15,9 @@ from homeassistant.const import (
CONF_UUID,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
@@ -52,6 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not daikin_api:
return False
await async_migrate_unique_id(hass, entry, daikin_api)
hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api})
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -67,7 +70,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
async def daikin_api_setup(hass, host, key, uuid, password):
async def daikin_api_setup(hass: HomeAssistant, host, key, uuid, password):
"""Create a Daikin instance only once."""
session = async_get_clientsession(hass)
@@ -127,3 +130,82 @@ class DaikinApi:
name=info.get("name"),
sw_version=info.get("ver", "").replace("_", "."),
)
async def async_migrate_unique_id(
hass: HomeAssistant, config_entry: ConfigEntry, api: DaikinApi
) -> None:
"""Migrate old entry."""
dev_reg = dr.async_get(hass)
old_unique_id = config_entry.unique_id
new_unique_id = api.device.mac
new_name = api.device.values["name"]
@callback
def _update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
"""Update unique ID of entity entry."""
return update_unique_id(entity_entry, new_unique_id)
if new_unique_id == old_unique_id:
return
# Migrate devices
for device_entry in dr.async_entries_for_config_entry(
dev_reg, config_entry.entry_id
):
for connection in device_entry.connections:
if connection[1] == old_unique_id:
new_connections = {
(CONNECTION_NETWORK_MAC, dr.format_mac(new_unique_id))
}
_LOGGER.debug(
"Migrating device %s connections to %s",
device_entry.name,
new_connections,
)
dev_reg.async_update_device(
device_entry.id,
merge_connections=new_connections,
)
if device_entry.name is None:
_LOGGER.debug(
"Migrating device name to %s",
new_name,
)
dev_reg.async_update_device(
device_entry.id,
name=new_name,
)
# Migrate entities
await er.async_migrate_entries(hass, config_entry.entry_id, _update_unique_id)
new_data = {**config_entry.data, KEY_MAC: dr.format_mac(new_unique_id)}
hass.config_entries.async_update_entry(
config_entry, unique_id=new_unique_id, data=new_data
)
@callback
def update_unique_id(
entity_entry: er.RegistryEntry, unique_id: str
) -> dict[str, str] | None:
"""Update unique ID of entity entry."""
if entity_entry.unique_id.startswith(unique_id):
# Already correct, nothing to do
return None
unique_id_parts = entity_entry.unique_id.split("-")
unique_id_parts[0] = unique_id
entity_new_unique_id = "-".join(unique_id_parts)
_LOGGER.debug(
"Migrating entity %s from %s to new id %s",
entity_entry.entity_id,
entity_entry.unique_id,
entity_new_unique_id,
)
return {"new_unique_id": entity_new_unique_id}

View File

@@ -7,6 +7,6 @@
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"quality_scale": "platinum",
"requirements": ["pydaikin==2.9.0"],
"requirements": ["pydaikin==2.10.5"],
"zeroconf": ["_dkapi._tcp.local."]
}

View File

@@ -42,7 +42,7 @@ async def async_setup_entry(
[
DaikinZoneSwitch(daikin_api, zone_id)
for zone_id, zone in enumerate(zones)
if zone != ("-", "0")
if zone[0] != "-"
]
)
if daikin_api.device.support_advanced_modes:

View File

@@ -60,7 +60,6 @@ class ServiceDetails(NamedTuple):
SERVICE_HANDLERS = {
SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"),
"yamaha": ServiceDetails("media_player", "yamaha"),
"openhome": ServiceDetails("media_player", "openhome"),
"bluesound": ServiceDetails("media_player", "bluesound"),
}
@@ -87,6 +86,7 @@ MIGRATED_SERVICE_HANDLERS = [
SERVICE_MOBILE_APP,
SERVICE_NETGEAR,
SERVICE_OCTOPRINT,
"openhome",
"philips_hue",
SERVICE_SAMSUNG_PRINTER,
"sonos",

View File

@@ -76,6 +76,7 @@ class ControllerEntity(ClimateEntity):
_attr_fan_modes = list(_HA_FAN_TO_ESCEA)
_attr_has_entity_name = True
_attr_name = None
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
_attr_icon = ICON
_attr_precision = PRECISION_WHOLE

View File

@@ -388,6 +388,7 @@ async def async_setup_entry( # noqa: C901
assert cli.api_version is not None
entry_data.api_version = cli.api_version
entry_data.available = True
entry_data.expected_disconnect = True
if entry_data.device_info.name:
reconnect_logic.name = entry_data.device_info.name

View File

@@ -34,7 +34,7 @@ from .const import (
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN,
)
from .dashboard import async_get_dashboard, async_set_dashboard_info
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
@@ -391,7 +391,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""
if (
self._device_name is None
or (dashboard := async_get_dashboard(self.hass)) is None
or (manager := await async_get_or_create_dashboard_manager(self.hass))
is None
or (dashboard := manager.async_get()) is None
):
return False

View File

@@ -93,13 +93,6 @@ class ESPHomeDashboardManager:
hass, addon_slug, url, async_get_clientsession(hass)
)
await dashboard.async_request_refresh()
if not cur_dashboard and not dashboard.last_update_success:
# If there was no previous dashboard and the new one is not available,
# we skip setup and wait for discovery.
_LOGGER.error(
"Dashboard unavailable; skipping setup: %s", dashboard.last_exception
)
return
self._current_dashboard = dashboard
@@ -143,7 +136,14 @@ class ESPHomeDashboardManager:
@callback
def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None:
"""Get an instance of the dashboard if set."""
"""Get an instance of the dashboard if set.
This is only safe to call after `async_setup` has been completed.
It should not be called from the config flow because there is a race
where manager can be an asyncio.Event instead of the actual manager
because the singleton decorator is not yet done.
"""
manager: ESPHomeDashboardManager | None = hass.data.get(KEY_DASHBOARD_MANAGER)
return manager.async_get() if manager else None

View File

@@ -30,9 +30,6 @@ async def async_setup_entry(
avm_wrapper.fritz_guest_wifi.get_info
)
if not guest_wifi_info.get("NewEnable"):
return
async_add_entities(
[
FritzGuestWifiQRImage(

View File

@@ -16,5 +16,5 @@
"iot_class": "local_polling",
"loggers": ["goalzero"],
"quality_scale": "silver",
"requirements": ["goalzero==0.2.1"]
"requirements": ["goalzero==0.2.2"]
}

View File

@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==2.6.5"],
"requirements": ["aiohomekit==2.6.7"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}

View File

@@ -43,6 +43,7 @@ class LEDBLEEntity(CoordinatorEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.RGB, ColorMode.WHITE}
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = LightEntityFeature.EFFECT
def __init__(

View File

@@ -22,6 +22,24 @@ PLATFORMS_BULB = [Platform.LIGHT]
_LOGGER = logging.getLogger(__name__)
async def _async_get_device_state(
device: MyStromSwitch | MyStromBulb, ip_address: str
) -> None:
try:
await device.get_state()
except MyStromConnectionError as err:
_LOGGER.error("No route to myStrom plug: %s", ip_address)
raise ConfigEntryNotReady() from err
def _get_mystrom_bulb(host: str, mac: str) -> MyStromBulb:
return MyStromBulb(host, mac)
def _get_mystrom_switch(host: str) -> MyStromSwitch:
return MyStromSwitch(host)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up myStrom from a config entry."""
host = entry.data[CONF_HOST]
@@ -32,14 +50,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("No route to myStrom plug: %s", host)
raise ConfigEntryNotReady() from err
info.setdefault("type", 101)
device_type = info["type"]
if device_type in [101, 106, 107]:
device = MyStromSwitch(host)
device = _get_mystrom_switch(host)
platforms = PLATFORMS_SWITCH
elif device_type == 102:
await _async_get_device_state(device, info["ip"])
elif device_type in [102, 105]:
mac = info["mac"]
device = MyStromBulb(host, mac)
device = _get_mystrom_bulb(host, mac)
platforms = PLATFORMS_BULB
await _async_get_device_state(device, info["ip"])
if device.bulb_type not in ["rgblamp", "strip"]:
_LOGGER.error(
"Device %s (%s) is not a myStrom bulb nor myStrom LED Strip",
@@ -51,12 +73,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("Unsupported myStrom device type: %s", device_type)
return False
try:
await device.get_state()
except MyStromConnectionError as err:
_LOGGER.error("No route to myStrom plug: %s", info["ip"])
raise ConfigEntryNotReady() from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = MyStromData(
device=device,
info=info,
@@ -69,10 +85,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
device_type = hass.data[DOMAIN][entry.entry_id].info["type"]
platforms = []
if device_type in [101, 106, 107]:
platforms = PLATFORMS_SWITCH
elif device_type == 102:
platforms = PLATFORMS_BULB
platforms.extend(PLATFORMS_SWITCH)
elif device_type in [102, 105]:
platforms.extend(PLATFORMS_BULB)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms):
hass.data[DOMAIN].pop(entry.entry_id)

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/python_script",
"loggers": ["RestrictedPython"],
"quality_scale": "internal",
"requirements": ["RestrictedPython==6.0"]
"requirements": ["RestrictedPython==6.1"]
}

View File

@@ -2,10 +2,12 @@
from __future__ import annotations
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
from pyrainbird.exceptions import RainbirdApiException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_SERIAL_NUMBER
@@ -29,11 +31,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.data[CONF_PASSWORD],
)
)
try:
model_info = await controller.get_model_and_version()
except RainbirdApiException as err:
raise ConfigEntryNotReady from err
coordinator = RainbirdUpdateCoordinator(
hass,
name=entry.title,
controller=controller,
serial_number=entry.data[CONF_SERIAL_NUMBER],
model_info=model_info,
)
await coordinator.async_config_entry_first_refresh()

View File

@@ -9,6 +9,7 @@ from typing import TypeVar
import async_timeout
from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException
from pyrainbird.data import ModelAndVersion
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
@@ -42,6 +43,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
name: str,
controller: AsyncRainbirdController,
serial_number: str,
model_info: ModelAndVersion,
) -> None:
"""Initialize ZoneStateUpdateCoordinator."""
super().__init__(
@@ -54,6 +56,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
self._controller = controller
self._serial_number = serial_number
self._zones: set[int] | None = None
self._model_info = model_info
@property
def controller(self) -> AsyncRainbirdController:
@@ -72,6 +75,8 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
default_name=f"{MANUFACTURER} Controller",
identifiers={(DOMAIN, self._serial_number)},
manufacturer=MANUFACTURER,
model=self._model_info.model_name,
sw_version=f"{self._model_info.major}.{self._model_info.minor}",
)
async def _async_update_data(self) -> RainbirdDeviceState:

View File

@@ -62,6 +62,7 @@ class RainMachineUpdateEntity(RainMachineEntity, UpdateEntity):
"""Define a RainMachine update entity."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_name = None
_attr_supported_features = (
UpdateEntityFeature.INSTALL
| UpdateEntityFeature.PROGRESS

View File

@@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"requirements": ["reolink-aio==0.7.1"]
"requirements": ["reolink-aio==0.7.3"]
}

View File

@@ -49,7 +49,7 @@ SELECT_ENTITIES = (
icon="mdi:spotlight-beam",
entity_category=EntityCategory.CONFIG,
translation_key="floodlight_mode",
get_options=[mode.name for mode in SpotlightModeEnum],
get_options=lambda api, ch: api.whiteled_mode_list(ch),
supported=lambda api, ch: api.supported(ch, "floodLight"),
value=lambda api, ch: SpotlightModeEnum(api.whiteled_mode(ch)).name,
method=lambda api, ch, name: api.set_whiteled(ch, mode=name),

View File

@@ -62,7 +62,9 @@
"state": {
"off": "Off",
"auto": "Auto",
"schedule": "Schedule"
"schedule": "Schedule",
"adaptive": "Adaptive",
"autoadaptive": "Auto adaptive"
}
},
"day_night_mode": {

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aioridwell"],
"requirements": ["aioridwell==2023.01.0"]
"requirements": ["aioridwell==2023.07.0"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/roborock",
"iot_class": "local_polling",
"loggers": ["roborock"],
"requirements": ["python-roborock==0.29.2"]
"requirements": ["python-roborock==0.30.0"]
}

View File

@@ -90,6 +90,7 @@ class SlimProtoPlayer(MediaPlayerEntity):
| MediaPlayerEntityFeature.BROWSE_MEDIA
)
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_name = None
def __init__(self, slimserver: SlimServer, player: SlimClient) -> None:
"""Initialize MediaPlayer entity."""

View File

@@ -33,6 +33,7 @@ class StookwijzerSensor(SensorEntity):
_attr_attribution = "Data provided by stookwijzer.nu"
_attr_device_class = SensorDeviceClass.ENUM
_attr_has_entity_name = True
_attr_name = None
_attr_translation_key = "stookwijzer"
def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None:

View File

@@ -122,6 +122,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
| CoverEntityFeature.STOP_TILT
| CoverEntityFeature.SET_TILT_POSITION
)
_attr_name = None
_attr_translation_key = "cover"
CLOSED_UP_THRESHOLD = 80
CLOSED_DOWN_THRESHOLD = 20

View File

@@ -49,9 +49,10 @@ async def async_setup_entry(
class UpbLight(UpbAttachedEntity, LightEntity):
"""Representation of an UPB Light."""
"""Representation of a UPB Light."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, element, unique_id, upb):
"""Initialize an UpbLight."""

View File

@@ -47,7 +47,7 @@ async def async_setup_entry(
class UpbLink(UpbEntity, Scene):
"""Representation of an UPB Link."""
"""Representation of a UPB Link."""
def __init__(self, element, unique_id, upb):
"""Initialize the base of all UPB devices."""

View File

@@ -9,7 +9,7 @@
},
"iot_class": "local_push",
"loggers": ["pywemo"],
"requirements": ["pywemo==0.9.1"],
"requirements": ["pywemo==1.1.0"],
"ssdp": [
{
"manufacturer": "Belkin International Inc."

View File

@@ -43,6 +43,7 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity):
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_name = None
def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None:
"""Initialize the Yale Alarm Device."""

View File

@@ -40,6 +40,8 @@ async def async_setup_entry(
class YaleDoorlock(YaleEntity, LockEntity):
"""Representation of a Yale doorlock."""
_attr_name = None
def __init__(
self, coordinator: YaleDataUpdateCoordinator, data: dict, code_format: int
) -> None:

View File

@@ -29,6 +29,7 @@ class YaleXSBLELock(YALEXSBLEEntity, LockEntity):
"""A yale xs ble lock."""
_attr_has_entity_name = True
_attr_name = None
@callback
def _async_update_state(

View File

@@ -25,10 +25,10 @@
"pyserial-asyncio==0.6",
"zha-quirks==0.0.101",
"zigpy-deconz==0.21.0",
"zigpy==0.56.1",
"zigpy==0.56.2",
"zigpy-xbee==0.18.1",
"zigpy-zigate==0.11.0",
"zigpy-znp==0.11.2"
"zigpy-znp==0.11.3"
],
"usb": [
{

View File

@@ -8,7 +8,7 @@ from .backports.enum import StrEnum
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2023
MINOR_VERSION: Final = 7
PATCH_VERSION: Final = "1"
PATCH_VERSION: Final = "2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)

View File

@@ -16,7 +16,6 @@ from collections.abc import (
)
import concurrent.futures
from contextlib import suppress
from contextvars import ContextVar
import datetime
import enum
import functools
@@ -156,8 +155,6 @@ MAX_EXPECTED_ENTITY_IDS = 16384
_LOGGER = logging.getLogger(__name__)
_cv_hass: ContextVar[HomeAssistant] = ContextVar("hass")
@functools.lru_cache(MAX_EXPECTED_ENTITY_IDS)
def split_entity_id(entity_id: str) -> tuple[str, str]:
@@ -200,16 +197,27 @@ def is_callback(func: Callable[..., Any]) -> bool:
return getattr(func, "_hass_callback", False) is True
class _Hass(threading.local):
"""Container which makes a HomeAssistant instance available to the event loop."""
hass: HomeAssistant | None = None
_hass = _Hass()
@callback
def async_get_hass() -> HomeAssistant:
"""Return the HomeAssistant instance.
Raises LookupError if no HomeAssistant instance is available.
Raises HomeAssistantError when called from the wrong thread.
This should be used where it's very cumbersome or downright impossible to pass
hass to the code which needs it.
"""
return _cv_hass.get()
if not _hass.hass:
raise HomeAssistantError("async_get_hass called from the wrong thread")
return _hass.hass
@enum.unique
@@ -293,9 +301,9 @@ class HomeAssistant:
config_entries: ConfigEntries = None # type: ignore[assignment]
def __new__(cls) -> HomeAssistant:
"""Set the _cv_hass context variable."""
"""Set the _hass thread local data."""
hass = super().__new__(cls)
_cv_hass.set(hass)
_hass.hass = hass
return hass
def __init__(self) -> None:
@@ -1760,7 +1768,7 @@ class ServiceRegistry:
the context. Will return NONE if the service does not exist as there is
other error handling when calling the service if it does not exist.
"""
if not (handler := self._services[domain][service]):
if not (handler := self._services[domain.lower()][service.lower()]):
return SupportsResponse.NONE
return handler.supports_response

View File

@@ -93,7 +93,7 @@ from homeassistant.core import (
split_entity_id,
valid_entity_id,
)
from homeassistant.exceptions import TemplateError
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.generated import currencies
from homeassistant.generated.countries import COUNTRIES
from homeassistant.generated.languages import LANGUAGES
@@ -609,7 +609,7 @@ def template(value: Any | None) -> template_helper.Template:
raise vol.Invalid("template value should be a string")
hass: HomeAssistant | None = None
with contextlib.suppress(LookupError):
with contextlib.suppress(HomeAssistantError):
hass = async_get_hass()
template_value = template_helper.Template(str(value), hass)
@@ -631,7 +631,7 @@ def dynamic_template(value: Any | None) -> template_helper.Template:
raise vol.Invalid("template value does not contain a dynamic template")
hass: HomeAssistant | None = None
with contextlib.suppress(LookupError):
with contextlib.suppress(HomeAssistantError):
hass = async_get_hass()
template_value = template_helper.Template(str(value), hass)
@@ -1098,7 +1098,7 @@ def _no_yaml_config_schema(
# pylint: disable-next=import-outside-toplevel
from .issue_registry import IssueSeverity, async_create_issue
with contextlib.suppress(LookupError):
with contextlib.suppress(HomeAssistantError):
hass = async_get_hass()
async_create_issue(
hass,

View File

@@ -670,6 +670,9 @@ def async_set_service_schema(
hass: HomeAssistant, domain: str, service: str, schema: dict[str, Any]
) -> None:
"""Register a description for a service."""
domain = domain.lower()
service = service.lower()
descriptions_cache: dict[
tuple[str, str], dict[str, Any] | None
] = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {})

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2023.7.1"
version = "2023.7.2"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"

View File

@@ -124,7 +124,7 @@ PyXiaomiGateway==0.14.3
RachioPy==1.0.3
# homeassistant.components.python_script
RestrictedPython==6.0
RestrictedPython==6.1
# homeassistant.components.remember_the_milk
RtmAPI==0.7.2
@@ -252,7 +252,7 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.homekit_controller
aiohomekit==2.6.5
aiohomekit==2.6.7
# homeassistant.components.emulated_hue
# homeassistant.components.http
@@ -330,7 +330,7 @@ aioqsw==0.3.2
aiorecollect==1.0.8
# homeassistant.components.ridwell
aioridwell==2023.01.0
aioridwell==2023.07.0
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -565,7 +565,7 @@ brunt==1.2.0
bt-proximity==0.2.1
# homeassistant.components.bthome
bthome-ble==2.12.0
bthome-ble==2.12.1
# homeassistant.components.bt_home_hub_5
bthomehub5-devicelist==0.1.1
@@ -864,7 +864,7 @@ gitterpy==0.1.7
glances-api==0.4.3
# homeassistant.components.goalzero
goalzero==0.2.1
goalzero==0.2.2
# homeassistant.components.goodwe
goodwe==0.2.31
@@ -1615,7 +1615,7 @@ pycsspeechtts==1.0.8
# pycups==1.9.73
# homeassistant.components.daikin
pydaikin==2.9.0
pydaikin==2.10.5
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@@ -2139,7 +2139,7 @@ python-qbittorrent==0.4.3
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==0.29.2
python-roborock==0.30.0
# homeassistant.components.smarttub
python-smarttub==0.0.33
@@ -2213,7 +2213,7 @@ pyvolumio==0.1.5
pywebpush==1.9.2
# homeassistant.components.wemo
pywemo==0.9.1
pywemo==1.1.0
# homeassistant.components.wilight
pywilight==0.0.74
@@ -2267,7 +2267,7 @@ renault-api==0.1.13
renson-endura-delta==1.5.0
# homeassistant.components.reolink
reolink-aio==0.7.1
reolink-aio==0.7.3
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2759,10 +2759,10 @@ zigpy-xbee==0.18.1
zigpy-zigate==0.11.0
# homeassistant.components.zha
zigpy-znp==0.11.2
zigpy-znp==0.11.3
# homeassistant.components.zha
zigpy==0.56.1
zigpy==0.56.2
# homeassistant.components.zoneminder
zm-py==0.5.2

View File

@@ -108,7 +108,7 @@ PyXiaomiGateway==0.14.3
RachioPy==1.0.3
# homeassistant.components.python_script
RestrictedPython==6.0
RestrictedPython==6.1
# homeassistant.components.remember_the_milk
RtmAPI==0.7.2
@@ -227,7 +227,7 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.homekit_controller
aiohomekit==2.6.5
aiohomekit==2.6.7
# homeassistant.components.emulated_hue
# homeassistant.components.http
@@ -302,7 +302,7 @@ aioqsw==0.3.2
aiorecollect==1.0.8
# homeassistant.components.ridwell
aioridwell==2023.01.0
aioridwell==2023.07.0
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -469,7 +469,7 @@ brottsplatskartan==0.0.1
brunt==1.2.0
# homeassistant.components.bthome
bthome-ble==2.12.0
bthome-ble==2.12.1
# homeassistant.components.buienradar
buienradar==1.0.5
@@ -677,7 +677,7 @@ gios==3.1.0
glances-api==0.4.3
# homeassistant.components.goalzero
goalzero==0.2.1
goalzero==0.2.2
# homeassistant.components.goodwe
goodwe==0.2.31
@@ -1197,7 +1197,7 @@ pycoolmasternet-async==0.1.5
pycsspeechtts==1.0.8
# homeassistant.components.daikin
pydaikin==2.9.0
pydaikin==2.10.5
# homeassistant.components.deconz
pydeconz==113
@@ -1565,7 +1565,7 @@ python-picnic-api==1.1.0
python-qbittorrent==0.4.3
# homeassistant.components.roborock
python-roborock==0.29.2
python-roborock==0.30.0
# homeassistant.components.smarttub
python-smarttub==0.0.33
@@ -1621,7 +1621,7 @@ pyvolumio==0.1.5
pywebpush==1.9.2
# homeassistant.components.wemo
pywemo==0.9.1
pywemo==1.1.0
# homeassistant.components.wilight
pywilight==0.0.74
@@ -1660,7 +1660,7 @@ renault-api==0.1.13
renson-endura-delta==1.5.0
# homeassistant.components.reolink
reolink-aio==0.7.1
reolink-aio==0.7.3
# homeassistant.components.rflink
rflink==0.0.65
@@ -2023,10 +2023,10 @@ zigpy-xbee==0.18.1
zigpy-zigate==0.11.0
# homeassistant.components.zha
zigpy-znp==0.11.2
zigpy-znp==0.11.3
# homeassistant.components.zha
zigpy==0.56.1
zigpy==0.56.2
# homeassistant.components.zwave_js
zwave-js-server-python==0.49.0

View File

@@ -0,0 +1,128 @@
"""Define tests for the Daikin init."""
import asyncio
from unittest.mock import AsyncMock, PropertyMock, patch
from aiohttp import ClientConnectionError
import pytest
from homeassistant.components.daikin import update_unique_id
from homeassistant.components.daikin.const import DOMAIN, KEY_MAC
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .test_config_flow import HOST, MAC
from tests.common import MockConfigEntry
@pytest.fixture
def mock_daikin():
"""Mock pydaikin."""
async def mock_daikin_factory(*args, **kwargs):
"""Mock the init function in pydaikin."""
return Appliance
with patch("homeassistant.components.daikin.Appliance") as Appliance:
Appliance.factory.side_effect = mock_daikin_factory
type(Appliance).update_status = AsyncMock()
type(Appliance).inside_temperature = PropertyMock(return_value=22)
type(Appliance).target_temperature = PropertyMock(return_value=22)
type(Appliance).zones = PropertyMock(return_value=[("Zone 1", "0", 0)])
type(Appliance).fan_rate = PropertyMock(return_value=[])
type(Appliance).swing_modes = PropertyMock(return_value=[])
yield Appliance
DATA = {
"ver": "1_1_8",
"name": "DaikinAP00000",
"mac": MAC,
"model": "NOTSUPPORT",
}
INVALID_DATA = {**DATA, "name": None, "mac": HOST}
async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None:
"""Test unique id migration."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=HOST,
title=None,
data={CONF_HOST: HOST, KEY_MAC: HOST},
)
config_entry.add_to_hass(hass)
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
type(mock_daikin).mac = PropertyMock(return_value=HOST)
type(mock_daikin).values = PropertyMock(return_value=INVALID_DATA)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.unique_id == HOST
assert device_registry.async_get_device({}, {(KEY_MAC, HOST)}).name is None
entity = entity_registry.async_get("climate.daikin_127_0_0_1")
assert entity.unique_id == HOST
assert update_unique_id(entity, MAC) is not None
assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(HOST)
type(mock_daikin).mac = PropertyMock(return_value=MAC)
type(mock_daikin).values = PropertyMock(return_value=DATA)
assert config_entry.unique_id != MAC
assert await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.unique_id == MAC
assert (
device_registry.async_get_device({}, {(KEY_MAC, MAC)}).name == "DaikinAP00000"
)
entity = entity_registry.async_get("climate.daikin_127_0_0_1")
assert entity.unique_id == MAC
assert update_unique_id(entity, MAC) is None
assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC)
async def test_client_connection_error(hass: HomeAssistant, mock_daikin) -> None:
"""Test unique id migration."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MAC,
data={CONF_HOST: HOST, KEY_MAC: MAC},
)
config_entry.add_to_hass(hass)
mock_daikin.factory.side_effect = ClientConnectionError
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.SETUP_RETRY
async def test_timeout_error(hass: HomeAssistant, mock_daikin) -> None:
"""Test unique id migration."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MAC,
data={CONF_HOST: HOST, KEY_MAC: MAC},
)
config_entry.add_to_hass(hass)
mock_daikin.factory.side_effect = asyncio.TimeoutError
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.SETUP_RETRY

View File

@@ -1308,3 +1308,45 @@ async def test_option_flow(
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_ALLOW_SERVICE_CALLS: option_value}
assert len(mock_reload.mock_calls) == int(option_value)
async def test_user_discovers_name_no_dashboard(
hass: HomeAssistant,
mock_client,
mock_zeroconf: None,
mock_setup_entry: None,
) -> None:
"""Test user step can discover the name and the there is not dashboard."""
mock_client.device_info.side_effect = [
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
DeviceInfo(
uses_password=False,
name="test",
mac_address="11:22:33:44:55:AA",
),
]
result = await hass.config_entries.flow.async_init(
"esphome",
context={"source": config_entries.SOURCE_USER},
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "encryption_key"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: VALID_NOISE_PSK,
CONF_DEVICE_NAME: "test",
}
assert mock_client.noise_psk == VALID_NOISE_PSK

View File

@@ -58,7 +58,9 @@ async def test_setup_dashboard_fails(
assert mock_config_entry.state == ConfigEntryState.LOADED
assert mock_get_devices.call_count == 1
assert dashboard.STORAGE_KEY not in hass_storage
# The dashboard addon might recover later so we still
# allow it to be set up.
assert dashboard.STORAGE_KEY in hass_storage
async def test_setup_dashboard_fails_when_already_setup(

View File

@@ -8,6 +8,9 @@
# name: test_image_entity[fc_data0]
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x94\x00\x00\x00\x94\x01\x00\x00\x00\x00]G=y\x00\x00\x00\xf5IDATx\xda\xedVQ\x0eC!\x0c"\xbb@\xef\x7fKn\xe0\x00\xfd\xdb\xcf6\xf9|\xc6\xc4\xc6\x0f\xd2\x02\xadb},\xe2\xb9\xfb\xe5\x0e\xc0(\x18\xf2\x84/|\xaeo\xef\x847\xda\x14\x1af\x1c\xde\xe3\x19(X\tKxN\xb2\x87\x17j9\x1d<m\x01)\xbbU\xe1\xcf\xa2\x9eU\xd1\xd7\xcbe.\xcc\xf6\xd05\x7f\x02\x82\x1d\xb8\x1c\xdd\xd7\x1b\xef\t\x90\x13an\xf1b\x13P\xb9\x01\xac\xd4k\xee\x04\xa5.\xd1.\xe8+\x90\x88\x1b\x0e\x0b\xfe\x03\xd3 \xd4Y\xe0\xef\x10\xa7z\xe3\xe9F\x7f(?;\xc6\x80\x95\xfc\xe2\x13\x1ddC\x0fZ\x07\xec6f\xc3/.\x94i\xddi\xf8\x8f\x9b9k<\x8d\xf9\xeci`\xfb\xed\xf1R\x99/g\x9e\xaei\xcc\x830\xb7\xf6\x83\xd4\xf1_\x9e\x0f\xf7.*\xf3\xc0\xf6\x1b\x86\xbf\x12\xde\xac\xed\x16\xb0\xf4\xbe\x9dO\x02\xd0\xe1\x8f\xee^\x0f|v\xf4\x15 \x13\xaf\x8e\xff\x9e\x7f\xe2\x9fwo\x06\xf4\x81v\xeb\xb3\xcc\xc3\x00\x00\x00\x00IEND\xaeB`\x82'
# ---
# name: test_image_entity[fc_data1]
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x94\x00\x00\x00\x94\x01\x00\x00\x00\x00]G=y\x00\x00\x00\xf5IDATx\xda\xedVQ\x0eC!\x0c"\xbb@\xef\x7fKn\xe0\x00\xfd\xdb\xcf6\xf9|\xc6\xc4\xc6\x0f\xd2\x02\xadb},\xe2\xb9\xfb\xe5\x0e\xc0(\x18\xf2\x84/|\xaeo\xef\x847\xda\x14\x1af\x1c\xde\xe3\x19(X\tKxN\xb2\x87\x17j9\x1d<m\x01)\xbbU\xe1\xcf\xa2\x9eU\xd1\xd7\xcbe.\xcc\xf6\xd05\x7f\x02\x82\x1d\xb8\x1c\xdd\xd7\x1b\xef\t\x90\x13an\xf1b\x13P\xb9\x01\xac\xd4k\xee\x04\xa5.\xd1.\xe8+\x90\x88\x1b\x0e\x0b\xfe\x03\xd3 \xd4Y\xe0\xef\x10\xa7z\xe3\xe9F\x7f(?;\xc6\x80\x95\xfc\xe2\x13\x1ddC\x0fZ\x07\xec6f\xc3/.\x94i\xddi\xf8\x8f\x9b9k<\x8d\xf9\xeci`\xfb\xed\xf1R\x99/g\x9e\xaei\xcc\x830\xb7\xf6\x83\xd4\xf1_\x9e\x0f\xf7.*\xf3\xc0\xf6\x1b\x86\xbf\x12\xde\xac\xed\x16\xb0\xf4\xbe\x9dO\x02\xd0\xe1\x8f\xee^\x0f|v\xf4\x15 \x13\xaf\x8e\xff\x9e\x7f\xe2\x9fwo\x06\xf4\x81v\xeb\xb3\xcc\xc3\x00\x00\x00\x00IEND\xaeB`\x82'
# ---
# name: test_image_update[fc_data0]
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x94\x00\x00\x00\x94\x01\x00\x00\x00\x00]G=y\x00\x00\x00\xf9IDATx\xda\xedV\xc1\r\xc40\x0cB\xb7\x80\xf7\xdf\x92\r\\\xb0\xfb\xeb\xe7\xaa\xf0l\xd4\xaaQ\x1e\xc8\x06L\x8a~,\xe2;{s\x06\xa0\xd8z9\xdb\xe6\x0f\xcf\xf5\xef\x99\xf0J\x0f\x85\x86*o\xcf\xf1\x04\x04\x1ak\xb6\x11<\x97\xa6\xa6\x83x&\xb32x\x86\xa4\xab\xeb\x08\x7f\x16\xf5^\x11}\xbd$\xb0\x80k=t\xcc\x9f\xfdg\xfa\xda\xe5\x1d\xe3\t\x8br_\xdb3\x85D}\x063u\x00\x03\xfd\xb6<\xe2\xeaL\xa2y<\xae\xcf\xe3!\x895\xbfL\xf07\x0eT]n7\xc3_{0\xd4\xefx:\xc0\x1f\xc6}\x9e\xb7\x84\x1e\xfb\x91\x0e\x12\x84\t=z\xd2t\x07\x8e\x1d\xc9\x03\xc7\xa9G\xb7\x12\xf3&0\x176\x19\x98\xc8g\x8b;\x88@\xc6\x7f\x93\xa9\xfbVD\xdf\x193\xde9\x1d\xd1\xc3\x9ev`E\xf2oo\xa3\xe1/\x847\xad\x8a?0t\xffN\xb4p\xf35\xf3\x7f\x80\xad\xafS\xf7\x1bD`D\x8f\xef\x9f\xf0\xe0\xec\x02\xa4\xc0\x83\x92\xcf\xf3\xf9a\x00\x00\x00\x00IEND\xaeB`\x82'
# ---

View File

@@ -60,11 +60,31 @@ GUEST_WIFI_CHANGED: dict[str, dict] = {
GUEST_WIFI_DISABLED: dict[str, dict] = {
"WLANConfiguration0": {},
"WLANConfiguration1": {"GetInfo": {"NewEnable": False}},
"WLANConfiguration1": {
"GetInfo": {
"NewEnable": False,
"NewStatus": "Up",
"NewSSID": "GuestWifi",
"NewBeaconType": "11iandWPA3",
"NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3",
"NewStandard": "ax",
"NewBSSID": "1C:ED:6F:12:34:13",
},
"GetSSID": {
"NewSSID": "GuestWifi",
},
"GetSecurityKeys": {"NewKeyPassphrase": "1234567890"},
},
}
@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED})])
@pytest.mark.parametrize(
("fc_data"),
[
({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED}),
({**MOCK_FB_SERVICES, **GUEST_WIFI_DISABLED}),
],
)
async def test_image_entity(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
@@ -150,23 +170,3 @@ async def test_image_update(
assert resp_body != resp_body_new
assert resp_body_new == snapshot
@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES, **GUEST_WIFI_DISABLED})])
async def test_image_guest_wifi_disabled(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
fc_class_mock,
fh_class_mock,
) -> None:
"""Test image entities."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA)
entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.LOADED
images = hass.states.async_all(IMAGE_DOMAIN)
assert len(images) == 0

View File

@@ -1 +1,174 @@
"""Tests for the myStrom integration."""
from typing import Any, Optional
def get_default_device_response(device_type: int | None) -> dict[str, Any]:
"""Return default device response."""
response = {
"version": "2.59.32",
"mac": "6001940376EB",
"ssid": "personal",
"ip": "192.168.0.23",
"mask": "255.255.255.0",
"gw": "192.168.0.1",
"dns": "192.168.0.1",
"static": False,
"connected": True,
"signal": 94,
}
if device_type is not None:
response["type"] = device_type
return response
def get_default_bulb_state() -> dict[str, Any]:
"""Get default bulb state."""
return {
"type": "rgblamp",
"battery": False,
"reachable": True,
"meshroot": True,
"on": False,
"color": "46;18;100",
"mode": "hsv",
"ramp": 10,
"power": 0.45,
"fw_version": "2.58.0",
}
def get_default_switch_state() -> dict[str, Any]:
"""Get default switch state."""
return {
"power": 1.69,
"Ws": 0.81,
"relay": True,
"temperature": 24.87,
"version": "2.59.32",
"mac": "6001940376EB",
"ssid": "personal",
"ip": "192.168.0.23",
"mask": "255.255.255.0",
"gw": "192.168.0.1",
"dns": "192.168.0.1",
"static": False,
"connected": True,
"signal": 94,
}
class MyStromDeviceMock:
"""Base device mock."""
def __init__(self, state: dict[str, Any]) -> None:
"""Initialize device mock."""
self._requested_state = False
self._state = state
async def get_state(self) -> None:
"""Set if state is requested."""
self._requested_state = True
class MyStromBulbMock(MyStromDeviceMock):
"""MyStrom Bulb mock."""
def __init__(self, mac: str, state: dict[str, Any]) -> None:
"""Initialize bulb mock."""
super().__init__(state)
self.mac = mac
@property
def firmware(self) -> Optional[str]:
"""Return current firmware."""
if not self._requested_state:
return None
return self._state["fw_version"]
@property
def consumption(self) -> Optional[float]:
"""Return current firmware."""
if not self._requested_state:
return None
return self._state["power"]
@property
def color(self) -> Optional[str]:
"""Return current color settings."""
if not self._requested_state:
return None
return self._state["color"]
@property
def mode(self) -> Optional[str]:
"""Return current mode."""
if not self._requested_state:
return None
return self._state["mode"]
@property
def transition_time(self) -> Optional[int]:
"""Return current transition time (ramp)."""
if not self._requested_state:
return None
return self._state["ramp"]
@property
def bulb_type(self) -> Optional[str]:
"""Return the type of the bulb."""
if not self._requested_state:
return None
return self._state["type"]
@property
def state(self) -> Optional[bool]:
"""Return the current state of the bulb."""
if not self._requested_state:
return None
return self._state["on"]
class MyStromSwitchMock(MyStromDeviceMock):
"""MyStrom Switch mock."""
@property
def relay(self) -> Optional[bool]:
"""Return the relay state."""
if not self._requested_state:
return None
return self._state["on"]
@property
def consumption(self) -> Optional[float]:
"""Return the current power consumption in mWh."""
if not self._requested_state:
return None
return self._state["power"]
@property
def consumedWs(self) -> Optional[float]:
"""The average of energy consumed per second since last report call."""
if not self._requested_state:
return None
return self._state["Ws"]
@property
def firmware(self) -> Optional[str]:
"""Return the current firmware."""
if not self._requested_state:
return None
return self._state["version"]
@property
def mac(self) -> Optional[str]:
"""Return the MAC address."""
if not self._requested_state:
return None
return self._state["mac"]
@property
def temperature(self) -> Optional[float]:
"""Return the current temperature in celsius."""
if not self._requested_state:
return None
return self._state["temperature"]

View File

@@ -2,11 +2,19 @@
from unittest.mock import AsyncMock, PropertyMock, patch
from pymystrom.exceptions import MyStromConnectionError
import pytest
from homeassistant.components.mystrom.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import (
MyStromBulbMock,
MyStromSwitchMock,
get_default_bulb_state,
get_default_device_response,
get_default_switch_state,
)
from .conftest import DEVICE_MAC
from tests.common import MockConfigEntry
@@ -16,36 +24,27 @@ async def init_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
device_type: int,
bulb_type: str = "strip",
) -> None:
"""Inititialize integration for testing."""
with patch(
"pymystrom.get_device_info",
side_effect=AsyncMock(return_value={"type": device_type, "mac": DEVICE_MAC}),
), patch("pymystrom.switch.MyStromSwitch.get_state", return_value={}), patch(
"pymystrom.bulb.MyStromBulb.get_state", return_value={}
side_effect=AsyncMock(return_value=get_default_device_response(device_type)),
), patch(
"pymystrom.bulb.MyStromBulb.bulb_type", bulb_type
"homeassistant.components.mystrom._get_mystrom_bulb",
return_value=MyStromBulbMock("6001940376EB", get_default_bulb_state()),
), patch(
"pymystrom.switch.MyStromSwitch.mac",
new_callable=PropertyMock,
return_value=DEVICE_MAC,
), patch(
"pymystrom.bulb.MyStromBulb.mac",
new_callable=PropertyMock,
return_value=DEVICE_MAC,
"homeassistant.components.mystrom._get_mystrom_switch",
return_value=MyStromSwitchMock(get_default_switch_state()),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.LOADED
async def test_init_switch_and_unload(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test the initialization of a myStrom switch."""
await init_integration(hass, config_entry, 101)
await init_integration(hass, config_entry, 106)
state = hass.states.get("switch.mystrom_device")
assert state is not None
assert config_entry.state is ConfigEntryState.LOADED
@@ -56,12 +55,35 @@ async def test_init_switch_and_unload(
assert not hass.data.get(DOMAIN)
async def test_init_bulb(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
@pytest.mark.parametrize(
("device_type", "platform", "entry_state", "entity_state_none"),
[
(None, "switch", ConfigEntryState.LOADED, False),
(102, "light", ConfigEntryState.LOADED, False),
(103, "button", ConfigEntryState.SETUP_ERROR, True),
(104, "button", ConfigEntryState.SETUP_ERROR, True),
(105, "light", ConfigEntryState.LOADED, False),
(106, "switch", ConfigEntryState.LOADED, False),
(107, "switch", ConfigEntryState.LOADED, False),
(110, "sensor", ConfigEntryState.SETUP_ERROR, True),
(113, "switch", ConfigEntryState.SETUP_ERROR, True),
(118, "button", ConfigEntryState.SETUP_ERROR, True),
(120, "switch", ConfigEntryState.SETUP_ERROR, True),
],
)
async def test_init_bulb(
hass: HomeAssistant,
config_entry: MockConfigEntry,
device_type: int,
platform: str,
entry_state: ConfigEntryState,
entity_state_none: bool,
) -> None:
"""Test the initialization of a myStrom bulb."""
await init_integration(hass, config_entry, 102)
state = hass.states.get("light.mystrom_device")
assert state is not None
assert config_entry.state is ConfigEntryState.LOADED
await init_integration(hass, config_entry, device_type)
state = hass.states.get(f"{platform}.mystrom_device")
assert (state is None) == entity_state_none
assert config_entry.state is entry_state
async def test_init_of_unknown_bulb(
@@ -120,7 +142,7 @@ async def test_init_cannot_connect_because_of_get_state(
"""Test error handling for failing get_state."""
with patch(
"pymystrom.get_device_info",
side_effect=AsyncMock(return_value={"type": 101, "mac": DEVICE_MAC}),
side_effect=AsyncMock(return_value=get_default_device_response(101)),
), patch(
"pymystrom.switch.MyStromSwitch.get_state", side_effect=MyStromConnectionError()
), patch(
@@ -129,4 +151,4 @@ async def test_init_cannot_connect_because_of_get_state(
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.SETUP_ERROR
assert config_entry.state == ConfigEntryState.SETUP_RETRY

View File

@@ -35,6 +35,8 @@ SERIAL_NUMBER = 0x12635436566
# Get serial number Command 0x85. Serial is 0x12635436566
SERIAL_RESPONSE = "850000012635436566"
# Model and version command 0x82
MODEL_AND_VERSION_RESPONSE = "820006090C"
# Get available stations command 0x83
AVAILABLE_STATIONS_RESPONSE = "83017F000000" # Mask for 7 zones
EMPTY_STATIONS_RESPONSE = "830000000000"
@@ -183,7 +185,13 @@ def mock_api_responses(
These are returned in the order they are requested by the update coordinator.
"""
return [stations_response, zone_state_response, rain_response, rain_delay_response]
return [
MODEL_AND_VERSION_RESPONSE,
stations_response,
zone_state_response,
rain_response,
rain_delay_response,
]
@pytest.fixture(name="responses")

View File

@@ -70,6 +70,8 @@ async def test_set_value(
device = device_registry.async_get_device({(DOMAIN, SERIAL_NUMBER)})
assert device
assert device.name == "Rain Bird Controller"
assert device.model == "ST8x-WiFi"
assert device.sw_version == "9.12"
aioclient_mock.mock_calls.clear()
responses.append(mock_response(ACK_ECHO))

View File

@@ -1,10 +1,11 @@
"""Test zha siren."""
from datetime import timedelta
from unittest.mock import patch
from unittest.mock import ANY, call, patch
import pytest
from zigpy.const import SIG_EP_PROFILE
import zigpy.profiles.zha as zha
import zigpy.zcl
import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.security as security
import zigpy.zcl.foundation as zcl_f
@@ -85,48 +86,76 @@ async def test_siren(hass: HomeAssistant, siren) -> None:
# turn on from HA
with patch(
"zigpy.zcl.Cluster.request",
"zigpy.device.Device.request",
return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]),
), patch(
"zigpy.zcl.Cluster.request",
side_effect=zigpy.zcl.Cluster.request,
autospec=True,
):
# turn on via UI
await hass.services.async_call(
SIREN_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert len(cluster.request.mock_calls) == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == 0
assert cluster.request.call_args[0][3] == 50 # bitmask for default args
assert cluster.request.call_args[0][4] == 5 # duration in seconds
assert cluster.request.call_args[0][5] == 0
assert cluster.request.call_args[0][6] == 2
assert cluster.request.mock_calls == [
call(
cluster,
False,
0,
ANY,
50, # bitmask for default args
5, # duration in seconds
0,
2,
manufacturer=None,
expect_reply=True,
tsn=None,
)
]
# test that the state has changed to on
assert hass.states.get(entity_id).state == STATE_ON
# turn off from HA
with patch(
"zigpy.zcl.Cluster.request",
"zigpy.device.Device.request",
return_value=mock_coro([0x01, zcl_f.Status.SUCCESS]),
), patch(
"zigpy.zcl.Cluster.request",
side_effect=zigpy.zcl.Cluster.request,
autospec=True,
):
# turn off via UI
await hass.services.async_call(
SIREN_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert len(cluster.request.mock_calls) == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == 0
assert cluster.request.call_args[0][3] == 2 # bitmask for default args
assert cluster.request.call_args[0][4] == 5 # duration in seconds
assert cluster.request.call_args[0][5] == 0
assert cluster.request.call_args[0][6] == 2
assert cluster.request.mock_calls == [
call(
cluster,
False,
0,
ANY,
2, # bitmask for default args
5, # duration in seconds
0,
2,
manufacturer=None,
expect_reply=True,
tsn=None,
)
]
# test that the state has changed to off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
with patch(
"zigpy.zcl.Cluster.request",
"zigpy.device.Device.request",
return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]),
), patch(
"zigpy.zcl.Cluster.request",
side_effect=zigpy.zcl.Cluster.request,
autospec=True,
):
# turn on via UI
await hass.services.async_call(
@@ -140,14 +169,21 @@ async def test_siren(hass: HomeAssistant, siren) -> None:
},
blocking=True,
)
assert len(cluster.request.mock_calls) == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == 0
assert cluster.request.call_args[0][3] == 97 # bitmask for passed args
assert cluster.request.call_args[0][4] == 10 # duration in seconds
assert cluster.request.call_args[0][5] == 0
assert cluster.request.call_args[0][6] == 2
assert cluster.request.mock_calls == [
call(
cluster,
False,
0,
ANY,
97, # bitmask for passed args
10, # duration in seconds
0,
2,
manufacturer=None,
expect_reply=True,
tsn=None,
)
]
# test that the state has changed to on
assert hass.states.get(entity_id).state == STATE_ON

View File

@@ -490,17 +490,7 @@ def hass_fixture_setup() -> list[bool]:
@pytest.fixture
def hass(_hass: HomeAssistant) -> HomeAssistant:
"""Fixture to provide a test instance of Home Assistant."""
# This wraps the async _hass fixture inside a sync fixture, to ensure
# the `hass` context variable is set in the execution context in which
# the test itself is executed
ha._cv_hass.set(_hass)
return _hass
@pytest.fixture
async def _hass(
async def hass(
hass_fixture_setup: list[bool],
event_loop: asyncio.AbstractEventLoop,
load_registries: bool,

View File

@@ -12,6 +12,7 @@ import voluptuous as vol
import homeassistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
issue_registry as ir,
@@ -383,7 +384,7 @@ def test_service() -> None:
schema("homeassistant.turn_on")
def test_service_schema() -> None:
def test_service_schema(hass: HomeAssistant) -> None:
"""Test service_schema validation."""
options = (
{},
@@ -1550,10 +1551,10 @@ def test_config_entry_only_schema_cant_find_module() -> None:
def test_config_entry_only_schema_no_hass(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test if the the hass context var is not set in our context."""
"""Test if the the hass context is not set in our context."""
with patch(
"homeassistant.helpers.config_validation.async_get_hass",
side_effect=LookupError,
side_effect=HomeAssistantError,
):
cv.config_entry_only_config_schema("test_domain")(
{"test_domain": {"foo": "bar"}}

View File

@@ -724,6 +724,27 @@ async def test_async_get_all_descriptions_dynamically_created_services(
}
async def test_register_with_mixed_case(hass: HomeAssistant) -> None:
"""Test registering a service with mixed case.
For backwards compatibility, we have historically allowed mixed case,
and automatically converted it to lowercase.
"""
logger = hass.components.logger
logger_config = {logger.DOMAIN: {}}
await async_setup_component(hass, logger.DOMAIN, logger_config)
logger_domain_mixed = "LoGgEr"
hass.services.async_register(
logger_domain_mixed, "NeW_SeRVICE", lambda x: None, None
)
service.async_set_service_schema(
hass, logger_domain_mixed, "NeW_SeRVICE", {"description": "new service"}
)
descriptions = await service.async_get_all_descriptions(hass)
assert "description" in descriptions[logger.DOMAIN]["new_service"]
assert descriptions[logger.DOMAIN]["new_service"]["description"] == "new service"
async def test_call_with_required_features(hass: HomeAssistant, mock_entities) -> None:
"""Test service calls invoked only if entity has required features."""
test_service_mock = AsyncMock(return_value=None)

View File

@@ -9,10 +9,12 @@ import gc
import logging
import os
from tempfile import TemporaryDirectory
import threading
import time
from typing import Any
from unittest.mock import MagicMock, Mock, PropertyMock, patch
import async_timeout
import pytest
import voluptuous as vol
@@ -40,6 +42,7 @@ from homeassistant.core import (
ServiceResponse,
State,
SupportsResponse,
callback,
)
from homeassistant.exceptions import (
HomeAssistantError,
@@ -202,6 +205,184 @@ def test_async_run_hass_job_delegates_non_async() -> None:
assert len(hass.async_add_hass_job.mock_calls) == 1
async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None:
"""Test calling async_get_hass via different paths.
The test asserts async_get_hass can be called from:
- Coroutines and callbacks
- Callbacks scheduled from callbacks, coroutines and threads
- Coroutines scheduled from callbacks, coroutines and threads
The test also asserts async_get_hass can not be called from threads
other than the event loop.
"""
task_finished = asyncio.Event()
def can_call_async_get_hass() -> bool:
"""Test if it's possible to call async_get_hass."""
try:
if ha.async_get_hass() is hass:
return True
raise Exception
except HomeAssistantError:
return False
raise Exception
# Test scheduling a coroutine which calls async_get_hass via hass.async_create_task
async def _async_create_task() -> None:
task_finished.set()
assert can_call_async_get_hass()
hass.async_create_task(_async_create_task(), "create_task")
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
# Test scheduling a callback which calls async_get_hass via hass.async_add_job
@callback
def _add_job() -> None:
assert can_call_async_get_hass()
task_finished.set()
hass.async_add_job(_add_job)
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
# Test scheduling a callback which calls async_get_hass from a callback
@callback
def _schedule_callback_from_callback() -> None:
@callback
def _callback():
assert can_call_async_get_hass()
task_finished.set()
# Test the scheduled callback itself can call async_get_hass
assert can_call_async_get_hass()
hass.async_add_job(_callback)
_schedule_callback_from_callback()
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
# Test scheduling a coroutine which calls async_get_hass from a callback
@callback
def _schedule_coroutine_from_callback() -> None:
async def _coroutine():
assert can_call_async_get_hass()
task_finished.set()
# Test the scheduled callback itself can call async_get_hass
assert can_call_async_get_hass()
hass.async_add_job(_coroutine())
_schedule_coroutine_from_callback()
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
# Test scheduling a callback which calls async_get_hass from a coroutine
async def _schedule_callback_from_coroutine() -> None:
@callback
def _callback():
assert can_call_async_get_hass()
task_finished.set()
# Test the coroutine itself can call async_get_hass
assert can_call_async_get_hass()
hass.async_add_job(_callback)
await _schedule_callback_from_coroutine()
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
# Test scheduling a coroutine which calls async_get_hass from a coroutine
async def _schedule_callback_from_coroutine() -> None:
async def _coroutine():
assert can_call_async_get_hass()
task_finished.set()
# Test the coroutine itself can call async_get_hass
assert can_call_async_get_hass()
await hass.async_create_task(_coroutine())
await _schedule_callback_from_coroutine()
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
# Test scheduling a callback which calls async_get_hass from an executor
def _async_add_executor_job_add_job() -> None:
@callback
def _async_add_job():
assert can_call_async_get_hass()
task_finished.set()
# Test the executor itself can not call async_get_hass
assert not can_call_async_get_hass()
hass.add_job(_async_add_job)
await hass.async_add_executor_job(_async_add_executor_job_add_job)
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
# Test scheduling a coroutine which calls async_get_hass from an executor
def _async_add_executor_job_create_task() -> None:
async def _async_create_task() -> None:
assert can_call_async_get_hass()
task_finished.set()
# Test the executor itself can not call async_get_hass
assert not can_call_async_get_hass()
hass.create_task(_async_create_task())
await hass.async_add_executor_job(_async_add_executor_job_create_task)
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
# Test scheduling a callback which calls async_get_hass from a worker thread
class MyJobAddJob(threading.Thread):
@callback
def _my_threaded_job_add_job(self) -> None:
assert can_call_async_get_hass()
task_finished.set()
def run(self) -> None:
# Test the worker thread itself can not call async_get_hass
assert not can_call_async_get_hass()
hass.add_job(self._my_threaded_job_add_job)
my_job_add_job = MyJobAddJob()
my_job_add_job.start()
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
my_job_add_job.join()
# Test scheduling a coroutine which calls async_get_hass from a worker thread
class MyJobCreateTask(threading.Thread):
async def _my_threaded_job_create_task(self) -> None:
assert can_call_async_get_hass()
task_finished.set()
def run(self) -> None:
# Test the worker thread itself can not call async_get_hass
assert not can_call_async_get_hass()
hass.create_task(self._my_threaded_job_create_task())
my_job_create_task = MyJobCreateTask()
my_job_create_task.start()
async with async_timeout.timeout(1):
await task_finished.wait()
task_finished.clear()
my_job_create_task.join()
async def test_stage_shutdown(hass: HomeAssistant) -> None:
"""Simulate a shutdown, test calling stuff."""
test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP)