Compare commits

..

2 Commits

Author SHA1 Message Date
Abílio Costa
80285fd3e6 Update homeassistant/components/uptimerobot/utils.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-27 15:41:11 +01:00
abmantis
ab4086be18 Move common Uptime Robot new device check logic to helper 2025-09-27 15:36:21 +01:00
70 changed files with 416 additions and 1056 deletions

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -15,7 +14,7 @@ from airos.exceptions import (
)
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -25,11 +24,6 @@ from homeassistant.const import (
)
from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
from .coordinator import AirOS8
@@ -60,107 +54,50 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.airos_device: AirOS8
self.errors: dict[str, str] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the manual input of host and credentials."""
self.errors = {}
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
validated_info = await self._validate_and_get_device_info(user_input)
if validated_info:
return self.async_create_entry(
title=validated_info["title"],
data=validated_info["data"],
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors
)
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
self.hass,
verify_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
)
async def _validate_and_get_device_info(
self, config_data: dict[str, Any]
) -> dict[str, Any] | None:
"""Validate user input with the device API."""
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
self.hass,
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
)
airos_device = AirOS8(
host=user_input[CONF_HOST],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=session,
use_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
try:
await airos_device.login()
airos_data = await airos_device.status()
airos_device = AirOS8(
host=config_data[CONF_HOST],
username=config_data[CONF_USERNAME],
password=config_data[CONF_PASSWORD],
session=session,
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
try:
await airos_device.login()
airos_data = await airos_device.status()
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
):
self.errors["base"] = "cannot_connect"
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
self.errors["base"] = "invalid_auth"
except AirOSKeyDataMissingError:
self.errors["base"] = "key_data_missing"
except Exception:
_LOGGER.exception("Unexpected exception during credential validation")
self.errors["base"] = "unknown"
else:
await self.async_set_unique_id(airos_data.derived.mac)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
):
errors["base"] = "cannot_connect"
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
errors["base"] = "invalid_auth"
except AirOSKeyDataMissingError:
errors["base"] = "key_data_missing"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(airos_data.derived.mac)
self._abort_if_unique_id_configured()
return {"title": airos_data.host.hostname, "data": config_data}
return None
async def async_step_reauth(
self,
user_input: Mapping[str, Any],
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm(user_input)
async def async_step_reauth_confirm(
self,
user_input: Mapping[str, Any],
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
self.errors = {}
if user_input:
validate_data = {**self._get_reauth_entry().data, **user_input}
if await self._validate_and_get_device_info(config_data=validate_data):
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=validate_data,
return self.async_create_entry(
title=airos_data.host.hostname, data=user_input
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
),
errors=self.errors,
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -14,7 +14,7 @@ from airos.exceptions import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
@@ -47,9 +47,9 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
try:
await self.airos_device.login()
return await self.airos_device.status()
except AirOSConnectionAuthenticationError as err:
except (AirOSConnectionAuthenticationError,) as err:
_LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryAuthFailed(
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from err
except (

View File

@@ -2,14 +2,6 @@
"config": {
"flow_title": "Ubiquiti airOS device",
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -42,9 +34,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "Re-authentication should be used for the same device not a new one"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {

View File

@@ -32,7 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_SOURCE: source_entity_id},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_handle_source_entity_changes(
@@ -47,9 +46,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))

View File

@@ -140,7 +140,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
VERSION = 1
MINOR_VERSION = 4

View File

@@ -69,9 +69,7 @@ class EcovacsMap(
await super().async_added_to_hass()
async def on_info(event: CachedMapInfoEvent) -> None:
for map_obj in event.maps:
if map_obj.using:
self._attr_extra_state_attributes["map_name"] = map_obj.name
self._attr_extra_state_attributes["map_name"] = event.name
async def on_changed(event: MapChangedEvent) -> None:
self._attr_image_last_updated = event.when

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==15.0.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==14.0.0"]
}

View File

@@ -3,15 +3,14 @@
from __future__ import annotations
from datetime import timedelta
from enum import IntEnum
import logging
from typing import Any
from pyephember2.pyephember2 import (
EphEmber,
ZoneMode,
boiler_state,
zone_current_temperature,
zone_is_active,
zone_is_hotwater,
zone_mode,
zone_name,
@@ -54,15 +53,6 @@ EPH_TO_HA_STATE = {
"OFF": HVACMode.OFF,
}
class EPHBoilerStates(IntEnum):
"""Boiler states for a zone given by the api."""
FIXME = 0
OFF = 1
ON = 2
HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()}
@@ -133,7 +123,7 @@ class EphEmberThermostat(ClimateEntity):
@property
def hvac_action(self) -> HVACAction:
"""Return current HVAC action."""
if boiler_state(self._zone) == EPHBoilerStates.ON:
if zone_is_active(self._zone):
return HVACAction.HEATING
return HVACAction.IDLE

View File

@@ -10,6 +10,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Filter from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -17,3 +18,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Filter config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -246,7 +246,6 @@ class FilterConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -108,7 +108,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_HUMIDIFIER: source_entity_id},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the humidifer,
@@ -141,7 +140,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_SENSOR: data["entity_id"]},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_track_entity_registry_updated_event(
@@ -150,6 +148,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, (Platform.HUMIDIFIER,))
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
@@ -187,6 +186,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(

View File

@@ -96,7 +96,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -35,7 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_HEATER: source_entity_id},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the heater, but
@@ -68,7 +67,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_SENSOR: data["entity_id"]},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_track_entity_registry_updated_event(
@@ -77,6 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
@@ -114,6 +113,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -104,7 +104,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -141,9 +141,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["group_type"],)
)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(

View File

@@ -329,7 +329,6 @@ class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
@callback
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:

View File

@@ -65,7 +65,6 @@ async def async_setup_entry(
entry,
options={**entry.options, CONF_ENTITY_ID: source_entity_id},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
async def source_entity_removed() -> None:
# The source entity has been removed, we remove the config entry because
@@ -87,6 +86,7 @@ async def async_setup_entry(
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -130,3 +130,8 @@ async def async_unload_entry(
) -> bool:
"""Unload History stats config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -162,7 +162,6 @@ class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -22,6 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -29,3 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Local file config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -65,7 +65,6 @@ class LocalFileConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -39,7 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_INDOOR_HUMIDITY: source_entity_id},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the humidity
@@ -80,7 +79,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, temp_sensor: data["entity_id"]},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
return async_sensor_updated
@@ -91,6 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -100,6 +99,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""

