This commit is contained in:
Franck Nijhof
2025-10-10 23:19:19 +02:00
committed by GitHub
128 changed files with 1798 additions and 620 deletions
Generated
+2 -2
View File
@@ -760,8 +760,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @MaestroOnICe
/tests/components/iometer/ @MaestroOnICe
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs
/homeassistant/components/ios/ @robbiet480
/tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
@@ -71,4 +71,4 @@ POLLEN_CATEGORY_MAP = {
}
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(minutes=30)
@@ -1,6 +1,9 @@
{
"entity": {
"sensor": {
"air_quality": {
"default": "mdi:air-filter"
},
"cloud_ceiling": {
"default": "mdi:weather-fog"
},
@@ -34,9 +37,6 @@
"thunderstorm_probability_night": {
"default": "mdi:weather-lightning"
},
"translation_key": {
"default": "mdi:air-filter"
},
"tree_pollen": {
"default": "mdi:tree-outline"
},
+25 -4
View File
@@ -1,7 +1,9 @@
"""Airgradient Update platform."""
from datetime import timedelta
import logging
from airgradient import AirGradientConnectionError
from propcache.api import cached_property
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
@@ -13,6 +15,7 @@ from .entity import AirGradientEntity
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(hours=1)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
@@ -31,6 +34,7 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
"""Representation of Airgradient Update."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_server_unreachable_logged = False
def __init__(self, coordinator: AirGradientCoordinator) -> None:
"""Initialize the entity."""
@@ -47,10 +51,27 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
"""Return the installed version of the entity."""
return self.coordinator.data.measures.firmware_version
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._attr_available
async def async_update(self) -> None:
"""Update the entity."""
self._attr_latest_version = (
await self.coordinator.client.get_latest_firmware_version(
self.coordinator.serial_number
try:
self._attr_latest_version = (
await self.coordinator.client.get_latest_firmware_version(
self.coordinator.serial_number
)
)
)
except AirGradientConnectionError:
self._attr_latest_version = None
self._attr_available = False
if not self._server_unreachable_logged:
_LOGGER.error(
"Unable to connect to AirGradient server to check for updates"
)
self._server_unreachable_logged = True
else:
self._server_unreachable_logged = False
self._attr_available = True
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["airos==0.5.4"]
"requirements": ["airos==0.5.5"]
}
@@ -18,7 +18,9 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_update_unique_id
@@ -51,11 +53,47 @@ BINARY_SENSORS: Final = (
),
is_supported=lambda device, key: device.sensors.get(key) is not None,
is_available_fn=lambda device, key: (
device.online and device.sensors[key].error is False
device.online
and (sensor := device.sensors.get(key)) is not None
and sensor.error is False
),
),
)
DEPRECATED_BINARY_SENSORS: Final = (
AmazonBinarySensorEntityDescription(
key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="babyCryDetectionState",
translation_key="baby_cry_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="beepingApplianceDetectionState",
translation_key="beeping_appliance_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="coughDetectionState",
translation_key="cough_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="dogBarkDetectionState",
translation_key="dog_bark_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="waterSoundsDetectionState",
translation_key="water_sounds_detection",
is_on_fn=lambda device, key: False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -66,6 +104,8 @@ async def async_setup_entry(
coordinator = entry.runtime_data
entity_registry = er.async_get(hass)
# Replace unique id for "detectionState" binary sensor
await async_update_unique_id(
hass,
@@ -75,6 +115,16 @@ async def async_setup_entry(
"detectionState",
)
# Clean up deprecated sensors
for sensor_desc in DEPRECATED_BINARY_SENSORS:
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{sensor_desc.key}"
if entity_id := entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, unique_id
):
_LOGGER.debug("Removing deprecated entity %s", entity_id)
entity_registry.async_remove(entity_id)
known_devices: set[str] = set()
def _check_device() -> None:
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==6.2.7"]
"requirements": ["aioamazondevices==6.4.0"]
}
@@ -32,7 +32,9 @@ class AmazonSensorEntityDescription(SensorEntityDescription):
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online and device.sensors[key].error is False
device.online
and (sensor := device.sensors.get(key)) is not None
and sensor.error is False
)
@@ -40,9 +42,9 @@ SENSORS: Final = (
AmazonSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement_fn=lambda device, _key: (
native_unit_of_measurement_fn=lambda device, key: (
UnitOfTemperature.CELSIUS
if device.sensors[_key].scale == "CELSIUS"
if key in device.sensors and device.sensors[key].scale == "CELSIUS"
else UnitOfTemperature.FAHRENHEIT
),
state_class=SensorStateClass.MEASUREMENT,
@@ -18,7 +18,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call, async_update_unique_id
from .utils import (
alexa_api_call,
async_remove_dnd_from_virtual_group,
async_update_unique_id,
)
PARALLEL_UPDATES = 1
@@ -29,7 +33,9 @@ class AmazonSwitchEntityDescription(SwitchEntityDescription):
is_on_fn: Callable[[AmazonDevice], bool]
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online and device.sensors[key].error is False
device.online
and (sensor := device.sensors.get(key)) is not None
and sensor.error is False
)
method: str
@@ -58,6 +64,9 @@ async def async_setup_entry(
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
)
# Remove DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator)
known_devices: set[str] = set()
def _check_device() -> None:
@@ -4,8 +4,10 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er
@@ -61,3 +63,21 @@ async def async_update_unique_id(
# Update the registry with the new unique_id
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
async def async_remove_dnd_from_virtual_group(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
) -> None:
"""Remove entity DND from virtual group."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
unique_id = f"{serial_num}-do_not_disturb"
entity_id = entity_registry.async_get_entity_id(
DOMAIN, SWITCH_DOMAIN, unique_id
)
is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY
if entity_id and is_group:
entity_registry.async_remove(entity_id)
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"requirements": ["brother==5.1.0"],
"requirements": ["brother==5.1.1"],
"zeroconf": [
{
"type": "_printer._tcp.local.",
@@ -4,6 +4,7 @@ from __future__ import annotations
from asyncio.exceptions import TimeoutError
from collections.abc import Mapping
import re
from typing import Any
from aiocomelit import (
@@ -27,25 +28,20 @@ from .utils import async_client_session
DEFAULT_HOST = "192.168.1.252"
DEFAULT_PIN = "111111"
pin_regex = r"^[0-9]{4,10}$"
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_PIN): cv.matches_regex(pin_regex)}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
STEP_RECONFIGURE = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
}
)
@@ -55,6 +51,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
api: ComelitCommonApi
if not re.fullmatch(r"[0-9]{4,10}", data[CONF_PIN]):
raise InvalidPin
session = await async_client_session(hass)
if data.get(CONF_TYPE, BRIDGE) == BRIDGE:
api = ComeliteSerialBridgeApi(
@@ -105,6 +104,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -146,6 +147,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -189,6 +192,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -210,3 +215,7 @@ class CannotConnect(HomeAssistantError):
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class InvalidPin(HomeAssistantError):
"""Error to indicate an invalid pin."""
@@ -161,7 +161,7 @@ class ComelitSerialBridge(
entry: ComelitConfigEntry,
host: str,
port: int,
pin: int,
pin: str,
session: ClientSession,
) -> None:
"""Initialize the scanner."""
@@ -195,7 +195,7 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
entry: ComelitConfigEntry,
host: str,
port: int,
pin: int,
pin: str,
session: ClientSession,
) -> None:
"""Initialize the scanner."""
+15 -5
View File
@@ -7,7 +7,14 @@ from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
from homeassistant.components.cover import (
STATE_CLOSED,
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
CoverDeviceClass,
CoverEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -62,7 +69,6 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
super().__init__(coordinator, device, config_entry_entry_id)
# Device doesn't provide a status so we assume UNKNOWN at first startup
self._last_action: int | None = None
self._last_state: str | None = None
def _current_action(self, action: str) -> bool:
"""Return the current cover action."""
@@ -98,7 +104,6 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
@bridge_api_call
async def _cover_set_state(self, action: int, state: int) -> None:
"""Set desired cover state."""
self._last_state = self.state
await self.coordinator.api.set_device_status(COVER, self._device.index, action)
self.coordinator.data[COVER][self._device.index].status = state
self.async_write_ha_state()
@@ -124,5 +129,10 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
await super().async_added_to_hass()
if last_state := await self.async_get_last_state():
self._last_state = last_state.state
if (state := await self.async_get_last_state()) is not None:
if state.state == STATE_CLOSED:
self._last_action = STATE_COVER.index(STATE_CLOSING)
if state.state == STATE_OPEN:
self._last_action = STATE_COVER.index(STATE_OPENING)
self._attr_is_closed = state.state == STATE_CLOSED
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==0.12.3"]
"requirements": ["aiocomelit==1.1.1"]
}
@@ -43,11 +43,13 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
@@ -514,7 +514,7 @@ class ChatLog:
"""Set the LLM system prompt."""
llm_api: llm.APIInstance | None = None
if user_llm_hass_api is None:
if not user_llm_hass_api:
pass
elif isinstance(user_llm_hass_api, llm.API):
llm_api = await user_llm_hass_api.async_get_api_instance(llm_context)
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["pycync==0.4.0"]
"requirements": ["pycync==0.4.1"]
}
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.16.0"],
"requirements": ["pydaikin==2.17.1"],
"zeroconf": ["_dkapi._tcp.local."]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==15.0.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.11.2"]
"requirements": ["env-canada==0.11.3"]
}
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251001.0"]
"requirements": ["home-assistant-frontend==20251001.2"]
}
@@ -76,10 +76,6 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry
) -> bool:
"""Unload a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
for service_name in hass.services.async_services_for_domain(DOMAIN):
hass.services.async_remove(DOMAIN, service_name)
conversation.async_unset_agent(hass, entry)
return True
@@ -26,7 +26,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.event import async_call_later
@@ -68,7 +68,13 @@ async def async_send_text_commands(
) -> list[CommandResponse]:
"""Send text commands to Google Assistant Service."""
# There can only be 1 entry (config_flow has single_instance_allowed)
entry: GoogleAssistantSDKConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
entries = hass.config_entries.async_loaded_entries(DOMAIN)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
entry: GoogleAssistantSDKConfigEntry = entries[0]
session = entry.runtime_data.session
try:
@@ -1,4 +1,4 @@
"""Support for Google Assistant SDK."""
"""Services for the Google Assistant SDK integration."""
from __future__ import annotations
@@ -65,6 +65,9 @@
}
},
"exceptions": {
"entry_not_loaded": {
"message": "Entry not loaded"
},
"grpc_error": {
"message": "Failed to communicate with Google Assistant"
}
@@ -456,6 +456,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
"""Initialize the agent."""
self.entry = entry
self.subentry = subentry
self.default_model = default_model
self._attr_name = subentry.title
self._genai_client = entry.runtime_data
self._attr_unique_id = subentry.subentry_id
@@ -489,7 +490,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
tools = tools or []
tools.append(Tool(google_search=GoogleSearch()))
model_name = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
model_name = options.get(CONF_CHAT_MODEL, self.default_model)
# Avoid INVALID_ARGUMENT Developer instruction is not enabled for <model>
supports_system_instruction = (
"gemma" not in model_name
@@ -620,7 +621,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
model = options.get(CONF_CHAT_MODEL, self.default_model)
thinking_config: ThinkingConfig | None = None
if model.startswith("models/gemini-2.5") and not model.endswith(
("tts", "image", "image-preview")
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.81", "babel==2.15.0"]
"requirements": ["holidays==0.82", "babel==2.15.0"]
}
@@ -1,15 +1,20 @@
"""Home Assistant Hardware integration helpers."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import logging
from typing import Protocol
from typing import TYPE_CHECKING, Protocol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from . import DATA_COMPONENT
from .util import FirmwareInfo
if TYPE_CHECKING:
from .util import FirmwareInfo
_LOGGER = logging.getLogger(__name__)
@@ -51,6 +56,7 @@ class HardwareInfoDispatcher:
self._notification_callbacks: defaultdict[
str, set[Callable[[FirmwareInfo], None]]
] = defaultdict(set)
self._active_firmware_updates: dict[str, str] = {}
def register_firmware_info_provider(
self, domain: str, platform: HardwareFirmwareInfoModule
@@ -118,6 +124,36 @@ class HardwareInfoDispatcher:
if fw_info is not None:
yield fw_info
def register_firmware_update_in_progress(
self, device: str, source_domain: str
) -> None:
"""Register that a firmware update is in progress for a device."""
if device in self._active_firmware_updates:
current_domain = self._active_firmware_updates[device]
raise ValueError(
f"Firmware update already in progress for {device} by {current_domain}"
)
self._active_firmware_updates[device] = source_domain
def unregister_firmware_update_in_progress(
self, device: str, source_domain: str
) -> None:
"""Unregister a firmware update for a device."""
if device not in self._active_firmware_updates:
raise ValueError(f"No firmware update in progress for {device}")
if self._active_firmware_updates[device] != source_domain:
current_domain = self._active_firmware_updates[device]
raise ValueError(
f"Firmware update for {device} is owned by {current_domain}, not {source_domain}"
)
del self._active_firmware_updates[device]
def is_firmware_update_in_progress(self, device: str) -> bool:
"""Check if a firmware update is in progress for a device."""
return device in self._active_firmware_updates
@hass_callback
def async_register_firmware_info_provider(
@@ -141,3 +177,42 @@ def async_notify_firmware_info(
) -> Awaitable[None]:
"""Notify the dispatcher of new firmware information."""
return hass.data[DATA_COMPONENT].notify_firmware_info(domain, firmware_info)
@hass_callback
def async_register_firmware_update_in_progress(
hass: HomeAssistant, device: str, source_domain: str
) -> None:
"""Register that a firmware update is in progress for a device."""
return hass.data[DATA_COMPONENT].register_firmware_update_in_progress(
device, source_domain
)
@hass_callback
def async_unregister_firmware_update_in_progress(
hass: HomeAssistant, device: str, source_domain: str
) -> None:
"""Unregister a firmware update for a device."""
return hass.data[DATA_COMPONENT].unregister_firmware_update_in_progress(
device, source_domain
)
@hass_callback
def async_is_firmware_update_in_progress(hass: HomeAssistant, device: str) -> bool:
"""Check if a firmware update is in progress for a device."""
return hass.data[DATA_COMPONENT].is_firmware_update_in_progress(device)
@asynccontextmanager
async def async_firmware_update_context(
hass: HomeAssistant, device: str, source_domain: str
) -> AsyncIterator[None]:
"""Register a device as having its firmware being actively updated."""
async_register_firmware_update_in_progress(hass, device, source_domain)
try:
yield
finally:
async_unregister_firmware_update_in_progress(hass, device, source_domain)
@@ -67,7 +67,7 @@
}
},
"abort": {
"not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.",
"not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.",
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
@@ -275,6 +275,7 @@ class BaseFirmwareUpdateEntity(
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.bootloader_reset_methods,
progress_callback=self._update_progress,
domain=self._config_entry.domain,
)
finally:
self._attr_in_progress = False
@@ -26,6 +26,7 @@ from homeassistant.helpers.singleton import singleton
from . import DATA_COMPONENT
from .const import (
DOMAIN,
OTBR_ADDON_MANAGER_DATA,
OTBR_ADDON_NAME,
OTBR_ADDON_SLUG,
@@ -33,6 +34,7 @@ from .const import (
ZIGBEE_FLASHER_ADDON_NAME,
ZIGBEE_FLASHER_ADDON_SLUG,
)
from .helpers import async_firmware_update_context
from .silabs_multiprotocol_addon import (
WaitingAddonManager,
get_multiprotocol_addon_manager,
@@ -359,45 +361,50 @@ async def async_flash_silabs_firmware(
expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (),
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = DOMAIN,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
async with async_firmware_update_context(hass, device, domain):
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(fw_image, progress_callback=progress_callback)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
return probed_firmware_info
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(
fw_image, progress_callback=progress_callback
)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
)
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return probed_firmware_info
@@ -1,12 +1,12 @@
{
"domain": "iometer",
"name": "IOmeter",
"codeowners": ["@MaestroOnICe"],
"codeowners": ["@jukrebs"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iometer",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["iometer==0.1.0"],
"requirements": ["iometer==0.2.0"],
"zeroconf": ["_iometer._tcp.local."]
}
@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.1"]
"requirements": ["pylamarzocco==2.1.2"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/melcloud",
"iot_class": "cloud_polling",
"loggers": ["pymelcloud"],
"requirements": ["python-melcloud==0.1.0"]
"requirements": ["python-melcloud==0.1.2"]
}
+1
View File
@@ -54,6 +54,7 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_PLATE_COUNT = 4
PLATE_COUNT = {
"KM7575": 6,
"KM7678": 6,
"KM7697": 6,
"KM7878": 6,
+7 -4
View File
@@ -253,6 +253,7 @@ class ModbusHub:
self._client: (
AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None
) = None
self._lock = asyncio.Lock()
self.event_connected = asyncio.Event()
self.hass = hass
self.name = client_config[CONF_NAME]
@@ -415,7 +416,9 @@ class ModbusHub:
"""Convert async to sync pymodbus call."""
if not self._client:
return None
result = await self.low_level_pb_call(unit, address, value, use_call)
if self._msg_wait:
await asyncio.sleep(self._msg_wait)
return result
async with self._lock:
result = await self.low_level_pb_call(unit, address, value, use_call)
if self._msg_wait:
# small delay until next request/response
await asyncio.sleep(self._msg_wait)
return result
+4 -1
View File
@@ -188,7 +188,10 @@ class MqttLock(MqttEntity, LockEntity):
return
if payload == self._config[CONF_PAYLOAD_RESET]:
# Reset the state to `unknown`
self._attr_is_locked = None
self._attr_is_locked = self._attr_is_locking = None
self._attr_is_unlocking = None
self._attr_is_open = self._attr_is_opening = None
self._attr_is_jammed = None
elif payload in self._valid_states:
self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED]
self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING]
@@ -157,7 +157,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
) from error
except NordPoolEmptyResponseError:
return {area: [] for area in areas}
except NordPoolError as error:
except (NordPoolError, TimeoutError) as error:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="connection_error",
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.15.5"]
"requirements": ["opower==0.15.6"]
}
@@ -75,6 +75,9 @@ async def _title(hass: HomeAssistant, discovery_info: HassioServiceInfo) -> str:
if device and ("Connect_ZBT-1" in device or "SkyConnect" in device):
return f"Home Assistant Connect ZBT-1 ({discovery_info.name})"
if device and "Nabu_Casa_ZBT-2" in device:
return f"Home Assistant Connect ZBT-2 ({discovery_info.name})"
return discovery_info.name
+57 -41
View File
@@ -39,6 +39,23 @@ from .renault_vehicle import COORDINATORS, RenaultVehicleProxy
LOGGER = logging.getLogger(__name__)
async def _get_filtered_vehicles(account: RenaultAccount) -> list[KamereonVehiclesLink]:
"""Filter out vehicles with missing details.
May be due to new purchases, or issue with the Renault servers.
"""
vehicles = await account.get_vehicles()
if not vehicles.vehicleLinks:
return []
result: list[KamereonVehiclesLink] = []
for link in vehicles.vehicleLinks:
if link.vehicleDetails is None:
LOGGER.warning("Ignoring vehicle with missing details: %s", link.vin)
continue
result.append(link)
return result
class RenaultHub:
"""Handle account communication with Renault servers."""
@@ -84,49 +101,48 @@ class RenaultHub:
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID]
self._account = await self._client.get_api_account(account_id)
vehicles = await self._account.get_vehicles()
if vehicles.vehicleLinks:
if any(
vehicle_link.vehicleDetails is None
for vehicle_link in vehicles.vehicleLinks
):
raise ConfigEntryNotReady(
"Failed to retrieve vehicle details from Renault servers"
)
num_call_per_scan = len(COORDINATORS) * len(vehicles.vehicleLinks)
scan_interval = timedelta(
seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
vehicle_links = await _get_filtered_vehicles(self._account)
if not vehicle_links:
LOGGER.debug(
"No valid vehicle details found for account_id: %s", account_id
)
raise ConfigEntryNotReady(
"Failed to retrieve vehicle details from Renault servers"
)
device_registry = dr.async_get(self._hass)
await asyncio.gather(
*(
self.async_initialise_vehicle(
vehicle_link,
self._account,
scan_interval,
config_entry,
device_registry,
)
for vehicle_link in vehicles.vehicleLinks
)
)
num_call_per_scan = len(COORDINATORS) * len(vehicle_links)
scan_interval = timedelta(
seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
)
# all vehicles have been initiated with the right number of active coordinators
num_call_per_scan = 0
for vehicle_link in vehicles.vehicleLinks:
device_registry = dr.async_get(self._hass)
await asyncio.gather(
*(
self.async_initialise_vehicle(
vehicle_link,
self._account,
scan_interval,
config_entry,
device_registry,
)
for vehicle_link in vehicle_links
)
)
# all vehicles have been initiated with the right number of active coordinators
num_call_per_scan = 0
for vehicle_link in vehicle_links:
vehicle = self._vehicles[str(vehicle_link.vin)]
num_call_per_scan += len(vehicle.coordinators)
new_scan_interval = timedelta(
seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
)
if new_scan_interval != scan_interval:
# we need to change the vehicles with the right scan interval
for vehicle_link in vehicle_links:
vehicle = self._vehicles[str(vehicle_link.vin)]
num_call_per_scan += len(vehicle.coordinators)
new_scan_interval = timedelta(
seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
)
if new_scan_interval != scan_interval:
# we need to change the vehicles with the right scan interval
for vehicle_link in vehicles.vehicleLinks:
vehicle = self._vehicles[str(vehicle_link.vin)]
vehicle.update_scan_interval(new_scan_interval)
vehicle.update_scan_interval(new_scan_interval)
async def async_initialise_vehicle(
self,
@@ -164,10 +180,10 @@ class RenaultHub:
"""Get Kamereon account ids."""
accounts = []
for account in await self._client.get_api_accounts():
vehicles = await account.get_vehicles()
vehicle_links = await _get_filtered_vehicles(account)
# Only add the account if it has linked vehicles.
if vehicles.vehicleLinks:
if vehicle_links:
accounts.append(account.account_id)
return accounts
@@ -82,7 +82,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
assert self._client
errors: dict[str, str] = {}
try:
await self._client.request_code()
await self._client.request_code_v4()
except RoborockAccountDoesNotExist:
errors["base"] = "invalid_email"
except RoborockUrlException:
@@ -111,7 +111,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
code = user_input[CONF_ENTRY_CODE]
_LOGGER.debug("Logging into Roborock account using email provided code")
try:
user_data = await self._client.code_login(code)
user_data = await self._client.code_login_v4(code)
except RoborockInvalidCode:
errors["base"] = "invalid_code"
except RoborockException:
@@ -129,7 +129,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
reauth_entry, data_updates={CONF_USER_DATA: user_data.as_dict()}
)
self._abort_if_unique_id_configured(error="already_configured_account")
return self._create_entry(self._client, self._username, user_data)
return await self._create_entry(self._client, self._username, user_data)
return self.async_show_form(
step_id="code",
@@ -176,7 +176,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_code()
return self.async_show_form(step_id="reauth_confirm", errors=errors)
def _create_entry(
async def _create_entry(
self, client: RoborockApiClient, username: str, user_data: UserData
) -> ConfigFlowResult:
"""Finished config flow and create entry."""
@@ -185,7 +185,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
data={
CONF_USERNAME: username,
CONF_USER_DATA: user_data.as_dict(),
CONF_BASE_URL: client.base_url,
CONF_BASE_URL: await client.base_url,
},
)
@@ -19,7 +19,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==2.49.1",
"python-roborock==2.50.2",
"vacuum-map-parser-roborock==0.1.4"
]
}
@@ -24,6 +24,7 @@
},
"config_subentries": {
"partition": {
"entry_type": "Partition",
"initiate_flow": {
"user": "Add partition"
},
@@ -57,6 +58,7 @@
}
},
"zone": {
"entry_type": "Zone",
"initiate_flow": {
"user": "Add zone"
},
@@ -91,6 +93,7 @@
}
},
"output": {
"entry_type": "Output",
"initiate_flow": {
"user": "Add output"
},
@@ -125,6 +128,7 @@
}
},
"switchable_output": {
"entry_type": "Switchable output",
"initiate_flow": {
"user": "Add switchable output"
},
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
"iot_class": "cloud_polling",
"loggers": ["sharkiq"],
"requirements": ["sharkiq==1.4.0"]
"requirements": ["sharkiq==1.4.2"]
}
@@ -319,7 +319,7 @@ RPC_SENSORS: Final = {
),
"presencezone_state": RpcBinarySensorDescription(
key="presencezone",
sub_key="state",
sub_key="value",
name="Occupancy",
device_class=BinarySensorDeviceClass.OCCUPANCY,
entity_class=RpcPresenceBinarySensor,
+2
View File
@@ -226,6 +226,8 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity):
def _update_callback(self) -> None:
"""Handle device update. Use a task when opening/closing is in progress."""
super()._update_callback()
if not self.coordinator.device.initialized:
return
if self.is_closing or self.is_opening:
self.launch_update_task()
+17 -19
View File
@@ -692,27 +692,25 @@ def async_remove_orphaned_entities(
"""Remove orphaned entities."""
orphaned_entities = []
entity_reg = er.async_get(hass)
device_reg = dr.async_get(hass)
if not (
devices := device_reg.devices.get_devices_for_config_entry_id(config_entry_id)
):
return
entities = er.async_entries_for_config_entry(entity_reg, config_entry_id)
for entity in entities:
if not entity.entity_id.startswith(platform):
continue
if key_suffix is not None and key_suffix not in entity.unique_id:
continue
# we are looking for the component ID, e.g. boolean:201, em1data:1
if not (match := COMPONENT_ID_PATTERN.search(entity.unique_id)):
continue
for device in devices:
entities = er.async_entries_for_device(entity_reg, device.id, True)
for entity in entities:
if not entity.entity_id.startswith(platform):
continue
if key_suffix is not None and key_suffix not in entity.unique_id:
continue
# we are looking for the component ID, e.g. boolean:201, em1data:1
if not (match := COMPONENT_ID_PATTERN.search(entity.unique_id)):
continue
key = match.group()
if key not in keys:
orphaned_entities.append(entity.unique_id.split("-", 1)[1])
key = match.group()
if key not in keys:
LOGGER.debug(
"Found orphaned Shelly entity: %s, unique id: %s",
entity.entity_id,
entity.unique_id,
)
orphaned_entities.append(entity.unique_id.split("-", 1)[1])
if orphaned_entities:
async_remove_shelly_rpc_entities(hass, platform, mac, orphaned_entities)
@@ -100,8 +100,9 @@ ATTR_PIN_VALUE = "pin"
ATTR_TIMESTAMP = "timestamp"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
DEFAULT_SOCKET_MIN_RETRY = 15
WEBSOCKET_RECONNECT_RETRIES = 3
WEBSOCKET_RETRY_DELAY = 2
EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT"
EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION"
@@ -419,6 +420,7 @@ class SimpliSafe:
self._api = api
self._hass = hass
self._system_notifications: dict[int, set[SystemNotification]] = {}
self._websocket_reconnect_retries: int = 0
self._websocket_reconnect_task: asyncio.Task | None = None
self.entry = entry
self.initial_event_to_use: dict[int, dict[str, Any]] = {}
@@ -469,6 +471,8 @@ class SimpliSafe:
"""Start a websocket reconnection loop."""
assert self._api.websocket
self._websocket_reconnect_retries += 1
try:
await self._api.websocket.async_connect()
await self._api.websocket.async_listen()
@@ -479,9 +483,21 @@ class SimpliSafe:
LOGGER.error("Failed to connect to websocket: %s", err)
except Exception as err: # noqa: BLE001
LOGGER.error("Unknown exception while connecting to websocket: %s", err)
else:
self._websocket_reconnect_retries = 0
LOGGER.debug("Reconnecting to websocket")
await self._async_cancel_websocket_loop()
if self._websocket_reconnect_retries >= WEBSOCKET_RECONNECT_RETRIES:
LOGGER.error("Max websocket connection retries exceeded")
return
delay = WEBSOCKET_RETRY_DELAY * (2 ** (self._websocket_reconnect_retries - 1))
LOGGER.info(
"Retrying websocket connection in %s seconds (attempt %s/%s)",
delay,
self._websocket_reconnect_retries,
WEBSOCKET_RECONNECT_RETRIES,
)
await asyncio.sleep(delay)
self._websocket_reconnect_task = self._hass.async_create_task(
self._async_start_websocket_loop()
)
@@ -109,6 +109,8 @@ PRESET_MODE_TO_HA = {
"quiet": "quiet",
"longWind": "long_wind",
"smart": "smart",
"motionIndirect": "motion_indirect",
"motionDirect": "motion_direct",
}
HA_MODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_HA.items()}
@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.3.0"]
"requirements": ["pysmartthings==3.3.1"]
}
@@ -87,7 +87,9 @@
"wind_free_sleep": "WindFree sleep",
"quiet": "Quiet",
"long_wind": "Long wind",
"smart": "Smart"
"smart": "Smart",
"motion_direct": "Motion direct",
"motion_indirect": "Motion indirect"
}
},
"fan_mode": {
@@ -10,6 +10,28 @@
"zigbee_type": {
"default": "mdi:zigbee"
}
},
"switch": {
"disable_led": {
"default": "mdi:led-off"
},
"auto_zigbee_update": {
"default": "mdi:autorenew"
},
"night_mode": {
"default": "mdi:lightbulb-night"
},
"vpn_enabled": {
"default": "mdi:shield-lock"
}
},
"button": {
"zigbee_flash_mode": {
"default": "mdi:memory-arrow-down"
},
"reconnect_zigbee_router": {
"default": "mdi:connection"
}
}
}
}
+1 -1
View File
@@ -51,7 +51,6 @@ SWITCHES: list[SmSwitchEntityDescription] = [
SmSwitchEntityDescription(
key="auto_zigbee_update",
translation_key="auto_zigbee_update",
entity_category=EntityCategory.CONFIG,
setting=Settings.ZB_AUTOUPDATE,
entity_registry_enabled_default=False,
state_fn=lambda x: x.auto_zigbee,
@@ -83,6 +82,7 @@ class SmSwitch(SmEntity, SwitchEntity):
coordinator: SmDataUpdateCoordinator
entity_description: SmSwitchEntityDescription
_attr_device_class = SwitchDeviceClass.SWITCH
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
@@ -193,7 +193,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
if player.player_id in entry.runtime_data.known_player_ids:
await player.async_update()
async_dispatcher_send(
hass, SIGNAL_PLAYER_REDISCOVERED, player.player_id, player.connected
hass,
SIGNAL_PLAYER_REDISCOVERED + entry.entry_id,
player.player_id,
player.connected,
)
else:
_LOGGER.debug("Adding new entity: %s", player)
@@ -203,7 +206,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
await player_coordinator.async_refresh()
entry.runtime_data.known_player_ids.add(player.player_id)
async_dispatcher_send(
hass, SIGNAL_PLAYER_DISCOVERED, player_coordinator
hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, player_coordinator
)
if players := await lms.async_get_players():
@@ -132,7 +132,9 @@ async def async_setup_entry(
async_add_entities(entities)
entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered)
async_dispatcher_connect(
hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, _player_discovered
)
)
@@ -117,7 +117,9 @@ class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# start listening for restored players
self._remove_dispatcher = async_dispatcher_connect(
self.hass, SIGNAL_PLAYER_REDISCOVERED, self.rediscovered
self.hass,
SIGNAL_PLAYER_REDISCOVERED + self.config_entry.entry_id,
self.rediscovered,
)
alarm_dict: dict[str, Alarm] = (
@@ -175,7 +175,9 @@ async def async_setup_entry(
async_add_entities([SqueezeBoxMediaPlayerEntity(coordinator)])
entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered)
async_dispatcher_connect(
hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, _player_discovered
)
)
# Register entity services
@@ -89,7 +89,9 @@ async def async_setup_entry(
async_add_entities([SqueezeBoxAlarmsEnabledEntity(coordinator)])
entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered)
async_dispatcher_connect(
hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, _player_discovered
)
)
@@ -143,6 +143,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
self.reauth_conf: Mapping[str, Any] = {}
self.reauth_reason: str | None = None
self.shares: list[SynoFileSharedFolder] | None = None
self.api: SynologyDSM | None = None
def _show_form(
self,
@@ -156,6 +157,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
description_placeholders = {}
data_schema = None
self.api = None
if step_id == "link":
user_input.update(self.discovered_conf)
@@ -194,14 +196,21 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
else:
port = DEFAULT_PORT
session = async_get_clientsession(self.hass, verify_ssl)
api = SynologyDSM(
session, host, port, username, password, use_ssl, timeout=DEFAULT_TIMEOUT
)
if self.api is None:
session = async_get_clientsession(self.hass, verify_ssl)
self.api = SynologyDSM(
session,
host,
port,
username,
password,
use_ssl,
timeout=DEFAULT_TIMEOUT,
)
errors = {}
try:
serial = await _login_and_fetch_syno_info(api, otp_code)
serial = await _login_and_fetch_syno_info(self.api, otp_code)
except SynologyDSMLogin2SARequiredException:
return await self.async_step_2sa(user_input)
except SynologyDSMLogin2SAFailedException:
@@ -221,10 +230,11 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "missing_data"
if errors:
self.api = None
return self._show_form(step_id, user_input, errors)
with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS):
self.shares = await api.file.get_shared_folders(only_writable=True)
self.shares = await self.api.file.get_shared_folders(only_writable=True)
if self.shares and not backup_path:
return await self.async_step_backup_share(user_input)
@@ -239,14 +249,14 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_VERIFY_SSL: verify_ssl,
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_MAC: api.network.macs,
CONF_MAC: self.api.network.macs,
}
config_options = {
CONF_BACKUP_PATH: backup_path,
CONF_BACKUP_SHARE: backup_share,
}
if otp_code:
config_data[CONF_DEVICE_TOKEN] = api.device_token
config_data[CONF_DEVICE_TOKEN] = self.api.device_token
if user_input.get(CONF_DISKS):
config_data[CONF_DISKS] = user_input[CONF_DISKS]
if user_input.get(CONF_VOLUMES):
@@ -336,6 +336,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
key="power_usage",
translation_key="power_usage",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=2,
icon="mdi:power-plug",
@@ -577,7 +578,6 @@ async def async_setup_entry(
key=f"gpu_{gpu.id}_power_usage",
name=f"{gpu.name} power usage",
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
value=lambda data, k=index: gpu_power_usage(data, k),
@@ -372,6 +372,7 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity
def _set_state(self, state, _=None):
"""Set up auto off."""
self._attr_is_on = state
self._delay_cancel = None
self.async_write_ha_state()
if not state:
+2 -1
View File
@@ -300,9 +300,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._current_state is not None
and (current_state := self.device.status.get(self._current_state))
is not None
and current_state != "stop"
):
return self.entity_description.current_state_inverse is not (
current_state in (True, "fully_close")
current_state in (True, "close", "fully_close")
)
return None
+6 -6
View File
@@ -212,17 +212,17 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
await self.device.turn_on()
if preset_mode == VS_FAN_MODE_AUTO:
success = await self.device.auto_mode()
success = await self.device.set_auto_mode()
elif preset_mode == VS_FAN_MODE_SLEEP:
success = await self.device.sleep_mode()
success = await self.device.set_sleep_mode()
elif preset_mode == VS_FAN_MODE_ADVANCED_SLEEP:
success = await self.device.advanced_sleep_mode()
success = await self.device.set_advanced_sleep_mode()
elif preset_mode == VS_FAN_MODE_PET:
success = await self.device.pet_mode()
success = await self.device.set_pet_mode()
elif preset_mode == VS_FAN_MODE_TURBO:
success = await self.device.turbo_mode()
success = await self.device.set_turbo_mode()
elif preset_mode == VS_FAN_MODE_NORMAL:
success = await self.device.normal_mode()
success = await self.device.set_normal_mode()
if not success:
raise HomeAssistantError(self.device.last_response.message)
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/vesync",
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
"requirements": ["pyvesync==3.0.0"]
"requirements": ["pyvesync==3.1.0"]
}
+4 -3
View File
@@ -34,12 +34,13 @@ CONF_HEATING_TYPE = "heating_type"
DEFAULT_CACHE_DURATION = 60
VICARE_BAR = "bar"
VICARE_CUBIC_METER = "cubicMeter"
VICARE_KW = "kilowatt"
VICARE_KWH = "kilowattHour"
VICARE_PERCENT = "percent"
VICARE_W = "watt"
VICARE_KW = "kilowatt"
VICARE_WH = "wattHour"
VICARE_KWH = "kilowattHour"
VICARE_CUBIC_METER = "cubicMeter"
class HeatingType(enum.Enum):
+10 -7
View File
@@ -41,6 +41,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
VICARE_BAR,
VICARE_CUBIC_METER,
VICARE_KW,
VICARE_KWH,
@@ -62,20 +63,22 @@ from .utils import (
_LOGGER = logging.getLogger(__name__)
VICARE_UNIT_TO_DEVICE_CLASS = {
VICARE_WH: SensorDeviceClass.ENERGY,
VICARE_KWH: SensorDeviceClass.ENERGY,
VICARE_W: SensorDeviceClass.POWER,
VICARE_KW: SensorDeviceClass.POWER,
VICARE_BAR: SensorDeviceClass.PRESSURE,
VICARE_CUBIC_METER: SensorDeviceClass.GAS,
VICARE_KW: SensorDeviceClass.POWER,
VICARE_KWH: SensorDeviceClass.ENERGY,
VICARE_WH: SensorDeviceClass.ENERGY,
VICARE_W: SensorDeviceClass.POWER,
}
VICARE_UNIT_TO_HA_UNIT = {
VICARE_BAR: UnitOfPressure.BAR,
VICARE_CUBIC_METER: UnitOfVolume.CUBIC_METERS,
VICARE_KW: UnitOfPower.KILO_WATT,
VICARE_KWH: UnitOfEnergy.KILO_WATT_HOUR,
VICARE_PERCENT: PERCENTAGE,
VICARE_W: UnitOfPower.WATT,
VICARE_KW: UnitOfPower.KILO_WATT,
VICARE_WH: UnitOfEnergy.WATT_HOUR,
VICARE_KWH: UnitOfEnergy.KILO_WATT_HOUR,
VICARE_CUBIC_METER: UnitOfVolume.CUBIC_METERS,
}
@@ -13,7 +13,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
@@ -69,7 +69,7 @@ class VictronRemoteMonitoringFlowHandler(ConfigFlow, domain=DOMAIN):
"""
client = VictronVRMClient(
token=api_token,
client_session=get_async_client(self.hass),
client_session=async_get_clientsession(self.hass),
)
try:
sites = await client.users.list_sites()
@@ -86,7 +86,7 @@ class VictronRemoteMonitoringFlowHandler(ConfigFlow, domain=DOMAIN):
"""Validate access to the selected site and return its data."""
client = VictronVRMClient(
token=api_token,
client_session=get_async_client(self.hass),
client_session=async_get_clientsession(self.hass),
)
try:
site_data = await client.users.get_site(site_id)
@@ -11,7 +11,7 @@ from victron_vrm.utils import dt_now
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN, LOGGER
@@ -26,8 +26,8 @@ class VRMForecastStore:
"""Class to hold the forecast data."""
site_id: int
solar: ForecastAggregations
consumption: ForecastAggregations
solar: ForecastAggregations | None
consumption: ForecastAggregations | None
async def get_forecast(client: VictronVRMClient, site_id: int) -> VRMForecastStore:
@@ -75,7 +75,7 @@ class VictronRemoteMonitoringDataUpdateCoordinator(
"""Initialize."""
self.client = VictronVRMClient(
token=config_entry.data[CONF_API_TOKEN],
client_session=get_async_client(hass),
client_session=async_get_clientsession(hass),
)
self.site_id = config_entry.data[CONF_SITE_ID]
super().__init__(
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["victron-vrm==0.1.7"]
"requirements": ["victron-vrm==0.1.8"]
}
@@ -39,7 +39,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_production_estimate_yesterday",
translation_key="energy_production_estimate_yesterday",
value_fn=lambda estimate: estimate.solar.yesterday_total,
value_fn=lambda store: (
store.solar.yesterday_total if store.solar is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -49,7 +51,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_production_estimate_today",
translation_key="energy_production_estimate_today",
value_fn=lambda estimate: estimate.solar.today_total,
value_fn=lambda store: (
store.solar.today_total if store.solar is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -59,7 +63,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_production_estimate_today_remaining",
translation_key="energy_production_estimate_today_remaining",
value_fn=lambda estimate: estimate.solar.today_left_total,
value_fn=lambda store: (
store.solar.today_left_total if store.solar is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -69,7 +75,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_production_estimate_tomorrow",
translation_key="energy_production_estimate_tomorrow",
value_fn=lambda estimate: estimate.solar.tomorrow_total,
value_fn=lambda store: (
store.solar.tomorrow_total if store.solar is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -79,25 +87,33 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="power_highest_peak_time_yesterday",
translation_key="power_highest_peak_time_yesterday",
value_fn=lambda estimate: estimate.solar.yesterday_peak_time,
value_fn=lambda store: (
store.solar.yesterday_peak_time if store.solar is not None else None
),
device_class=SensorDeviceClass.TIMESTAMP,
),
VRMForecastsSensorEntityDescription(
key="power_highest_peak_time_today",
translation_key="power_highest_peak_time_today",
value_fn=lambda estimate: estimate.solar.today_peak_time,
value_fn=lambda store: (
store.solar.today_peak_time if store.solar is not None else None
),
device_class=SensorDeviceClass.TIMESTAMP,
),
VRMForecastsSensorEntityDescription(
key="power_highest_peak_time_tomorrow",
translation_key="power_highest_peak_time_tomorrow",
value_fn=lambda estimate: estimate.solar.tomorrow_peak_time,
value_fn=lambda store: (
store.solar.tomorrow_peak_time if store.solar is not None else None
),
device_class=SensorDeviceClass.TIMESTAMP,
),
VRMForecastsSensorEntityDescription(
key="energy_production_current_hour",
translation_key="energy_production_current_hour",
value_fn=lambda estimate: estimate.solar.current_hour_total,
value_fn=lambda store: (
store.solar.current_hour_total if store.solar is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -107,7 +123,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_production_next_hour",
translation_key="energy_production_next_hour",
value_fn=lambda estimate: estimate.solar.next_hour_total,
value_fn=lambda store: (
store.solar.next_hour_total if store.solar is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -118,7 +136,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_consumption_estimate_yesterday",
translation_key="energy_consumption_estimate_yesterday",
value_fn=lambda estimate: estimate.consumption.yesterday_total,
value_fn=lambda store: (
store.consumption.yesterday_total if store.consumption is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -128,7 +148,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_consumption_estimate_today",
translation_key="energy_consumption_estimate_today",
value_fn=lambda estimate: estimate.consumption.today_total,
value_fn=lambda store: (
store.consumption.today_total if store.consumption is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -138,7 +160,11 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_consumption_estimate_today_remaining",
translation_key="energy_consumption_estimate_today_remaining",
value_fn=lambda estimate: estimate.consumption.today_left_total,
value_fn=lambda store: (
store.consumption.today_left_total
if store.consumption is not None
else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -148,7 +174,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_consumption_estimate_tomorrow",
translation_key="energy_consumption_estimate_tomorrow",
value_fn=lambda estimate: estimate.consumption.tomorrow_total,
value_fn=lambda store: (
store.consumption.tomorrow_total if store.consumption is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -158,25 +186,39 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="consumption_highest_peak_time_yesterday",
translation_key="consumption_highest_peak_time_yesterday",
value_fn=lambda estimate: estimate.consumption.yesterday_peak_time,
value_fn=lambda store: (
store.consumption.yesterday_peak_time
if store.consumption is not None
else None
),
device_class=SensorDeviceClass.TIMESTAMP,
),
VRMForecastsSensorEntityDescription(
key="consumption_highest_peak_time_today",
translation_key="consumption_highest_peak_time_today",
value_fn=lambda estimate: estimate.consumption.today_peak_time,
value_fn=lambda store: (
store.consumption.today_peak_time if store.consumption is not None else None
),
device_class=SensorDeviceClass.TIMESTAMP,
),
VRMForecastsSensorEntityDescription(
key="consumption_highest_peak_time_tomorrow",
translation_key="consumption_highest_peak_time_tomorrow",
value_fn=lambda estimate: estimate.consumption.tomorrow_peak_time,
value_fn=lambda store: (
store.consumption.tomorrow_peak_time
if store.consumption is not None
else None
),
device_class=SensorDeviceClass.TIMESTAMP,
),
VRMForecastsSensorEntityDescription(
key="energy_consumption_current_hour",
translation_key="energy_consumption_current_hour",
value_fn=lambda estimate: estimate.consumption.current_hour_total,
value_fn=lambda store: (
store.consumption.current_hour_total
if store.consumption is not None
else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -186,7 +228,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
VRMForecastsSensorEntityDescription(
key="energy_consumption_next_hour",
translation_key="energy_consumption_next_hour",
value_fn=lambda estimate: estimate.consumption.next_hour_total,
value_fn=lambda store: (
store.consumption.next_hour_total if store.consumption is not None else None
),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+1 -1
View File
@@ -3,7 +3,7 @@
from enum import StrEnum
DOMAIN = "wallbox"
UPDATE_INTERVAL = 60
UPDATE_INTERVAL = 90
BIDIRECTIONAL_MODEL_PREFIXES = ["QS"]
@@ -209,7 +209,12 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) from wallbox_connection_error
async def _async_update_data(self) -> dict[str, Any]:
"""Get new sensor data for Wallbox component."""
"""Get new sensor data for Wallbox component. Set update interval to be UPDATE_INTERVAL * #wallbox chargers configured, this is necessary due to rate limitations."""
self.update_interval = timedelta(
seconds=UPDATE_INTERVAL
* max(len(self.hass.config_entries.async_loaded_entries(DOMAIN)), 1)
)
return await self.hass.async_add_executor_job(self._get_data)
@_require_authentication
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.81"]
"requirements": ["holidays==0.82"]
}
+14 -1
View File
@@ -13,6 +13,7 @@ from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH
from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError
from homeassistant.components.homeassistant_hardware.helpers import (
async_is_firmware_update_in_progress,
async_notify_firmware_info,
async_register_firmware_info_provider,
)
@@ -119,6 +120,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
def _raise_if_port_in_use(hass: HomeAssistant, device_path: str) -> None:
"""Ensure that the specified serial port is not in use by a firmware update."""
if async_is_firmware_update_in_progress(hass, device_path):
raise ConfigEntryNotReady(
f"Firmware update in progress for device {device_path}"
)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up ZHA.
@@ -152,6 +161,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
_LOGGER.debug("Trigger cache: %s", zha_lib_data.device_trigger_cache)
# Check if firmware update is in progress for this device
device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
_raise_if_port_in_use(hass, device_path)
try:
await zha_gateway.async_initialize()
except NetworkSettingsInconsistent as exc:
@@ -168,7 +181,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
raise ConfigEntryNotReady from exc
except Exception as exc:
_LOGGER.debug("Failed to set up ZHA", exc_info=exc)
device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
_raise_if_port_in_use(hass, device_path)
if (
not device_path.startswith("socket://")
+4 -1
View File
@@ -744,8 +744,11 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
# Without confirmation, discovery can automatically progress into parts of the
# config flow logic that interacts with hardware.
# Ignore Zeroconf discoveries during onboarding, as they may be in use already.
if user_input is not None or (
not onboarding.async_is_onboarded(self.hass) and not zha_config_entries
not onboarding.async_is_onboarded(self.hass)
and not zha_config_entries
and self.source != SOURCE_ZEROCONF
):
# Probe the radio type if we don't have one yet
if self._radio_mgr.radio_type is None:
+14 -3
View File
@@ -11,7 +11,13 @@ from typing import Any
from propcache.api import cached_property
from zha.mixins import LogMixin
from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, EntityCategory
from homeassistant.const import (
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NAME,
ATTR_VIA_DEVICE,
EntityCategory,
)
from homeassistant.core import State, callback
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -85,14 +91,19 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
ieee = zha_device_info["ieee"]
zha_gateway = self.entity_data.device_proxy.gateway_proxy.gateway
return DeviceInfo(
device_info = DeviceInfo(
connections={(CONNECTION_ZIGBEE, ieee)},
identifiers={(DOMAIN, ieee)},
manufacturer=zha_device_info[ATTR_MANUFACTURER],
model=zha_device_info[ATTR_MODEL],
name=zha_device_info[ATTR_NAME],
via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)),
)
if ieee != str(zha_gateway.state.node_info.ieee):
device_info[ATTR_VIA_DEVICE] = (
DOMAIN,
str(zha_gateway.state.node_info.ieee),
)
return device_info
@callback
def _handle_entity_events(self, event: Any) -> None:
@@ -956,6 +956,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key,
}
)
if self.restart_addon:
manager = get_addon_manager(self.hass)
await manager.async_stop_addon()
self._abort_if_unique_id_configured(
updates={
@@ -1501,41 +1504,51 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
if not is_hassio(self.hass):
return self.async_abort(reason="not_hassio")
if (
discovery_info.zwave_home_id
and (
current_config_entries := self._async_current_entries(
include_ignore=False
if discovery_info.zwave_home_id:
if (
(
current_config_entries := self._async_current_entries(
include_ignore=False
)
)
)
and (home_id := str(discovery_info.zwave_home_id))
and (
existing_entry := next(
(
entry
for entry in current_config_entries
if entry.unique_id == home_id
),
None,
and (home_id := str(discovery_info.zwave_home_id))
and (
existing_entry := next(
(
entry
for entry in current_config_entries
if entry.unique_id == home_id
),
None,
)
)
# Only update existing entries that are configured via sockets
and existing_entry.data.get(CONF_SOCKET_PATH)
# And use the add-on
and existing_entry.data.get(CONF_USE_ADDON)
):
manager = get_addon_manager(self.hass)
await self._async_set_addon_config(
{CONF_ADDON_SOCKET: discovery_info.socket_path}
)
if self.restart_addon:
await manager.async_stop_addon()
self.hass.config_entries.async_update_entry(
existing_entry,
data={
**existing_entry.data,
CONF_SOCKET_PATH: discovery_info.socket_path,
},
)
self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
return self.async_abort(reason="already_configured")
# We are not aborting if home ID configured here, we just want to make sure that it's set
# We will update a USB based config entry automatically in `async_step_finish_addon_setup_user`
await self.async_set_unique_id(
str(discovery_info.zwave_home_id), raise_on_progress=False
)
# Only update existing entries that are configured via sockets
and existing_entry.data.get(CONF_SOCKET_PATH)
# And use the add-on
and existing_entry.data.get(CONF_USE_ADDON)
):
await self._async_set_addon_config(
{CONF_ADDON_SOCKET: discovery_info.socket_path}
)
# Reloading will sync add-on options to config entry data
self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
return self.async_abort(reason="already_configured")
# We are not aborting if home ID configured here, we just want to make sure that it's set
# We will update a USB based config entry automatically in `async_step_finish_addon_setup_user`
await self.async_set_unique_id(
str(discovery_info.zwave_home_id), raise_on_progress=False
)
self.socket_path = discovery_info.socket_path
self.context["title_placeholders"] = {
CONF_NAME: f"{discovery_info.name} via ESPHome"
+1 -1
View File
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 10
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, 13, 2)
+1 -1
View File
@@ -39,7 +39,7 @@ habluetooth==5.6.4
hass-nabucasa==1.1.1
hassil==3.2.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20251001.0
home-assistant-frontend==20251001.2
home-assistant-intents==2025.10.1
httpx==0.28.1
ifaddr==0.2.0
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.10.1"
version = "2025.10.2"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+19 -19
View File
@@ -185,7 +185,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.1
# homeassistant.components.alexa_devices
aioamazondevices==6.2.7
aioamazondevices==6.4.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -217,7 +217,7 @@ aiobafi6==0.9.0
aiobotocore==2.21.1
# homeassistant.components.comelit
aiocomelit==0.12.3
aiocomelit==1.1.1
# homeassistant.components.dhcp
aiodhcpwatcher==1.2.1
@@ -453,7 +453,7 @@ airgradient==0.9.2
airly==1.1.0
# homeassistant.components.airos
airos==0.5.4
airos==0.5.5
# homeassistant.components.airthings_ble
airthings-ble==0.9.2
@@ -688,7 +688,7 @@ bring-api==1.1.0
broadlink==0.19.0
# homeassistant.components.brother
brother==5.1.0
brother==5.1.1
# homeassistant.components.brottsplatskartan
brottsplatskartan==1.0.5
@@ -782,7 +782,7 @@ decora-wifi==1.4
# decora==0.6
# homeassistant.components.ecovacs
deebot-client==15.0.0
deebot-client==15.1.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -895,7 +895,7 @@ enocean==0.50
enturclient==0.2.4
# homeassistant.components.environment_canada
env-canada==0.11.2
env-canada==0.11.3
# homeassistant.components.season
ephem==4.1.6
@@ -1183,10 +1183,10 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.81
holidays==0.82
# homeassistant.components.frontend
home-assistant-frontend==20251001.0
home-assistant-frontend==20251001.2
# homeassistant.components.conversation
home-assistant-intents==2025.10.1
@@ -1273,7 +1273,7 @@ insteon-frontend-home-assistant==0.5.0
intellifire4py==4.1.9
# homeassistant.components.iometer
iometer==0.1.0
iometer==0.2.0
# homeassistant.components.iotty
iottycloud==0.3.0
@@ -1649,7 +1649,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
opower==0.15.5
opower==0.15.6
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1933,10 +1933,10 @@ pycsspeechtts==1.0.8
# pycups==2.0.4
# homeassistant.components.cync
pycync==0.4.0
pycync==0.4.1
# homeassistant.components.daikin
pydaikin==2.16.0
pydaikin==2.17.1
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@@ -2132,7 +2132,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
pylamarzocco==2.1.1
pylamarzocco==2.1.2
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2384,7 +2384,7 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2
# homeassistant.components.smartthings
pysmartthings==3.3.0
pysmartthings==3.3.1
# homeassistant.components.smarty
pysmarty2==0.10.3
@@ -2501,7 +2501,7 @@ python-linkplay==0.2.12
python-matter-server==8.1.0
# homeassistant.components.melcloud
python-melcloud==0.1.0
python-melcloud==0.1.2
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -2541,7 +2541,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==2.49.1
python-roborock==2.50.2
# homeassistant.components.smarttub
python-smarttub==0.0.44
@@ -2611,7 +2611,7 @@ pyvera==0.3.16
pyversasense==0.0.6
# homeassistant.components.vesync
pyvesync==3.0.0
pyvesync==3.1.0
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2801,7 +2801,7 @@ sentry-sdk==1.45.1
sfrbox-api==0.0.12
# homeassistant.components.sharkiq
sharkiq==1.4.0
sharkiq==1.4.2
# homeassistant.components.aquostv
sharp_aquos_rc==0.3.2
@@ -3089,7 +3089,7 @@ velbus-aio==2025.8.0
venstarcolortouch==0.21
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.7
victron-vrm==0.1.8
# homeassistant.components.vilfo
vilfo-api-client==0.5.0
+19 -19
View File
@@ -173,7 +173,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.1
# homeassistant.components.alexa_devices
aioamazondevices==6.2.7
aioamazondevices==6.4.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -205,7 +205,7 @@ aiobafi6==0.9.0
aiobotocore==2.21.1
# homeassistant.components.comelit
aiocomelit==0.12.3
aiocomelit==1.1.1
# homeassistant.components.dhcp
aiodhcpwatcher==1.2.1
@@ -435,7 +435,7 @@ airgradient==0.9.2
airly==1.1.0
# homeassistant.components.airos
airos==0.5.4
airos==0.5.5
# homeassistant.components.airthings_ble
airthings-ble==0.9.2
@@ -615,7 +615,7 @@ bring-api==1.1.0
broadlink==0.19.0
# homeassistant.components.brother
brother==5.1.0
brother==5.1.1
# homeassistant.components.brottsplatskartan
brottsplatskartan==1.0.5
@@ -682,7 +682,7 @@ debugpy==1.8.16
# decora==0.6
# homeassistant.components.ecovacs
deebot-client==15.0.0
deebot-client==15.1.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -777,7 +777,7 @@ energyzero==2.1.1
enocean==0.50
# homeassistant.components.environment_canada
env-canada==0.11.2
env-canada==0.11.3
# homeassistant.components.season
ephem==4.1.6
@@ -1032,10 +1032,10 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.81
holidays==0.82
# homeassistant.components.frontend
home-assistant-frontend==20251001.0
home-assistant-frontend==20251001.2
# homeassistant.components.conversation
home-assistant-intents==2025.10.1
@@ -1107,7 +1107,7 @@ insteon-frontend-home-assistant==0.5.0
intellifire4py==4.1.9
# homeassistant.components.iometer
iometer==0.1.0
iometer==0.2.0
# homeassistant.components.iotty
iottycloud==0.3.0
@@ -1405,7 +1405,7 @@ openhomedevice==2.2.0
openwebifpy==4.3.1
# homeassistant.components.opower
opower==0.15.5
opower==0.15.6
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1623,10 +1623,10 @@ pycsspeechtts==1.0.8
# pycups==2.0.4
# homeassistant.components.cync
pycync==0.4.0
pycync==0.4.1
# homeassistant.components.daikin
pydaikin==2.16.0
pydaikin==2.17.1
# homeassistant.components.deako
pydeako==0.6.0
@@ -1777,7 +1777,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8
# homeassistant.components.lamarzocco
pylamarzocco==2.1.1
pylamarzocco==2.1.2
# homeassistant.components.lastfm
pylast==5.1.0
@@ -1987,7 +1987,7 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2
# homeassistant.components.smartthings
pysmartthings==3.3.0
pysmartthings==3.3.1
# homeassistant.components.smarty
pysmarty2==0.10.3
@@ -2074,7 +2074,7 @@ python-linkplay==0.2.12
python-matter-server==8.1.0
# homeassistant.components.melcloud
python-melcloud==0.1.0
python-melcloud==0.1.2
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -2111,7 +2111,7 @@ python-pooldose==0.5.0
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==2.49.1
python-roborock==2.50.2
# homeassistant.components.smarttub
python-smarttub==0.0.44
@@ -2169,7 +2169,7 @@ pyuptimerobot==22.2.0
pyvera==0.3.16
# homeassistant.components.vesync
pyvesync==3.0.0
pyvesync==3.1.0
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2326,7 +2326,7 @@ sentry-sdk==1.45.1
sfrbox-api==0.0.12
# homeassistant.components.sharkiq
sharkiq==1.4.0
sharkiq==1.4.2
# homeassistant.components.simplefin
simplefin4py==0.0.18
@@ -2560,7 +2560,7 @@ velbus-aio==2025.8.0
venstarcolortouch==0.21
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.7
victron-vrm==0.1.8
# homeassistant.components.vilfo
vilfo-api-client==0.5.0
+64 -1
View File
@@ -3,10 +3,12 @@
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from airgradient import AirGradientConnectionError
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -67,3 +69,64 @@ async def test_update_mechanism(
assert state.state == STATE_ON
assert state.attributes["installed_version"] == "3.1.4"
assert state.attributes["latest_version"] == "3.1.5"
async def test_update_errors(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test update entity errors."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get("update.airgradient_firmware")
assert state.state == STATE_ON
mock_airgradient_client.get_latest_firmware_version.side_effect = (
AirGradientConnectionError("Boom")
)
freezer.tick(timedelta(hours=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("update.airgradient_firmware")
assert state.state == STATE_UNAVAILABLE
assert "Unable to connect to AirGradient server to check for updates" in caplog.text
caplog.clear()
freezer.tick(timedelta(hours=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("update.airgradient_firmware")
assert state.state == STATE_UNAVAILABLE
assert (
"Unable to connect to AirGradient server to check for updates"
not in caplog.text
)
mock_airgradient_client.get_latest_firmware_version.side_effect = None
freezer.tick(timedelta(hours=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("update.airgradient_firmware")
assert state.state == STATE_ON
mock_airgradient_client.get_latest_firmware_version.side_effect = (
AirGradientConnectionError("Boom")
)
freezer.tick(timedelta(hours=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("update.airgradient_firmware")
assert state.state == STATE_UNAVAILABLE
assert "Unable to connect to AirGradient server to check for updates" in caplog.text
+20 -3
View File
@@ -22,9 +22,21 @@ TEST_DEVICE_1 = AmazonDevice(
entity_id="11111111-2222-3333-4444-555555555555",
endpoint_id="G1234567890123456789012345678A",
sensors={
"dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None),
"dnd": AmazonDeviceSensor(
name="dnd",
value=False,
error=False,
error_msg=None,
error_type=None,
scale=None,
),
"temperature": AmazonDeviceSensor(
name="temperature", value="22.5", error=False, scale="CELSIUS"
name="temperature",
value="22.5",
error=False,
error_msg=None,
error_type=None,
scale="CELSIUS",
),
},
)
@@ -46,7 +58,12 @@ TEST_DEVICE_2 = AmazonDevice(
endpoint_id="G1234567890123456789012345678A",
sensors={
"temperature": AmazonDeviceSensor(
name="temperature", value="22.5", error=False, scale="CELSIUS"
name="temperature",
value="22.5",
error=False,
error_msg=None,
error_type=None,
scale="CELSIUS",
)
},
)
@@ -15,11 +15,11 @@
'sensors': dict({
'dnd': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)",
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)",
}),
'temperature': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')",
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')",
}),
}),
'serial number': 'echo_test_serial_number',
@@ -45,11 +45,11 @@
'sensors': dict({
'dnd': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)",
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)",
}),
'temperature': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')",
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')",
}),
}),
'serial number': 'echo_test_serial_number',
@@ -21,12 +21,16 @@
'sensors': dict({
'dnd': dict({
'error': False,
'error_msg': None,
'error_type': None,
'name': 'dnd',
'scale': None,
'value': False,
}),
'temperature': dict({
'error': False,
'error_msg': None,
'error_type': None,
'name': 'temperature',
'scale': 'CELSIUS',
'value': '22.5',
@@ -63,12 +67,16 @@
'sensors': dict({
'dnd': dict({
'error': False,
'error_msg': None,
'error_type': None,
'name': 'dnd',
'scale': None,
'value': False,
}),
'temperature': dict({
'error': False,
'error_msg': None,
'error_type': None,
'name': 'temperature',
'scale': 'CELSIUS',
'value': '22.5',
@@ -11,10 +11,12 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.alexa_devices.const import DOMAIN
from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN
@@ -137,3 +139,51 @@ async def test_dynamic_device(
assert (state := hass.states.get(entity_id_2))
assert state.state == STATE_ON
@pytest.mark.parametrize(
"key",
[
"bluetooth",
"babyCryDetectionState",
"beepingApplianceDetectionState",
"coughDetectionState",
"dogBarkDetectionState",
"waterSoundsDetectionState",
],
)
async def test_deprecated_sensor_removal(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
key: str,
) -> None:
"""Test deprecated sensors are removed."""
mock_config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="Amazon",
model="Echo Dot",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity = entity_registry.async_get_or_create(
BINARY_SENSOR_DOMAIN,
DOMAIN,
unique_id=f"{TEST_DEVICE_1_SN}-{key}",
device_id=device.id,
config_entry=mock_config_entry,
has_entity_name=True,
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity2 = entity_registry.async_get(entity.entity_id)
assert entity2 is None
+12 -2
View File
@@ -136,7 +136,12 @@ async def test_unit_of_measurement(
TEST_DEVICE_1_SN
].sensors = {
sensor: AmazonDeviceSensor(
name=sensor, value=api_value, error=False, scale=scale
name=sensor,
value=api_value,
error=False,
error_msg=None,
error_type=None,
scale=scale,
)
}
@@ -161,7 +166,12 @@ async def test_sensor_unavailable(
TEST_DEVICE_1_SN
].sensors = {
"illuminance": AmazonDeviceSensor(
name="illuminance", value="800", error=True, scale=None
name="illuminance",
value="800",
error=True,
error_msg=None,
error_type=None,
scale=None,
)
}
+28 -4
View File
@@ -70,9 +70,21 @@ async def test_switch_dnd(
device_data = deepcopy(TEST_DEVICE_1)
device_data.sensors = {
"dnd": AmazonDeviceSensor(name="dnd", value=True, error=False, scale=None),
"dnd": AmazonDeviceSensor(
name="dnd",
value=True,
error=False,
error_msg=None,
error_type=None,
scale=None,
),
"temperature": AmazonDeviceSensor(
name="temperature", value="22.5", error=False, scale="CELSIUS"
name="temperature",
value="22.5",
error=False,
error_msg=None,
error_type=None,
scale="CELSIUS",
),
}
mock_amazon_devices_client.get_devices_data.return_value = {
@@ -94,9 +106,21 @@ async def test_switch_dnd(
)
device_data.sensors = {
"dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None),
"dnd": AmazonDeviceSensor(
name="dnd",
value=False,
error=False,
error_msg=None,
error_type=None,
scale=None,
),
"temperature": AmazonDeviceSensor(
name="temperature", value="22.5", error=False, scale="CELSIUS"
name="temperature",
value="22.5",
error=False,
error_msg=None,
error_type=None,
scale="CELSIUS",
),
}
mock_amazon_devices_client.get_devices_data.return_value = {
@@ -2,6 +2,7 @@
from unittest.mock import AsyncMock
from aioamazondevices.const import SPEAKER_GROUP_FAMILY, SPEAKER_GROUP_MODEL
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
import pytest
@@ -94,3 +95,42 @@ async def test_alexa_unique_id_migration(
assert migrated_entity is not None
assert migrated_entity.config_entry_id == mock_config_entry.entry_id
assert migrated_entity.unique_id == f"{TEST_DEVICE_1_SN}-dnd"
async def test_alexa_dnd_group_removal(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test dnd switch is removed for Speaker Groups."""
mock_config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="Amazon",
model=SPEAKER_GROUP_MODEL,
entry_type=dr.DeviceEntryType.SERVICE,
)
entity = entity_registry.async_get_or_create(
DOMAIN,
SWITCH_DOMAIN,
unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb",
device_id=device.id,
config_entry=mock_config_entry,
has_entity_name=True,
)
mock_amazon_devices_client.get_devices_data.return_value[
TEST_DEVICE_1_SN
].device_family = SPEAKER_GROUP_FAMILY
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert not hass.states.get(entity.entity_id)
+15 -10
View File
@@ -6,11 +6,12 @@ from aiocomelit import CannotAuthenticate, CannotConnect
from aiocomelit.const import BRIDGE, VEDO
import pytest
from homeassistant.components.comelit.config_flow import InvalidPin
from homeassistant.components.comelit.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType, InvalidData
from homeassistant.data_entry_flow import FlowResultType
from .const import (
BAD_PIN,
@@ -97,6 +98,7 @@ async def test_flow_vedo(
(CannotConnect, "cannot_connect"),
(CannotAuthenticate, "invalid_auth"),
(ConnectionResetError, "unknown"),
(InvalidPin, "invalid_pin"),
],
)
async def test_exception_connection(
@@ -181,6 +183,7 @@ async def test_reauth_successful(
(CannotConnect, "cannot_connect"),
(CannotAuthenticate, "invalid_auth"),
(ConnectionResetError, "unknown"),
(InvalidPin, "invalid_pin"),
],
)
async def test_reauth_not_successful(
@@ -261,6 +264,7 @@ async def test_reconfigure_successful(
(CannotConnect, "cannot_connect"),
(CannotAuthenticate, "invalid_auth"),
(ConnectionResetError, "unknown"),
(InvalidPin, "invalid_pin"),
],
)
async def test_reconfigure_fails(
@@ -326,16 +330,17 @@ async def test_pin_format_serial_bridge(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
with pytest.raises(InvalidData):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: BRIDGE_HOST,
CONF_PORT: BRIDGE_PORT,
CONF_PIN: BAD_PIN,
},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: BRIDGE_HOST,
CONF_PORT: BRIDGE_PORT,
CONF_PIN: BAD_PIN,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "invalid_pin"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
+19 -23
View File
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch
from aiocomelit.api import ComelitSerialBridgeObject
from aiocomelit.const import COVER, WATT
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.comelit.const import SCAN_INTERVAL
@@ -17,14 +18,20 @@ from homeassistant.components.cover import (
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
CoverState,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
mock_restore_cache,
snapshot_platform,
)
ENTITY_ID = "cover.cover0"
@@ -162,37 +169,26 @@ async def test_cover_stop_if_stopped(
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
"cover_state",
[
CoverState.OPEN,
CoverState.CLOSED,
],
)
async def test_cover_restore_state(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_serial_bridge: AsyncMock,
mock_serial_bridge_config_entry: MockConfigEntry,
cover_state: CoverState,
) -> None:
"""Test cover restore state on reload."""
mock_serial_bridge.reset_mock()
mock_restore_cache(hass, [State(ENTITY_ID, cover_state)])
await setup_integration(hass, mock_serial_bridge_config_entry)
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_UNKNOWN
# Open cover
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
mock_serial_bridge.set_device_status.assert_called()
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_OPENING
await hass.config_entries.async_reload(mock_serial_bridge_config_entry.entry_id)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_OPENING
assert state.state == cover_state
async def test_cover_dynamic(
@@ -14,7 +14,7 @@ from homeassistant.components.google_assistant_sdk import DOMAIN
from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUAGE_CODES
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
@@ -37,19 +37,29 @@ async def test_setup_success(
hass: HomeAssistant,
setup_integration: ComponentSetup,
) -> None:
"""Test successful setup and unload."""
"""Test successful setup, unload, and re-setup."""
# Initial setup
await setup_integration()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
assert hass.services.has_service(DOMAIN, "send_text_command")
await hass.config_entries.async_unload(entries[0].entry_id)
# Unload the entry
entry_id = entries[0].entry_id
await hass.config_entries.async_unload(entry_id)
await hass.async_block_till_done()
assert not hass.data.get(DOMAIN)
assert entries[0].state is ConfigEntryState.NOT_LOADED
assert not hass.services.async_services().get(DOMAIN, {})
assert hass.services.has_service(DOMAIN, "send_text_command")
# Re-setup the entry
assert await hass.config_entries.async_setup(entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
assert hass.services.has_service(DOMAIN, "send_text_command")
@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"])
@@ -134,6 +144,12 @@ async def test_setup_client_error(
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.SETUP_RETRY
with pytest.raises(ServiceValidationError) as exc:
await hass.services.async_call(
DOMAIN, "send_text_command", {"command": "some command"}, blocking=True
)
assert exc.value.translation_key == "entry_not_loaded"
@pytest.mark.parametrize(
("configured_language_code", "expected_language_code"),
@@ -22,6 +22,9 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import
BaseFirmwareConfigFlow,
BaseFirmwareOptionsFlow,
)
from homeassistant.components.homeassistant_hardware.helpers import (
async_firmware_update_context,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
@@ -302,18 +305,21 @@ def mock_firmware_info(
expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (),
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = "homeassistant_hardware",
) -> FirmwareInfo:
await asyncio.sleep(0)
progress_callback(0, 100)
await asyncio.sleep(0)
progress_callback(50, 100)
await asyncio.sleep(0)
progress_callback(100, 100)
async with async_firmware_update_context(hass, device, domain):
await asyncio.sleep(0)
progress_callback(0, 100)
await asyncio.sleep(0)
progress_callback(50, 100)
await asyncio.sleep(0)
progress_callback(100, 100)
if flashed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
if flashed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return flashed_firmware_info
return flashed_firmware_info
with (
patch(
@@ -7,9 +7,13 @@ import pytest
from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT
from homeassistant.components.homeassistant_hardware.helpers import (
async_firmware_update_context,
async_is_firmware_update_in_progress,
async_notify_firmware_info,
async_register_firmware_info_callback,
async_register_firmware_info_provider,
async_register_firmware_update_in_progress,
async_unregister_firmware_update_in_progress,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
@@ -183,3 +187,73 @@ async def test_dispatcher_callback_error_handling(
assert callback1.mock_calls == [call(FIRMWARE_INFO_EZSP)]
assert callback2.mock_calls == [call(FIRMWARE_INFO_EZSP)]
async def test_firmware_update_tracking(hass: HomeAssistant) -> None:
"""Test firmware update tracking API."""
await async_setup_component(hass, "homeassistant_hardware", {})
device_path = "/dev/ttyUSB0"
assert not async_is_firmware_update_in_progress(hass, device_path)
# Register an update in progress
async_register_firmware_update_in_progress(hass, device_path, "zha")
assert async_is_firmware_update_in_progress(hass, device_path)
with pytest.raises(ValueError, match="Firmware update already in progress"):
async_register_firmware_update_in_progress(hass, device_path, "skyconnect")
assert async_is_firmware_update_in_progress(hass, device_path)
# Unregister the update with correct domain
async_unregister_firmware_update_in_progress(hass, device_path, "zha")
assert not async_is_firmware_update_in_progress(hass, device_path)
# Test unregistering with wrong domain should raise an error
async_register_firmware_update_in_progress(hass, device_path, "zha")
with pytest.raises(ValueError, match="is owned by zha, not skyconnect"):
async_unregister_firmware_update_in_progress(hass, device_path, "skyconnect")
# Still registered to zha
assert async_is_firmware_update_in_progress(hass, device_path)
async_unregister_firmware_update_in_progress(hass, device_path, "zha")
assert not async_is_firmware_update_in_progress(hass, device_path)
async def test_firmware_update_context_manager(hass: HomeAssistant) -> None:
"""Test firmware update progress context manager."""
await async_setup_component(hass, "homeassistant_hardware", {})
device_path = "/dev/ttyUSB0"
# Initially no updates in progress
assert not async_is_firmware_update_in_progress(hass, device_path)
# Test successful completion
async with async_firmware_update_context(hass, device_path, "zha"):
assert async_is_firmware_update_in_progress(hass, device_path)
# Should be cleaned up after context
assert not async_is_firmware_update_in_progress(hass, device_path)
# Test exception handling
with pytest.raises(ValueError, match="test error"): # noqa: PT012
async with async_firmware_update_context(hass, device_path, "zha"):
assert async_is_firmware_update_in_progress(hass, device_path)
raise ValueError("test error")
# Should still be cleaned up after exception
assert not async_is_firmware_update_in_progress(hass, device_path)
# Test concurrent context manager attempts should fail
async with async_firmware_update_context(hass, device_path, "zha"):
assert async_is_firmware_update_in_progress(hass, device_path)
# Second context manager should fail to register
with pytest.raises(ValueError, match="Firmware update already in progress"):
async with async_firmware_update_context(hass, device_path, "skyconnect"):
pytest.fail("We should not enter this context manager")
# Should be cleaned up after first context
assert not async_is_firmware_update_in_progress(hass, device_path)
@@ -364,6 +364,8 @@ async def test_update_entity_installation(
expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (),
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = "homeassistant_hardware",
) -> FirmwareInfo:
await asyncio.sleep(0)
progress_callback(0, 100)
@@ -537,6 +537,8 @@ async def test_probe_silabs_firmware_type(
async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None:
"""Test async_flash_silabs_firmware."""
await async_setup_component(hass, "homeassistant_hardware", {})
owner1 = create_mock_owner()
owner2 = create_mock_owner()
@@ -625,6 +627,8 @@ async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None:
async def test_async_flash_silabs_firmware_flash_failure(hass: HomeAssistant) -> None:
"""Test async_flash_silabs_firmware flash failure."""
await async_setup_component(hass, "homeassistant_hardware", {})
owner1 = create_mock_owner()
owner2 = create_mock_owner()
@@ -679,6 +683,8 @@ async def test_async_flash_silabs_firmware_flash_failure(hass: HomeAssistant) ->
async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) -> None:
"""Test async_flash_silabs_firmware probe failure."""
await async_setup_component(hass, "homeassistant_hardware", {})
owner1 = create_mock_owner()
owner2 = create_mock_owner()
+8
View File
@@ -75,6 +75,7 @@ CONFIG_WITH_STATES = {
"state_opening": "opening",
"state_unlocked": "unlocked",
"state_unlocking": "unlocking",
"state_jammed": "jammed",
}
}
}
@@ -89,6 +90,7 @@ CONFIG_WITH_STATES = {
(CONFIG_WITH_STATES, "opening", LockState.OPENING),
(CONFIG_WITH_STATES, "unlocked", LockState.UNLOCKED),
(CONFIG_WITH_STATES, "unlocking", LockState.UNLOCKING),
(CONFIG_WITH_STATES, "jammed", LockState.JAMMED),
],
)
async def test_controlling_state_via_topic(
@@ -111,6 +113,12 @@ async def test_controlling_state_via_topic(
state = hass.states.get("lock.test")
assert state.state == lock_state
async_fire_mqtt_message(hass, "state-topic", "None")
await hass.async_block_till_done()
state = hass.states.get("lock.test")
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
("hass_config", "payload", "lock_state"),

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