Compare commits

...

32 Commits

Author SHA1 Message Date
Mike O'Driscoll
2285db5bb1 Casper Glow - Add Select Options (#166553)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-28 17:48:22 +01:00
Manu
738b85c17d Add event platform to HTML5 integration (#166577) 2026-03-28 17:39:21 +01:00
Erwin Douna
b7bb185d50 Add new OAuth exceptions to Neato (#166584)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-28 17:27:09 +01:00
mettolen
f4544cf952 Fix Huum test coverage and upgrade to silver (#166548)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-28 17:26:18 +01:00
crash0verride11
beab473dcc Correct Musiccast sound mode name (#166644)
Co-authored-by: crash0verride11 <3526616+crash0verride11@users.noreply.github.com>
Co-authored-by: jtjart <80978647+jtjart@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-28 17:23:57 +01:00
Anis Kadri
96891228c9 Add select platform to UniFi Access integration (#166096)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-28 17:19:49 +01:00
Will Moss
a4a36b5cbd Handle Oauth2 ImplementationUnavailableError in microbees (#166654)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:18:05 +01:00
David Knowles
4a0a400e22 Bump pydrawise to 2026.3.0 (#166750) 2026-03-28 17:12:24 +01:00
Andrew Jackson
fbe4195ae0 Add event entity to Transmission (#166686) 2026-03-28 17:06:11 +01:00
Will Moss
116fa57903 Handle Oauth2 ImplementationUnavailableError in monzo (#166653)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:56:39 +01:00
Will Moss
2399da93db Handle Oauth2 ImplementationUnavailableError in google_assistant_sdk (#166649)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:55:55 +01:00
Will Moss
3850bb0e57 Handle Oauth2 ImplementationUnavailableError in google_mail (#166650)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:55:24 +01:00
Will Moss
f45c84b2a8 Handle Oauth2 ImplementationUnavailableError in iotty (#166652)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:54:00 +01:00
Will Moss
a2e60f84da Handle Oauth2 ImplementationUnavailableError in google_sheets (#166651)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:53:47 +01:00
Will Moss
3757289c73 Handle Oauth2 ImplementationUnavailableError in geocaching (#166648)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:53:20 +01:00
Will Moss
09067a18b7 Handle Oauth2 ImplementationUnavailableError in husqvarna_automower (#166633)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:52:42 +01:00
Will Moss
6eb834946b Handle Oauth2 ImplementationUnavailableError in lyric (#166655)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:51:48 +01:00
Will Moss
0e1663f259 Handle Oauth2 ImplementationUnavailableError in gentex_homelink (#166646)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:51:09 +01:00
Will Moss
0ba3a94a3b Handle Oauth2 ImplementationUnavailableError in google_tasks (#166657)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:50:01 +01:00
Martin Hjelmare
3562a3800f Improve energyid config flow tests (#166749) 2026-03-28 16:46:49 +01:00
Michael
de0efa1639 Bump aioimmich to 0.12.1 (#166746) 2026-03-28 15:50:26 +01:00
Mattie
818cf41c22 Bump python-qube-heatpump to 1.8.0 (#166713) 2026-03-28 15:49:24 +01:00
Denis Shulyaka
25bfb16936 Exception translations for Anthropic integration (#166723) 2026-03-28 15:40:03 +01:00
Raman Gupta
75782e6f17 Remove dispatcher pattern and use options properties in Vizio (#164711)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:38:59 +01:00
Åke Strandberg
3e5c291338 Add missing code for miele washing machine (#166731) 2026-03-28 15:27:06 +01:00
Louis Christ
30163fa2e7 Bump pyblu to 2.0.6 (#166738) 2026-03-28 15:26:35 +01:00
Steve Easley
16231d8d36 Bump kaleidescape dependency to 1.1.4 (#166744) 2026-03-28 15:21:26 +01:00
Ludovic BOUÉ
0c0d6595d6 Add Matter range hood fixture (#166743)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-03-28 15:20:51 +01:00
Martin Hjelmare
a443060faa Improve comelit type handling (#166740) 2026-03-28 15:20:23 +01:00
Noah Husby
9807722077 Bump aiorussound to 4.9.1 (#166718) 2026-03-28 11:15:29 +01:00
TimL
12b485b17e Add Remote platform to SMLIGHT Integration (#166728) 2026-03-28 07:50:36 +01:00
Joakim Plate
45def46a45 Bump gardena bluetooth to 2.3.0 (#166719) 2026-03-28 00:57:27 +01:00
114 changed files with 3599 additions and 395 deletions

View File

@@ -45,9 +45,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
try:
await client.models.list(timeout=10.0)
except anthropic.AuthenticationError as err:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.AnthropicError as err:
raise ConfigEntryNotReady(err) from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
) from err
entry.runtime_data = client

View File

@@ -12,6 +12,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .const import DOMAIN
from .entity import AnthropicBaseLLMEntity
if TYPE_CHECKING:
@@ -60,7 +61,7 @@ class AnthropicTaskEntity(
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
"Last content in chat log is not an AssistantContent"
translation_domain=DOMAIN, translation_key="response_not_found"
)
text = chat_log.content[-1].content or ""
@@ -78,7 +79,9 @@ class AnthropicTaskEntity(
err,
text,
)
raise HomeAssistantError("Error with Claude structured response") from err
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="json_parse_error"
) from err
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,

View File

@@ -401,7 +401,11 @@ def _convert_content(
messages[-1]["content"] = messages[-1]["content"][0]["text"]
else:
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
raise HomeAssistantError("Unexpected content type in chat log")
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unexpected_chat_log_content",
translation_placeholders={"type": type(content).__name__},
)
return messages, container_id
@@ -443,7 +447,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
Each message could contain multiple blocks of the same type.
"""
if stream is None or not hasattr(stream, "__aiter__"):
raise HomeAssistantError("Expected a stream of messages")
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
)
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
current_tool_args: str
@@ -605,7 +611,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
chat_log.async_trace(_create_token_stats(input_usage, usage))
content_details.container = response.delta.container
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_refusal"
)
elif isinstance(response, RawMessageStopEvent):
if content_details:
content_details.delete_empty()
@@ -664,7 +672,9 @@ class AnthropicBaseLLMEntity(Entity):
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise HomeAssistantError("First message must be a system message")
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="system_message_not_found"
)
# System prompt with caching enabled
system_prompt: list[TextBlockParam] = [
@@ -754,7 +764,7 @@ class AnthropicBaseLLMEntity(Entity):
last_message = messages[-1]
if last_message["role"] != "user":
raise HomeAssistantError(
"Last message must be a user message to add attachments"
translation_domain=DOMAIN, translation_key="user_message_not_found"
)
if isinstance(last_message["content"], str):
last_message["content"] = [
@@ -859,11 +869,19 @@ class AnthropicBaseLLMEntity(Entity):
except anthropic.AuthenticationError as err:
self.entry.async_start_reauth(self.hass)
raise HomeAssistantError(
"Authentication error with Anthropic API, reauthentication required"
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
) from err
if not chat_log.unresponded_tool_results:
@@ -883,15 +901,23 @@ async def async_prepare_files_for_prompt(
for file_path, mime_type in files:
if not file_path.exists():
raise HomeAssistantError(f"`{file_path}` does not exist")
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="wrong_file_path",
translation_placeholders={"file_path": file_path.as_posix()},
)
if mime_type is None:
mime_type = guess_file_type(file_path)[0]
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
"Only images and PDF are supported by the Anthropic API,"
f"`{file_path}` is not an image file or PDF"
translation_domain=DOMAIN,
translation_key="wrong_file_type",
translation_placeholders={
"file_path": file_path.as_posix(),
"mime_type": mime_type or "unknown",
},
)
if mime_type == "image/jpg":
mime_type = "image/jpeg"

View File

@@ -88,7 +88,7 @@ rules:
comment: |
No entities disabled by default.
entity-translations: todo
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues: done

View File

@@ -161,7 +161,9 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
is None
or (subentry := entry.subentries.get(self._current_subentry_id)) is None
):
raise HomeAssistantError("Subentry not found")
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="subentry_not_found"
)
updated_data = {
**subentry.data,
@@ -190,4 +192,6 @@ async def async_create_fix_flow(
"""Create flow."""
if issue_id == "model_deprecated":
return ModelDeprecatedRepairFlow()
raise HomeAssistantError("Unknown issue ID")
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unknown_issue_id"
)

View File

@@ -149,6 +149,47 @@
}
}
},
"exceptions": {
"api_authentication_error": {
"message": "Authentication error with Anthropic API: {message}. Reauthentication required."
},
"api_error": {
"message": "Anthropic API error: {message}."
},
"api_refusal": {
"message": "Potential policy violation detected."
},
"json_parse_error": {
"message": "Error with Claude structured response."
},
"response_not_found": {
"message": "Last content in chat log is not an AssistantContent."
},
"subentry_not_found": {
"message": "Subentry not found."
},
"system_message_not_found": {
"message": "First message must be a system message."
},
"unexpected_chat_log_content": {
"message": "Unexpected content type in chat log: {type}."
},
"unexpected_stream_object": {
"message": "Expected a stream of messages."
},
"unknown_issue_id": {
"message": "Unknown issue ID."
},
"user_message_not_found": {
"message": "Last message must be a user message to add attachments."
},
"wrong_file_path": {
"message": "`{file_path}` does not exist."
},
"wrong_file_type": {
"message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF."
}
},
"issues": {
"model_deprecated": {
"fix_flow": {

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyblu==2.0.5"],
"requirements": ["pyblu==2.0.6"],
"zeroconf": [
{
"type": "_musc._tcp.local."

View File

@@ -11,7 +11,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.SELECT,
]
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:

View File

@@ -12,5 +12,7 @@ SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS)
DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0]
DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES)
# Interval between periodic state polls to catch externally-triggered changes.
STATE_POLL_INTERVAL = timedelta(seconds=30)

View File

@@ -19,7 +19,7 @@ from homeassistant.components.bluetooth.active_update_coordinator import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from .const import STATE_POLL_INTERVAL
from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL
_LOGGER = logging.getLogger(__name__)
@@ -51,6 +51,15 @@ class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
)
self.title = title
# The device API couples brightness and dimming time into a
# single command (set_brightness_and_dimming_time), so both
# values must be tracked here for cross-entity use.
self.last_brightness_pct: int = (
device.state.brightness_level
if device.state.brightness_level is not None
else SORTED_BRIGHTNESS_LEVELS[0]
)
@callback
def _needs_poll(
self,

View File

@@ -12,6 +12,11 @@
"resume": {
"default": "mdi:play"
}
},
"select": {
"dimming_time": {
"default": "mdi:timer-outline"
}
}
}
}

View File

@@ -71,6 +71,7 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
self._attr_color_mode = ColorMode.BRIGHTNESS
if state.brightness_level is not None:
self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level)
self.coordinator.last_brightness_pct = state.brightness_level
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
@@ -97,6 +98,7 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
)
)
self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct)
self.coordinator.last_brightness_pct = brightness_pct
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""

View File

@@ -52,8 +52,10 @@ rules:
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-category: done
entity-device-class:
status: exempt
comment: No applicable device classes for binary_sensor, button, light, or select entities.
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done

View File

@@ -0,0 +1,92 @@
"""Casper Glow integration select platform for dimming time."""
from __future__ import annotations
from pycasperglow import GlowState
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DIMMING_TIME_OPTIONS
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the select platform for Casper Glow."""
async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)])
class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity):
"""Select entity for Casper Glow dimming time."""
_attr_translation_key = "dimming_time"
_attr_entity_category = EntityCategory.CONFIG
_attr_options = list(DIMMING_TIME_OPTIONS)
_attr_unit_of_measurement = UnitOfTime.MINUTES
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the dimming time select entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time"
@property
def current_option(self) -> str | None:
"""Return the currently selected dimming time from the coordinator."""
if self.coordinator.last_dimming_time_minutes is None:
return None
return str(self.coordinator.last_dimming_time_minutes)
async def async_added_to_hass(self) -> None:
"""Restore last known dimming time and register state update callback."""
await super().async_added_to_hass()
if self.coordinator.last_dimming_time_minutes is None and (
last_state := await self.async_get_last_state()
):
if last_state.state in DIMMING_TIME_OPTIONS:
self.coordinator.last_dimming_time_minutes = int(last_state.state)
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
if state.brightness_level is not None:
self.coordinator.last_brightness_pct = state.brightness_level
if (
state.configured_dimming_time_minutes is not None
and self.coordinator.last_dimming_time_minutes is None
):
self.coordinator.last_dimming_time_minutes = (
state.configured_dimming_time_minutes
)
# Dimming time is not part of the device state
# that is provided via BLE update. Therefore
# we need to trigger a state update for the select entity
# to update the current state.
self.async_write_ha_state()
async def async_select_option(self, option: str) -> None:
"""Set the dimming time."""
await self._async_command(
self._device.set_brightness_and_dimming_time(
self.coordinator.last_brightness_pct, int(option)
)
)
self.coordinator.last_dimming_time_minutes = int(option)
# Dimming time is not part of the device state
# that is provided via BLE update. Therefore
# we need to trigger a state update for the select entity
# to update the current state.
self.async_write_ha_state()

View File

@@ -39,6 +39,11 @@
"resume": {
"name": "Resume dimming"
}
},
"select": {
"dimming_time": {
"name": "Dimming time"
}
}
},
"exceptions": {

View File

@@ -9,7 +9,6 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -92,7 +91,7 @@ async def async_setup_entry(
entities: list[ClimateEntity] = []
for device in coordinator.data[CLIMATE].values():
values = load_api_data(device, CLIMATE_DOMAIN)
values = load_api_data(device, "climate")
if values[0] == 0 and values[4] == 0:
# No climate data, device is only a humidifier/dehumidifier
@@ -140,7 +139,7 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
def _update_attributes(self) -> None:
"""Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index]
values = load_api_data(device, CLIMATE_DOMAIN)
values = load_api_data(device, "climate")
_active = values[1]
_mode = values[2] # Values from API: "O", "L", "U"

View File

@@ -9,7 +9,6 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
from homeassistant.components.humidifier import (
DOMAIN as HUMIDIFIER_DOMAIN,
MODE_AUTO,
MODE_NORMAL,
HumidifierAction,
@@ -68,7 +67,7 @@ async def async_setup_entry(
entities: list[ComelitHumidifierEntity] = []
for device in coordinator.data[CLIMATE].values():
values = load_api_data(device, HUMIDIFIER_DOMAIN)
values = load_api_data(device, "humidifier")
if values[0] == 0 and values[4] == 0:
# No humidity data, device is only a climate
@@ -142,7 +141,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
def _update_attributes(self) -> None:
"""Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index]
values = load_api_data(device, HUMIDIFIER_DOMAIN)
values = load_api_data(device, "humidifier")
_active = values[1]
_mode = values[2] # Values from API: "O", "L", "U"

View File

@@ -113,9 +113,6 @@
"humidity_while_off": {
"message": "Cannot change humidity while off"
},
"invalid_clima_data": {
"message": "Invalid 'clima' data"
},
"update_failed": {
"message": "Failed to update data: {error}"
}

View File

@@ -2,13 +2,12 @@
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import TYPE_CHECKING, Any, Concatenate
from typing import TYPE_CHECKING, Any, Concatenate, Literal
from aiocomelit.api import ComelitSerialBridgeObject
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession, CookieJar
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -30,17 +29,19 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession:
)
def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]:
def load_api_data(
device: ComelitSerialBridgeObject,
domain: Literal["climate", "humidifier"],
) -> list[Any]:
"""Load data from the API."""
# This function is called when the data is loaded from the API
if not isinstance(device.val, list):
raise HomeAssistantError(
translation_domain=domain, translation_key="invalid_clima_data"
)
# This function is called when the data is loaded from the API.
# For climate and humidifier device.val is always a list.
if TYPE_CHECKING:
assert isinstance(device.val, list)
# CLIMATE has a 2 item tuple:
# - first for Clima
# - second for Humidifier
return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1]
return device.val[0] if domain == "climate" else device.val[1]
async def cleanup_stale_entity(

View File

@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.1.0"]
"requirements": ["gardena-bluetooth==2.3.0"]
}

View File

@@ -7,7 +7,7 @@ from homelink.mqtt_provider import MQTTProvider
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from . import oauth2
@@ -29,11 +29,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) ->
hass, DOMAIN, auth_implementation
)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
)
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
authenticated_session = oauth2.AsyncConfigEntryAuth(

View File

@@ -49,5 +49,10 @@
}
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -2,11 +2,14 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .const import DOMAIN
from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
@@ -14,7 +17,13 @@ PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: GeocachingConfigEntry) -> bool:
"""Set up Geocaching from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
oauth_session = OAuth2Session(hass, entry, implementation)
coordinator = GeocachingDataUpdateCoordinator(

View File

@@ -65,5 +65,10 @@
"unit_of_measurement": "souvenirs"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -17,6 +17,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import config_validation as cv, discovery, intent
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -52,7 +53,13 @@ async def async_setup_entry(
hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry
) -> bool:
"""Set up Google Assistant SDK from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()

View File

@@ -48,6 +48,9 @@
"grpc_error": {
"message": "Failed to communicate with Google Assistant"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"reauth_required": {
"message": "Credentials are invalid, re-authentication required"
}

View File

@@ -5,8 +5,10 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -34,7 +36,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool:
"""Set up Google Mail from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
auth = AsyncConfigEntryAuth(hass, session)
await auth.check_and_refresh_token()

View File

@@ -51,6 +51,9 @@
"exceptions": {
"missing_from_for_alias": {
"message": "Missing 'from' email when setting an alias to show. You have to provide a 'from' email"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"services": {

View File

@@ -15,6 +15,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -40,7 +41,13 @@ async def async_setup_entry(
hass: HomeAssistant, entry: GoogleSheetsConfigEntry
) -> bool:
"""Set up Google Sheets from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()

View File

@@ -42,6 +42,11 @@
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"services": {
"append_sheet": {
"description": "Appends data to a worksheet in Google Sheets.",

View File

@@ -25,11 +25,17 @@ PLATFORMS: list[Platform] = [Platform.TODO]
async def async_setup_entry(hass: HomeAssistant, entry: GoogleTasksConfigEntry) -> bool:
"""Set up Google Tasks from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
)
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
auth = api.AsyncConfigEntryAuth(hass, session)
try:

View File

@@ -42,5 +42,10 @@
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["python_qube_heatpump"],
"quality_scale": "bronze",
"requirements": ["python-qube-heatpump==1.7.0"]
"requirements": ["python-qube-heatpump==1.8.0"]
}

View File

@@ -9,7 +9,7 @@ from .const import DOMAIN
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.NOTIFY]
PLATFORMS = [Platform.EVENT, Platform.NOTIFY]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@@ -7,3 +7,9 @@ SERVICE_DISMISS = "dismiss"
ATTR_VAPID_PUB_KEY = "vapid_pub_key"
ATTR_VAPID_PRV_KEY = "vapid_prv_key"
ATTR_VAPID_EMAIL = "vapid_email"
REGISTRATIONS_FILE = "html5_push_registrations.conf"
ATTR_ACTION = "action"
ATTR_DATA = "data"
ATTR_TAG = "tag"

View File

@@ -0,0 +1,73 @@
"""Base entities for HTML5 integration."""
from __future__ import annotations
from typing import NotRequired, TypedDict
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class Keys(TypedDict):
"""Types for keys."""
p256dh: str
auth: str
class Subscription(TypedDict):
"""Types for subscription."""
endpoint: str
expirationTime: int | None
keys: Keys
class Registration(TypedDict):
"""Types for registration."""
subscription: Subscription
browser: str
name: NotRequired[str]
class HTML5Entity(Entity):
"""Base entity for HTML5 integration."""
_attr_has_entity_name = True
_attr_name = None
_key: str
def __init__(
self,
config_entry: ConfigEntry,
target: str,
registrations: dict[str, Registration],
session: ClientSession,
json_path: str,
) -> None:
"""Initialize the entity."""
self.config_entry = config_entry
self.target = target
self.registrations = registrations
self.registration = registrations[target]
self.session = session
self.json_path = json_path
self._attr_unique_id = f"{config_entry.entry_id}_{target}_{self._key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=target,
model=self.registration["browser"].capitalize(),
identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")},
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.target in self.registrations

View File

@@ -0,0 +1,67 @@
"""Event platform for HTML5 integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.event import EventEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_ACTION, ATTR_DATA, ATTR_TAG, DOMAIN, REGISTRATIONS_FILE
from .entity import HTML5Entity
from .notify import _load_config
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the event entity platform."""
json_path = hass.config.path(REGISTRATIONS_FILE)
registrations = await hass.async_add_executor_job(_load_config, json_path)
session = async_get_clientsession(hass)
async_add_entities(
HTML5EventEntity(config_entry, target, registrations, session, json_path)
for target in registrations
)
class HTML5EventEntity(HTML5Entity, EventEntity):
"""Representation of an event entity."""
_key = "event"
_attr_event_types = ["clicked", "received", "closed"]
_attr_translation_key = "event"
@callback
def _async_handle_event(
self, target: str, event_type: str, event_data: dict[str, Any]
) -> None:
"""Handle the event."""
if target == self.target:
self._trigger_event(
event_type,
{
**event_data.get(ATTR_DATA, {}),
ATTR_ACTION: event_data.get(ATTR_ACTION),
ATTR_TAG: event_data.get(ATTR_TAG),
},
)
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register event callback."""
self.async_on_remove(
async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event)
)

View File

@@ -1,4 +1,11 @@
{
"entity": {
"event": {
"event": {
"default": "mdi:gesture-tap-button"
}
}
},
"services": {
"dismiss": {
"service": "mdi:bell-off"

View File

@@ -8,7 +8,7 @@ from http import HTTPStatus
import json
import logging
import time
from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
import uuid
@@ -38,7 +38,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.json import save_json
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -46,17 +46,19 @@ from homeassistant.util import ensure_unique_string
from homeassistant.util.json import load_json_object
from .const import (
ATTR_ACTION,
ATTR_TAG,
ATTR_VAPID_EMAIL,
ATTR_VAPID_PRV_KEY,
ATTR_VAPID_PUB_KEY,
DOMAIN,
REGISTRATIONS_FILE,
SERVICE_DISMISS,
)
from .entity import HTML5Entity, Registration
_LOGGER = logging.getLogger(__name__)
REGISTRATIONS_FILE = "html5_push_registrations.conf"
ATTR_SUBSCRIPTION = "subscription"
ATTR_BROWSER = "browser"
@@ -67,8 +69,6 @@ ATTR_AUTH = "auth"
ATTR_P256DH = "p256dh"
ATTR_EXPIRATIONTIME = "expirationTime"
ATTR_TAG = "tag"
ATTR_ACTION = "action"
ATTR_ACTIONS = "actions"
ATTR_TYPE = "type"
ATTR_URL = "url"
@@ -156,29 +156,6 @@ HTML5_SHOWNOTIFICATION_PARAMETERS = (
)
class Keys(TypedDict):
"""Types for keys."""
p256dh: str
auth: str
class Subscription(TypedDict):
"""Types for subscription."""
endpoint: str
expirationTime: int | None
keys: Keys
class Registration(TypedDict):
"""Types for registration."""
subscription: Subscription
browser: str
name: NotRequired[str]
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
@@ -419,7 +396,15 @@ class HTML5PushCallbackView(HomeAssistantView):
)
event_name = f"{NOTIFY_CALLBACK_EVENT}.{event_payload[ATTR_TYPE]}"
request.app[KEY_HASS].bus.fire(event_name, event_payload)
hass = request.app[KEY_HASS]
hass.bus.fire(event_name, event_payload)
async_dispatcher_send(
hass,
DOMAIN,
event_payload[ATTR_TARGET],
event_payload[ATTR_TYPE],
event_payload,
)
return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]})
@@ -613,37 +598,11 @@ async def async_setup_entry(
)
class HTML5NotifyEntity(NotifyEntity):
class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
"""Representation of a notification entity."""
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = NotifyEntityFeature.TITLE
def __init__(
self,
config_entry: ConfigEntry,
target: str,
registrations: dict[str, Registration],
session: ClientSession,
json_path: str,
) -> None:
"""Initialize the entity."""
self.config_entry = config_entry
self.target = target
self.registrations = registrations
self.registration = registrations[target]
self.session = session
self.json_path = json_path
self._attr_unique_id = f"{config_entry.entry_id}_{target}_device"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=target,
model=self.registration["browser"].capitalize(),
identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")},
)
_key = "device"
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to a device."""
@@ -714,8 +673,3 @@ class HTML5NotifyEntity(NotifyEntity):
translation_key="connection_error",
translation_placeholders={"target": self.target},
) from e
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.target in self.registrations

View File

@@ -20,6 +20,23 @@
}
}
},
"entity": {
"event": {
"event": {
"state_attributes": {
"action": { "name": "Action" },
"event_type": {
"state": {
"clicked": "Clicked",
"closed": "Closed",
"received": "Received"
}
},
"tag": { "name": "Tag" }
}
}
}
},
"exceptions": {
"channel_expired": {
"message": "Notification channel for {target} has expired"

View File

@@ -11,6 +11,9 @@ from homeassistant.helpers import (
config_entry_oauth2_flow,
config_validation as cv,
)
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
@@ -42,11 +45,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool:
"""Set up this integration using UI."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
api_api = api.AsyncConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass),

View File

@@ -491,6 +491,9 @@
"command_send_failed": {
"message": "Failed to send command: {exception}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"work_area_not_existing": {
"message": "The selected work area does not exist."
},

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.3.0"]
}

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/huum",
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["huum==0.8.2"]
}

View File

@@ -39,7 +39,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
test-coverage: done
# Gold
devices: done

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
"requirements": ["pydrawise==2025.9.0"]
"requirements": ["pydrawise==2026.3.0"]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "platinum",
"requirements": ["aioimmich==0.12.0"]
"requirements": ["aioimmich==0.12.1"]
}

View File

@@ -6,11 +6,14 @@ import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .const import DOMAIN
from .coordinator import (
IottyConfigEntry,
IottyConfigEntryData,
@@ -26,7 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: IottyConfigEntry) -> boo
"""Set up iotty from a config entry."""
_LOGGER.debug("async_setup_entry entry_id=%s", entry.entry_id)
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
data_update_coordinator = IottyDataUpdateCoordinator(hass, entry, session)

View File

@@ -25,5 +25,10 @@
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pykaleidescape==1.1.3"],
"requirements": ["pykaleidescape==1.1.4"],
"ssdp": [
{
"deviceType": "schemas-upnp-org:device:Basic:1",

View File

@@ -6,6 +6,7 @@ from aiolyric import Lyric
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
@@ -27,11 +28,17 @@ PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool:
"""Set up Honeywell Lyric from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
)
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
if not isinstance(implementation, LyricLocalOAuth2Implementation):
raise TypeError("Unexpected auth implementation; can't find oauth client id")

View File

@@ -64,6 +64,11 @@
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"services": {
"set_hold_time": {
"description": "Sets the time period to keep the temperature and override the schedule.",

View File

@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow
from .const import PLATFORMS
from .const import DOMAIN, PLATFORMS
from .coordinator import MicroBeesUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -50,11 +50,17 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry)
async def async_setup_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) -> bool:
"""Set up microBees from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
)
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
try:

View File

@@ -35,5 +35,10 @@
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -440,6 +440,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
no_program = 0, -1
cottons = 1, 10001
normal = 2
minimum_iron = 3
delicates = 4, 10022
woollens = 8, 10040

View File

@@ -6,13 +6,16 @@ import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .api import AuthenticatedMonzoAPI
from .const import DOMAIN
from .coordinator import MonzoConfigEntry, MonzoCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -39,7 +42,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> b
async def async_setup_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> bool:
"""Set up Monzo from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)

View File

@@ -50,5 +50,10 @@
"name": "Total balance"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -2,14 +2,19 @@
import logging
import aiohttp
from aiohttp import ClientError
from pybotvac import Account
from pybotvac.exceptions import NeatoException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -58,10 +63,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as ex:
_LOGGER.debug("API error: %s (%s)", ex.code, ex.message)
if ex.code in (401, 403):
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
except OAuth2TokenRequestReauthError as ex:
raise ConfigEntryAuthFailed from ex
except (OAuth2TokenRequestError, ClientError) as ex:
raise ConfigEntryNotReady from ex
neato_session = api.ConfigEntryAuth(hass, entry, implementation)

View File

@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiorussound"],
"quality_scale": "silver",
"requirements": ["aiorussound==4.9.0"],
"requirements": ["aiorussound==4.9.1"],
"zeroconf": ["_rio._tcp.local."]
}

View File

@@ -19,6 +19,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.REMOTE,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,

View File

@@ -0,0 +1,70 @@
"""Remote platform for SLZB-Ultima."""
import asyncio
from collections.abc import Iterable
from typing import Any
from pysmlight.exceptions import SmlightError
from pysmlight.models import IRPayload
from homeassistant.components.remote import (
ATTR_DELAY_SECS,
ATTR_NUM_REPEATS,
DEFAULT_DELAY_SECS,
DEFAULT_NUM_REPEATS,
RemoteEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SmConfigEntry, SmDataUpdateCoordinator
from .entity import SmEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: SmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize remote for SLZB-Ultima device."""
coordinator = entry.runtime_data.data
if coordinator.data.info.has_peripherals:
async_add_entities([SmRemoteEntity(coordinator)])
class SmRemoteEntity(SmEntity, RemoteEntity):
"""Representation of a SLZB-Ultima remote."""
_attr_translation_key = "remote"
_attr_is_on = True
def __init__(self, coordinator: SmDataUpdateCoordinator) -> None:
"""Initialize the SLZB-Ultima remote."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-remote"
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a sequence of commands to a device."""
num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS)
delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
for _ in range(num_repeats):
for cmd in command:
try:
await self.coordinator.async_execute_command(
self.coordinator.client.actions.send_ir_code,
IRPayload(code=cmd),
)
except SmlightError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_ir_code_failed",
translation_placeholders={"error": str(err)},
) from err
await asyncio.sleep(delay_secs)

View File

@@ -84,6 +84,11 @@
"name": "Ambilight"
}
},
"remote": {
"remote": {
"name": "IR Remote"
}
},
"sensor": {
"core_temperature": {
"name": "Core chip temp"
@@ -159,6 +164,9 @@
},
"firmware_update_failed": {
"message": "Firmware update failed for {device_name}."
},
"send_ir_code_failed": {
"message": "Failed to send IR code: {error}."
}
},
"issues": {

View File

@@ -40,7 +40,7 @@ from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR, Platform.SWITCH]
MIGRATION_NAME_TO_KEY = {
# Sensors

View File

@@ -55,6 +55,10 @@ EVENT_STARTED_TORRENT = "transmission_started_torrent"
EVENT_REMOVED_TORRENT = "transmission_removed_torrent"
EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent"
EVENT_TYPE_STARTED = "started"
EVENT_TYPE_REMOVED = "removed"
EVENT_TYPE_DOWNLOADED = "downloaded"
STATE_UP_DOWN = "up_down"
STATE_SEEDING = "seeding"
STATE_DOWNLOADING = "downloading"

View File

@@ -1,19 +1,24 @@
"""Coordinator for transmssion integration."""
"""Coordinator for transmission integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from functools import partial
import logging
import transmission_rpc
from transmission_rpc.session import SessionStats
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.const import ATTR_ID, ATTR_NAME, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_DOWNLOAD_PATH,
ATTR_LABELS,
CONF_LIMIT,
CONF_ORDER,
DEFAULT_LIMIT,
@@ -23,13 +28,28 @@ from .const import (
EVENT_DOWNLOADED_TORRENT,
EVENT_REMOVED_TORRENT,
EVENT_STARTED_TORRENT,
EVENT_TYPE_DOWNLOADED,
EVENT_TYPE_REMOVED,
EVENT_TYPE_STARTED,
)
_LOGGER = logging.getLogger(__name__)
type EventCallback = Callable[[TransmissionEventData], None]
type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator]
@dataclass
class TransmissionEventData:
"""Data for a single event."""
event_type: str
name: str
id: int
download_path: str
labels: list[str]
class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
"""Transmission dataupdate coordinator class."""
@@ -49,6 +69,7 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
self._all_torrents: list[transmission_rpc.Torrent] = []
self._completed_torrents: list[transmission_rpc.Torrent] = []
self._started_torrents: list[transmission_rpc.Torrent] = []
self._event_listeners: dict[str, EventCallback] = {}
self.torrents: list[transmission_rpc.Torrent] = []
super().__init__(
hass,
@@ -68,9 +89,32 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
"""Return order."""
return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) # type: ignore[no-any-return]
@callback
def async_add_event_listener(
self, update_callback: EventCallback, target_event_id: str
) -> Callable[[], None]:
"""Listen for updates."""
self._event_listeners[target_event_id] = update_callback
return partial(self.__async_remove_listener_internal, target_event_id)
def __async_remove_listener_internal(self, listener_id: str) -> None:
self._event_listeners.pop(listener_id, None)
@callback
def _async_notify_event_listeners(self, event: TransmissionEventData) -> None:
"""Notify event listeners in the event loop."""
for listener in list(self._event_listeners.values()):
listener(event)
async def _async_update_data(self) -> SessionStats:
"""Update transmission data."""
return await self.hass.async_add_executor_job(self.update)
data = await self.hass.async_add_executor_job(self.update)
self.check_completed_torrent()
self.check_started_torrent()
self.check_removed_torrent()
return data
def update(self) -> SessionStats:
"""Get the latest data from Transmission instance."""
@@ -82,10 +126,6 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
except transmission_rpc.TransmissionError as err:
raise UpdateFailed("Unable to connect to Transmission client") from err
self.check_completed_torrent()
self.check_started_torrent()
self.check_removed_torrent()
return data
def init_torrent_list(self) -> None:
@@ -108,15 +148,24 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
for torrent in current_completed_torrents:
if torrent.id not in old_completed_torrents:
self.hass.bus.fire(
# Once event triggers are out of labs we can remove the bus event
self.hass.bus.async_fire(
EVENT_DOWNLOADED_TORRENT,
{
"name": torrent.name,
"id": torrent.id,
"download_path": torrent.download_dir,
"labels": torrent.labels,
ATTR_NAME: torrent.name,
ATTR_ID: torrent.id,
ATTR_DOWNLOAD_PATH: torrent.download_dir,
ATTR_LABELS: torrent.labels,
},
)
event = TransmissionEventData(
event_type=EVENT_TYPE_DOWNLOADED,
name=torrent.name,
id=torrent.id,
download_path=torrent.download_dir or "",
labels=torrent.labels,
)
self._async_notify_event_listeners(event)
self._completed_torrents = current_completed_torrents
@@ -130,15 +179,24 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
for torrent in current_started_torrents:
if torrent.id not in old_started_torrents:
self.hass.bus.fire(
# Once event triggers are out of labs we can remove the bus event
self.hass.bus.async_fire(
EVENT_STARTED_TORRENT,
{
"name": torrent.name,
"id": torrent.id,
"download_path": torrent.download_dir,
"labels": torrent.labels,
ATTR_NAME: torrent.name,
ATTR_ID: torrent.id,
ATTR_DOWNLOAD_PATH: torrent.download_dir,
ATTR_LABELS: torrent.labels,
},
)
event = TransmissionEventData(
event_type=EVENT_TYPE_STARTED,
name=torrent.name,
id=torrent.id,
download_path=torrent.download_dir or "",
labels=torrent.labels,
)
self._async_notify_event_listeners(event)
self._started_torrents = current_started_torrents
@@ -148,15 +206,24 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
for torrent in self._all_torrents:
if torrent.id not in current_torrents:
self.hass.bus.fire(
# Once event triggers are out of labs we can remove the bus event
self.hass.bus.async_fire(
EVENT_REMOVED_TORRENT,
{
"name": torrent.name,
"id": torrent.id,
"download_path": torrent.download_dir,
"labels": torrent.labels,
ATTR_NAME: torrent.name,
ATTR_ID: torrent.id,
ATTR_DOWNLOAD_PATH: torrent.download_dir,
ATTR_LABELS: torrent.labels,
},
)
event = TransmissionEventData(
event_type=EVENT_TYPE_REMOVED,
name=torrent.name,
id=torrent.id,
download_path=torrent.download_dir or "",
labels=torrent.labels,
)
self._async_notify_event_listeners(event)
self._all_torrents = self.torrents.copy()

View File

@@ -0,0 +1,85 @@
"""Define events for the Transmission integration."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.const import ATTR_ID, ATTR_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_DOWNLOAD_PATH,
ATTR_LABELS,
EVENT_TYPE_DOWNLOADED,
EVENT_TYPE_REMOVED,
EVENT_TYPE_STARTED,
)
from .coordinator import TransmissionConfigEntry, TransmissionEventData
from .entity import TransmissionEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TransmissionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Transmission event platform."""
coordinator = config_entry.runtime_data
description = EventEntityDescription(
key="torrent",
translation_key="torrent",
event_types=[
EVENT_TYPE_STARTED,
EVENT_TYPE_DOWNLOADED,
EVENT_TYPE_REMOVED,
],
)
async_add_entities([TransmissionEvent(coordinator, description)])
class TransmissionEvent(TransmissionEntity, EventEntity):
"""Representation of a Transmission event entity."""
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
if TYPE_CHECKING:
assert self._attr_unique_id
self.async_on_remove(
self.coordinator.async_add_event_listener(
self._handle_event, self._attr_unique_id
)
)
@callback
def _handle_event(self, event_data: TransmissionEventData) -> None:
"""Handle the torrent events."""
event_type = event_data.event_type
if event_type not in self.event_types:
_LOGGER.warning("Event type %s is not known", event_type)
return
self._trigger_event(
event_type,
{
ATTR_NAME: event_data.name,
ATTR_ID: event_data.id,
ATTR_DOWNLOAD_PATH: event_data.download_path,
ATTR_LABELS: event_data.labels,
},
)
self.async_write_ha_state()

View File

@@ -8,6 +8,11 @@
}
}
},
"event": {
"torrent": {
"default": "mdi:folder-file-outline"
}
},
"sensor": {
"active_torrents": {
"default": "mdi:counter"

View File

@@ -50,6 +50,20 @@
}
}
},
"event": {
"torrent": {
"name": "Torrent",
"state_attributes": {
"event_type": {
"state": {
"downloaded": "Downloaded",
"removed": "Removed",
"started": "Started"
}
}
}
}
},
"sensor": {
"active_torrents": {
"name": "Active torrents",

View File

@@ -16,6 +16,7 @@ PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.EVENT,
Platform.IMAGE,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@@ -15,7 +15,9 @@ from unifi_access_api import (
ApiNotFoundError,
Door,
DoorLockRelayStatus,
DoorLockRule,
DoorLockRuleStatus,
DoorLockRuleType,
EmergencyStatus,
UnifiAccessApiClient,
WsMessageHandler,
@@ -40,6 +42,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_LOCK_RULE_INTERVAL = 10
type UnifiAccessConfigEntry = ConfigEntry[UnifiAccessCoordinator]
@@ -102,6 +105,34 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
self._event_listeners.append(event_callback)
return _unsubscribe
async def async_set_lock_rule(self, door_id: str, rule_type: str) -> None:
"""Set a temporary lock rule for a door."""
if not rule_type:
return
lock_rule_type = DoorLockRuleType(rule_type)
rule = DoorLockRule(type=lock_rule_type, interval=DEFAULT_LOCK_RULE_INTERVAL)
await self.client.set_door_lock_rule(door_id, rule)
if self.data is None or door_id not in self.data.doors:
return
new_status = DoorLockRuleStatus(
type=DoorLockRuleType.NONE
if lock_rule_type == DoorLockRuleType.RESET
else lock_rule_type
)
updated_data = replace(
self.data,
door_lock_rules={
**self.data.door_lock_rules,
door_id: new_status,
},
)
if self.last_update_success:
self.async_set_updated_data(updated_data)
else:
# Preserve coordinator error state while updating cached data
self.data = updated_data
self.async_update_listeners()
async def _async_setup(self) -> None:
"""Set up the WebSocket connection for push updates."""
handlers: dict[str, WsMessageHandler] = {

View File

@@ -0,0 +1,102 @@
"""Select platform for the UniFi Access integration."""
from __future__ import annotations
from unifi_access_api import Door, DoorLockRuleType, UnifiAccessError
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
from .entity import UnifiAccessEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: UnifiAccessConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up UniFi Access select entities."""
coordinator = entry.runtime_data
added_doors: set[str] = set()
@callback
def _async_add_lock_rule_selects() -> None:
new_door_ids = sorted(coordinator.get_lock_rule_sensor_door_ids() - added_doors)
if not new_door_ids:
return
async_add_entities(
UnifiAccessDoorLockRuleSelectEntity(
coordinator, coordinator.data.doors[door_id]
)
for door_id in new_door_ids
if door_id in coordinator.data.doors
)
added_doors.update(new_door_ids)
_async_add_lock_rule_selects()
entry.async_on_unload(coordinator.async_add_listener(_async_add_lock_rule_selects))
class UnifiAccessDoorLockRuleSelectEntity(UnifiAccessEntity, SelectEntity):
"""Select entity for choosing the active temporary lock rule on a door."""
_attr_translation_key = "door_lock_rule"
def __init__(
self,
coordinator: UnifiAccessCoordinator,
door: Door,
) -> None:
"""Initialize the door lock rule select entity."""
super().__init__(coordinator, door, "lock_rule_select")
@property
def current_option(self) -> str | None:
"""Return the currently active lock rule, or None if no rule is set."""
rule_status = self.coordinator.get_lock_rule_status(self._door_id)
if rule_status is None or rule_status.type in (
DoorLockRuleType.NONE,
DoorLockRuleType.RESET,
DoorLockRuleType.LOCK_NOW,
):
return None
value = rule_status.type.value
return value if value in self.options else None
@property
def options(self) -> list[str]:
"""Return the available lock rule options."""
opts = ["keep_lock", "keep_unlock", "custom", "reset"]
rule_status = self.coordinator.get_lock_rule_status(self._door_id)
if rule_status is not None and rule_status.type in (
DoorLockRuleType.SCHEDULE,
DoorLockRuleType.LOCK_EARLY,
):
opts.extend(["schedule", "lock_early"])
return opts
@property
def available(self) -> bool:
"""Return whether the select should currently be shown as available."""
return super().available and (
self._door_id in self.coordinator.get_lock_rule_sensor_door_ids()
)
async def async_select_option(self, option: str) -> None:
"""Apply the selected lock rule to the door."""
if option == DoorLockRuleType.SCHEDULE.value:
return
try:
await self.coordinator.async_set_lock_rule(self._door_id, option)
except UnifiAccessError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="lock_rule_failed",
) from err

View File

@@ -67,6 +67,19 @@
"name": "Thumbnail"
}
},
"select": {
"door_lock_rule": {
"name": "Lock rule",
"state": {
"custom": "Custom",
"keep_lock": "Keep locked",
"keep_unlock": "Keep unlocked",
"lock_early": "Lock early",
"reset": "Reset",
"schedule": "Schedule"
}
}
},
"sensor": {
"door_lock_rule": {
"name": "Lock rule",
@@ -97,6 +110,9 @@
"emergency_failed": {
"message": "Failed to set emergency status."
},
"lock_rule_failed": {
"message": "Failed to update the door lock rule."
},
"unlock_failed": {
"message": "Failed to unlock the door."
}

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Any
from pyvizio.api.apps import AppConfig, find_app_name
from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
@@ -14,10 +16,6 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_DEVICE_CLASS, CONF_EXCLUDE, CONF_INCLUDE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -122,9 +120,7 @@ class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity):
self._available_inputs: list[str] = []
self._available_apps: list[str] = []
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
self._all_apps = apps_coordinator.data if apps_coordinator else None
self._conf_apps = config_entry.options.get(CONF_APPS, {})
self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get(
CONF_ADDITIONAL_CONFIGS, []
)
@@ -142,6 +138,16 @@ class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity):
self._attr_device_class = device_class
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)})
@property
def _volume_step(self) -> int:
"""Return the configured volume step."""
return self._config_entry.options[CONF_VOLUME_STEP]
@property
def _conf_apps(self) -> dict:
"""Return the configured app filter options."""
return self._config_entry.options.get(CONF_APPS, {})
def _apps_list(self, apps: list[str]) -> list[str]:
"""Return process apps list based on configured filters."""
if self._conf_apps.get(CONF_INCLUDE):
@@ -225,22 +231,6 @@ class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity):
additional_app["name"] for additional_app in self._additional_app_configs
]
@staticmethod
async def _async_send_update_options_signal(
hass: HomeAssistant, config_entry: VizioConfigEntry
) -> None:
"""Send update event when Vizio config entry is updated."""
# Move this method to component level if another entity ever gets added for a
# single config entry.
# See here: https://github.com/home-assistant/core/pull/30653#discussion_r366426121
async_dispatcher_send(hass, config_entry.entry_id, config_entry)
async def _async_update_options(self, config_entry: VizioConfigEntry) -> None:
"""Update options if the update signal comes from this entity."""
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
# Update so that CONF_ADDITIONAL_CONFIGS gets retained for imports
self._conf_apps.update(config_entry.options.get(CONF_APPS, {}))
async def async_update_setting(
self, setting_type: str, setting_name: str, new_value: int | str
) -> None:
@@ -259,19 +249,10 @@ class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity):
# Process initial coordinator data
self._handle_coordinator_update()
# Register callback for when config entry is updated.
self.async_on_remove(
self._config_entry.add_update_listener(
self._async_send_update_options_signal
)
)
async def _async_write_state(*_: Any) -> None:
self._handle_coordinator_update()
# Register callback for update event
self.async_on_remove(
async_dispatcher_connect(
self.hass, self._config_entry.entry_id, self._async_update_options
)
)
self.async_on_remove(self._config_entry.add_update_listener(_async_write_state))
if not (apps_coordinator := self._apps_coordinator):
return

View File

@@ -81,8 +81,8 @@
"usa_a": "Hall in USA A",
"usa_b": "Hall in USA B",
"vienna": "Hall in Vienna",
"village_gate": "Village gate",
"village_vanguard": "Village vanguard",
"village_gate": "Village Gate",
"village_vanguard": "Village Vanguard",
"warehouse_loft": "Warehouse loft"
}
}