View File

@@ -100,7 +100,6 @@ class MoldIndicatorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
VERSION = 1
MINOR_VERSION = 2

View File

@@ -51,10 +51,7 @@ from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
STATE_CLASS_UNITS,
SensorDeviceClass,
)
from homeassistant.components.sensor.helpers import (
create_sensor_device_class_select_selector,
create_sensor_state_class_select_selector,
SensorStateClass,
)
from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.config_entries import (
@@ -706,6 +703,14 @@ SCALE_SELECTOR = NumberSelector(
step=1,
)
)
SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class_sensor",
sort=True,
)
)
SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[EntityCategory.DIAGNOSTIC.value],
@@ -714,6 +719,13 @@ SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector(
sort=True,
)
)
SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_STATE_CLASS,
)
)
SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[platform.value for platform in VALID_COLOR_MODES],
@@ -1272,12 +1284,10 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
Platform.NOTIFY.value: {},
Platform.SENSOR.value: {
CONF_DEVICE_CLASS: PlatformField(
selector=create_sensor_device_class_select_selector(),
required=False,
selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False
),
CONF_STATE_CLASS: PlatformField(
selector=create_sensor_state_class_select_selector(),
required=False,
selector=SENSOR_STATE_CLASS_SELECTOR, required=False
),
CONF_UNIT_OF_MEASUREMENT: PlatformField(
selector=unit_of_measurement_selector,

View File

@@ -1200,6 +1200,67 @@
"window": "[%key:component::cover::entity_component::window::name%]"
}
},
"device_class_sensor": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"date": "[%key:component::sensor::entity_component::date::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"enum": "Enumeration",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
"device_class_switch": {
"options": {
"outlet": "[%key:component::switch::entity_component::outlet::name%]",
@@ -1261,6 +1322,14 @@
"custom": "Custom"
}
},
"state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]",
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
},
"supported_color_modes": {
"options": {
"onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]",

View File

@@ -52,10 +52,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo
try:
info = await async_interview(host)
except TimeoutError as exc:
raise ConfigEntryNotReady(f"Timed out interviewing: {host}") from exc
except OSError as exc:
raise ConfigEntryNotReady(f"Unexpected exception interviewing: {host}") from exc
raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc
if info is None:
raise ConfigEntryNotReady(f"Unable to connect to: {host}")
manager = ReceiverManager(hass, entry, info)

View File

@@ -109,22 +109,24 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Config flow manual: %s", host)
try:
info = await async_interview(host)
except TimeoutError:
_LOGGER.warning("Timed out interviewing: %s", host)
errors["base"] = "cannot_connect"
except OSError:
_LOGGER.exception("Unexpected exception interviewing: %s", host)
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._receiver_info = info
await self.async_set_unique_id(info.identifier, raise_on_progress=False)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
if info is None:
errors["base"] = "cannot_connect"
else:
self._abort_if_unique_id_configured()
self._receiver_info = info
return await self.async_step_configure_receiver()
await self.async_set_unique_id(
info.identifier, raise_on_progress=False
)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured()
return await self.async_step_configure_receiver()
suggested_values = user_input
if suggested_values is None and self.source == SOURCE_RECONFIGURE:
@@ -212,13 +214,14 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
try:
info = await async_interview(host)
except TimeoutError:
_LOGGER.warning("Timed out interviewing: %s", host)
return self.async_abort(reason="cannot_connect")
except OSError:
_LOGGER.exception("Unexpected exception interviewing: %s", host)
_LOGGER.exception("Unexpected exception interviewing host %s", host)
return self.async_abort(reason="unknown")
if info is None:
_LOGGER.debug("SSDP eiscp is None: %s", host)
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(info.identifier)
self._abort_if_unique_id_configured(updates={CONF_HOST: info.host})

View File

@@ -124,10 +124,13 @@ class ReceiverManager:
self.callbacks.clear()
async def async_interview(host: str) -> ReceiverInfo:
async def async_interview(host: str) -> ReceiverInfo | None:
"""Interview the receiver."""
async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT):
return await aioonkyo.interview(host)
info: ReceiverInfo | None = None
with contextlib.suppress(asyncio.TimeoutError):
async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT):
info = await aioonkyo.interview(host)
return info
async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]:

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -88,48 +87,6 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth when Portainer API authentication fails."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth: ask for new API token and validate."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
try:
await _validate_input(
self.hass,
data={
**reauth_entry.data,
CONF_API_TOKEN: user_input[CONF_API_TOKEN],
},
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except PortainerTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
errors=errors,
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -18,7 +18,7 @@ from pyportainer.models.portainer import Endpoint
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -66,7 +66,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
try:
await self.portainer.get_endpoints()
except PortainerAuthenticationError as err:
raise ConfigEntryAuthFailed(
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
@@ -94,7 +94,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
endpoints = await self.portainer.get_endpoints()
except PortainerAuthenticationError as err:
_LOGGER.error("Authentication error: %s", repr(err))
raise ConfigEntryAuthFailed(
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
@@ -121,7 +121,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
) from err
except PortainerAuthenticationError as err:
_LOGGER.exception("Authentication error")
raise ConfigEntryAuthFailed(
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},

View File

@@ -13,15 +13,6 @@
"verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate"
},
"description": "You can create an access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**"
},
"reauth_confirm": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"data_description": {
"api_token": "The new API access token for authenticating with Portainer"
},
"description": "The access token for your Portainer instance needs to be re-authenticated. You can create a new access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**"
}
},
"error": {
@@ -31,8 +22,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"device": {

View File

@@ -9,9 +9,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["entity_type"],)
)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(

View File

@@ -184,7 +184,6 @@ class RandomConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
@callback
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:

View File

@@ -6,14 +6,9 @@ from datetime import date, datetime
import logging
from homeassistant.core import callback
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.util import dt as dt_util
from . import DOMAIN, SensorDeviceClass, SensorStateClass
from . import SensorDeviceClass
_LOGGER = logging.getLogger(__name__)
@@ -42,31 +37,3 @@ def async_parse_date_datetime(
_LOGGER.warning("%s rendered invalid date %s", entity_id, value)
return None
@callback
def create_sensor_device_class_select_selector() -> SelectSelector:
"""Create sensor device class select selector."""
return SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
translation_domain=DOMAIN,
sort=True,
)
)
@callback
def create_sensor_state_class_select_selector() -> SelectSelector:
"""Create sensor state class select selector."""
return SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="state_class",
translation_domain=DOMAIN,
sort=True,
)
)

