This commit is contained in:
Paulus Schoutsen 2022-09-11 13:28:28 -04:00 committed by GitHub
commit 2bd71f62ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 784 additions and 164 deletions

View File

@ -15,6 +15,11 @@
}, },
"description": "Select the NMI of the site you would like to add" "description": "Select the NMI of the site you would like to add"
} }
},
"error": {
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
"no_site": "No site provided",
"unknown_error": "[%key:common::config_flow::error::unknown%]"
} }
} }
} }

View File

@ -15,6 +15,11 @@
}, },
"description": "Go to {api_url} to generate an API key" "description": "Go to {api_url} to generate an API key"
} }
},
"error": {
"invalid_api_token": "Invalid API key",
"no_site": "No site provided",
"unknown_error": "Unexpected error"
} }
} }
} }

View File

@ -6,7 +6,7 @@
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"bleak==0.16.0", "bleak==0.16.0",
"bluetooth-adapters==0.3.5", "bluetooth-adapters==0.4.1",
"bluetooth-auto-recovery==0.3.2" "bluetooth-auto-recovery==0.3.2"
], ],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],

View File

@ -68,4 +68,4 @@ class EcowittBinarySensorEntity(EcowittEntity, BinarySensorEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if the binary sensor is on.""" """Return true if the binary sensor is on."""
return self.ecowitt.value > 0 return bool(self.ecowitt.value)

View File

@ -25,13 +25,13 @@ async def async_get_device_diagnostics(
"device": { "device": {
"name": station.station, "name": station.station,
"model": station.model, "model": station.model,
"frequency": station.frequency, "frequency": station.frequence,
"version": station.version, "version": station.version,
}, },
"raw": ecowitt.last_values[station_id], "raw": ecowitt.last_values[station_id],
"sensors": { "sensors": {
sensor.key: sensor.value sensor.key: sensor.value
for sensor in station.sensors for sensor in ecowitt.sensors.values()
if sensor.station.key == station_id if sensor.station.key == station_id
}, },
} }

View File

@ -3,7 +3,8 @@
"name": "Ecowitt", "name": "Ecowitt",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ecowitt", "documentation": "https://www.home-assistant.io/integrations/ecowitt",
"requirements": ["aioecowitt==2022.08.3"], "dependencies": ["webhook"],
"requirements": ["aioecowitt==2022.09.1"],
"codeowners": ["@pvizeli"], "codeowners": ["@pvizeli"],
"iot_class": "local_push" "iot_class": "local_push"
} }

View File

@ -1,5 +1,8 @@
"""Support for Ecowitt Weather Stations.""" """Support for Ecowitt Weather Stations."""
from __future__ import annotations
import dataclasses import dataclasses
from datetime import datetime
from typing import Final from typing import Final
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
@ -242,6 +245,6 @@ class EcowittSensorEntity(EcowittEntity, SensorEntity):
self.entity_description = description self.entity_description = description
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType | datetime:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.ecowitt.value return self.ecowitt.value

View File

@ -3,7 +3,7 @@
"name": "HomeKit Controller", "name": "HomeKit Controller",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller", "documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"requirements": ["aiohomekit==1.5.2"], "requirements": ["aiohomekit==1.5.6"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }], "bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }],
"dependencies": ["bluetooth", "zeroconf"], "dependencies": ["bluetooth", "zeroconf"],

View File

@ -31,9 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.info("Polling on %s", entry.data[CONF_DEVICE]) _LOGGER.info("Polling on %s", entry.data[CONF_DEVICE])
return await hass.async_add_executor_job(api.read) return await hass.async_add_executor_job(api.read)
# No automatic polling and no initial refresh of data is being done at this point, # Polling is only daily to prevent battery drain.
# to prevent battery drain. The user will have to do it manually.
coordinator = DataUpdateCoordinator( coordinator = DataUpdateCoordinator(
hass, hass,
_LOGGER, _LOGGER,

View File

@ -14,7 +14,7 @@ from homeassistant import config_entries
from homeassistant.const import CONF_DEVICE from homeassistant.const import CONF_DEVICE
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN from .const import DOMAIN, ULTRAHEAT_TIMEOUT
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -43,6 +43,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
dev_path = await self.hass.async_add_executor_job( dev_path = await self.hass.async_add_executor_job(
get_serial_by_id, user_input[CONF_DEVICE] get_serial_by_id, user_input[CONF_DEVICE]
) )
_LOGGER.debug("Using this path : %s", dev_path)
try: try:
return await self.validate_and_create_entry(dev_path) return await self.validate_and_create_entry(dev_path)
@ -76,6 +77,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Try to connect to the device path and return an entry.""" """Try to connect to the device path and return an entry."""
model, device_number = await self.validate_ultraheat(dev_path) model, device_number = await self.validate_ultraheat(dev_path)
_LOGGER.debug("Got model %s and device_number %s", model, device_number)
await self.async_set_unique_id(device_number) await self.async_set_unique_id(device_number)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
data = { data = {
@ -94,7 +96,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
reader = UltraheatReader(port) reader = UltraheatReader(port)
heat_meter = HeatMeterService(reader) heat_meter = HeatMeterService(reader)
try: try:
async with async_timeout.timeout(10): async with async_timeout.timeout(ULTRAHEAT_TIMEOUT):
# validate and retrieve the model and device number for a unique id # validate and retrieve the model and device number for a unique id
data = await self.hass.async_add_executor_job(heat_meter.read) data = await self.hass.async_add_executor_job(heat_meter.read)
_LOGGER.debug("Got data from Ultraheat API: %s", data) _LOGGER.debug("Got data from Ultraheat API: %s", data)

View File

@ -11,6 +11,7 @@ from homeassistant.helpers.entity import EntityCategory
DOMAIN = "landisgyr_heat_meter" DOMAIN = "landisgyr_heat_meter"
GJ_TO_MWH = 0.277778 # conversion factor GJ_TO_MWH = 0.277778 # conversion factor
ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time
HEAT_METER_SENSOR_TYPES = ( HEAT_METER_SENSOR_TYPES = (
SensorEntityDescription( SensorEntityDescription(

View File

@ -6,7 +6,7 @@ from datetime import timedelta
import logging import logging
import async_timeout import async_timeout
from led_ble import BLEAK_EXCEPTIONS, LEDBLE from led_ble import BLEAK_EXCEPTIONS, LEDBLE, get_device
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
@ -27,7 +27,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LED BLE from a config entry.""" """Set up LED BLE from a config entry."""
address: str = entry.data[CONF_ADDRESS] address: str = entry.data[CONF_ADDRESS]
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) ble_device = bluetooth.async_ble_device_from_address(
hass, address.upper(), True
) or await get_device(address)
if not ble_device: if not ble_device:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Could not find LED BLE device with address {address}" f"Could not find LED BLE device with address {address}"

View File

@ -48,12 +48,12 @@ class LEDBLEEntity(CoordinatorEntity, LightEntity):
"""Initialize an ledble light.""" """Initialize an ledble light."""
super().__init__(coordinator) super().__init__(coordinator)
self._device = device self._device = device
self._attr_unique_id = device._address self._attr_unique_id = device.address
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
name=name, name=name,
model=hex(device.model_num), model=hex(device.model_num),
sw_version=hex(device.version_num), sw_version=hex(device.version_num),
connections={(dr.CONNECTION_BLUETOOTH, device._address)}, connections={(dr.CONNECTION_BLUETOOTH, device.address)},
) )
self._async_update_attrs() self._async_update_attrs()

View File

@ -3,7 +3,7 @@
"name": "LED BLE", "name": "LED BLE",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ble_ble", "documentation": "https://www.home-assistant.io/integrations/ble_ble",
"requirements": ["led-ble==0.7.1"], "requirements": ["led-ble==0.9.1"],
"dependencies": ["bluetooth"], "dependencies": ["bluetooth"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"bluetooth": [ "bluetooth": [

View File

@ -219,15 +219,10 @@ class LIFXLight(LIFXEntity, LightEntity):
elif power_on: elif power_on:
await self.set_power(True, duration=fade) await self.set_power(True, duration=fade)
else: else:
if power_on:
await self.set_power(True)
if hsbk: if hsbk:
await self.set_color(hsbk, kwargs, duration=fade) await self.set_color(hsbk, kwargs, duration=fade)
# The response from set_color will tell us if the
# bulb is actually on or not, so we don't need to
# call power_on if its already on
if power_on and self.bulb.power_level == 0:
await self.set_power(True)
elif power_on:
await self.set_power(True)
if power_off: if power_off:
await self.set_power(False, duration=fade) await self.set_power(False, duration=fade)

View File

@ -20,7 +20,13 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
SERVICE_RELOAD, SERVICE_RELOAD,
) )
from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.core import (
CALLBACK_TYPE,
HassJob,
HomeAssistant,
ServiceCall,
callback,
)
from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.exceptions import TemplateError, Unauthorized
from homeassistant.helpers import ( from homeassistant.helpers import (
config_validation as cv, config_validation as cv,
@ -68,9 +74,11 @@ from .const import ( # noqa: F401
CONFIG_ENTRY_IS_SETUP, CONFIG_ENTRY_IS_SETUP,
DATA_MQTT, DATA_MQTT,
DATA_MQTT_CONFIG, DATA_MQTT_CONFIG,
DATA_MQTT_DISCOVERY_REGISTRY_HOOKS,
DATA_MQTT_RELOAD_DISPATCHERS, DATA_MQTT_RELOAD_DISPATCHERS,
DATA_MQTT_RELOAD_ENTRY, DATA_MQTT_RELOAD_ENTRY,
DATA_MQTT_RELOAD_NEEDED, DATA_MQTT_RELOAD_NEEDED,
DATA_MQTT_SUBSCRIPTIONS_TO_RESTORE,
DATA_MQTT_UPDATED_CONFIG, DATA_MQTT_UPDATED_CONFIG,
DEFAULT_ENCODING, DEFAULT_ENCODING,
DEFAULT_QOS, DEFAULT_QOS,
@ -314,7 +322,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Bail out # Bail out
return False return False
hass.data[DATA_MQTT_DISCOVERY_REGISTRY_HOOKS] = {}
hass.data[DATA_MQTT] = MQTT(hass, entry, conf) hass.data[DATA_MQTT] = MQTT(hass, entry, conf)
# Restore saved subscriptions
if DATA_MQTT_SUBSCRIPTIONS_TO_RESTORE in hass.data:
hass.data[DATA_MQTT].subscriptions = hass.data.pop(
DATA_MQTT_SUBSCRIPTIONS_TO_RESTORE
)
entry.add_update_listener(_async_config_entry_updated) entry.add_update_listener(_async_config_entry_updated)
await hass.data[DATA_MQTT].async_connect() await hass.data[DATA_MQTT].async_connect()
@ -438,6 +452,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_forward_entry_setup_and_setup_discovery(config_entry): async def async_forward_entry_setup_and_setup_discovery(config_entry):
"""Forward the config entry setup to the platforms and set up discovery.""" """Forward the config entry setup to the platforms and set up discovery."""
reload_manual_setup: bool = False
# Local import to avoid circular dependencies # Local import to avoid circular dependencies
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from . import device_automation, tag from . import device_automation, tag
@ -460,8 +475,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await _async_setup_discovery(hass, conf, entry) await _async_setup_discovery(hass, conf, entry)
# Setup reload service after all platforms have loaded # Setup reload service after all platforms have loaded
await async_setup_reload_service() await async_setup_reload_service()
# When the entry is reloaded, also reload manual set up items to enable MQTT
if DATA_MQTT_RELOAD_ENTRY in hass.data:
hass.data.pop(DATA_MQTT_RELOAD_ENTRY)
reload_manual_setup = True
# When the entry was disabled before, reload manual set up items to enable MQTT again
if DATA_MQTT_RELOAD_NEEDED in hass.data: if DATA_MQTT_RELOAD_NEEDED in hass.data:
hass.data.pop(DATA_MQTT_RELOAD_NEEDED) hass.data.pop(DATA_MQTT_RELOAD_NEEDED)
reload_manual_setup = True
if reload_manual_setup:
await async_reload_manual_mqtt_items(hass) await async_reload_manual_mqtt_items(hass)
await async_forward_entry_setup_and_setup_discovery(entry) await async_forward_entry_setup_and_setup_discovery(entry)
@ -613,8 +637,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
mqtt_client.cleanup() mqtt_client.cleanup()
# Trigger reload manual MQTT items at entry setup # Trigger reload manual MQTT items at entry setup
# Reload the legacy yaml platform
await async_reload_integration_platforms(hass, DOMAIN, RELOADABLE_PLATFORMS)
if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is False: if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is False:
# The entry is disabled reload legacy manual items when the entry is enabled again # The entry is disabled reload legacy manual items when the entry is enabled again
hass.data[DATA_MQTT_RELOAD_NEEDED] = True hass.data[DATA_MQTT_RELOAD_NEEDED] = True
@ -622,7 +644,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# The entry is reloaded: # The entry is reloaded:
# Trigger re-fetching the yaml config at entry setup # Trigger re-fetching the yaml config at entry setup
hass.data[DATA_MQTT_RELOAD_ENTRY] = True hass.data[DATA_MQTT_RELOAD_ENTRY] = True
# Stop the loop # Reload the legacy yaml platform to make entities unavailable
await async_reload_integration_platforms(hass, DOMAIN, RELOADABLE_PLATFORMS)
# Cleanup entity registry hooks
registry_hooks: dict[tuple, CALLBACK_TYPE] = hass.data[
DATA_MQTT_DISCOVERY_REGISTRY_HOOKS
]
while registry_hooks:
registry_hooks.popitem()[1]()
# Wait for all ACKs and stop the loop
await mqtt_client.async_disconnect() await mqtt_client.async_disconnect()
# Store remaining subscriptions to be able to restore or reload them
# when the entry is set up again
if mqtt_client.subscriptions:
hass.data[DATA_MQTT_SUBSCRIPTIONS_TO_RESTORE] = mqtt_client.subscriptions
return True return True

View File

@ -309,7 +309,7 @@ class MQTT:
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass,
config_entry, config_entry,
conf, conf,
) -> None: ) -> None:
@ -435,12 +435,13 @@ class MQTT:
"""Return False if there are unprocessed ACKs.""" """Return False if there are unprocessed ACKs."""
return not bool(self._pending_operations) return not bool(self._pending_operations)
# wait for ACK-s to be processesed (unsubscribe only) # wait for ACKs to be processed
async with self._pending_operations_condition: async with self._pending_operations_condition:
await self._pending_operations_condition.wait_for(no_more_acks) await self._pending_operations_condition.wait_for(no_more_acks)
# stop the MQTT loop # stop the MQTT loop
await self.hass.async_add_executor_job(stop) async with self._paho_lock:
await self.hass.async_add_executor_job(stop)
async def async_subscribe( async def async_subscribe(
self, self,
@ -501,7 +502,8 @@ class MQTT:
async with self._paho_lock: async with self._paho_lock:
mid = await self.hass.async_add_executor_job(_client_unsubscribe, topic) mid = await self.hass.async_add_executor_job(_client_unsubscribe, topic)
await self._register_mid(mid) await self._register_mid(mid)
self.hass.async_create_task(self._wait_for_mid(mid))
self.hass.async_create_task(self._wait_for_mid(mid))
async def _async_perform_subscriptions( async def _async_perform_subscriptions(
self, subscriptions: Iterable[tuple[str, int]] self, subscriptions: Iterable[tuple[str, int]]

View File

@ -32,6 +32,8 @@ CONF_TLS_VERSION = "tls_version"
CONFIG_ENTRY_IS_SETUP = "mqtt_config_entry_is_setup" CONFIG_ENTRY_IS_SETUP = "mqtt_config_entry_is_setup"
DATA_MQTT = "mqtt" DATA_MQTT = "mqtt"
DATA_MQTT_SUBSCRIPTIONS_TO_RESTORE = "mqtt_client_subscriptions"
DATA_MQTT_DISCOVERY_REGISTRY_HOOKS = "mqtt_discovery_registry_hooks"
DATA_MQTT_CONFIG = "mqtt_config" DATA_MQTT_CONFIG = "mqtt_config"
MQTT_DATA_DEVICE_TRACKER_LEGACY = "mqtt_device_tracker_legacy" MQTT_DATA_DEVICE_TRACKER_LEGACY = "mqtt_device_tracker_legacy"
DATA_MQTT_RELOAD_DISPATCHERS = "mqtt_reload_dispatchers" DATA_MQTT_RELOAD_DISPATCHERS = "mqtt_reload_dispatchers"

View File

@ -249,7 +249,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
except KeyError: except KeyError:
pass pass
except ValueError: except ValueError:
_LOGGER.warning("Invalid RGB color value received") _LOGGER.warning(
"Invalid RGB color value received for entity %s", self.entity_id
)
return return
try: try:
@ -259,7 +261,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
except KeyError: except KeyError:
pass pass
except ValueError: except ValueError:
_LOGGER.warning("Invalid XY color value received") _LOGGER.warning(
"Invalid XY color value received for entity %s", self.entity_id
)
return return
try: try:
@ -269,12 +273,16 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
except KeyError: except KeyError:
pass pass
except ValueError: except ValueError:
_LOGGER.warning("Invalid HS color value received") _LOGGER.warning(
"Invalid HS color value received for entity %s", self.entity_id
)
return return
else: else:
color_mode = values["color_mode"] color_mode = values["color_mode"]
if not self._supports_color_mode(color_mode): if not self._supports_color_mode(color_mode):
_LOGGER.warning("Invalid color mode received") _LOGGER.warning(
"Invalid color mode received for entity %s", self.entity_id
)
return return
try: try:
if color_mode == ColorMode.COLOR_TEMP: if color_mode == ColorMode.COLOR_TEMP:
@ -314,7 +322,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
self._color_mode = ColorMode.XY self._color_mode = ColorMode.XY
self._xy = (x, y) self._xy = (x, y)
except (KeyError, ValueError): except (KeyError, ValueError):
_LOGGER.warning("Invalid or incomplete color value received") _LOGGER.warning(
"Invalid or incomplete color value received for entity %s",
self.entity_id,
)
def _prepare_subscribe_topics(self): def _prepare_subscribe_topics(self):
"""(Re)Subscribe to topics.""" """(Re)Subscribe to topics."""
@ -351,7 +362,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
except KeyError: except KeyError:
pass pass
except (TypeError, ValueError): except (TypeError, ValueError):
_LOGGER.warning("Invalid brightness value received") _LOGGER.warning(
"Invalid brightness value received for entity %s",
self.entity_id,
)
if ( if (
self._supported_features self._supported_features
@ -366,7 +380,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
except KeyError: except KeyError:
pass pass
except ValueError: except ValueError:
_LOGGER.warning("Invalid color temp value received") _LOGGER.warning(
"Invalid color temp value received for entity %s",
self.entity_id,
)
if self._supported_features and LightEntityFeature.EFFECT: if self._supported_features and LightEntityFeature.EFFECT:
with suppress(KeyError): with suppress(KeyError):

View File

@ -28,7 +28,13 @@ from homeassistant.const import (
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
) )
from homeassistant.core import Event, HomeAssistant, async_get_hass, callback from homeassistant.core import (
CALLBACK_TYPE,
Event,
HomeAssistant,
async_get_hass,
callback,
)
from homeassistant.helpers import ( from homeassistant.helpers import (
config_validation as cv, config_validation as cv,
device_registry as dr, device_registry as dr,
@ -48,6 +54,7 @@ from homeassistant.helpers.entity import (
async_generate_entity_id, async_generate_entity_id,
) )
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_entity_registry_updated_event
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.json import json_loads from homeassistant.helpers.json import json_loads
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -64,7 +71,9 @@ from .const import (
CONF_TOPIC, CONF_TOPIC,
DATA_MQTT, DATA_MQTT,
DATA_MQTT_CONFIG, DATA_MQTT_CONFIG,
DATA_MQTT_DISCOVERY_REGISTRY_HOOKS,
DATA_MQTT_RELOAD_DISPATCHERS, DATA_MQTT_RELOAD_DISPATCHERS,
DATA_MQTT_RELOAD_ENTRY,
DATA_MQTT_UPDATED_CONFIG, DATA_MQTT_UPDATED_CONFIG,
DEFAULT_ENCODING, DEFAULT_ENCODING,
DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_AVAILABLE,
@ -363,6 +372,12 @@ async def async_setup_platform_helper(
async_setup_entities: SetupEntity, async_setup_entities: SetupEntity,
) -> None: ) -> None:
"""Help to set up the platform for manual configured MQTT entities.""" """Help to set up the platform for manual configured MQTT entities."""
if DATA_MQTT_RELOAD_ENTRY in hass.data:
_LOGGER.debug(
"MQTT integration is %s, skipping setup of manually configured MQTT items while unloading the config entry",
platform_domain,
)
return
if not (entry_status := mqtt_config_entry_enabled(hass)): if not (entry_status := mqtt_config_entry_enabled(hass)):
_LOGGER.warning( _LOGGER.warning(
"MQTT integration is %s, skipping setup of manually configured MQTT %s", "MQTT integration is %s, skipping setup of manually configured MQTT %s",
@ -647,6 +662,17 @@ async def async_remove_discovery_payload(hass: HomeAssistant, discovery_data: di
await async_publish(hass, discovery_topic, "", retain=True) await async_publish(hass, discovery_topic, "", retain=True)
async def async_clear_discovery_topic_if_entity_removed(
hass: HomeAssistant,
discovery_data: dict[str, Any],
event: Event,
) -> None:
"""Clear the discovery topic if the entity is removed."""
if event.data["action"] == "remove":
# publish empty payload to config topic to avoid re-adding
await async_remove_discovery_payload(hass, discovery_data)
class MqttDiscoveryDeviceUpdate: class MqttDiscoveryDeviceUpdate:
"""Add support for auto discovery for platforms without an entity.""" """Add support for auto discovery for platforms without an entity."""
@ -780,7 +806,8 @@ class MqttDiscoveryUpdate(Entity):
def __init__( def __init__(
self, self,
discovery_data: dict, hass: HomeAssistant,
discovery_data: dict | None,
discovery_update: Callable | None = None, discovery_update: Callable | None = None,
) -> None: ) -> None:
"""Initialize the discovery update mixin.""" """Initialize the discovery update mixin."""
@ -788,6 +815,14 @@ class MqttDiscoveryUpdate(Entity):
self._discovery_update = discovery_update self._discovery_update = discovery_update
self._remove_discovery_updated: Callable | None = None self._remove_discovery_updated: Callable | None = None
self._removed_from_hass = False self._removed_from_hass = False
if discovery_data is None:
return
self._registry_hooks: dict[tuple, CALLBACK_TYPE] = hass.data[
DATA_MQTT_DISCOVERY_REGISTRY_HOOKS
]
discovery_hash: tuple[str, str] = discovery_data[ATTR_DISCOVERY_HASH]
if discovery_hash in self._registry_hooks:
self._registry_hooks.pop(discovery_hash)()
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Subscribe to discovery updates.""" """Subscribe to discovery updates."""
@ -850,7 +885,7 @@ class MqttDiscoveryUpdate(Entity):
async def async_removed_from_registry(self) -> None: async def async_removed_from_registry(self) -> None:
"""Clear retained discovery topic in broker.""" """Clear retained discovery topic in broker."""
if not self._removed_from_hass: if not self._removed_from_hass and self._discovery_data is not None:
# Stop subscribing to discovery updates to not trigger when we clear the # Stop subscribing to discovery updates to not trigger when we clear the
# discovery topic # discovery topic
self._cleanup_discovery_on_remove() self._cleanup_discovery_on_remove()
@ -861,7 +896,20 @@ class MqttDiscoveryUpdate(Entity):
@callback @callback
def add_to_platform_abort(self) -> None: def add_to_platform_abort(self) -> None:
"""Abort adding an entity to a platform.""" """Abort adding an entity to a platform."""
if self._discovery_data: if self._discovery_data is not None:
discovery_hash: tuple = self._discovery_data[ATTR_DISCOVERY_HASH]
if self.registry_entry is not None:
self._registry_hooks[
discovery_hash
] = async_track_entity_registry_updated_event(
self.hass,
self.entity_id,
partial(
async_clear_discovery_topic_if_entity_removed,
self.hass,
self._discovery_data,
),
)
stop_discovery_updates(self.hass, self._discovery_data) stop_discovery_updates(self.hass, self._discovery_data)
send_discovery_done(self.hass, self._discovery_data) send_discovery_done(self.hass, self._discovery_data)
super().add_to_platform_abort() super().add_to_platform_abort()
@ -969,7 +1017,7 @@ class MqttEntity(
# Initialize mixin classes # Initialize mixin classes
MqttAttributes.__init__(self, config) MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config) MqttAvailability.__init__(self, config)
MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttDiscoveryUpdate.__init__(self, hass, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry) MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry)
def _init_entity_id(self): def _init_entity_id(self):

View File

@ -128,7 +128,7 @@ class PushoverNotificationService(BaseNotificationService):
self.pushover.send_message( self.pushover.send_message(
self._user_key, self._user_key,
message, message,
kwargs.get(ATTR_TARGET), ",".join(kwargs.get(ATTR_TARGET, [])),
title, title,
url, url,
url_title, url_title,

View File

@ -3,7 +3,7 @@
"name": "RainMachine", "name": "RainMachine",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rainmachine", "documentation": "https://www.home-assistant.io/integrations/rainmachine",
"requirements": ["regenmaschine==2022.09.0"], "requirements": ["regenmaschine==2022.09.1"],
"codeowners": ["@bachya"], "codeowners": ["@bachya"],
"iot_class": "local_polling", "iot_class": "local_polling",
"homekit": { "homekit": {

View File

@ -2,7 +2,7 @@
"domain": "sensibo", "domain": "sensibo",
"name": "Sensibo", "name": "Sensibo",
"documentation": "https://www.home-assistant.io/integrations/sensibo", "documentation": "https://www.home-assistant.io/integrations/sensibo",
"requirements": ["pysensibo==1.0.19"], "requirements": ["pysensibo==1.0.20"],
"config_flow": true, "config_flow": true,
"codeowners": ["@andrey-git", "@gjohansson-ST"], "codeowners": ["@andrey-git", "@gjohansson-ST"],
"iot_class": "cloud_polling", "iot_class": "cloud_polling",

View File

@ -84,11 +84,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
address: str = entry.data[CONF_ADDRESS] address: str = entry.data[CONF_ADDRESS]
ble_device = bluetooth.async_ble_device_from_address( ble_device = bluetooth.async_ble_device_from_address(
hass, address.upper(), connectable hass, address.upper(), connectable
) ) or await switchbot.get_device(address)
if not ble_device: if not ble_device:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Could not find Switchbot {sensor_type} with address {address}" f"Could not find Switchbot {sensor_type} with address {address}"
) )
await switchbot.close_stale_connections(ble_device)
cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice)
device = cls( device = cls(
device=ble_device, device=ble_device,

View File

@ -69,13 +69,16 @@ class SwitchbotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator):
change: bluetooth.BluetoothChange, change: bluetooth.BluetoothChange,
) -> None: ) -> None:
"""Handle a Bluetooth event.""" """Handle a Bluetooth event."""
self.ble_device = service_info.device
if adv := switchbot.parse_advertisement_data( if adv := switchbot.parse_advertisement_data(
service_info.device, service_info.advertisement service_info.device, service_info.advertisement
): ):
self.data = flatten_sensors_data(adv.data)
if "modelName" in self.data: if "modelName" in self.data:
self._ready_event.set() self._ready_event.set()
_LOGGER.debug("%s: Switchbot data: %s", self.ble_device.address, self.data) _LOGGER.debug("%s: Switchbot data: %s", self.ble_device.address, self.data)
if not self.device.advertisement_changed(adv):
return
self.data = flatten_sensors_data(adv.data)
self.device.update_from_advertisement(adv) self.device.update_from_advertisement(adv)
super()._async_handle_bluetooth_event(service_info, change) super()._async_handle_bluetooth_event(service_info, change)

View File

@ -2,7 +2,7 @@
"domain": "switchbot", "domain": "switchbot",
"name": "SwitchBot", "name": "SwitchBot",
"documentation": "https://www.home-assistant.io/integrations/switchbot", "documentation": "https://www.home-assistant.io/integrations/switchbot",
"requirements": ["PySwitchbot==0.18.27"], "requirements": ["PySwitchbot==0.19.5"],
"config_flow": true, "config_flow": true,
"dependencies": ["bluetooth"], "dependencies": ["bluetooth"],
"codeowners": [ "codeowners": [

View File

@ -71,14 +71,16 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Switchbot sensor based on a config entry.""" """Set up Switchbot sensor based on a config entry."""
coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities( entities = [
SwitchBotSensor( SwitchBotSensor(
coordinator, coordinator,
sensor, sensor,
) )
for sensor in coordinator.data["data"] for sensor in coordinator.data["data"]
if sensor in SENSOR_TYPES if sensor in SENSOR_TYPES
) ]
entities.append(SwitchbotRSSISensor(coordinator, "rssi"))
async_add_entities(entities)
class SwitchBotSensor(SwitchbotEntity, SensorEntity): class SwitchBotSensor(SwitchbotEntity, SensorEntity):
@ -98,6 +100,15 @@ class SwitchBotSensor(SwitchbotEntity, SensorEntity):
self.entity_description = SENSOR_TYPES[sensor] self.entity_description = SENSOR_TYPES[sensor]
@property @property
def native_value(self) -> str: def native_value(self) -> str | int:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.data["data"][self._sensor] return self.data["data"][self._sensor]
class SwitchbotRSSISensor(SwitchBotSensor):
"""Representation of a Switchbot RSSI sensor."""
@property
def native_value(self) -> str | int:
"""Return the state of the sensor."""
return self.coordinator.ble_device.rssi

View File

@ -30,13 +30,19 @@ OPTIONS_SCHEMA = vol.Schema(
vol.Required( vol.Required(
CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS
): selector.NumberSelector( ): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX), selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, step=1e-3
),
), ),
vol.Optional(CONF_LOWER): selector.NumberSelector( vol.Optional(CONF_LOWER): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX), selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, step=1e-3
),
), ),
vol.Optional(CONF_UPPER): selector.NumberSelector( vol.Optional(CONF_UPPER): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX), selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, step=1e-3
),
), ),
} }
) )

View File

@ -722,6 +722,9 @@ async def handle_supported_brands(
for int_or_exc in ints_or_excs.values(): for int_or_exc in ints_or_excs.values():
if isinstance(int_or_exc, Exception): if isinstance(int_or_exc, Exception):
raise int_or_exc raise int_or_exc
# Happens if a custom component without supported brands overrides a built-in one with supported brands
if "supported_brands" not in int_or_exc.manifest:
continue
data[int_or_exc.domain] = int_or_exc.manifest["supported_brands"] data[int_or_exc.domain] = int_or_exc.manifest["supported_brands"]
connection.send_result(msg["id"], data) connection.send_result(msg["id"], data)

View File

@ -39,6 +39,9 @@ BINARY_SENSOR_DESCRIPTIONS = {
key=XiaomiBinarySensorDeviceClass.SMOKE, key=XiaomiBinarySensorDeviceClass.SMOKE,
device_class=BinarySensorDeviceClass.SMOKE, device_class=BinarySensorDeviceClass.SMOKE,
), ),
XiaomiBinarySensorDeviceClass.MOISTURE: BinarySensorEntityDescription(
key=XiaomiBinarySensorDeviceClass.MOISTURE,
),
} }

View File

@ -3,7 +3,7 @@
"name": "Yale Access Bluetooth", "name": "Yale Access Bluetooth",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
"requirements": ["yalexs-ble==1.6.4"], "requirements": ["yalexs-ble==1.8.1"],
"dependencies": ["bluetooth"], "dependencies": ["bluetooth"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"bluetooth": [{ "manufacturer_id": 465 }], "bluetooth": [{ "manufacturer_id": 465 }],

View File

@ -8,8 +8,8 @@
"pyserial==3.5", "pyserial==3.5",
"pyserial-asyncio==0.6", "pyserial-asyncio==0.6",
"zha-quirks==0.0.79", "zha-quirks==0.0.79",
"zigpy-deconz==0.18.0", "zigpy-deconz==0.18.1",
"zigpy==0.50.2", "zigpy==0.50.3",
"zigpy-xbee==0.15.0", "zigpy-xbee==0.15.0",
"zigpy-zigate==0.9.2", "zigpy-zigate==0.9.2",
"zigpy-znp==0.8.2" "zigpy-znp==0.8.2"

View File

@ -12,7 +12,12 @@ from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import NodeStatus from zwave_js_server.const import NodeStatus
from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand
from zwave_js_server.model.driver import Driver from zwave_js_server.model.driver import Driver
from zwave_js_server.model.firmware import FirmwareUpdateInfo, FirmwareUpdateProgress from zwave_js_server.model.firmware import (
FirmwareUpdateFinished,
FirmwareUpdateInfo,
FirmwareUpdateProgress,
FirmwareUpdateStatus,
)
from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node import Node as ZwaveNode
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
@ -82,7 +87,10 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
self._status_unsub: Callable[[], None] | None = None self._status_unsub: Callable[[], None] | None = None
self._poll_unsub: Callable[[], None] | None = None self._poll_unsub: Callable[[], None] | None = None
self._progress_unsub: Callable[[], None] | None = None self._progress_unsub: Callable[[], None] | None = None
self._finished_unsub: Callable[[], None] | None = None
self._num_files_installed: int = 0 self._num_files_installed: int = 0
self._finished_event = asyncio.Event()
self._finished_status: FirmwareUpdateStatus | None = None
# Entity class attributes # Entity class attributes
self._attr_name = "Firmware" self._attr_name = "Firmware"
@ -119,18 +127,38 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
def _reset_progress(self) -> None: def _update_finished(self, event: dict[str, Any]) -> None:
"""Reset update install progress.""" """Update install progress on event."""
finished: FirmwareUpdateFinished = event["firmware_update_finished"]
self._finished_status = finished.status
self._finished_event.set()
@callback
def _unsub_firmware_events_and_reset_progress(
self, write_state: bool = False
) -> None:
"""Unsubscribe from firmware events and reset update install progress."""
if self._progress_unsub: if self._progress_unsub:
self._progress_unsub() self._progress_unsub()
self._progress_unsub = None self._progress_unsub = None
if self._finished_unsub:
self._finished_unsub()
self._finished_unsub = None
self._finished_status = None
self._finished_event.clear()
self._num_files_installed = 0 self._num_files_installed = 0
self._attr_in_progress = False self._attr_in_progress = 0
self.async_write_ha_state() if write_state:
self.async_write_ha_state()
async def _async_update(self, _: HomeAssistant | datetime | None = None) -> None: async def _async_update(self, _: HomeAssistant | datetime | None = None) -> None:
"""Update the entity.""" """Update the entity."""
self._poll_unsub = None self._poll_unsub = None
# If device is asleep/dead, wait for it to wake up/become alive before
# attempting an update
for status, event_name in ( for status, event_name in (
(NodeStatus.ASLEEP, "wake up"), (NodeStatus.ASLEEP, "wake up"),
(NodeStatus.DEAD, "alive"), (NodeStatus.DEAD, "alive"),
@ -187,19 +215,40 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
"""Install an update.""" """Install an update."""
firmware = self._latest_version_firmware firmware = self._latest_version_firmware
assert firmware assert firmware
self._attr_in_progress = 0 self._unsub_firmware_events_and_reset_progress(True)
self.async_write_ha_state()
self._progress_unsub = self.node.on( self._progress_unsub = self.node.on(
"firmware update progress", self._update_progress "firmware update progress", self._update_progress
) )
self._finished_unsub = self.node.once(
"firmware update finished", self._update_finished
)
for file in firmware.files: for file in firmware.files:
try: try:
await self.driver.controller.async_begin_ota_firmware_update( await self.driver.controller.async_begin_ota_firmware_update(
self.node, file self.node, file
) )
except BaseZwaveJSServerError as err: except BaseZwaveJSServerError as err:
self._reset_progress() self._unsub_firmware_events_and_reset_progress()
raise HomeAssistantError(err) from err raise HomeAssistantError(err) from err
# We need to block until we receive the `firmware update finished` event
await self._finished_event.wait()
assert self._finished_status is not None
# If status is not OK, we should throw an error to let the user know
if self._finished_status not in (
FirmwareUpdateStatus.OK_NO_RESTART,
FirmwareUpdateStatus.OK_RESTART_PENDING,
FirmwareUpdateStatus.OK_WAITING_FOR_ACTIVATION,
):
status = self._finished_status
self._unsub_firmware_events_and_reset_progress()
raise HomeAssistantError(status.name.replace("_", " ").title())
# If we get here, the firmware installation was successful and we need to
# update progress accordingly
self._num_files_installed += 1 self._num_files_installed += 1
self._attr_in_progress = floor( self._attr_in_progress = floor(
100 * self._num_files_installed / len(firmware.files) 100 * self._num_files_installed / len(firmware.files)
@ -208,7 +257,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
self._attr_installed_version = self._attr_latest_version = firmware.version self._attr_installed_version = self._attr_latest_version = firmware.version
self._latest_version_firmware = None self._latest_version_firmware = None
self._reset_progress() self._unsub_firmware_events_and_reset_progress()
async def async_poll_value(self, _: bool) -> None: async def async_poll_value(self, _: bool) -> None:
"""Poll a value.""" """Poll a value."""
@ -255,6 +304,4 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
self._poll_unsub() self._poll_unsub()
self._poll_unsub = None self._poll_unsub = None
if self._progress_unsub: self._unsub_firmware_events_and_reset_progress()
self._progress_unsub()
self._progress_unsub = None

View File

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

View File

@ -454,6 +454,22 @@ class EntityPlatform:
self.scan_interval, self.scan_interval,
) )
def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]:
"""Check if an entity_id already exists.
Returns a tuple [already_exists, restored]
"""
already_exists = entity_id in self.entities
restored = False
if not already_exists and not self.hass.states.async_available(entity_id):
existing = self.hass.states.get(entity_id)
if existing is not None and ATTR_RESTORED in existing.attributes:
restored = True
else:
already_exists = True
return (already_exists, restored)
async def _async_add_entity( # noqa: C901 async def _async_add_entity( # noqa: C901
self, self,
entity: Entity, entity: Entity,
@ -480,12 +496,31 @@ class EntityPlatform:
entity.add_to_platform_abort() entity.add_to_platform_abort()
return return
requested_entity_id = None
suggested_object_id: str | None = None suggested_object_id: str | None = None
generate_new_entity_id = False generate_new_entity_id = False
# Get entity_id from unique ID registration # Get entity_id from unique ID registration
if entity.unique_id is not None: if entity.unique_id is not None:
registered_entity_id = entity_registry.async_get_entity_id(
self.domain, self.platform_name, entity.unique_id
)
if registered_entity_id:
already_exists, _ = self._entity_id_already_exists(registered_entity_id)
if already_exists:
# If there's a collision, the entry belongs to another entity
entity.registry_entry = None
msg = (
f"Platform {self.platform_name} does not generate unique IDs. "
)
if entity.entity_id:
msg += f"ID {entity.unique_id} is already used by {registered_entity_id} - ignoring {entity.entity_id}"
else:
msg += f"ID {entity.unique_id} already exists - ignoring {registered_entity_id}"
self.logger.error(msg)
entity.add_to_platform_abort()
return
if self.config_entry is not None: if self.config_entry is not None:
config_entry_id: str | None = self.config_entry.entry_id config_entry_id: str | None = self.config_entry.entry_id
else: else:
@ -541,7 +576,6 @@ class EntityPlatform:
pass pass
if entity.entity_id is not None: if entity.entity_id is not None:
requested_entity_id = entity.entity_id
suggested_object_id = split_entity_id(entity.entity_id)[1] suggested_object_id = split_entity_id(entity.entity_id)[1]
else: else:
if device and entity.has_entity_name: # type: ignore[unreachable] if device and entity.has_entity_name: # type: ignore[unreachable]
@ -592,16 +626,6 @@ class EntityPlatform:
entity.registry_entry = entry entity.registry_entry = entry
entity.entity_id = entry.entity_id entity.entity_id = entry.entity_id
if entry.disabled:
self.logger.debug(
"Not adding entity %s because it's disabled",
entry.name
or entity.name
or f'"{self.platform_name} {entity.unique_id}"',
)
entity.add_to_platform_abort()
return
# We won't generate an entity ID if the platform has already set one # We won't generate an entity ID if the platform has already set one
# We will however make sure that platform cannot pick a registered ID # We will however make sure that platform cannot pick a registered ID
elif entity.entity_id is not None and entity_registry.async_is_registered( elif entity.entity_id is not None and entity_registry.async_is_registered(
@ -628,28 +652,22 @@ class EntityPlatform:
entity.add_to_platform_abort() entity.add_to_platform_abort()
raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}") raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}")
already_exists = entity.entity_id in self.entities already_exists, restored = self._entity_id_already_exists(entity.entity_id)
restored = False
if not already_exists and not self.hass.states.async_available(
entity.entity_id
):
existing = self.hass.states.get(entity.entity_id)
if existing is not None and ATTR_RESTORED in existing.attributes:
restored = True
else:
already_exists = True
if already_exists: if already_exists:
if entity.unique_id is not None: self.logger.error(
msg = f"Platform {self.platform_name} does not generate unique IDs. " f"Entity id already exists - ignoring: {entity.entity_id}"
if requested_entity_id: )
msg += f"ID {entity.unique_id} is already used by {entity.entity_id} - ignoring {requested_entity_id}" entity.add_to_platform_abort()
else: return
msg += f"ID {entity.unique_id} already exists - ignoring {entity.entity_id}"
else: if entity.registry_entry and entity.registry_entry.disabled:
msg = f"Entity id already exists - ignoring: {entity.entity_id}" self.logger.debug(
self.logger.error(msg) "Not adding entity %s because it's disabled",
entry.name
or entity.name
or f'"{self.platform_name} {entity.unique_id}"',
)
entity.add_to_platform_abort() entity.add_to_platform_abort()
return return

View File

@ -11,7 +11,7 @@ attrs==21.2.0
awesomeversion==22.8.0 awesomeversion==22.8.0
bcrypt==3.1.7 bcrypt==3.1.7
bleak==0.16.0 bleak==0.16.0
bluetooth-adapters==0.3.5 bluetooth-adapters==0.4.1
bluetooth-auto-recovery==0.3.2 bluetooth-auto-recovery==0.3.2
certifi>=2021.5.30 certifi>=2021.5.30
ciso8601==2.2.0 ciso8601==2.2.0

View File

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

View File

@ -37,7 +37,7 @@ PyRMVtransport==0.3.3
PySocks==1.7.1 PySocks==1.7.1
# homeassistant.components.switchbot # homeassistant.components.switchbot
PySwitchbot==0.18.27 PySwitchbot==0.19.5
# homeassistant.components.transport_nsw # homeassistant.components.transport_nsw
PyTransportNSW==0.1.1 PyTransportNSW==0.1.1
@ -147,7 +147,7 @@ aioeafm==0.1.2
aioeagle==1.1.0 aioeagle==1.1.0
# homeassistant.components.ecowitt # homeassistant.components.ecowitt
aioecowitt==2022.08.3 aioecowitt==2022.09.1
# homeassistant.components.emonitor # homeassistant.components.emonitor
aioemonitor==1.0.5 aioemonitor==1.0.5
@ -171,7 +171,7 @@ aioguardian==2022.07.0
aioharmony==0.2.9 aioharmony==0.2.9
# homeassistant.components.homekit_controller # homeassistant.components.homekit_controller
aiohomekit==1.5.2 aiohomekit==1.5.6
# homeassistant.components.emulated_hue # homeassistant.components.emulated_hue
# homeassistant.components.http # homeassistant.components.http
@ -430,7 +430,7 @@ bluemaestro-ble==0.2.0
# bluepy==1.3.0 # bluepy==1.3.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-adapters==0.3.5 bluetooth-adapters==0.4.1
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.2 bluetooth-auto-recovery==0.3.2
@ -968,7 +968,7 @@ lakeside==0.12
laundrify_aio==1.1.2 laundrify_aio==1.1.2
# homeassistant.components.led_ble # homeassistant.components.led_ble
led-ble==0.7.1 led-ble==0.9.1
# homeassistant.components.foscam # homeassistant.components.foscam
libpyfoscam==1.0 libpyfoscam==1.0
@ -1838,7 +1838,7 @@ pysaj==0.0.16
pysdcp==1 pysdcp==1
# homeassistant.components.sensibo # homeassistant.components.sensibo
pysensibo==1.0.19 pysensibo==1.0.20
# homeassistant.components.serial # homeassistant.components.serial
# homeassistant.components.zha # homeassistant.components.zha
@ -2118,7 +2118,7 @@ raincloudy==0.0.7
raspyrfm-client==1.2.8 raspyrfm-client==1.2.8
# homeassistant.components.rainmachine # homeassistant.components.rainmachine
regenmaschine==2022.09.0 regenmaschine==2022.09.1
# homeassistant.components.renault # homeassistant.components.renault
renault-api==0.1.11 renault-api==0.1.11
@ -2542,7 +2542,7 @@ xs1-api-client==3.0.0
yalesmartalarmclient==0.3.9 yalesmartalarmclient==0.3.9
# homeassistant.components.yalexs_ble # homeassistant.components.yalexs_ble
yalexs-ble==1.6.4 yalexs-ble==1.8.1
# homeassistant.components.august # homeassistant.components.august
yalexs==1.2.1 yalexs==1.2.1
@ -2578,7 +2578,7 @@ zhong_hong_hvac==1.0.9
ziggo-mediabox-xl==1.1.0 ziggo-mediabox-xl==1.1.0
# homeassistant.components.zha # homeassistant.components.zha
zigpy-deconz==0.18.0 zigpy-deconz==0.18.1
# homeassistant.components.zha # homeassistant.components.zha
zigpy-xbee==0.15.0 zigpy-xbee==0.15.0
@ -2590,7 +2590,7 @@ zigpy-zigate==0.9.2
zigpy-znp==0.8.2 zigpy-znp==0.8.2
# homeassistant.components.zha # homeassistant.components.zha
zigpy==0.50.2 zigpy==0.50.3
# homeassistant.components.zoneminder # homeassistant.components.zoneminder
zm-py==0.5.2 zm-py==0.5.2

View File

@ -33,7 +33,7 @@ PyRMVtransport==0.3.3
PySocks==1.7.1 PySocks==1.7.1
# homeassistant.components.switchbot # homeassistant.components.switchbot
PySwitchbot==0.18.27 PySwitchbot==0.19.5
# homeassistant.components.transport_nsw # homeassistant.components.transport_nsw
PyTransportNSW==0.1.1 PyTransportNSW==0.1.1
@ -134,7 +134,7 @@ aioeafm==0.1.2
aioeagle==1.1.0 aioeagle==1.1.0
# homeassistant.components.ecowitt # homeassistant.components.ecowitt
aioecowitt==2022.08.3 aioecowitt==2022.09.1
# homeassistant.components.emonitor # homeassistant.components.emonitor
aioemonitor==1.0.5 aioemonitor==1.0.5
@ -155,7 +155,7 @@ aioguardian==2022.07.0
aioharmony==0.2.9 aioharmony==0.2.9
# homeassistant.components.homekit_controller # homeassistant.components.homekit_controller
aiohomekit==1.5.2 aiohomekit==1.5.6
# homeassistant.components.emulated_hue # homeassistant.components.emulated_hue
# homeassistant.components.http # homeassistant.components.http
@ -341,7 +341,7 @@ blinkpy==0.19.0
bluemaestro-ble==0.2.0 bluemaestro-ble==0.2.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-adapters==0.3.5 bluetooth-adapters==0.4.1
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-auto-recovery==0.3.2 bluetooth-auto-recovery==0.3.2
@ -706,7 +706,7 @@ lacrosse-view==0.0.9
laundrify_aio==1.1.2 laundrify_aio==1.1.2
# homeassistant.components.led_ble # homeassistant.components.led_ble
led-ble==0.7.1 led-ble==0.9.1
# homeassistant.components.foscam # homeassistant.components.foscam
libpyfoscam==1.0 libpyfoscam==1.0
@ -1285,7 +1285,7 @@ pyruckus==0.16
pysabnzbd==1.1.1 pysabnzbd==1.1.1
# homeassistant.components.sensibo # homeassistant.components.sensibo
pysensibo==1.0.19 pysensibo==1.0.20
# homeassistant.components.serial # homeassistant.components.serial
# homeassistant.components.zha # homeassistant.components.zha
@ -1451,7 +1451,7 @@ radios==0.1.1
radiotherm==2.1.0 radiotherm==2.1.0
# homeassistant.components.rainmachine # homeassistant.components.rainmachine
regenmaschine==2022.09.0 regenmaschine==2022.09.1
# homeassistant.components.renault # homeassistant.components.renault
renault-api==0.1.11 renault-api==0.1.11
@ -1746,7 +1746,7 @@ xmltodict==0.13.0
yalesmartalarmclient==0.3.9 yalesmartalarmclient==0.3.9
# homeassistant.components.yalexs_ble # homeassistant.components.yalexs_ble
yalexs-ble==1.6.4 yalexs-ble==1.8.1
# homeassistant.components.august # homeassistant.components.august
yalexs==1.2.1 yalexs==1.2.1
@ -1767,7 +1767,7 @@ zeroconf==0.39.1
zha-quirks==0.0.79 zha-quirks==0.0.79
# homeassistant.components.zha # homeassistant.components.zha
zigpy-deconz==0.18.0 zigpy-deconz==0.18.1
# homeassistant.components.zha # homeassistant.components.zha
zigpy-xbee==0.15.0 zigpy-xbee==0.15.0
@ -1779,7 +1779,7 @@ zigpy-zigate==0.9.2
zigpy-znp==0.8.2 zigpy-znp==0.8.2
# homeassistant.components.zha # homeassistant.components.zha
zigpy==0.50.2 zigpy==0.50.3
# homeassistant.components.zwave_js # homeassistant.components.zwave_js
zwave-js-server-python==0.41.1 zwave-js-server-python==0.41.1

View File

@ -4,6 +4,7 @@ from unittest.mock import patch
import pytest import pytest
from homeassistant import config as hass_config
import homeassistant.components.automation as automation import homeassistant.components.automation as automation
from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_automation import DeviceAutomationType
from homeassistant.components.mqtt import _LOGGER, DOMAIN, debug_info from homeassistant.components.mqtt import _LOGGER, DOMAIN, debug_info
@ -1425,7 +1426,24 @@ async def test_unload_entry(hass, calls, device_reg, mqtt_mock, tmp_path) -> Non
await help_test_unload_config_entry(hass, tmp_path, {}) await help_test_unload_config_entry(hass, tmp_path, {})
# Fake short press 2 # Rediscover message and fake short press 2 (non impact)
async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
await hass.async_block_till_done()
async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1
mqtt_entry = hass.config_entries.async_entries("mqtt")[0]
# Load the entry again
new_yaml_config_file = tmp_path / "configuration.yaml"
new_yaml_config_file.write_text("")
with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file):
await mqtt_entry.async_setup(hass)
# Rediscover and fake short press 3
async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
await hass.async_block_till_done()
async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
await hass.async_block_till_done()
assert len(calls) == 2

View File

@ -1,4 +1,5 @@
"""The tests for the MQTT discovery.""" """The tests for the MQTT discovery."""
import copy
import json import json
from pathlib import Path from pathlib import Path
import re import re
@ -23,6 +24,8 @@ from homeassistant.const import (
import homeassistant.core as ha import homeassistant.core as ha
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .test_common import help_test_unload_config_entry
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
async_capture_events, async_capture_events,
@ -1356,3 +1359,170 @@ async def test_mqtt_discovery_unsubscribe_once(
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.async_block_till_done() await hass.async_block_till_done()
mqtt_client_mock.unsubscribe.assert_called_once_with("comp/discovery/#") mqtt_client_mock.unsubscribe.assert_called_once_with("comp/discovery/#")
@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR])
async def test_clear_config_topic_disabled_entity(
hass, mqtt_mock_entry_no_yaml_config, device_reg, caplog
):
"""Test the discovery topic is removed when a disabled entity is removed."""
mqtt_mock = await mqtt_mock_entry_no_yaml_config()
# discover an entity that is not enabled by default
config = {
"name": "sbfspot_12345",
"state_topic": "homeassistant_test/sensor/sbfspot_0/sbfspot_12345/",
"unique_id": "sbfspot_12345",
"enabled_by_default": False,
"device": {
"identifiers": ["sbfspot_12345"],
"name": "sbfspot_12345",
"sw_version": "1.0",
"connections": [["mac", "12:34:56:AB:CD:EF"]],
},
}
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/sbfspot_12345/config",
json.dumps(config),
)
await hass.async_block_till_done()
# discover an entity that is not unique (part 1), will be added
config_not_unique1 = copy.deepcopy(config)
config_not_unique1["name"] = "sbfspot_12345_1"
config_not_unique1["unique_id"] = "not_unique"
config_not_unique1.pop("enabled_by_default")
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/sbfspot_12345_1/config",
json.dumps(config_not_unique1),
)
# discover an entity that is not unique (part 2), will not be added
config_not_unique2 = copy.deepcopy(config_not_unique1)
config_not_unique2["name"] = "sbfspot_12345_2"
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/sbfspot_12345_2/config",
json.dumps(config_not_unique2),
)
await hass.async_block_till_done()
assert "Platform mqtt does not generate unique IDs" in caplog.text
assert hass.states.get("sensor.sbfspot_12345") is None # disabled
assert hass.states.get("sensor.sbfspot_12345_1") is not None # enabled
assert hass.states.get("sensor.sbfspot_12345_2") is None # not unique
# Verify device is created
device_entry = device_reg.async_get_device(set(), {("mac", "12:34:56:AB:CD:EF")})
assert device_entry is not None
# Remove the device from the registry
device_reg.async_remove_device(device_entry.id)
await hass.async_block_till_done()
await hass.async_block_till_done()
# Assert all valid discovery topics are cleared
assert mqtt_mock.async_publish.call_count == 2
assert (
call("homeassistant/sensor/sbfspot_0/sbfspot_12345/config", "", 0, True)
in mqtt_mock.async_publish.mock_calls
)
assert (
call("homeassistant/sensor/sbfspot_0/sbfspot_12345_1/config", "", 0, True)
in mqtt_mock.async_publish.mock_calls
)
@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR])
async def test_clean_up_registry_monitoring(
hass, mqtt_mock_entry_no_yaml_config, device_reg, tmp_path
):
"""Test registry monitoring hook is removed after a reload."""
await mqtt_mock_entry_no_yaml_config()
hooks: dict = hass.data[mqtt.const.DATA_MQTT_DISCOVERY_REGISTRY_HOOKS]
# discover an entity that is not enabled by default
config1 = {
"name": "sbfspot_12345",
"state_topic": "homeassistant_test/sensor/sbfspot_0/sbfspot_12345/",
"unique_id": "sbfspot_12345",
"enabled_by_default": False,
"device": {
"identifiers": ["sbfspot_12345"],
"name": "sbfspot_12345",
"sw_version": "1.0",
"connections": [["mac", "12:34:56:AB:CD:EF"]],
},
}
# Publish it config
# Since it is not enabled_by_default the sensor will not be loaded
# it should register a hook for monitoring the entiry registry
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/sbfspot_12345/config",
json.dumps(config1),
)
await hass.async_block_till_done()
assert len(hooks) == 1
# Publish it again no new monitor should be started
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/sbfspot_12345/config",
json.dumps(config1),
)
await hass.async_block_till_done()
assert len(hooks) == 1
# Verify device is created
device_entry = device_reg.async_get_device(set(), {("mac", "12:34:56:AB:CD:EF")})
assert device_entry is not None
# Enload the entry
# The monitoring should be cleared
await help_test_unload_config_entry(hass, tmp_path, {})
assert len(hooks) == 0
@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR])
async def test_unique_id_collission_has_priority(
hass, mqtt_mock_entry_no_yaml_config, entity_reg
):
"""Test tehe unique_id collision detection has priority over registry disabled items."""
await mqtt_mock_entry_no_yaml_config()
config = {
"name": "sbfspot_12345",
"state_topic": "homeassistant_test/sensor/sbfspot_0/sbfspot_12345/",
"unique_id": "sbfspot_12345",
"enabled_by_default": False,
"device": {
"identifiers": ["sbfspot_12345"],
"name": "sbfspot_12345",
"sw_version": "1.0",
"connections": [["mac", "12:34:56:AB:CD:EF"]],
},
}
# discover an entity that is not unique and disabled by default (part 1), will be added
config_not_unique1 = copy.deepcopy(config)
config_not_unique1["name"] = "sbfspot_12345_1"
config_not_unique1["unique_id"] = "not_unique"
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/sbfspot_12345_1/config",
json.dumps(config_not_unique1),
)
# discover an entity that is not unique (part 2), will not be added, and the registry entry is cleared
config_not_unique2 = copy.deepcopy(config_not_unique1)
config_not_unique2["name"] = "sbfspot_12345_2"
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/sbfspot_12345_2/config",
json.dumps(config_not_unique2),
)
await hass.async_block_till_done()
assert hass.states.get("sensor.sbfspot_12345_1") is None # not enabled
assert hass.states.get("sensor.sbfspot_12345_2") is None # not unique
# Verify the first entity is created
assert entity_reg.async_get("sensor.sbfspot_12345_1") is not None
# Verify the second entity is not created because it is not unique
assert entity_reg.async_get("sensor.sbfspot_12345_2") is None

View File

@ -1760,6 +1760,12 @@ async def test_validate_config_invalid(websocket_client, key, config, error):
async def test_supported_brands(hass, websocket_client): async def test_supported_brands(hass, websocket_client):
"""Test supported brands.""" """Test supported brands."""
# Custom components without supported brands that override a built-in component with
# supported brand will still be listed in HAS_SUPPORTED_BRANDS and should be ignored.
mock_integration(
hass,
MockModule("override_without_brands"),
)
mock_integration( mock_integration(
hass, hass,
MockModule("test", partial_manifest={"supported_brands": {"hello": "World"}}), MockModule("test", partial_manifest={"supported_brands": {"hello": "World"}}),
@ -1773,7 +1779,7 @@ async def test_supported_brands(hass, websocket_client):
with patch( with patch(
"homeassistant.generated.supported_brands.HAS_SUPPORTED_BRANDS", "homeassistant.generated.supported_brands.HAS_SUPPORTED_BRANDS",
("abcd", "test"), ("abcd", "test", "override_without_brands"),
): ):
await websocket_client.send_json({"id": 7, "type": "supported_brands"}) await websocket_client.send_json({"id": 7, "type": "supported_brands"})
msg = await websocket_client.receive_json() msg = await websocket_client.receive_json()

View File

@ -52,3 +52,48 @@ async def test_smoke_sensor(hass):
assert await hass.config_entries.async_unload(entry.entry_id) assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
async def test_moisture(hass):
"""Make sure that formldehyde sensors are correctly mapped."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="C4:7C:8D:6A:3E:7A",
)
entry.add_to_hass(hass)
saved_callback = None
def _async_register_callback(_hass, _callback, _matcher, _mode):
nonlocal saved_callback
saved_callback = _callback
return lambda: None
with patch(
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
_async_register_callback,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
# WARNING: This test data is synthetic, rather than captured from a real device
# obj type is 0x1014, payload len is 0x2 and payload is 0xf400
saved_callback(
make_advertisement(
"C4:7C:8D:6A:3E:7A", b"q \x5d\x01iz>j\x8d|\xc4\r\x14\x10\x02\xf4\x00"
),
BluetoothChange.ADVERTISEMENT,
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
sensor = hass.states.get("binary_sensor.smart_flower_pot_6a3e7a_moisture")
sensor_attr = sensor.attributes
assert sensor.state == "on"
assert sensor_attr[ATTR_FRIENDLY_NAME] == "Smart Flower Pot 6A3E7A Moisture"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()

View File

@ -2,11 +2,12 @@
import copy import copy
import json import json
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch
import uuid import uuid
import pytest import pytest
import serial.tools.list_ports import serial.tools.list_ports
from zigpy.backups import BackupManager
import zigpy.config import zigpy.config
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
from zigpy.exceptions import NetworkNotFormed from zigpy.exceptions import NetworkNotFormed
@ -49,6 +50,7 @@ def disable_platform_only():
def mock_app(): def mock_app():
"""Mock zigpy app interface.""" """Mock zigpy app interface."""
mock_app = AsyncMock() mock_app = AsyncMock()
mock_app.backups = create_autospec(BackupManager, instance=True)
mock_app.backups.backups = [] mock_app.backups.backups = []
with patch( with patch(

View File

@ -1,9 +1,11 @@
"""Test the Z-Wave JS update entities.""" """Test the Z-Wave JS update entities."""
import asyncio
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from zwave_js_server.event import Event from zwave_js_server.event import Event
from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.exceptions import FailedZWaveCommand
from zwave_js_server.model.firmware import FirmwareUpdateStatus
from homeassistant.components.update.const import ( from homeassistant.components.update.const import (
ATTR_AUTO_UPDATE, ATTR_AUTO_UPDATE,
@ -51,7 +53,7 @@ FIRMWARE_UPDATES = {
} }
async def test_update_entity_success( async def test_update_entity_states(
hass, hass,
client, client,
climate_radio_thermostat_ct100_plus_different_endpoints, climate_radio_thermostat_ct100_plus_different_endpoints,
@ -60,7 +62,7 @@ async def test_update_entity_success(
caplog, caplog,
hass_ws_client, hass_ws_client,
): ):
"""Test update entity.""" """Test update entity states."""
ws_client = await hass_ws_client(hass) ws_client = await hass_ws_client(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -137,39 +139,14 @@ async def test_update_entity_success(
client.async_send_command.reset_mock() client.async_send_command.reset_mock()
# Test successful install call without a version
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: UPDATE_ENTITY,
},
blocking=True,
)
args = client.async_send_command.call_args_list[0][0][0] async def test_update_entity_install_raises(
assert args["command"] == "controller.begin_ota_firmware_update"
assert (
args["nodeId"]
== climate_radio_thermostat_ct100_plus_different_endpoints.node_id
)
assert args["update"] == {
"target": 0,
"url": "https://example2.com",
"integrity": "sha2",
}
client.async_send_command.reset_mock()
async def test_update_entity_install_failure(
hass, hass,
client, client,
climate_radio_thermostat_ct100_plus_different_endpoints, climate_radio_thermostat_ct100_plus_different_endpoints,
controller_node,
integration, integration,
): ):
"""Test update entity failed install.""" """Test update entity install raises exception."""
client.async_send_command.return_value = FIRMWARE_UPDATES client.async_send_command.return_value = FIRMWARE_UPDATES
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1))
@ -287,11 +264,10 @@ async def test_update_entity_ha_not_running(
assert args["nodeId"] == zen_31.node_id assert args["nodeId"] == zen_31.node_id
async def test_update_entity_failure( async def test_update_entity_update_failure(
hass, hass,
client, client,
climate_radio_thermostat_ct100_plus_different_endpoints, climate_radio_thermostat_ct100_plus_different_endpoints,
controller_node,
integration, integration,
): ):
"""Test update entity update failed.""" """Test update entity update failed."""
@ -311,3 +287,169 @@ async def test_update_entity_failure(
args["nodeId"] args["nodeId"]
== climate_radio_thermostat_ct100_plus_different_endpoints.node_id == climate_radio_thermostat_ct100_plus_different_endpoints.node_id
) )
async def test_update_entity_progress(
hass,
client,
climate_radio_thermostat_ct100_plus_different_endpoints,
integration,
):
"""Test update entity progress."""
node = climate_radio_thermostat_ct100_plus_different_endpoints
client.async_send_command.return_value = FIRMWARE_UPDATES
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1))
await hass.async_block_till_done()
state = hass.states.get(UPDATE_ENTITY)
assert state
assert state.state == STATE_ON
attrs = state.attributes
assert attrs[ATTR_INSTALLED_VERSION] == "10.7"
assert attrs[ATTR_LATEST_VERSION] == "11.2.4"
client.async_send_command.reset_mock()
client.async_send_command.return_value = None
# Test successful install call without a version
install_task = hass.async_create_task(
hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: UPDATE_ENTITY,
},
blocking=True,
)
)
# Sleep so that task starts
await asyncio.sleep(0.1)
event = Event(
type="firmware update progress",
data={
"source": "node",
"event": "firmware update progress",
"nodeId": node.node_id,
"sentFragments": 1,
"totalFragments": 20,
},
)
node.receive_event(event)
# Validate that the progress is updated
state = hass.states.get(UPDATE_ENTITY)
assert state
attrs = state.attributes
assert attrs[ATTR_IN_PROGRESS] == 5
event = Event(
type="firmware update finished",
data={
"source": "node",
"event": "firmware update finished",
"nodeId": node.node_id,
"status": FirmwareUpdateStatus.OK_NO_RESTART,
},
)
node.receive_event(event)
await hass.async_block_till_done()
# Validate that progress is reset and entity reflects new version
state = hass.states.get(UPDATE_ENTITY)
assert state
attrs = state.attributes
assert attrs[ATTR_IN_PROGRESS] is False
assert attrs[ATTR_INSTALLED_VERSION] == "11.2.4"
assert attrs[ATTR_LATEST_VERSION] == "11.2.4"
assert state.state == STATE_OFF
await install_task
async def test_update_entity_install_failed(
hass,
client,
climate_radio_thermostat_ct100_plus_different_endpoints,
integration,
caplog,
):
"""Test update entity install returns error status."""
node = climate_radio_thermostat_ct100_plus_different_endpoints
client.async_send_command.return_value = FIRMWARE_UPDATES
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1))
await hass.async_block_till_done()
state = hass.states.get(UPDATE_ENTITY)
assert state
assert state.state == STATE_ON
attrs = state.attributes
assert attrs[ATTR_INSTALLED_VERSION] == "10.7"
assert attrs[ATTR_LATEST_VERSION] == "11.2.4"
client.async_send_command.reset_mock()
client.async_send_command.return_value = None
async def call_install():
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: UPDATE_ENTITY,
},
blocking=True,
)
# Test install call - we expect it to raise
install_task = hass.async_create_task(call_install())
# Sleep so that task starts
await asyncio.sleep(0.1)
event = Event(
type="firmware update progress",
data={
"source": "node",
"event": "firmware update progress",
"nodeId": node.node_id,
"sentFragments": 1,
"totalFragments": 20,
},
)
node.receive_event(event)
# Validate that the progress is updated
state = hass.states.get(UPDATE_ENTITY)
assert state
attrs = state.attributes
assert attrs[ATTR_IN_PROGRESS] == 5
event = Event(
type="firmware update finished",
data={
"source": "node",
"event": "firmware update finished",
"nodeId": node.node_id,
"status": FirmwareUpdateStatus.ERROR_TIMEOUT,
},
)
node.receive_event(event)
await hass.async_block_till_done()
# Validate that progress is reset and entity reflects old version
state = hass.states.get(UPDATE_ENTITY)
assert state
attrs = state.attributes
assert attrs[ATTR_IN_PROGRESS] is False
assert attrs[ATTR_INSTALLED_VERSION] == "10.7"
assert attrs[ATTR_LATEST_VERSION] == "11.2.4"
assert state.state == STATE_ON
# validate that the install task failed
with pytest.raises(HomeAssistantError):
await install_task

View File

@ -438,13 +438,15 @@ async def test_async_remove_with_platform_update_finishes(hass):
async def test_not_adding_duplicate_entities_with_unique_id(hass, caplog): async def test_not_adding_duplicate_entities_with_unique_id(hass, caplog):
"""Test for not adding duplicate entities.""" """Test for not adding duplicate entities.
Also test that the entity registry is not updated for duplicates.
"""
caplog.set_level(logging.ERROR) caplog.set_level(logging.ERROR)
component = EntityComponent(_LOGGER, DOMAIN, hass) component = EntityComponent(_LOGGER, DOMAIN, hass)
await component.async_add_entities( ent1 = MockEntity(name="test1", unique_id="not_very_unique")
[MockEntity(name="test1", unique_id="not_very_unique")] await component.async_add_entities([ent1])
)
assert len(hass.states.async_entity_ids()) == 1 assert len(hass.states.async_entity_ids()) == 1
assert not caplog.text assert not caplog.text
@ -466,6 +468,11 @@ async def test_not_adding_duplicate_entities_with_unique_id(hass, caplog):
assert ent2.platform is None assert ent2.platform is None
assert len(hass.states.async_entity_ids()) == 1 assert len(hass.states.async_entity_ids()) == 1
registry = er.async_get(hass)
# test the entity name was not updated
entry = registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique")
assert entry.original_name == "test1"
async def test_using_prescribed_entity_id(hass): async def test_using_prescribed_entity_id(hass):
"""Test for using predefined entity ID.""" """Test for using predefined entity ID."""
@ -577,6 +584,28 @@ async def test_registry_respect_entity_disabled(hass):
assert hass.states.async_entity_ids() == [] assert hass.states.async_entity_ids() == []
async def test_unique_id_conflict_has_priority_over_disabled_entity(hass, caplog):
"""Test that an entity that is not unique has priority over a disabled entity."""
component = EntityComponent(_LOGGER, DOMAIN, hass)
entity1 = MockEntity(
name="test1", unique_id="not_very_unique", enabled_by_default=False
)
entity2 = MockEntity(
name="test2", unique_id="not_very_unique", enabled_by_default=False
)
await component.async_add_entities([entity1])
await component.async_add_entities([entity2])
assert len(hass.states.async_entity_ids()) == 1
assert "Platform test_domain does not generate unique IDs." in caplog.text
assert entity1.registry_entry is not None
assert entity2.registry_entry is None
registry = er.async_get(hass)
# test the entity name was not updated
entry = registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique")
assert entry.original_name == "test1"
async def test_entity_registry_updates_name(hass): async def test_entity_registry_updates_name(hass):
"""Test that updates on the entity registry update platform entities.""" """Test that updates on the entity registry update platform entities."""
registry = mock_registry( registry = mock_registry(