14
requirements_all.txt generated
View File

@@ -294,7 +294,7 @@ aiohue==4.8.0
aioimaplib==2.0.1
# homeassistant.components.immich
aioimmich==0.12.0
aioimmich==0.12.1
# homeassistant.components.apache_kafka
aiokafka==0.10.0
@@ -389,7 +389,7 @@ aioridwell==2025.09.0
aioruckus==0.42
# homeassistant.components.russound_rio
aiorussound==4.9.0
aiorussound==4.9.1
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -1032,7 +1032,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.1.0
gardena-bluetooth==2.3.0
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1989,7 +1989,7 @@ pybbox==0.0.5-alpha
pyblackbird==0.6
# homeassistant.components.bluesound
pyblu==2.0.5
pyblu==2.0.6
# homeassistant.components.neato
pybotvac==0.0.28
@@ -2055,7 +2055,7 @@ pydiscovergy==3.0.2
pydoods==1.0.2
# homeassistant.components.hydrawise
pydrawise==2025.9.0
pydrawise==2026.3.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==3.0.0
@@ -2209,7 +2209,7 @@ pyituran==0.1.5
pyjvcprojector==2.0.3
# homeassistant.components.kaleidescape
pykaleidescape==1.1.3
pykaleidescape==1.1.4
# homeassistant.components.kira
pykira==0.1.1
@@ -2654,7 +2654,7 @@ python-picnic-api2==1.3.1
python-pooldose==0.9.0
# homeassistant.components.hr_energy_qube
python-qube-heatpump==1.7.0
python-qube-heatpump==1.8.0
# homeassistant.components.rabbitair
python-rabbitair==0.0.8