View File

@@ -334,76 +334,5 @@
"title": "The unit of {statistic_id} has changed",
"description": ""
}
},
"selector": {
"device_class": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"date": "[%key:component::sensor::entity_component::date::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"enum": "Enumeration",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
"state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]",
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
}
}
}

View File

@@ -1,25 +0,0 @@
"""Helpers for switch entities."""
from homeassistant.core import callback
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from . import DOMAIN, SwitchDeviceClass
@callback
def create_switch_device_class_select_selector() -> SelectSelector:
"""Create sensor device class select selector."""
return SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SwitchDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
translation_domain=DOMAIN,
sort=True,
)
)

View File

@@ -55,13 +55,5 @@
"name": "[%key:common::action::toggle%]",
"description": "Toggles a switch on/off."
}
},
"selector": {
"device_class": {
"options": {
"outlet": "[%key:component::switch::entity_component::outlet::name%]",
"switch": "[%key:component::switch::title%]"
}
}
}
}

View File

@@ -52,7 +52,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_ENTITY_ID: source_entity_id},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
async def source_entity_removed() -> None:
# The source entity has been removed, we remove the config entry because
@@ -70,6 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
source_entity_removed=source_entity_removed,
)
)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options[CONF_TARGET_DOMAIN],)
@@ -113,6 +113,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(

View File

@@ -56,7 +56,6 @@ class SwitchAsXConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
VERSION = 1
MINOR_VERSION = 3

View File

@@ -56,6 +56,7 @@ async def async_setup_entry(
entry.runtime_data = SystemMonitorData(coordinator, psutil_wrapper)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -66,6 +67,11 @@ async def async_unload_entry(
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: SystemMonitorConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_entry(
hass: HomeAssistant, entry: SystemMonitorConfigEntry
) -> bool:

View File

@@ -92,8 +92,6 @@ class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
VERSION = 1
MINOR_VERSION = 3

View File

@@ -102,9 +102,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["template_type"],)
)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(

View File

@@ -695,7 +695,6 @@ class TemplateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
@callback
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:

View File

@@ -13,9 +13,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, (Platform.BINARY_SENSOR,)
)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(

View File

@@ -43,7 +43,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -139,11 +139,7 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
raise DeviceNotFound("Unable to find device")
try:
client = await Client.connect(
device,
self._notify_callback,
disconnected_callback=self._disconnected_callback,
)
client = await Client.connect(device, self._notify_callback)
except BleakError as exc:
self.logger.debug("Connection failed", exc_info=True)
raise DeviceNotFound("Unable to connect to device") from exc
@@ -173,6 +169,9 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
self.client = None
async def _get_connected_client(self) -> Client:
if self.client and not self.client.is_connected:
await self.client.disconnect()
self.client = None
if self.client:
return self.client
@@ -197,12 +196,6 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]:
"""Poll the device."""
if self.client and not self.client.is_connected:
await self.client.disconnect()
self.client = None
self._async_request_refresh_soon()
raise DeviceFailed("Device was disconnected")
client = await self._get_connected_client()
try:
await client.request(PacketA0Notify)
@@ -213,17 +206,6 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
raise DeviceFailed(f"Device failed {exc}") from exc
return self.data
@callback
def _async_request_refresh_soon(self) -> None:
self.config_entry.async_create_task(
self.hass, self.async_request_refresh(), eager_start=False
)
@callback
def _disconnected_callback(self) -> None:
"""Handle Bluetooth device being disconnected."""
self._async_request_refresh_soon()
@callback
def _async_handle_bluetooth_event(
self,
@@ -231,5 +213,5 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
change: BluetoothChange,
) -> None:
"""Handle a Bluetooth event."""
if isinstance(self.last_exception, DeviceNotFound):
self._async_request_refresh_soon()
if not self.client and isinstance(self.last_exception, DeviceNotFound):
self.hass.async_create_task(self.async_refresh())

View File

@@ -36,7 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_ENTITY_ID: source_entity_id},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
async def source_entity_removed() -> None:
# The source entity has been removed, we remove the config entry because
@@ -58,6 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
@@ -96,6 +96,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle an Trend options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -110,7 +110,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
options_flow = {
"init": SchemaFlowFormStep(get_extended_options_schema),
}
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from pyuptimerobot import UptimeRobotMonitor
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -12,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
from .utils import new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -25,29 +28,23 @@ async def async_setup_entry(
"""Set up the UptimeRobot binary_sensors."""
coordinator = entry.runtime_data
known_devices: set[int] = set()
def _check_device() -> None:
entities: list[UptimeRobotBinarySensor] = []
for monitor in coordinator.data:
if monitor.id in known_devices:
continue
known_devices.add(monitor.id)
entities.append(
UptimeRobotBinarySensor(
coordinator,
BinarySensorEntityDescription(
key=str(monitor.id),
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
monitor=monitor,
)
def _add_new_entities(new_monitors: list[UptimeRobotMonitor]) -> None:
"""Add entities for new monitors."""
entities = [
UptimeRobotBinarySensor(
coordinator,
BinarySensorEntityDescription(
key=str(monitor.id),
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
monitor=monitor,
)
for monitor in new_monitors
]
if entities:
async_add_entities(entities)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
entry.async_on_unload(new_device_listener(coordinator, _add_new_entities))
class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity):

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from pyuptimerobot import UptimeRobotMonitor
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -13,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
from .utils import new_device_listener
SENSORS_INFO = {
0: "pause",
@@ -34,38 +37,32 @@ async def async_setup_entry(
"""Set up the UptimeRobot sensors."""
coordinator = entry.runtime_data
known_devices: set[int] = set()
def _check_device() -> None:
entities: list[UptimeRobotSensor] = []
for monitor in coordinator.data:
if monitor.id in known_devices:
continue
known_devices.add(monitor.id)
entities.append(
UptimeRobotSensor(
coordinator,
SensorEntityDescription(
key=str(monitor.id),
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[
"down",
"not_checked_yet",
"pause",
"seems_down",
"up",
],
translation_key="monitor_status",
),
monitor=monitor,
)
def _add_new_entities(new_monitors: list[UptimeRobotMonitor]) -> None:
"""Add entities for new monitors."""
entities = [
UptimeRobotSensor(
coordinator,
SensorEntityDescription(
key=str(monitor.id),
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[
"down",
"not_checked_yet",
"pause",
"seems_down",
"up",
],
translation_key="monitor_status",
),
monitor=monitor,
)
for monitor in new_monitors
]
if entities:
async_add_entities(entities)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
entry.async_on_unload(new_device_listener(coordinator, _add_new_entities))
class UptimeRobotSensor(UptimeRobotEntity, SensorEntity):

View File

@@ -4,7 +4,11 @@ from __future__ import annotations
from typing import Any
from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException
from pyuptimerobot import (
UptimeRobotAuthenticationException,
UptimeRobotException,
UptimeRobotMonitor,
)
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -18,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import API_ATTR_OK, DOMAIN
from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
from .utils import new_device_listener
# Limit the number of parallel updates to 1
PARALLEL_UPDATES = 1
@@ -31,29 +36,23 @@ async def async_setup_entry(
"""Set up the UptimeRobot switches."""
coordinator = entry.runtime_data
known_devices: set[int] = set()
def _check_device() -> None:
entities: list[UptimeRobotSwitch] = []
for monitor in coordinator.data:
if monitor.id in known_devices:
continue
known_devices.add(monitor.id)
entities.append(
UptimeRobotSwitch(
coordinator,
SwitchEntityDescription(
key=str(monitor.id),
device_class=SwitchDeviceClass.SWITCH,
),
monitor=monitor,
)
def _add_new_entities(new_monitors: list[UptimeRobotMonitor]) -> None:
"""Add entities for new monitors."""
entities = [
UptimeRobotSwitch(
coordinator,
SwitchEntityDescription(
key=str(monitor.id),
device_class=SwitchDeviceClass.SWITCH,
),
monitor=monitor,
)
for monitor in new_monitors
]
if entities:
async_add_entities(entities)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
entry.async_on_unload(new_device_listener(coordinator, _add_new_entities))
class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity):

View File

@@ -0,0 +1,34 @@
"""Utility functions for the UptimeRobot integration."""
from collections.abc import Callable
from pyuptimerobot import UptimeRobotMonitor
from .coordinator import UptimeRobotDataUpdateCoordinator
def new_device_listener(
coordinator: UptimeRobotDataUpdateCoordinator,
new_devices_callback: Callable[[list[UptimeRobotMonitor]], None],
) -> Callable[[], None]:
"""Subscribe to coordinator updates to check for new devices."""
known_devices: set[int] = set()
def _check_devices() -> None:
"""Check for new devices and call callback with any new monitors."""
if not coordinator.data:
return
new_monitors: list[UptimeRobotMonitor] = []
for monitor in coordinator.data:
if monitor.id not in known_devices:
known_devices.add(monitor.id)
new_monitors.append(monitor)
if new_monitors:
new_devices_callback(new_monitors)
# Check for devices immediately
_check_devices()
return coordinator.async_add_listener(_check_devices)

View File

@@ -228,7 +228,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id},
)
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_handle_source_entity_changes(
@@ -259,9 +258,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, (Platform.SELECT, Platform.SENSOR)
)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
platforms_to_unload = [Platform.SENSOR]

View File

@@ -134,7 +134,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta
from holidays import HolidayBase
@@ -15,6 +15,8 @@ from . import WorkdayConfigEntry
from .const import CONF_EXCLUDES, CONF_OFFSET, CONF_WORKDAYS
from .entity import BaseWorkdayEntity
CALENDAR_DAYS_AHEAD = 365
async def async_setup_entry(
hass: HomeAssistant,
@@ -71,10 +73,8 @@ class WorkdayCalendarEntity(BaseWorkdayEntity, CalendarEntity):
def update_data(self, now: datetime) -> None:
"""Update data."""
event_list = []
start_date = date(now.year, 1, 1)
end_number_of_days = date(now.year + 1, 12, 31) - start_date
for i in range(end_number_of_days.days + 1):
future_date = start_date + timedelta(days=i)
for i in range(CALENDAR_DAYS_AHEAD):
future_date = now.date() + timedelta(days=i)
if self.date_is_workday(future_date):
event = CalendarEvent(
summary=self._name,

View File

@@ -10,6 +10,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Worldclock from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -17,3 +18,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload World clock config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -97,7 +97,6 @@ class WorldclockConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -1273,7 +1273,6 @@ class SelectSelectorConfig(BaseSelectorConfig, total=False):
custom_value: bool
mode: SelectSelectorMode
translation_key: str
translation_domain: str
sort: bool
@@ -1292,7 +1291,6 @@ class SelectSelector(Selector[SelectSelectorConfig]):
vol.Coerce(SelectSelectorMode), lambda val: val.value
),
vol.Optional("translation_key"): cv.string,
vol.Optional("translation_domain"): cv.string,
vol.Optional("sort", default=False): cv.boolean,
}
)

2
requirements_all.txt generated
View File

@@ -782,7 +782,7 @@ decora-wifi==1.4
# decora==0.6
# homeassistant.components.ecovacs
deebot-client==15.0.0
deebot-client==14.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns

View File

@@ -682,7 +682,7 @@ debugpy==1.8.16
# decora==0.6
# homeassistant.components.ecovacs
deebot-client==15.0.0
deebot-client==14.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns

View File

@@ -24,9 +24,6 @@ from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
NEW_PASSWORD = "new_password"
REAUTH_STEP = "reauth_confirm"
MOCK_CONFIG = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
@@ -36,11 +33,6 @@ MOCK_CONFIG = {
CONF_VERIFY_SSL: False,
},
}
MOCK_CONFIG_REAUTH = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_PASSWORD: "wrong-password",
}
async def test_form_creates_entry(
@@ -97,6 +89,7 @@ async def test_form_duplicate_entry(
@pytest.mark.parametrize(
("exception", "error"),
[
(AirOSConnectionAuthenticationError, "invalid_auth"),
(AirOSDeviceConnectionError, "cannot_connect"),
(AirOSKeyDataMissingError, "key_data_missing"),
(Exception, "unknown"),
@@ -135,95 +128,3 @@ async def test_form_exception_handling(
assert result["title"] == "NanoStation 5AC ap name"
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("reauth_exception", "expected_error"),
[
(None, None),
(AirOSConnectionAuthenticationError, "invalid_auth"),
(AirOSDeviceConnectionError, "cannot_connect"),
(AirOSKeyDataMissingError, "key_data_missing"),
(Exception, "unknown"),
],
ids=[
"reauth_succes",
"invalid_auth",
"cannot_connect",
"key_data_missing",
"unknown",
],
)
async def test_reauth_flow_scenarios(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_config_entry: MockConfigEntry,
reauth_exception: Exception,
expected_error: str,
) -> None:
"""Test reauthentication from start (failure) to finish (success)."""
mock_config_entry.add_to_hass(hass)
mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
await hass.config_entries.async_setup(mock_config_entry.entry_id)
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow["step_id"] == REAUTH_STEP
mock_airos_client.login.side_effect = reauth_exception
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
if expected_error:
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == REAUTH_STEP
assert result["errors"] == {"base": expected_error}
# Retry
mock_airos_client.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
# Always test resolution
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD
async def test_reauth_unique_id_mismatch(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauthentication failure when the unique ID changes."""
mock_config_entry.add_to_hass(hass)
mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
await hass.config_entries.async_setup(mock_config_entry.entry_id)
flows = hass.config_entries.flow.async_progress()
flow = flows[0]
mock_airos_client.login.side_effect = None
mock_airos_client.status.return_value.derived.mac = "FF:23:45:67:89:AB"
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
user_input={CONF_PASSWORD: NEW_PASSWORD},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert updated_entry.data[CONF_PASSWORD] != NEW_PASSWORD

View File

@@ -3,7 +3,11 @@
from datetime import timedelta
from unittest.mock import AsyncMock
from airos.exceptions import AirOSDataMissingError, AirOSDeviceConnectionError
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -35,6 +39,7 @@ async def test_all_entities(
@pytest.mark.parametrize(
("exception"),
[
AirOSConnectionAuthenticationError,
TimeoutError,
AirOSDeviceConnectionError,
AirOSDataMissingError,

View File

@@ -29,12 +29,12 @@ RECEIVER_INFO_2 = ReceiverInfo(
def mock_discovery(receiver_infos: Iterable[ReceiverInfo] | None) -> Generator[None]:
"""Mock discovery functions."""
async def get_info(host: str) -> ReceiverInfo:
async def get_info(host: str) -> ReceiverInfo | None:
"""Get receiver info by host."""
for info in receiver_infos:
if info.host == host:
return info
raise TimeoutError
return None
def get_infos(host: str) -> MagicMock:
"""Get receiver infos from broadcast."""

View File

@@ -49,14 +49,14 @@ async def test_all_entities(
PortainerTimeoutError("timeout"),
],
)
async def test_refresh_endpoints_exceptions(
async def test_refresh_exceptions(
hass: HomeAssistant,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test entities go unavailable after coordinator refresh failures, for the endpoint fetch."""
"""Test entities go unavailable after coordinator refresh failures."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
@@ -69,26 +69,8 @@ async def test_refresh_endpoints_exceptions(
state = hass.states.get("binary_sensor.practical_morse_status")
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("exception"),
[
PortainerAuthenticationError("bad creds"),
PortainerConnectionError("cannot connect"),
PortainerTimeoutError("timeout"),
],
)
async def test_refresh_containers_exceptions(
hass: HomeAssistant,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test entities go unavailable after coordinator refresh failures, for the container fetch."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
# Reset endpoints; fail on containers fetch
mock_portainer_client.get_endpoints.side_effect = None
mock_portainer_client.get_containers.side_effect = exception
freezer.tick(DEFAULT_SCAN_INTERVAL)

View File

@@ -126,99 +126,3 @@ async def test_duplicate_entry(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_full_flow_reauth(
hass: HomeAssistant,
mock_portainer_client: AsyncMock,
mock_setup_entry: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the full flow of the config flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
# There is no user input
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_TOKEN: "new_api_key"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_TOKEN] == "new_api_key"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "reason"),
[
(
PortainerAuthenticationError,
"invalid_auth",
),
(
PortainerConnectionError,
"cannot_connect",
),
(
PortainerTimeoutError,
"timeout_connect",
),
(
Exception("Some other error"),
"unknown",
),
],
)
async def test_reauth_flow_exceptions(
hass: HomeAssistant,
mock_portainer_client: AsyncMock,
mock_setup_entry: MagicMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
reason: str,
) -> None:
"""Test we handle all exceptions in the reauth flow."""
mock_config_entry.add_to_hass(hass)
mock_portainer_client.get_endpoints.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_TOKEN: "new_api_key"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": reason}
# Now test that we can recover from the error
mock_portainer_client.get_endpoints.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_TOKEN: "new_api_key"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_TOKEN] == "new_api_key"
assert len(mock_setup_entry.mock_calls) == 1

View File

@@ -1,13 +1,9 @@
"""Tests for sensor helpers."""
"""The test for sensor helpers."""
import pytest
from homeassistant.components.sensor import DOMAIN, SensorDeviceClass, SensorStateClass
from homeassistant.components.sensor.helpers import (
async_parse_date_datetime,
create_sensor_device_class_select_selector,
create_sensor_state_class_select_selector,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.sensor.helpers import async_parse_date_datetime
def test_async_parse_datetime(caplog: pytest.LogCaptureFixture) -> None:
@@ -43,23 +39,3 @@ def test_async_parse_datetime(caplog: pytest.LogCaptureFixture) -> None:
# Invalid date
assert async_parse_date_datetime("December 12th", entity_id, device_class) is None
assert "sensor.timestamp rendered invalid date December 12th" in caplog.text
def test_create_sensor_device_class_select_selector() -> None:
"Test Create sensor state class select selector helper."
selector = create_sensor_device_class_select_selector()
assert selector.config["options"] == list(SensorDeviceClass)
assert selector.config["translation_domain"] == DOMAIN
assert selector.config["translation_key"] == "device_class"
assert selector.config["sort"]
assert not selector.config["custom_value"]
def test_create_sensor_state_class_select_selector() -> None:
"Test Create sensor state class select selector helper."
selector = create_sensor_state_class_select_selector()
assert selector.config["options"] == list(SensorStateClass)
assert selector.config["translation_domain"] == DOMAIN
assert selector.config["translation_key"] == "state_class"
assert selector.config["sort"]
assert not selector.config["custom_value"]

View File

@@ -136,8 +136,7 @@ async def test_options_form(hass: HomeAssistant) -> None:
async def test_user_form_timeout(hass: HomeAssistant) -> None:
"""Test we handle server search timeout and allow manual entry."""
# First flow: simulate timeout
"""Test we handle server search timeout."""
with (
patch(
"homeassistant.components.squeezebox.config_flow.async_discover",
@@ -151,46 +150,16 @@ async def test_user_form_timeout(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_server_found"}
# Second flow: simulate successful discovery
with (
patch(
"homeassistant.components.squeezebox.config_flow.async_discover",
mock_discover,
),
patch(
"pysqueezebox.Server.async_query",
return_value={"uuid": UUID},
),
patch(
"homeassistant.components.squeezebox.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
# simulate manual input of host
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: HOST2}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == HOST
assert result["data"] == {
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
}
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "edit"
assert CONF_HOST in result2["data_schema"].schema
for key in result2["data_schema"].schema:
if key == CONF_HOST:
assert key.description == {"suggested_value": HOST2}
async def test_user_form_duplicate(hass: HomeAssistant) -> None:
@@ -347,15 +316,11 @@ async def test_form_validate_exception(hass: HomeAssistant) -> None:
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error, then succeed after retry."""
# Start the flow
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "edit"}
)
assert result["type"] is FlowResultType.FORM
# First attempt: simulate cannot connect
with patch(
"pysqueezebox.Server.async_query",
return_value=False,
@@ -365,47 +330,17 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
},
)
# We should still be in a form, with an error
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# Second attempt: simulate a successful connection
with patch(
"pysqueezebox.Server.async_query",
return_value={"uuid": UUID},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == HOST # the flow uses host as title
assert result["data"] == {
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
}
assert result["context"]["unique_id"] == UUID
async def test_discovery(hass: HomeAssistant) -> None:
"""Test handling of discovered server, then completing the flow."""
# Initial discovery: server responds with a uuid
"""Test handling of discovered server."""
with patch(
"pysqueezebox.Server.async_query",
return_value={"uuid": UUID},
@@ -415,109 +350,24 @@ async def test_discovery(hass: HomeAssistant) -> None:
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={CONF_HOST: HOST, CONF_PORT: PORT, "uuid": UUID},
)
# Discovery puts us into the edit step
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit"
# Complete the edit step with user input
with patch(
"pysqueezebox.Server.async_query",
return_value={"uuid": UUID},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
},
)
# Flow should now complete with a config entry
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == HOST
assert result["data"] == {
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
}
assert result["context"]["unique_id"] == UUID
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit"
async def test_discovery_no_uuid(hass: HomeAssistant) -> None:
"""Test discovery without uuid first fails, then succeeds when uuid is available."""
# Initial discovery: no uuid returned
with patch(
"pysqueezebox.Server.async_query",
new=patch_async_query_unauthorized,
):
"""Test handling of discovered server with unavailable uuid."""
with patch("pysqueezebox.Server.async_query", new=patch_async_query_unauthorized):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False},
)
# Flow shows the edit form
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit"
# First attempt to complete: still no uuid → error on the form
with patch(
"pysqueezebox.Server.async_query",
new=patch_async_query_unauthorized,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
# Second attempt: now the server responds with a uuid
with patch(
"pysqueezebox.Server.async_query",
return_value={"uuid": UUID},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
},
)
# Flow should now complete successfully
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == HOST
assert result["data"] == {
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
}
assert result["context"]["unique_id"] == UUID
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit"
async def test_dhcp_discovery(hass: HomeAssistant) -> None:
"""Test we can process discovery from dhcp and complete the flow."""
"""Test we can process discovery from dhcp."""
with (
patch(
"pysqueezebox.Server.async_query",
@@ -532,48 +382,17 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None:
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DhcpServiceInfo(
ip=HOST,
ip="1.1.1.1",
macaddress="aabbccddeeff",
hostname="any",
),
)
# DHCP discovery puts us into the edit step
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit"
# Complete the edit step with user input
with patch(
"pysqueezebox.Server.async_query",
return_value={"uuid": UUID},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
},
)
# Flow should now complete with a config entry
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == HOST
assert result["data"] == {
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
}
assert result["context"]["unique_id"] == UUID
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit"
async def test_dhcp_discovery_no_server_found(hass: HomeAssistant) -> None:
"""Test we can handle dhcp discovery when no server is found."""
with (
patch(
"homeassistant.components.squeezebox.config_flow.async_discover",
@@ -585,43 +404,13 @@ async def test_dhcp_discovery_no_server_found(hass: HomeAssistant) -> None:
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DhcpServiceInfo(
ip=HOST,
ip="1.1.1.1",
macaddress="aabbccddeeff",
hostname="any",
),
)
# First step: user form with only host
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# Provide just the host to move into edit step
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: HOST},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit"
# Now try to complete the edit step with full schema
with patch(
"homeassistant.components.squeezebox.config_flow.async_discover",
mock_failed_discover,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit"
assert result["errors"] == {"base": "unknown"}
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
async def test_dhcp_discovery_existing_player(

View File

@@ -1,16 +0,0 @@
"""Tests for switch helpers."""
from homeassistant.components.switch import DOMAIN, SwitchDeviceClass
from homeassistant.components.switch.helpers import (
create_switch_device_class_select_selector,
)
def test_create_switch_device_class_select_selector() -> None:
"Test Create sensor state class select selector helper."
selector = create_switch_device_class_select_selector()
assert selector.config["options"] == list(SwitchDeviceClass)
assert selector.config["translation_domain"] == DOMAIN
assert selector.config["translation_key"] == "device_class"
assert selector.config["sort"]
assert not selector.config["custom_value"]

View File

@@ -57,18 +57,9 @@ def mock_client(enable_bluetooth: None, mock_client_class: Mock) -> Generator[Mo
client_object.mocked_notify = None
async def _connect(
address: str,
callback: Callable[[Packet], None] | None = None,
disconnected_callback: Callable[[], None] | None = None,
address: str, callback: Callable[[Packet], None] | None = None
) -> Mock:
client_object.mocked_notify = callback
if disconnected_callback:
def _disconnected_callback():
client_object.is_connected = False
disconnected_callback()
client_object.mocked_disconnected_callback = _disconnected_callback
return client_object
async def _disconnect() -> None:

View File

@@ -1,8 +1,7 @@
"""Test sensors for ToGrill integration."""
from unittest.mock import Mock, patch
from unittest.mock import Mock
from habluetooth import BluetoothServiceInfoBleak
import pytest
from syrupy.assertion import SnapshotAssertion
from togrill_bluetooth.packets import PacketA0Notify, PacketA1Notify
@@ -17,16 +16,6 @@ from tests.common import MockConfigEntry, snapshot_platform
from tests.components.bluetooth import inject_bluetooth_service_info
def patch_async_ble_device_from_address(
return_value: BluetoothServiceInfoBleak | None = None,
):
"""Patch async_ble_device_from_address to return a mocked BluetoothServiceInfoBleak."""
return patch(
"homeassistant.components.bluetooth.async_ble_device_from_address",
return_value=return_value,
)
@pytest.mark.parametrize(
"packets",
[
@@ -68,51 +57,3 @@ async def test_setup(
mock_client.mocked_notify(packet)
await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id)
async def test_device_disconnected(
hass: HomeAssistant,
mock_entry: MockConfigEntry,
mock_client: Mock,
) -> None:
"""Test the switch set."""
inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO)
await setup_entry(hass, mock_entry, [Platform.SENSOR])
entity_id = "sensor.pro_05_battery"
state = hass.states.get(entity_id)
assert state
assert state.state == "0"
with patch_async_ble_device_from_address():
mock_client.mocked_disconnected_callback()
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == "unavailable"
async def test_device_discovered(
hass: HomeAssistant,
mock_entry: MockConfigEntry,
mock_client: Mock,
) -> None:
"""Test the switch set."""
await setup_entry(hass, mock_entry, [Platform.SENSOR])
entity_id = "sensor.pro_05_battery"
state = hass.states.get(entity_id)
assert state
assert state.state == "unavailable"
inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == "0"

View File

@@ -721,7 +721,6 @@ def test_text_selector_schema(schema, valid_selections, invalid_selections) -> N
{
"options": ["red", "green", "blue"],
"translation_key": "color",
"translation_domain": "homeassistant",
},
("red", "green", "blue"),
("cat", 0, None, ["red"]),