View File

@@ -282,7 +282,7 @@ aiohue==4.8.0
aioimaplib==2.0.1
# homeassistant.components.immich
aioimmich==0.12.0
aioimmich==0.12.1
# homeassistant.components.apache_kafka
aiokafka==0.10.0
@@ -374,7 +374,7 @@ aioridwell==2025.09.0
aioruckus==0.42
# homeassistant.components.russound_rio
aiorussound==4.9.0
aiorussound==4.9.1
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -914,7 +914,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.1.0
gardena-bluetooth==2.3.0
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1723,7 +1723,7 @@ pybalboa==1.1.3
pyblackbird==0.6
# homeassistant.components.bluesound
pyblu==2.0.5
pyblu==2.0.6
# homeassistant.components.neato
pybotvac==0.0.28
@@ -1768,7 +1768,7 @@ pydexcom==0.5.1
pydiscovergy==3.0.2
# homeassistant.components.hydrawise
pydrawise==2025.9.0
pydrawise==2026.3.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==3.0.0
@@ -1892,7 +1892,7 @@ pyituran==0.1.5
pyjvcprojector==2.0.3
# homeassistant.components.kaleidescape
pykaleidescape==1.1.3
pykaleidescape==1.1.4
# homeassistant.components.kira
pykira==0.1.1
@@ -2256,7 +2256,7 @@ python-picnic-api2==1.3.1
python-pooldose==0.9.0
# homeassistant.components.hr_energy_qube
python-qube-heatpump==1.7.0
python-qube-heatpump==1.8.0
# homeassistant.components.rabbitair
python-rabbitair==0.0.8

View File

@@ -645,7 +645,7 @@ async def test_double_system_messages(
assert result.response.error_code == "unknown"
assert (
result.response.speech["plain"]["speech"]
== "Unexpected content type in chat log"
== "Unexpected content type in chat log: SystemContent"
)

View File

@@ -43,7 +43,7 @@ from tests.common import MockConfigEntry
),
body={"type": "error", "error": {"type": "invalid_request_error"}},
),
"anthropic integration not ready yet: Your credit balance is too low to access the Claude API",
"Your credit balance is too low to access the Claude API",
),
],
)

View File

@@ -1,6 +1,6 @@
"""Casper Glow session fixtures."""
from collections.abc import Generator
from collections.abc import Callable, Generator
from unittest.mock import MagicMock, patch
from pycasperglow import GlowState
@@ -51,6 +51,21 @@ def mock_casper_glow() -> Generator[MagicMock]:
yield mock_device
@pytest.fixture
def fire_callbacks(
mock_casper_glow: MagicMock,
) -> Callable[[GlowState], None]:
"""Return a helper that fires all registered device callbacks with a given state."""
def _fire(state: GlowState) -> None:
for cb in (
call[0][0] for call in mock_casper_glow.register_callback.call_args_list
):
cb(state)
return _fire
@pytest.fixture
async def config_entry(
hass: HomeAssistant,

View File

@@ -0,0 +1,67 @@
# serializer version: 1
# name: test_entities[select.jar_dimming_time-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'15',
'30',
'45',
'60',
'90',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.jar_dimming_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Dimming time',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Dimming time',
'platform': 'casper_glow',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dimming_time',
'unique_id': 'aa:bb:cc:dd:ee:ff_dimming_time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_entities[select.jar_dimming_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Jar Dimming time',
'options': list([
'15',
'30',
'45',
'60',
'90',
]),
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'select.jar_dimming_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -1,5 +1,6 @@
"""Test the Casper Glow light platform."""
from collections.abc import Callable
from unittest.mock import MagicMock, patch
from pycasperglow import CasperGlowError, GlowState
@@ -82,21 +83,19 @@ async def test_turn_off(
async def test_state_update_via_callback(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
fire_callbacks: Callable[[GlowState], None],
) -> None:
"""Test that the entity updates state when the device fires a callback."""
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
callback = mock_casper_glow.register_callback.call_args[0][0]
callback(GlowState(is_on=True))
fire_callbacks(GlowState(is_on=True))
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_ON
callback(GlowState(is_on=False))
fire_callbacks(GlowState(is_on=False))
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
@@ -115,25 +114,6 @@ async def test_color_mode(
assert ColorMode.BRIGHTNESS in state.attributes["supported_color_modes"]
async def test_turn_on_with_brightness(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
) -> None:
"""Test turning on the light with brightness."""
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 255},
blocking=True,
)
mock_casper_glow.turn_on.assert_called_once_with()
mock_casper_glow.set_brightness_and_dimming_time.assert_called_once_with(
100, DEFAULT_DIMMING_TIME_MINUTES
)
@pytest.mark.parametrize(
("ha_brightness", "device_pct"),
[
@@ -169,11 +149,10 @@ async def test_brightness_snap_to_nearest(
async def test_brightness_update_via_callback(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
fire_callbacks: Callable[[GlowState], None],
) -> None:
"""Test that brightness updates via device callback."""
callback = mock_casper_glow.register_callback.call_args[0][0]
callback(GlowState(is_on=True, brightness_level=80))
fire_callbacks(GlowState(is_on=True, brightness_level=80))
state = hass.states.get(ENTITY_ID)
assert state is not None
@@ -181,18 +160,29 @@ async def test_brightness_update_via_callback(
assert state.attributes.get(ATTR_BRIGHTNESS) == 153
async def test_turn_on_error(
@pytest.mark.usefixtures("config_entry")
@pytest.mark.parametrize(
("service", "mock_method"),
[
(SERVICE_TURN_ON, "turn_on"),
(SERVICE_TURN_OFF, "turn_off"),
],
)
async def test_command_error(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
service: str,
mock_method: str,
) -> None:
"""Test that a turn on error raises HomeAssistantError without marking entity unavailable."""
mock_casper_glow.turn_on.side_effect = CasperGlowError("Connection failed")
"""Test that a device error raises HomeAssistantError without marking entity unavailable."""
getattr(mock_casper_glow, mock_method).side_effect = CasperGlowError(
"Connection failed"
)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
service,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
@@ -202,27 +192,11 @@ async def test_turn_on_error(
assert state.state == STATE_UNKNOWN
async def test_turn_off_error(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
) -> None:
"""Test that a turn off error raises HomeAssistantError."""
mock_casper_glow.turn_off.side_effect = CasperGlowError("Connection failed")
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
async def test_state_update_via_callback_after_command_failure(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
fire_callbacks: Callable[[GlowState], None],
) -> None:
"""Test that device callbacks correctly update state even after a command failure."""
mock_casper_glow.turn_on.side_effect = CasperGlowError("Connection failed")
@@ -241,8 +215,7 @@ async def test_state_update_via_callback_after_command_failure(
assert state.state == STATE_UNKNOWN
# Device sends a push state update — entity reflects true device state
callback = mock_casper_glow.register_callback.call_args[0][0]
callback(GlowState(is_on=True))
fire_callbacks(GlowState(is_on=True))
state = hass.states.get(ENTITY_ID)
assert state is not None

View File

@@ -0,0 +1,199 @@
"""Test the Casper Glow select platform."""
from collections.abc import Callable
from unittest.mock import MagicMock, patch
from pycasperglow import CasperGlowError, GlowState
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.casper_glow.const import (
DIMMING_TIME_OPTIONS,
SORTED_BRIGHTNESS_LEVELS,
)
from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform
ENTITY_ID = "select.jar_dimming_time"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test all select entities match the snapshot."""
with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SELECT]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_select_state_from_callback(
hass: HomeAssistant,
config_entry: MockConfigEntry,
fire_callbacks: Callable[[GlowState], None],
) -> None:
"""Test that the select entity shows dimming time reported by device callback."""
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
fire_callbacks(
GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[2]))
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == DIMMING_TIME_OPTIONS[2]
async def test_select_option(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
fire_callbacks: Callable[[GlowState], None],
) -> None:
"""Test selecting a dimming time option."""
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: ENTITY_ID, "option": DIMMING_TIME_OPTIONS[1]},
blocking=True,
)
mock_casper_glow.set_brightness_and_dimming_time.assert_called_once_with(
SORTED_BRIGHTNESS_LEVELS[0], int(DIMMING_TIME_OPTIONS[1])
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == DIMMING_TIME_OPTIONS[1]
# A subsequent device callback must not overwrite the user's selection.
fire_callbacks(
GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[0]))
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == DIMMING_TIME_OPTIONS[1]
async def test_select_option_error(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
) -> None:
"""Test that a set_brightness_and_dimming_time error raises HomeAssistantError."""
mock_casper_glow.set_brightness_and_dimming_time.side_effect = CasperGlowError(
"Connection failed"
)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: ENTITY_ID, "option": DIMMING_TIME_OPTIONS[1]},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
async def test_select_state_update_via_callback_after_command_failure(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
fire_callbacks: Callable[[GlowState], None],
) -> None:
"""Test that device callbacks correctly update state even after a command failure."""
mock_casper_glow.set_brightness_and_dimming_time.side_effect = CasperGlowError(
"Connection failed"
)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: ENTITY_ID, "option": DIMMING_TIME_OPTIONS[1]},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
# Device sends a push state update — entity reflects true state
fire_callbacks(
GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[1]))
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == DIMMING_TIME_OPTIONS[1]
async def test_select_ignores_remaining_time_updates(
hass: HomeAssistant,
config_entry: MockConfigEntry,
fire_callbacks: Callable[[GlowState], None],
) -> None:
"""Test that callbacks with only remaining time do not change the select state."""
fire_callbacks(GlowState(dimming_time_minutes=44))
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
async def test_restore_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
) -> None:
"""Test that the dimming time is restored from the last known state on restart."""
mock_restore_cache(hass, (State(ENTITY_ID, DIMMING_TIME_OPTIONS[3]),))
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == DIMMING_TIME_OPTIONS[3]
# Coordinator should be seeded with the restored value.
assert mock_config_entry.runtime_data.last_dimming_time_minutes == int(
DIMMING_TIME_OPTIONS[3]
)
@pytest.mark.parametrize(
"restored_state",
[STATE_UNKNOWN, "invalid", "999"],
)
async def test_restore_state_ignores_invalid(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_casper_glow: MagicMock,
restored_state: str,
) -> None:
"""Test that invalid or unsupported restored states are ignored."""
mock_restore_cache(hass, (State(ENTITY_ID, restored_state),))
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
assert mock_config_entry.runtime_data.last_dimming_time_minutes is None

View File

@@ -126,43 +126,6 @@ async def test_climate_data_update(
assert state.attributes[ATTR_TEMPERATURE] == temp
async def test_climate_data_update_bad_data(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_serial_bridge: AsyncMock,
mock_serial_bridge_config_entry: MockConfigEntry,
) -> None:
"""Test climate data update."""
await setup_integration(hass, mock_serial_bridge_config_entry)
assert (state := hass.states.get(ENTITY_ID))
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_TEMPERATURE] == 5.0
mock_serial_bridge.get_all_devices.return_value[CLIMATE] = {
0: ComelitSerialBridgeObject(
index=0,
name="Climate0",
status=0,
human_status="off",
type="climate",
val="bad_data", # type: ignore[arg-type]
protected=0,
zone="Living room",
power=0.0,
power_unit=WATT,
),
}
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_TEMPERATURE] == 5.0
async def test_climate_set_temperature(
hass: HomeAssistant,
mock_serial_bridge: AsyncMock,

View File

@@ -126,43 +126,6 @@ async def test_humidifier_data_update(
assert state.attributes[ATTR_HUMIDITY] == humidity
async def test_humidifier_data_update_bad_data(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_serial_bridge: AsyncMock,
mock_serial_bridge_config_entry: MockConfigEntry,
) -> None:
"""Test humidifier data update."""
await setup_integration(hass, mock_serial_bridge_config_entry)
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_ON
assert state.attributes[ATTR_HUMIDITY] == 50.0
mock_serial_bridge.get_all_devices.return_value[CLIMATE] = {
0: ComelitSerialBridgeObject(
index=0,
name="Climate0",
status=0,
human_status="off",
type="climate",
val="bad_data", # type: ignore[arg-type]
protected=0,
zone="Living room",
power=0.0,
power_unit=WATT,
),
}
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_ON
assert state.attributes[ATTR_HUMIDITY] == 50.0
async def test_humidifier_set_humidity(
hass: HomeAssistant,
mock_serial_bridge: AsyncMock,

View File

@@ -40,6 +40,15 @@ def mock_polling_interval_fixture() -> Generator[int]:
yield polling_interval
@pytest.fixture(autouse=True)
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.energyid.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup
async def test_config_flow_user_step_success_claimed(hass: HomeAssistant) -> None:
"""Test user step where device is already claimed."""
mock_client = MagicMock()
@@ -105,7 +114,6 @@ async def test_config_flow_auth_and_claim_step_success(hass: HomeAssistant) -> N
"homeassistant.components.energyid.config_flow.WebhookClient",
side_effect=mock_webhook_client,
),
patch("homeassistant.components.energyid.config_flow.asyncio.sleep"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -146,13 +154,13 @@ async def test_config_flow_claim_timeout(hass: HomeAssistant) -> None:
"homeassistant.components.energyid.config_flow.WebhookClient",
return_value=mock_unclaimed_client,
),
patch(
"homeassistant.components.energyid.config_flow.asyncio.sleep",
) as mock_sleep,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert mock_unclaimed_client.authenticate.call_count == 0
result_external = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
@@ -160,8 +168,17 @@ async def test_config_flow_claim_timeout(hass: HomeAssistant) -> None:
CONF_PROVISIONING_SECRET: TEST_PROVISIONING_SECRET,
},
)
assert result_external["type"] is FlowResultType.EXTERNAL_STEP
# Wait for the polling to time out.
await hass.async_block_till_done()
# Verify polling actually ran the expected number of times
# +1 for the initial attempt before polling starts
assert mock_unclaimed_client.authenticate.call_count == MAX_POLLING_ATTEMPTS + 1
mock_unclaimed_client.authenticate.reset_mock()
# Simulate polling timeout, then user continuing the flow
result_after_timeout = await hass.config_entries.flow.async_configure(
result_external["flow_id"]
@@ -169,14 +186,10 @@ async def test_config_flow_claim_timeout(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
# After timeout, polling stops and user continues - should see external step again
assert mock_unclaimed_client.authenticate.call_count == 1
assert result_after_timeout["type"] is FlowResultType.EXTERNAL_STEP
assert result_after_timeout["step_id"] == "auth_and_claim"
# Verify polling actually ran the expected number of times
# Sleep happens at beginning of polling loop, so MAX_POLLING_ATTEMPTS + 1 sleeps
# but only MAX_POLLING_ATTEMPTS authentication attempts
assert mock_sleep.call_count == MAX_POLLING_ATTEMPTS + 1
async def test_duplicate_unique_id_prevented(hass: HomeAssistant) -> None:
"""Test that duplicate device_id (unique_id) is detected and aborted."""
@@ -546,7 +559,6 @@ async def test_config_flow_reauth_needs_claim(hass: HomeAssistant) -> None:
"homeassistant.components.energyid.config_flow.WebhookClient",
return_value=mock_client,
),
patch("homeassistant.components.energyid.config_flow.asyncio.sleep"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -607,7 +619,6 @@ async def test_polling_stops_on_invalid_auth_error(hass: HomeAssistant) -> None:
"homeassistant.components.energyid.config_flow.WebhookClient",
side_effect=mock_webhook_client,
),
patch("homeassistant.components.energyid.config_flow.asyncio.sleep"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -655,7 +666,6 @@ async def test_polling_stops_on_cannot_connect_error(hass: HomeAssistant) -> Non
"homeassistant.components.energyid.config_flow.WebhookClient",
side_effect=mock_webhook_client,
),
patch("homeassistant.components.energyid.config_flow.asyncio.sleep"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -707,7 +717,6 @@ async def test_auth_and_claim_subsequent_auth_error(hass: HomeAssistant) -> None
"homeassistant.components.energyid.config_flow.WebhookClient",
side_effect=mock_webhook_client,
),
patch("homeassistant.components.energyid.config_flow.asyncio.sleep"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -815,7 +824,6 @@ async def test_polling_cancellation_on_auth_failure(hass: HomeAssistant) -> None
"homeassistant.components.energyid.config_flow.WebhookClient",
side_effect=mock_webhook_client,
),
patch("homeassistant.components.energyid.config_flow.asyncio.sleep"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -888,7 +896,6 @@ async def test_polling_cancellation_on_success(hass: HomeAssistant) -> None:
"homeassistant.components.energyid.config_flow.WebhookClient",
side_effect=mock_webhook_client,
),
patch("homeassistant.components.energyid.config_flow.asyncio.sleep"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -911,25 +918,16 @@ async def test_polling_cancellation_on_success(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
# Verify polling made authentication attempt
# auth_call_count should be 1 (polling detected device is claimed)
assert auth_call_count >= 1
claimed_auth_count = auth_call_count
assert auth_call_count == 2 # One for polling, one for the final check
# User continues - device is already claimed, polling should be cancelled
result_done = await hass.config_entries.flow.async_configure(
result_external["flow_id"]
)
assert result_done["type"] is FlowResultType.EXTERNAL_STEP_DONE
assert result_done["type"] is FlowResultType.CREATE_ENTRY
# Verify polling was cancelled - the auth count should only increase by 1
# (for the manual check when user continues, not from polling)
assert auth_call_count == claimed_auth_count + 1
# Final call to create entry
final_result = await hass.config_entries.flow.async_configure(
result_external["flow_id"]
)
assert final_result["type"] is FlowResultType.CREATE_ENTRY
# Verify polling was cancelled - the auth count should not increase
assert auth_call_count == 2
# Wait a bit and verify no further authentication attempts from polling
await hass.async_block_till_done()

View File

@@ -8,6 +8,9 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.components.gentex_homelink.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
import homeassistant.helpers.device_registry as dr
from . import setup_integration, update_callback
@@ -72,3 +75,21 @@ async def test_load_unload_entry(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("aioclient_mock_fixture")
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -0,0 +1,28 @@
"""Tests for the Geocaching integration."""
from unittest.mock import patch
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from tests.common import MockConfigEntry
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.geocaching.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -16,6 +16,9 @@ from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUA
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from homeassistant.setup import async_setup_component
from .conftest import ComponentSetup, ExpectedCredentials
@@ -492,3 +495,20 @@ async def test_conversation_agent_language_changed(
mock_text_assistant.assert_has_calls([call(ExpectedCredentials(), "es-ES")])
mock_text_assistant.assert_has_calls([call().assist(text1)])
mock_text_assistant.assert_has_calls([call().assist(text2)])
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.google_assistant_sdk.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -11,9 +11,13 @@ from homeassistant.components.google_mail import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from .conftest import GOOGLE_TOKEN_URI, ComponentSetup
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -134,3 +138,20 @@ async def test_device_info(
assert device.identifiers == {(DOMAIN, entry.entry_id)}
assert device.manufacturer == "Google, Inc."
assert device.name == "example@gmail.com"
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.google_mail.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -35,6 +35,9 @@ from homeassistant.exceptions import (
OAuth2TokenRequestTransientError,
ServiceValidationError,
)
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@@ -558,3 +561,20 @@ async def test_get_sheet_invalid_worksheet(
blocking=True,
return_response=True,
)
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.google_sheets.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -5,7 +5,7 @@ import http
from http import HTTPStatus
import json
import time
from unittest.mock import Mock
from unittest.mock import Mock, patch
from aiohttp import ClientError
from httplib2 import Response
@@ -15,6 +15,9 @@ from homeassistant.components.google_tasks import DOMAIN
from homeassistant.components.google_tasks.const import OAUTH2_TOKEN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from .conftest import LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_WATER
@@ -152,3 +155,20 @@ async def test_setup_error(
assert not await integration_setup()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.google_tasks.config_entry_oauth2_flow.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -0,0 +1,63 @@
# serializer version: 1
# name: test_setup[event.my_desktop-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'event_types': list([
'clicked',
'received',
'closed',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.my_desktop',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'html5',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'event',
'unique_id': 'ABCDEFGHIJKLMNOPQRSTUVWXYZ_my-desktop_event',
'unit_of_measurement': None,
})
# ---
# name: test_setup[event.my_desktop-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'event_type': None,
'event_types': list([
'clicked',
'received',
'closed',
]),
'friendly_name': 'my-desktop',
}),
'context': <ANY>,
'entity_id': 'event.my_desktop',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -0,0 +1,120 @@
"""Tests for the HTML5 event platform."""
from collections.abc import Generator
from http import HTTPStatus
from typing import Any
from unittest.mock import MagicMock, patch
from aiohttp.hdrs import AUTHORIZATION
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.html5.notify import ATTR_ACTION, ATTR_TAG, ATTR_TYPE
from homeassistant.components.notify import ATTR_DATA, ATTR_TARGET
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from .test_notify import SUBSCRIPTION_1
from tests.common import MockConfigEntry, snapshot_platform
from tests.typing import ClientSessionGenerator
@pytest.fixture
def event_only() -> Generator[None]:
"""Enable only the event platform."""
with patch(
"homeassistant.components.html5.PLATFORMS",
[Platform.EVENT],
):
yield
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid", "event_only")
@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z")
async def test_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
load_config: MagicMock,
) -> None:
"""Snapshot test states of event platform."""
load_config.return_value = {"my-desktop": SUBSCRIPTION_1}
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize(
("event_payload"),
[
{
ATTR_TARGET: "my-desktop",
ATTR_TYPE: "clicked",
ATTR_ACTION: "open_app",
ATTR_TAG: "1234",
ATTR_DATA: {"customKey": "Value"},
},
{
ATTR_TARGET: "my-desktop",
ATTR_TYPE: "received",
ATTR_TAG: "1234",
ATTR_DATA: {"customKey": "Value"},
},
{
ATTR_TARGET: "my-desktop",
ATTR_TYPE: "closed",
ATTR_TAG: "1234",
ATTR_DATA: {"customKey": "Value"},
},
],
)
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z")
async def test_events(
hass: HomeAssistant,
config_entry: MockConfigEntry,
load_config: MagicMock,
event_payload: dict[str, Any],
hass_client: ClientSessionGenerator,
mock_jwt: MagicMock,
) -> None:
"""Test events."""
load_config.return_value = {"my-desktop": SUBSCRIPTION_1}
await async_setup_component(hass, "http", {})
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert (state := hass.states.get("event.my_desktop")) is not None
assert state.state == STATE_UNKNOWN
client = await hass_client()
mock_jwt.decode.return_value = {ATTR_TARGET: event_payload[ATTR_TARGET]}
resp = await client.post(
"/api/notify.html5/callback",
json=event_payload,
headers={AUTHORIZATION: "Bearer JWT"},
)
assert resp.status == HTTPStatus.OK
assert (state := hass.states.get("event.my_desktop"))
assert state.state == "1970-01-01T00:00:00.000+00:00"
assert state.attributes.get("action") == event_payload.get(ATTR_ACTION)
assert state.attributes.get("tag") == event_payload[ATTR_TAG]
assert state.attributes.get("customKey") == event_payload[ATTR_DATA]["customKey"]

View File

@@ -1,5 +1,6 @@
"""Test HTML5 notify platform."""
from collections.abc import Generator
from http import HTTPStatus
import json
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
@@ -18,7 +19,12 @@ from homeassistant.components.notify import (
SERVICE_SEND_MESSAGE,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@@ -89,6 +95,16 @@ VAPID_HEADERS = {
}
@pytest.fixture(autouse=True)
def notify_only() -> Generator[None]:
"""Enable only the notify platform."""
with patch(
"homeassistant.components.html5.PLATFORMS",
[Platform.NOTIFY],
):
yield
async def test_get_service_with_no_json(hass: HomeAssistant) -> None:
"""Test empty json file."""
await async_setup_component(hass, "http", {})

View File

@@ -24,6 +24,9 @@ from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERV
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from homeassistant.util import dt as dt_util
from . import setup_integration
@@ -722,3 +725,20 @@ async def test_websocket_watchdog(
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_automower_client.get_status.call_count == 2
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -0,0 +1,32 @@
# serializer version: 1
# name: test_device_entry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'huum',
'AABBCC112233',
),
}),
'labels': set({
}),
'manufacturer': 'Huum',
'model': 'UKU WiFi',
'model_id': None,
'name': 'Huum sauna',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---

View File

@@ -5,6 +5,8 @@ from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
from huum.const import SaunaStatus
from huum.exceptions import SafetyException
from huum.schemas import SaunaConfig
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -21,6 +23,7 @@ from homeassistant.components.huum.const import (
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -85,6 +88,62 @@ async def test_set_temperature(
mock_huum_client.turn_on.assert_awaited_once_with(60)
@pytest.mark.usefixtures("init_integration")
async def test_set_hvac_mode_off(
hass: HomeAssistant,
mock_huum_client: AsyncMock,
) -> None:
"""Test setting HVAC mode to off."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF},
blocking=True,
)
mock_huum_client.turn_off.assert_awaited_once()
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_not_heating(
hass: HomeAssistant,
mock_huum_client: AsyncMock,
) -> None:
"""Test setting temperature is ignored when not heating."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_TEMPERATURE: 60,
},
blocking=True,
)
mock_huum_client.turn_on.assert_not_called()
@pytest.mark.usefixtures("init_integration")
async def test_turn_on_safety_exception(
hass: HomeAssistant,
mock_huum_client: AsyncMock,
) -> None:
"""Test that SafetyException is raised as HomeAssistantError."""
mock_huum_client.turn_on.side_effect = SafetyException("Door is open")
mock_huum_client.status.return_value.status = SaunaStatus.ONLINE_HEATING
with pytest.raises(HomeAssistantError, match="Unable to turn on sauna"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_TEMPERATURE: 60,
},
blocking=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_temperature_range(
hass: HomeAssistant,
@@ -109,9 +168,27 @@ async def test_temperature_range(
assert state.attributes["min_temp"] == CONFIG_DEFAULT_MIN_TEMP
assert state.attributes["max_temp"] == CONFIG_DEFAULT_MAX_TEMP
# No sauna config should return default values.
mock_huum_client.status.return_value.sauna_config = None
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(ENTITY_ID)
assert state.attributes["min_temp"] == CONFIG_DEFAULT_MIN_TEMP
assert state.attributes["max_temp"] == CONFIG_DEFAULT_MAX_TEMP
# Custom configured API response.
mock_huum_client.status.return_value.sauna_config.min_temp = 50
mock_huum_client.status.return_value.sauna_config.max_temp = 80
mock_huum_client.status.return_value.sauna_config = SaunaConfig(
child_lock="OFF",
max_heating_time=3,
min_heating_time=0,
max_temp=80,
min_temp=50,
max_timer=0,
min_timer=0,
)
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)

View File

@@ -1,16 +1,19 @@
"""Tests for the Huum __init__."""
from datetime import timedelta
from unittest.mock import AsyncMock
from huum.exceptions import Forbidden, NotAuthenticated
from freezegun.api import FrozenDateTimeFactory
from huum.exceptions import Forbidden, NotAuthenticated, RequestError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant import config_entries
from homeassistant.components.huum.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.usefixtures("init_integration")
@@ -27,21 +30,88 @@ async def test_loading_and_unloading_config_entry(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize("side_effect", [Forbidden, NotAuthenticated])
async def test_auth_error_triggers_reauth(
@pytest.mark.parametrize(
"exception",
[
Forbidden("Forbidden"),
NotAuthenticated("Not authenticated"),
],
)
async def test_setup_entry_auth_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
side_effect: type[Exception],
mock_config_entry: MockConfigEntry,
exception: Exception,
) -> None:
"""Test that an auth error during coordinator refresh triggers reauth."""
"""Test setup triggers reauth on auth errors."""
mock_config_entry.add_to_hass(hass)
mock_huum_client.status.side_effect = side_effect
mock_huum_client.status.side_effect = exception
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert any(
mock_config_entry.async_get_active_flows(hass, {config_entries.SOURCE_REAUTH})
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 1
assert flows[0]["context"]["source"] == SOURCE_REAUTH
async def test_setup_entry_connection_error(
hass: HomeAssistant,
mock_huum_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup retries on connection error."""
mock_config_entry.add_to_hass(hass)
mock_huum_client.status.side_effect = RequestError("Request error")
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert hass.config_entries.flow.async_progress_by_handler(DOMAIN) == []
@pytest.mark.usefixtures("init_integration")
async def test_device_entry(
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test device registry entry."""
assert (
device_entry := device_registry.async_get_device(
identifiers={(DOMAIN, mock_config_entry.entry_id)}
)
)
assert device_entry == snapshot
@pytest.mark.parametrize(
"side_effect",
[
Forbidden("Forbidden"),
NotAuthenticated("Not authenticated"),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_coordinator_update_auth_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
freezer: FrozenDateTimeFactory,
side_effect: Exception,
) -> None:
"""Test that an auth error during coordinator refresh triggers reauth."""
mock_huum_client.status.side_effect = side_effect
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_config_entry.state is ConfigEntryState.LOADED
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 1
assert flows[0]["context"]["source"] == SOURCE_REAUTH

View File

@@ -1,11 +1,14 @@
"""Tests for the iotty integration."""
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from homeassistant.components.iotty.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from tests.common import MockConfigEntry
@@ -41,6 +44,23 @@ async def test_load_unload_coordinator_called(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.iotty.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_load_unload_iottyproxy_called(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,

View File

@@ -0,0 +1,40 @@
"""Tests for the Honeywell Lyric integration."""
from unittest.mock import patch
from homeassistant.components.lyric.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from tests.common import MockConfigEntry
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_at": 9999999999,
"token_type": "Bearer",
},
},
)
entry.add_to_hass(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY

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