This commit is contained in:
Franck Nijhof 2024-01-12 18:01:20 +01:00 committed by GitHub
commit 99ee57aefc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
105 changed files with 1612 additions and 263 deletions

View File

@ -1550,7 +1550,7 @@ build.json @home-assistant/supervisor
/tests/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant
/homeassistant/components/zone/ @home-assistant/core /homeassistant/components/zone/ @home-assistant/core
/tests/components/zone/ @home-assistant/core /tests/components/zone/ @home-assistant/core
/homeassistant/components/zoneminder/ @rohankapoorcom /homeassistant/components/zoneminder/ @rohankapoorcom @nabbi
/homeassistant/components/zwave_js/ @home-assistant/z-wave /homeassistant/components/zwave_js/ @home-assistant/z-wave
/tests/components/zwave_js/ @home-assistant/z-wave /tests/components/zwave_js/ @home-assistant/z-wave
/homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS /homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS

View File

@ -15,6 +15,9 @@ from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import STORAGE_ACCESS_TOKEN, STORAGE_REFRESH_TOKEN
from .diagnostics import async_redact_lwa_params
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
LWA_TOKEN_URI = "https://api.amazon.com/auth/o2/token" LWA_TOKEN_URI = "https://api.amazon.com/auth/o2/token"
@ -24,8 +27,6 @@ PREEMPTIVE_REFRESH_TTL_IN_SECONDS = 300
STORAGE_KEY = "alexa_auth" STORAGE_KEY = "alexa_auth"
STORAGE_VERSION = 1 STORAGE_VERSION = 1
STORAGE_EXPIRE_TIME = "expire_time" STORAGE_EXPIRE_TIME = "expire_time"
STORAGE_ACCESS_TOKEN = "access_token"
STORAGE_REFRESH_TOKEN = "refresh_token"
class Auth: class Auth:
@ -56,7 +57,7 @@ class Auth:
} }
_LOGGER.debug( _LOGGER.debug(
"Calling LWA to get the access token (first time), with: %s", "Calling LWA to get the access token (first time), with: %s",
json.dumps(lwa_params), json.dumps(async_redact_lwa_params(lwa_params)),
) )
return await self._async_request_new_token(lwa_params) return await self._async_request_new_token(lwa_params)
@ -133,7 +134,7 @@ class Auth:
return None return None
response_json = await response.json() response_json = await response.json()
_LOGGER.debug("LWA response body : %s", response_json) _LOGGER.debug("LWA response body : %s", async_redact_lwa_params(response_json))
access_token: str = response_json["access_token"] access_token: str = response_json["access_token"]
refresh_token: str = response_json["refresh_token"] refresh_token: str = response_json["refresh_token"]

View File

@ -1112,13 +1112,17 @@ class AlexaThermostatController(AlexaCapability):
"""Return what properties this entity supports.""" """Return what properties this entity supports."""
properties = [{"name": "thermostatMode"}] properties = [{"name": "thermostatMode"}]
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE: if self.entity.domain == climate.DOMAIN:
if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
properties.append({"name": "lowerSetpoint"})
properties.append({"name": "upperSetpoint"})
if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE:
properties.append({"name": "targetSetpoint"})
elif (
self.entity.domain == water_heater.DOMAIN
and supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE
):
properties.append({"name": "targetSetpoint"}) properties.append({"name": "targetSetpoint"})
if supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE:
properties.append({"name": "targetSetpoint"})
if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
properties.append({"name": "lowerSetpoint"})
properties.append({"name": "upperSetpoint"})
return properties return properties
def properties_proactively_reported(self) -> bool: def properties_proactively_reported(self) -> bool:

View File

@ -90,6 +90,9 @@ API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"}
# we add PRESET_MODE_NA if a fan / humidifier has only one preset_mode # we add PRESET_MODE_NA if a fan / humidifier has only one preset_mode
PRESET_MODE_NA = "-" PRESET_MODE_NA = "-"
STORAGE_ACCESS_TOKEN = "access_token"
STORAGE_REFRESH_TOKEN = "refresh_token"
class Cause: class Cause:
"""Possible causes for property changes. """Possible causes for property changes.

View File

@ -0,0 +1,34 @@
"""Diagnostics helpers for Alexa."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import callback
STORAGE_ACCESS_TOKEN = "access_token"
STORAGE_REFRESH_TOKEN = "refresh_token"
TO_REDACT_LWA = {
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
STORAGE_ACCESS_TOKEN,
STORAGE_REFRESH_TOKEN,
}
TO_REDACT_AUTH = {"correlationToken", "token"}
@callback
def async_redact_lwa_params(lwa_params: dict[str, str]) -> dict[str, str]:
"""Redact lwa_params."""
return async_redact_data(lwa_params, TO_REDACT_LWA)
@callback
def async_redact_auth_data(mapping: Mapping[Any, Any]) -> dict[str, str]:
"""React auth data."""
return async_redact_data(mapping, TO_REDACT_AUTH)

View File

@ -144,7 +144,6 @@ async def async_api_accept_grant(
Async friendly. Async friendly.
""" """
auth_code: str = directive.payload["grant"]["code"] auth_code: str = directive.payload["grant"]["code"]
_LOGGER.debug("AcceptGrant code: %s", auth_code)
if config.supports_auth: if config.supports_auth:
await config.async_accept_grant(auth_code) await config.async_accept_grant(auth_code)

View File

@ -25,6 +25,7 @@ from .const import (
CONF_LOCALE, CONF_LOCALE,
EVENT_ALEXA_SMART_HOME, EVENT_ALEXA_SMART_HOME,
) )
from .diagnostics import async_redact_auth_data
from .errors import AlexaBridgeUnreachableError, AlexaError from .errors import AlexaBridgeUnreachableError, AlexaError
from .handlers import HANDLERS from .handlers import HANDLERS
from .state_report import AlexaDirective from .state_report import AlexaDirective
@ -149,12 +150,21 @@ class SmartHomeView(HomeAssistantView):
user: User = request["hass_user"] user: User = request["hass_user"]
message: dict[str, Any] = await request.json() message: dict[str, Any] = await request.json()
_LOGGER.debug("Received Alexa Smart Home request: %s", message) if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Received Alexa Smart Home request: %s",
async_redact_auth_data(message),
)
response = await async_handle_message( response = await async_handle_message(
hass, self.smart_home_config, message, context=core.Context(user_id=user.id) hass, self.smart_home_config, message, context=core.Context(user_id=user.id)
) )
_LOGGER.debug("Sending Alexa Smart Home response: %s", response) if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Sending Alexa Smart Home response: %s",
async_redact_auth_data(response),
)
return b"" if response is None else self.json(response) return b"" if response is None else self.json(response)

View File

@ -34,6 +34,7 @@ from .const import (
DOMAIN, DOMAIN,
Cause, Cause,
) )
from .diagnostics import async_redact_auth_data
from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink
@ -43,6 +44,8 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10 DEFAULT_TIMEOUT = 10
TO_REDACT = {"correlationToken", "token"}
class AlexaDirective: class AlexaDirective:
"""An incoming Alexa directive.""" """An incoming Alexa directive."""
@ -379,7 +382,9 @@ async def async_send_changereport_message(
response_text = await response.text() response_text = await response.text()
if _LOGGER.isEnabledFor(logging.DEBUG): if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Sent: %s", json.dumps(message_serialized)) _LOGGER.debug(
"Sent: %s", json.dumps(async_redact_auth_data(message_serialized))
)
_LOGGER.debug("Received (%s): %s", response.status, response_text) _LOGGER.debug("Received (%s): %s", response.status, response_text)
if response.status == HTTPStatus.ACCEPTED: if response.status == HTTPStatus.ACCEPTED:
@ -533,7 +538,9 @@ async def async_send_doorbell_event_message(
response_text = await response.text() response_text = await response.text()
if _LOGGER.isEnabledFor(logging.DEBUG): if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Sent: %s", json.dumps(message_serialized)) _LOGGER.debug(
"Sent: %s", json.dumps(async_redact_auth_data(message_serialized))
)
_LOGGER.debug("Received (%s): %s", response.status, response_text) _LOGGER.debug("Received (%s): %s", response.status, response_text)
if response.status == HTTPStatus.ACCEPTED: if response.status == HTTPStatus.ACCEPTED:

View File

@ -0,0 +1,39 @@
"""Diagnostics support for A. O. Smith."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from . import AOSmithData
from .const import DOMAIN
TO_REDACT = {
"address",
"city",
"contactId",
"dsn",
"email",
"firstName",
"heaterSsid",
"id",
"lastName",
"phone",
"postalCode",
"registeredOwner",
"serial",
"ssid",
"state",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
data: AOSmithData = hass.data[DOMAIN][config_entry.entry_id]
all_device_info = await data.client.get_all_device_info()
return async_redact_data(all_device_info, TO_REDACT)

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aosmith", "documentation": "https://www.home-assistant.io/integrations/aosmith",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.1"] "requirements": ["py-aosmith==1.0.4"]
} }

View File

@ -13,7 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = 30 SCAN_INTERVAL = 300
class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/blink", "documentation": "https://www.home-assistant.io/integrations/blink",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["blinkpy"], "loggers": ["blinkpy"],
"requirements": ["blinkpy==0.22.4"] "requirements": ["blinkpy==0.22.5"]
} }

View File

@ -17,9 +17,9 @@
"bleak==0.21.1", "bleak==0.21.1",
"bleak-retry-connector==3.4.0", "bleak-retry-connector==3.4.0",
"bluetooth-adapters==0.16.2", "bluetooth-adapters==0.16.2",
"bluetooth-auto-recovery==1.2.3", "bluetooth-auto-recovery==1.3.0",
"bluetooth-data-tools==1.19.0", "bluetooth-data-tools==1.19.0",
"dbus-fast==2.21.0", "dbus-fast==2.21.0",
"habluetooth==2.0.2" "habluetooth==2.1.0"
] ]
} }

View File

@ -291,7 +291,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
} }
async def _on_start() -> None: async def _on_start() -> None:
"""Discover platforms.""" """Handle cloud started after login."""
nonlocal loaded nonlocal loaded
# Prevent multiple discovery # Prevent multiple discovery
@ -299,14 +299,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return return
loaded = True loaded = True
tts_info = {"platform_loaded": tts_platform_loaded}
await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config)
await tts_platform_loaded.wait()
# The config entry should be loaded after the legacy tts platform is loaded
# to make sure that the tts integration is setup before we try to migrate
# old assist pipelines in the cloud stt entity.
await hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) await hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"})
async def _on_connect() -> None: async def _on_connect() -> None:
@ -335,6 +327,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
account_link.async_setup(hass) account_link.async_setup(hass)
hass.async_create_task(
async_load_platform(
hass,
Platform.TTS,
DOMAIN,
{"platform_loaded": tts_platform_loaded},
config,
)
)
async_call_later( async_call_later(
hass=hass, hass=hass,
delay=timedelta(hours=STARTUP_REPAIR_DELAY), delay=timedelta(hours=STARTUP_REPAIR_DELAY),

View File

@ -72,6 +72,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
_reauth_entry: ConfigEntry | None _reauth_entry: ConfigEntry | None
_reauth_host: str _reauth_host: str
_reauth_port: int _reauth_port: int
_reauth_type: str
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -109,6 +110,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
) )
self._reauth_host = entry_data[CONF_HOST] self._reauth_host = entry_data[CONF_HOST]
self._reauth_port = entry_data.get(CONF_PORT, DEFAULT_PORT) self._reauth_port = entry_data.get(CONF_PORT, DEFAULT_PORT)
self._reauth_type = entry_data.get(CONF_TYPE, BRIDGE)
self.context["title_placeholders"] = {"host": self._reauth_host} self.context["title_placeholders"] = {"host": self._reauth_host}
return await self.async_step_reauth_confirm() return await self.async_step_reauth_confirm()
@ -127,6 +129,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
{ {
CONF_HOST: self._reauth_host, CONF_HOST: self._reauth_host,
CONF_PORT: self._reauth_port, CONF_PORT: self._reauth_port,
CONF_TYPE: self._reauth_type,
} }
| user_input, | user_input,
) )
@ -144,6 +147,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: self._reauth_host, CONF_HOST: self._reauth_host,
CONF_PORT: self._reauth_port, CONF_PORT: self._reauth_port,
CONF_PIN: user_input[CONF_PIN], CONF_PIN: user_input[CONF_PIN],
CONF_TYPE: self._reauth_type,
}, },
) )
self.hass.async_create_task( self.hass.async_create_task(

View File

@ -81,15 +81,11 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try: try:
await self.api.login() await self.api.login()
return await self._async_update_system_data() return await self._async_update_system_data()
except exceptions.CannotConnect as err: except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err:
_LOGGER.warning("Connection error for %s", self._host) raise UpdateFailed(repr(err)) from err
await self.api.close()
raise UpdateFailed(f"Error fetching data: {repr(err)}") from err
except exceptions.CannotAuthenticate: except exceptions.CannotAuthenticate:
raise ConfigEntryAuthFailed raise ConfigEntryAuthFailed
return {}
@abstractmethod @abstractmethod
async def _async_update_system_data(self) -> dict[str, Any]: async def _async_update_system_data(self) -> dict[str, Any]:
"""Class method for updating data.""" """Class method for updating data."""

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/comelit", "documentation": "https://www.home-assistant.io/integrations/comelit",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiocomelit"], "loggers": ["aiocomelit"],
"requirements": ["aiocomelit==0.7.0"] "requirements": ["aiocomelit==0.7.3"]
} }

View File

@ -481,7 +481,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def _get_toggle_function( def _get_toggle_function(
self, fns: dict[str, Callable[_P, _R]] self, fns: dict[str, Callable[_P, _R]]
) -> Callable[_P, _R]: ) -> Callable[_P, _R]:
if CoverEntityFeature.STOP | self.supported_features and ( if self.supported_features & CoverEntityFeature.STOP and (
self.is_closing or self.is_opening self.is_closing or self.is_opening
): ):
return fns["stop"] return fns["stop"]

View File

@ -479,10 +479,20 @@ class EnvoyInverterEntity(EnvoySensorBaseEntity):
) )
@property @property
def native_value(self) -> datetime.datetime | float: def native_value(self) -> datetime.datetime | float | None:
"""Return the state of the sensor.""" """Return the state of the sensor."""
inverters = self.data.inverters inverters = self.data.inverters
assert inverters is not None assert inverters is not None
# Some envoy fw versions return an empty inverter array every 4 hours when
# no production is taking place. Prevent collection failure due to this
# as other data seems fine. Inverters will show unknown during this cycle.
if self._serial_number not in inverters:
_LOGGER.debug(
"Inverter %s not in returned inverters array (size: %s)",
self._serial_number,
len(inverters),
)
return None
return self.entity_description.value_fn(inverters[self._serial_number]) return self.entity_description.value_fn(inverters[self._serial_number])

View File

@ -497,7 +497,6 @@ class EvoBroker:
session_id = get_session_id(self.client_v1) session_id = get_session_id(self.client_v1)
self.temps = {} # these are now stale, will fall back to v2 temps
try: try:
temps = await self.client_v1.get_temperatures() temps = await self.client_v1.get_temperatures()
@ -523,6 +522,11 @@ class EvoBroker:
), ),
err, err,
) )
self.temps = {} # high-precision temps now considered stale
except Exception:
self.temps = {} # high-precision temps now considered stale
raise
else: else:
if str(self.client_v1.location_id) != self._location.locationId: if str(self.client_v1.location_id) != self._location.locationId:
@ -654,6 +658,7 @@ class EvoChild(EvoDevice):
assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check
if self._evo_broker.temps.get(self._evo_id) is not None: if self._evo_broker.temps.get(self._evo_id) is not None:
# use high-precision temps if available
return self._evo_broker.temps[self._evo_id] return self._evo_broker.temps[self._evo_id]
return self._evo_device.temperature return self._evo_device.temperature

View File

@ -118,7 +118,6 @@ class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorE
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
_id = coordinator.data.code _id = coordinator.data.code
self._attr_name = f"{_id} {description.name}"
self._attr_unique_id = f"{_id}_{description.key}" self._attr_unique_id = f"{_id}_{description.key}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, _id)}, identifiers={(DOMAIN, _id)},

View File

@ -27,6 +27,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
DOMAIN, DOMAIN,
MAX_TEMP,
MIN_TEMP,
PRESET_TO_VENTILATION_MODE_MAP, PRESET_TO_VENTILATION_MODE_MAP,
VENTILATION_TO_PRESET_MODE_MAP, VENTILATION_TO_PRESET_MODE_MAP,
) )
@ -67,6 +69,8 @@ class FlexitClimateEntity(ClimateEntity):
_attr_target_temperature_step = PRECISION_HALVES _attr_target_temperature_step = PRECISION_HALVES
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_max_temp = MAX_TEMP
_attr_min_temp = MIN_TEMP
def __init__(self, device: FlexitBACnet) -> None: def __init__(self, device: FlexitBACnet) -> None:
"""Initialize the unit.""" """Initialize the unit."""

View File

@ -15,6 +15,9 @@ from homeassistant.components.climate import (
DOMAIN = "flexit_bacnet" DOMAIN = "flexit_bacnet"
MAX_TEMP = 30
MIN_TEMP = 10
VENTILATION_TO_PRESET_MODE_MAP = { VENTILATION_TO_PRESET_MODE_MAP = {
VENTILATION_MODE_STOP: PRESET_NONE, VENTILATION_MODE_STOP: PRESET_NONE,
VENTILATION_MODE_AWAY: PRESET_AWAY, VENTILATION_MODE_AWAY: PRESET_AWAY,

View File

@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aio_geojson_generic_client"], "loggers": ["aio_geojson_generic_client"],
"requirements": ["aio-geojson-generic-client==0.3"] "requirements": ["aio-geojson-generic-client==0.4"]
} }

View File

@ -43,6 +43,18 @@ async def async_setup_entry(
) )
language = lang language = lang
break break
if (
obj_holidays.supported_languages
and language not in obj_holidays.supported_languages
and (default_language := obj_holidays.default_language)
):
obj_holidays = country_holidays(
country,
subdiv=province,
years={dt_util.now().year, dt_util.now().year + 1},
language=default_language,
)
language = default_language
async_add_entities( async_add_entities(
[ [

View File

@ -118,7 +118,7 @@ async def async_setup_platform(
mode = get_ip_mode(host) mode = get_ip_mode(host)
mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host}))
if mac is None: if mac is None or mac == "00:00:00:00:00:00":
raise PlatformNotReady("Cannot get the ip address of kef speaker.") raise PlatformNotReady("Cannot get the ip address of kef speaker.")
unique_id = f"kef-{mac}" unique_id = f"kef-{mac}"

View File

@ -82,6 +82,9 @@ DATA_HASS_CONFIG: Final = "knx_hass_config"
ATTR_COUNTER: Final = "counter" ATTR_COUNTER: Final = "counter"
ATTR_SOURCE: Final = "source" ATTR_SOURCE: Final = "source"
# dispatcher signal for KNX interface device triggers
SIGNAL_KNX_TELEGRAM_DICT: Final = "knx_telegram_dict"
AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]]
MessageCallbackType = Callable[[Telegram], None] MessageCallbackType = Callable[[Telegram], None]

View File

@ -9,11 +9,12 @@ from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEM
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import selector from homeassistant.helpers import selector
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from . import KNXModule from . import KNXModule
from .const import DOMAIN from .const import DOMAIN, SIGNAL_KNX_TELEGRAM_DICT
from .project import KNXProject from .project import KNXProject
from .schema import ga_list_validator from .schema import ga_list_validator
from .telegrams import TelegramDict from .telegrams import TelegramDict
@ -87,7 +88,6 @@ async def async_attach_trigger(
trigger_data = trigger_info["trigger_data"] trigger_data = trigger_info["trigger_data"]
dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, []) dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, [])
job = HassJob(action, f"KNX device trigger {trigger_info}") job = HassJob(action, f"KNX device trigger {trigger_info}")
knx: KNXModule = hass.data[DOMAIN]
@callback @callback
def async_call_trigger_action(telegram: TelegramDict) -> None: def async_call_trigger_action(telegram: TelegramDict) -> None:
@ -99,6 +99,8 @@ async def async_attach_trigger(
{"trigger": {**trigger_data, **telegram}}, {"trigger": {**trigger_data, **telegram}},
) )
return knx.telegrams.async_listen_telegram( return async_dispatcher_connect(
async_call_trigger_action, name="KNX device trigger call" hass,
signal=SIGNAL_KNX_TELEGRAM_DICT,
target=async_call_trigger_action,
) )

View File

@ -11,10 +11,11 @@ from xknx.telegram import Telegram
from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .const import DOMAIN from .const import DOMAIN, SIGNAL_KNX_TELEGRAM_DICT
from .project import KNXProject from .project import KNXProject
STORAGE_VERSION: Final = 1 STORAGE_VERSION: Final = 1
@ -87,6 +88,7 @@ class Telegrams:
"""Handle incoming and outgoing telegrams from xknx.""" """Handle incoming and outgoing telegrams from xknx."""
telegram_dict = self.telegram_to_dict(telegram) telegram_dict = self.telegram_to_dict(telegram)
self.recent_telegrams.append(telegram_dict) self.recent_telegrams.append(telegram_dict)
async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM_DICT, telegram_dict)
for job in self._jobs: for job in self._jobs:
self.hass.async_run_hass_job(job, telegram_dict) self.hass.async_run_hass_job(job, telegram_dict)

View File

@ -2,7 +2,11 @@
import logging import logging
from bleak_retry_connector import BleakError, close_stale_connections, get_device from bleak_retry_connector import (
BleakError,
close_stale_connections_by_address,
get_device,
)
from ld2410_ble import LD2410BLE from ld2410_ble import LD2410BLE
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
@ -24,6 +28,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LD2410 BLE from a config entry.""" """Set up LD2410 BLE from a config entry."""
address: str = entry.data[CONF_ADDRESS] address: str = entry.data[CONF_ADDRESS]
await close_stale_connections_by_address(address)
ble_device = bluetooth.async_ble_device_from_address( ble_device = bluetooth.async_ble_device_from_address(
hass, address.upper(), True hass, address.upper(), True
) or await get_device(address) ) or await get_device(address)
@ -32,8 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
f"Could not find LD2410B device with address {address}" f"Could not find LD2410B device with address {address}"
) )
await close_stale_connections(ble_device)
ld2410_ble = LD2410BLE(ble_device) ld2410_ble = LD2410BLE(ble_device)
coordinator = LD2410BLECoordinator(hass, ld2410_ble) coordinator = LD2410BLECoordinator(hass, ld2410_ble)

View File

@ -3,7 +3,7 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"station_id": "Sensor ID", "sensor_id": "Sensor ID",
"show_on_map": "Show on map" "show_on_map": "Show on map"
} }
} }

View File

@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Coroutine
import json import json
import logging import logging
from typing import Any
import aiohttp import aiohttp
from aiohttp.hdrs import CONTENT_TYPE from aiohttp.hdrs import CONTENT_TYPE
@ -267,11 +269,11 @@ class MicrosoftFace:
"""Store group/person data and IDs.""" """Store group/person data and IDs."""
return self._store return self._store
async def update_store(self): async def update_store(self) -> None:
"""Load all group/person data into local store.""" """Load all group/person data into local store."""
groups = await self.call_api("get", "persongroups") groups = await self.call_api("get", "persongroups")
remove_tasks = [] remove_tasks: list[Coroutine[Any, Any, None]] = []
new_entities = [] new_entities = []
for group in groups: for group in groups:
g_id = group["personGroupId"] g_id = group["personGroupId"]
@ -293,7 +295,7 @@ class MicrosoftFace:
self._store[g_id][person["name"]] = person["personId"] self._store[g_id][person["name"]] = person["personId"]
if remove_tasks: if remove_tasks:
await asyncio.gather(remove_tasks) await asyncio.gather(*remove_tasks)
await self._component.async_add_entities(new_entities) await self._component.async_add_entities(new_entities)
async def call_api(self, method, function, data=None, binary=False, params=None): async def call_api(self, method, function, data=None, binary=False, params=None):

View File

@ -7,5 +7,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["dnspython", "mcstatus"], "loggers": ["dnspython", "mcstatus"],
"quality_scale": "gold", "quality_scale": "gold",
"requirements": ["mcstatus==11.0.0"] "requirements": ["mcstatus==11.1.1"]
} }

View File

@ -70,8 +70,8 @@ MQTT_TEXT_ATTRIBUTES_BLOCKED = frozenset(
def valid_text_size_configuration(config: ConfigType) -> ConfigType: def valid_text_size_configuration(config: ConfigType) -> ConfigType:
"""Validate that the text length configuration is valid, throws if it isn't.""" """Validate that the text length configuration is valid, throws if it isn't."""
if config[CONF_MIN] >= config[CONF_MAX]: if config[CONF_MIN] > config[CONF_MAX]:
raise vol.Invalid("text length min must be >= max") raise vol.Invalid("text length min must be <= max")
if config[CONF_MAX] > MAX_LENGTH_STATE_STATE: if config[CONF_MAX] > MAX_LENGTH_STATE_STATE:
raise vol.Invalid(f"max text length must be <= {MAX_LENGTH_STATE_STATE}") raise vol.Invalid(f"max text length must be <= {MAX_LENGTH_STATE_STATE}")

View File

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

View File

@ -61,10 +61,7 @@ def async_load_screenlogic_services(hass: HomeAssistant):
color_num, color_num,
) )
try: try:
if not await coordinator.gateway.async_set_color_lights(color_num): await coordinator.gateway.async_set_color_lights(color_num)
raise HomeAssistantError(
f"Failed to call service '{SERVICE_SET_COLOR_MODE}'"
)
# Debounced refresh to catch any secondary # Debounced refresh to catch any secondary
# changes in the device # changes in the device
await coordinator.async_request_refresh() await coordinator.async_request_refresh()

View File

@ -9,7 +9,7 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aioshelly"], "loggers": ["aioshelly"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aioshelly==7.0.0"], "requirements": ["aioshelly==7.1.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_http._tcp.local.", "type": "_http._tcp.local.",

View File

@ -36,6 +36,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.util.enum import try_parse_enum
from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS
from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator
@ -969,7 +970,7 @@ def _build_block_description(entry: RegistryEntry) -> BlockSensorDescription:
name="", name="",
icon=entry.original_icon, icon=entry.original_icon,
native_unit_of_measurement=entry.unit_of_measurement, native_unit_of_measurement=entry.unit_of_measurement,
device_class=entry.original_device_class, device_class=try_parse_enum(SensorDeviceClass, entry.original_device_class),
) )

View File

@ -61,6 +61,10 @@ REPEAT_MODE_MAPPING_TO_SPOTIFY = {
value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items()
} }
# This is a minimal representation of the DJ playlist that Spotify now offers
# The DJ is not fully integrated with the playlist API, so needs to have the playlist response mocked in order to maintain functionality
SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -423,7 +427,19 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
if context and (self._playlist is None or self._playlist["uri"] != uri): if context and (self._playlist is None or self._playlist["uri"] != uri):
self._playlist = None self._playlist = None
if context["type"] == MediaType.PLAYLIST: if context["type"] == MediaType.PLAYLIST:
self._playlist = self.data.client.playlist(uri) # The Spotify API does not currently support doing a lookup for the DJ playlist, so just use the minimal mock playlist object
if uri == SPOTIFY_DJ_PLAYLIST["uri"]:
self._playlist = SPOTIFY_DJ_PLAYLIST
else:
# Make sure any playlist lookups don't break the current playback state update
try:
self._playlist = self.data.client.playlist(uri)
except SpotifyException:
_LOGGER.debug(
"Unable to load spotify playlist '%s'. Continuing without playlist data",
uri,
)
self._playlist = None
device = self._currently_playing.get("device") device = self._currently_playing.get("device")
if device is not None: if device is not None:

View File

@ -10,6 +10,7 @@ from opendata_transport.exceptions import (
from homeassistant import config_entries, core from homeassistant import config_entries, core
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_DESTINATION, CONF_START, DOMAIN from .const import CONF_DESTINATION, CONF_START, DOMAIN
@ -65,3 +66,51 @@ async def async_unload_entry(
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok return unload_ok
async def async_migrate_entry(
hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry
) -> bool:
"""Migrate config entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.minor_version > 3:
# This means the user has downgraded from a future version
return False
if config_entry.minor_version == 1:
# Remove wrongly registered devices and entries
new_unique_id = (
f"{config_entry.data[CONF_START]} {config_entry.data[CONF_DESTINATION]}"
)
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=config_entry.entry_id
)
for dev in device_entries:
device_registry.async_remove_device(dev.id)
entity_id = entity_registry.async_get_entity_id(
Platform.SENSOR, DOMAIN, "None_departure"
)
if entity_id:
entity_registry.async_update_entity(
entity_id=entity_id,
new_unique_id=f"{new_unique_id}_departure",
)
_LOGGER.debug(
"Faulty entity with unique_id 'None_departure' migrated to new unique_id '%s'",
f"{new_unique_id}_departure",
)
# Set a valid unique id for config entries
config_entry.unique_id = new_unique_id
config_entry.minor_version = 2
hass.config_entries.async_update_entry(config_entry)
_LOGGER.debug(
"Migration to minor version %s successful", config_entry.minor_version
)
return True

View File

@ -31,6 +31,7 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Swiss public transport config flow.""" """Swiss public transport config flow."""
VERSION = 1 VERSION = 1
MINOR_VERSION = 2
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -59,6 +60,9 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unknown error") _LOGGER.exception("Unknown error")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(
f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}"
)
return self.async_create_entry( return self.async_create_entry(
title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}", title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}",
data=user_input, data=user_input,
@ -98,6 +102,9 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
await self.async_set_unique_id(
f"{import_input[CONF_START]} {import_input[CONF_DESTINATION]}"
)
return self.async_create_entry( return self.async_create_entry(
title=import_input[CONF_NAME], title=import_input[CONF_NAME],
data=import_input, data=import_input,

View File

@ -122,15 +122,25 @@ class SwissPublicTransportSensor(
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
) )
async def async_added_to_hass(self) -> None:
"""Prepare the extra attributes at start."""
self._async_update_attrs()
await super().async_added_to_hass()
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle the state update and prepare the extra state attributes.""" """Handle the state update and prepare the extra state attributes."""
self._async_update_attrs()
return super()._handle_coordinator_update()
@callback
def _async_update_attrs(self) -> None:
"""Update the extra state attributes based on the coordinator data."""
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
key: value key: value
for key, value in self.coordinator.data.items() for key, value in self.coordinator.data.items()
if key not in {"departure"} if key not in {"departure"}
} }
return super()._handle_coordinator_update()
@property @property
def native_value(self) -> str: def native_value(self) -> str:

View File

@ -89,8 +89,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# New device - create device # New device - create device
_LOGGER.info( _LOGGER.info(
"Discovered Switcher device - id: %s, name: %s, type: %s (%s)", "Discovered Switcher device - id: %s, key: %s, name: %s, type: %s (%s)",
device.device_id, device.device_id,
device.device_key,
device.name, device.name,
device.device_type.value, device.device_type.value,
device.device_type.hex_rep, device.device_type.hex_rep,

View File

@ -142,7 +142,9 @@ class SwitcherThermostatButtonEntity(
try: try:
async with SwitcherType2Api( async with SwitcherType2Api(
self.coordinator.data.ip_address, self.coordinator.data.device_id self.coordinator.data.ip_address,
self.coordinator.data.device_id,
self.coordinator.data.device_key,
) as swapi: ) as swapi:
response = await self.entity_description.press_fn(swapi, self._remote) response = await self.entity_description.press_fn(swapi, self._remote)
except (asyncio.TimeoutError, OSError, RuntimeError) as err: except (asyncio.TimeoutError, OSError, RuntimeError) as err:

View File

@ -162,7 +162,9 @@ class SwitcherClimateEntity(
try: try:
async with SwitcherType2Api( async with SwitcherType2Api(
self.coordinator.data.ip_address, self.coordinator.data.device_id self.coordinator.data.ip_address,
self.coordinator.data.device_id,
self.coordinator.data.device_key,
) as swapi: ) as swapi:
response = await swapi.control_breeze_device(self._remote, **kwargs) response = await swapi.control_breeze_device(self._remote, **kwargs)
except (asyncio.TimeoutError, OSError, RuntimeError) as err: except (asyncio.TimeoutError, OSError, RuntimeError) as err:

View File

@ -98,7 +98,9 @@ class SwitcherCoverEntity(
try: try:
async with SwitcherType2Api( async with SwitcherType2Api(
self.coordinator.data.ip_address, self.coordinator.data.device_id self.coordinator.data.ip_address,
self.coordinator.data.device_id,
self.coordinator.data.device_key,
) as swapi: ) as swapi:
response = await getattr(swapi, api)(*args) response = await getattr(swapi, api)(*args)
except (asyncio.TimeoutError, OSError, RuntimeError) as err: except (asyncio.TimeoutError, OSError, RuntimeError) as err:

View File

@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from .const import DATA_DEVICE, DOMAIN from .const import DATA_DEVICE, DOMAIN
TO_REDACT = {"device_id", "ip_address", "mac_address"} TO_REDACT = {"device_id", "device_key", "ip_address", "mac_address"}
async def async_get_config_entry_diagnostics( async def async_get_config_entry_diagnostics(

View File

@ -7,5 +7,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aioswitcher"], "loggers": ["aioswitcher"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aioswitcher==3.3.0"] "requirements": ["aioswitcher==3.4.1"]
} }

View File

@ -105,13 +105,17 @@ class SwitcherBaseSwitchEntity(
async def _async_call_api(self, api: str, *args: Any) -> None: async def _async_call_api(self, api: str, *args: Any) -> None:
"""Call Switcher API.""" """Call Switcher API."""
_LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) _LOGGER.debug(
"Calling api for %s, api: '%s', args: %s", self.coordinator.name, api, args
)
response: SwitcherBaseResponse = None response: SwitcherBaseResponse = None
error = None error = None
try: try:
async with SwitcherType1Api( async with SwitcherType1Api(
self.coordinator.data.ip_address, self.coordinator.data.device_id self.coordinator.data.ip_address,
self.coordinator.data.device_id,
self.coordinator.data.device_key,
) as swapi: ) as swapi:
response = await getattr(swapi, api)(*args) response = await getattr(swapi, api)(*args)
except (asyncio.TimeoutError, OSError, RuntimeError) as err: except (asyncio.TimeoutError, OSError, RuntimeError) as err:

View File

@ -405,7 +405,7 @@ async def async_setup_entry(
is_enabled = check_legacy_resource( is_enabled = check_legacy_resource(
f"{_type}_{argument}", legacy_resources f"{_type}_{argument}", legacy_resources
) )
loaded_resources.add(f"{_type}_{argument}") loaded_resources.add(f"{_type}_{slugify(argument)}")
entities.append( entities.append(
SystemMonitorSensor( SystemMonitorSensor(
sensor_registry, sensor_registry,
@ -425,7 +425,7 @@ async def async_setup_entry(
is_enabled = check_legacy_resource( is_enabled = check_legacy_resource(
f"{_type}_{argument}", legacy_resources f"{_type}_{argument}", legacy_resources
) )
loaded_resources.add(f"{_type}_{argument}") loaded_resources.add(f"{_type}_{slugify(argument)}")
entities.append( entities.append(
SystemMonitorSensor( SystemMonitorSensor(
sensor_registry, sensor_registry,
@ -449,7 +449,7 @@ async def async_setup_entry(
sensor_registry[(_type, argument)] = SensorData( sensor_registry[(_type, argument)] = SensorData(
argument, None, None, None, None argument, None, None, None, None
) )
loaded_resources.add(f"{_type}_{argument}") loaded_resources.add(f"{_type}_{slugify(argument)}")
entities.append( entities.append(
SystemMonitorSensor( SystemMonitorSensor(
sensor_registry, sensor_registry,
@ -478,10 +478,13 @@ async def async_setup_entry(
# of mount points automatically discovered # of mount points automatically discovered
for resource in legacy_resources: for resource in legacy_resources:
if resource.startswith("disk_"): if resource.startswith("disk_"):
check_resource = slugify(resource)
_LOGGER.debug( _LOGGER.debug(
"Check resource %s already loaded in %s", resource, loaded_resources "Check resource %s already loaded in %s",
check_resource,
loaded_resources,
) )
if resource not in loaded_resources: if check_resource not in loaded_resources:
split_index = resource.rfind("_") split_index = resource.rfind("_")
_type = resource[:split_index] _type = resource[:split_index]
argument = resource[split_index + 1 :] argument = resource[split_index + 1 :]

View File

@ -25,6 +25,11 @@ def get_all_disk_mounts() -> set[str]:
"No permission for running user to access %s", part.mountpoint "No permission for running user to access %s", part.mountpoint
) )
continue continue
except OSError as err:
_LOGGER.debug(
"Mountpoint %s was excluded because of: %s", part.mountpoint, err
)
continue
if usage.total > 0 and part.device != "": if usage.total > 0 and part.device != "":
disks.add(part.mountpoint) disks.add(part.mountpoint)
_LOGGER.debug("Adding disks: %s", ", ".join(disks)) _LOGGER.debug("Adding disks: %s", ", ".join(disks))

View File

@ -186,12 +186,13 @@ class TadoConnector:
def get_mobile_devices(self): def get_mobile_devices(self):
"""Return the Tado mobile devices.""" """Return the Tado mobile devices."""
return self.tado.getMobileDevices() return self.tado.get_mobile_devices()
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self): def update(self):
"""Update the registered zones.""" """Update the registered zones."""
self.update_devices() self.update_devices()
self.update_mobile_devices()
self.update_zones() self.update_zones()
self.update_home() self.update_home()
@ -203,17 +204,31 @@ class TadoConnector:
_LOGGER.error("Unable to connect to Tado while updating mobile devices") _LOGGER.error("Unable to connect to Tado while updating mobile devices")
return return
if not mobile_devices:
_LOGGER.debug("No linked mobile devices found for home ID %s", self.home_id)
return
# Errors are planned to be converted to exceptions
# in PyTado library, so this can be removed
if "errors" in mobile_devices and mobile_devices["errors"]:
_LOGGER.error(
"Error for home ID %s while updating mobile devices: %s",
self.home_id,
mobile_devices["errors"],
)
return
for mobile_device in mobile_devices: for mobile_device in mobile_devices:
self.data["mobile_device"][mobile_device["id"]] = mobile_device self.data["mobile_device"][mobile_device["id"]] = mobile_device
_LOGGER.debug(
"Dispatching update to %s mobile device: %s",
self.home_id,
mobile_device,
)
_LOGGER.debug(
"Dispatching update to %s mobile devices: %s",
self.home_id,
mobile_devices,
)
dispatcher_send( dispatcher_send(
self.hass, self.hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self.home_id),
) )
def update_devices(self): def update_devices(self):
@ -224,6 +239,20 @@ class TadoConnector:
_LOGGER.error("Unable to connect to Tado while updating devices") _LOGGER.error("Unable to connect to Tado while updating devices")
return return
if not devices:
_LOGGER.debug("No linked devices found for home ID %s", self.home_id)
return
# Errors are planned to be converted to exceptions
# in PyTado library, so this can be removed
if "errors" in devices and devices["errors"]:
_LOGGER.error(
"Error for home ID %s while updating devices: %s",
self.home_id,
devices["errors"],
)
return
for device in devices: for device in devices:
device_short_serial_no = device["shortSerialNo"] device_short_serial_no = device["shortSerialNo"]
_LOGGER.debug("Updating device %s", device_short_serial_no) _LOGGER.debug("Updating device %s", device_short_serial_no)

View File

@ -179,7 +179,7 @@ TADO_TO_HA_SWING_MODE_MAP = {
DOMAIN = "tado" DOMAIN = "tado"
SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}_{}" SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}_{}"
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received" SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received_{}"
UNIQUE_ID = "unique_id" UNIQUE_ID = "unique_id"
DEFAULT_NAME = "Tado" DEFAULT_NAME = "Tado"

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
import voluptuous as vol import voluptuous as vol
@ -22,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from . import TadoConnector
from .const import CONF_HOME_ID, DATA, DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED from .const import CONF_HOME_ID, DATA, DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -90,7 +90,7 @@ async def async_setup_entry(
entry.async_on_unload( entry.async_on_unload(
async_dispatcher_connect( async_dispatcher_connect(
hass, hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(tado.home_id),
update_devices, update_devices,
) )
) )
@ -99,12 +99,12 @@ async def async_setup_entry(
@callback @callback
def add_tracked_entities( def add_tracked_entities(
hass: HomeAssistant, hass: HomeAssistant,
tado: Any, tado: TadoConnector,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
tracked: set[str], tracked: set[str],
) -> None: ) -> None:
"""Add new tracker entities from Tado.""" """Add new tracker entities from Tado."""
_LOGGER.debug("Fetching Tado devices from API") _LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities")
new_tracked = [] new_tracked = []
for device_key, device in tado.data["mobile_device"].items(): for device_key, device in tado.data["mobile_device"].items():
if device_key in tracked: if device_key in tracked:
@ -128,7 +128,7 @@ class TadoDeviceTrackerEntity(TrackerEntity):
self, self,
device_id: str, device_id: str,
device_name: str, device_name: str,
tado: Any, tado: TadoConnector,
) -> None: ) -> None:
"""Initialize a Tado Device Tracker entity.""" """Initialize a Tado Device Tracker entity."""
super().__init__() super().__init__()
@ -169,7 +169,7 @@ class TadoDeviceTrackerEntity(TrackerEntity):
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self._tado.home_id),
self.on_demand_update, self.on_demand_update,
) )
) )

View File

@ -121,5 +121,6 @@ class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity):
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="communication_error", translation_key="communication_error",
) from exc ) from exc
self._attr_is_closing = False finally:
self._attr_is_closing = False
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()

View File

@ -220,6 +220,26 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity):
hue, sat = tuple(int(val) for val in hs_color) hue, sat = tuple(int(val) for val in hs_color)
await self.device.set_hsv(hue, sat, brightness, transition=transition) await self.device.set_hsv(hue, sat, brightness, transition=transition)
async def _async_set_color_temp(
self, color_temp: float | int, brightness: int | None, transition: int | None
) -> None:
device = self.device
valid_temperature_range = device.valid_temperature_range
requested_color_temp = round(color_temp)
# Clamp color temp to valid range
# since if the light in a group we will
# get requests for color temps for the range
# of the group and not the light
clamped_color_temp = min(
valid_temperature_range.max,
max(valid_temperature_range.min, requested_color_temp),
)
await device.set_color_temp(
clamped_color_temp,
brightness=brightness,
transition=transition,
)
async def _async_turn_on_with_brightness( async def _async_turn_on_with_brightness(
self, brightness: int | None, transition: int | None self, brightness: int | None, transition: int | None
) -> None: ) -> None:
@ -234,10 +254,8 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity):
"""Turn the light on.""" """Turn the light on."""
brightness, transition = self._async_extract_brightness_transition(**kwargs) brightness, transition = self._async_extract_brightness_transition(**kwargs)
if ATTR_COLOR_TEMP_KELVIN in kwargs: if ATTR_COLOR_TEMP_KELVIN in kwargs:
await self.device.set_color_temp( await self._async_set_color_temp(
int(kwargs[ATTR_COLOR_TEMP_KELVIN]), kwargs[ATTR_COLOR_TEMP_KELVIN], brightness, transition
brightness=brightness,
transition=transition,
) )
if ATTR_HS_COLOR in kwargs: if ATTR_HS_COLOR in kwargs:
await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition)
@ -324,10 +342,8 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb):
# we have to set an HSV value to clear the effect # we have to set an HSV value to clear the effect
# before we can set a color temp # before we can set a color temp
await self.device.set_hsv(0, 0, brightness) await self.device.set_hsv(0, 0, brightness)
await self.device.set_color_temp( await self._async_set_color_temp(
int(kwargs[ATTR_COLOR_TEMP_KELVIN]), kwargs[ATTR_COLOR_TEMP_KELVIN], brightness, transition
brightness=brightness,
transition=transition,
) )
elif ATTR_HS_COLOR in kwargs: elif ATTR_HS_COLOR in kwargs:
await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition)

View File

@ -94,7 +94,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
tasks = [_setup_lte(hass, conf) for conf in domain_config] tasks = [_setup_lte(hass, conf) for conf in domain_config]
if tasks: if tasks:
await asyncio.wait(tasks) await asyncio.gather(*tasks)
for conf in domain_config: for conf in domain_config:
for notify_conf in conf.get(CONF_NOTIFY, []): for notify_conf in conf.get(CONF_NOTIFY, []):

View File

@ -67,7 +67,6 @@ async def get_extended_options_schema(handler: SchemaCommonFlowHandler) -> vol.S
CONF_MIN_GRADIENT, default=DEFAULT_MIN_GRADIENT CONF_MIN_GRADIENT, default=DEFAULT_MIN_GRADIENT
): selector.NumberSelector( ): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=0,
step="any", step="any",
mode=selector.NumberSelectorMode.BOX, mode=selector.NumberSelectorMode.BOX,
), ),

View File

@ -173,15 +173,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ufp_value="is_vehicle_detection_on", ufp_value="is_vehicle_detection_on",
ufp_perm=PermRequired.NO_WRITE, ufp_perm=PermRequired.NO_WRITE,
), ),
ProtectBinaryEntityDescription(
key="smart_face",
name="Detections: Face",
icon="mdi:mdi-face",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_required_field="can_detect_face",
ufp_value="is_face_detection_on",
ufp_perm=PermRequired.NO_WRITE,
),
ProtectBinaryEntityDescription( ProtectBinaryEntityDescription(
key="smart_package", key="smart_package",
name="Detections: Package", name="Detections: Package",
@ -202,13 +193,22 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
), ),
ProtectBinaryEntityDescription( ProtectBinaryEntityDescription(
key="smart_smoke", key="smart_smoke",
name="Detections: Smoke/CO", name="Detections: Smoke",
icon="mdi:fire", icon="mdi:fire",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
ufp_required_field="can_detect_smoke", ufp_required_field="can_detect_smoke",
ufp_value="is_smoke_detection_on", ufp_value="is_smoke_detection_on",
ufp_perm=PermRequired.NO_WRITE, ufp_perm=PermRequired.NO_WRITE,
), ),
ProtectBinaryEntityDescription(
key="smart_cmonx",
name="Detections: CO",
icon="mdi:molecule-co",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_required_field="can_detect_co",
ufp_value="is_co_detection_on",
ufp_perm=PermRequired.NO_WRITE,
),
) )
LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
@ -342,7 +342,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
key="motion", key="motion",
name="Motion", name="Motion",
device_class=BinarySensorDeviceClass.MOTION, device_class=BinarySensorDeviceClass.MOTION,
ufp_value="is_motion_detected", ufp_value="is_motion_currently_detected",
ufp_enabled="is_motion_detection_on", ufp_enabled="is_motion_detection_on",
ufp_event_obj="last_motion_event", ufp_event_obj="last_motion_event",
), ),
@ -350,7 +350,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
key="smart_obj_any", key="smart_obj_any",
name="Object Detected", name="Object Detected",
icon="mdi:eye", icon="mdi:eye",
ufp_value="is_smart_detected", ufp_value="is_smart_currently_detected",
ufp_required_field="feature_flags.has_smart_detect", ufp_required_field="feature_flags.has_smart_detect",
ufp_event_obj="last_smart_detect_event", ufp_event_obj="last_smart_detect_event",
), ),
@ -358,7 +358,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
key="smart_obj_person", key="smart_obj_person",
name="Person Detected", name="Person Detected",
icon="mdi:walk", icon="mdi:walk",
ufp_value="is_smart_detected", ufp_value="is_person_currently_detected",
ufp_required_field="can_detect_person", ufp_required_field="can_detect_person",
ufp_enabled="is_person_detection_on", ufp_enabled="is_person_detection_on",
ufp_event_obj="last_person_detect_event", ufp_event_obj="last_person_detect_event",
@ -367,25 +367,16 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
key="smart_obj_vehicle", key="smart_obj_vehicle",
name="Vehicle Detected", name="Vehicle Detected",
icon="mdi:car", icon="mdi:car",
ufp_value="is_smart_detected", ufp_value="is_vehicle_currently_detected",
ufp_required_field="can_detect_vehicle", ufp_required_field="can_detect_vehicle",
ufp_enabled="is_vehicle_detection_on", ufp_enabled="is_vehicle_detection_on",
ufp_event_obj="last_vehicle_detect_event", ufp_event_obj="last_vehicle_detect_event",
), ),
ProtectBinaryEventEntityDescription(
key="smart_obj_face",
name="Face Detected",
icon="mdi:mdi-face",
ufp_value="is_smart_detected",
ufp_required_field="can_detect_face",
ufp_enabled="is_face_detection_on",
ufp_event_obj="last_face_detect_event",
),
ProtectBinaryEventEntityDescription( ProtectBinaryEventEntityDescription(
key="smart_obj_package", key="smart_obj_package",
name="Package Detected", name="Package Detected",
icon="mdi:package-variant-closed", icon="mdi:package-variant-closed",
ufp_value="is_smart_detected", ufp_value="is_package_currently_detected",
ufp_required_field="can_detect_package", ufp_required_field="can_detect_package",
ufp_enabled="is_package_detection_on", ufp_enabled="is_package_detection_on",
ufp_event_obj="last_package_detect_event", ufp_event_obj="last_package_detect_event",
@ -394,7 +385,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
key="smart_audio_any", key="smart_audio_any",
name="Audio Object Detected", name="Audio Object Detected",
icon="mdi:eye", icon="mdi:eye",
ufp_value="is_smart_detected", ufp_value="is_audio_currently_detected",
ufp_required_field="feature_flags.has_smart_detect", ufp_required_field="feature_flags.has_smart_detect",
ufp_event_obj="last_smart_audio_detect_event", ufp_event_obj="last_smart_audio_detect_event",
), ),
@ -402,7 +393,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
key="smart_audio_smoke", key="smart_audio_smoke",
name="Smoke Alarm Detected", name="Smoke Alarm Detected",
icon="mdi:fire", icon="mdi:fire",
ufp_value="is_smart_detected", ufp_value="is_smoke_currently_detected",
ufp_required_field="can_detect_smoke", ufp_required_field="can_detect_smoke",
ufp_enabled="is_smoke_detection_on", ufp_enabled="is_smoke_detection_on",
ufp_event_obj="last_smoke_detect_event", ufp_event_obj="last_smoke_detect_event",
@ -410,10 +401,10 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
ProtectBinaryEventEntityDescription( ProtectBinaryEventEntityDescription(
key="smart_audio_cmonx", key="smart_audio_cmonx",
name="CO Alarm Detected", name="CO Alarm Detected",
icon="mdi:fire", icon="mdi:molecule-co",
ufp_value="is_smart_detected", ufp_value="is_cmonx_currently_detected",
ufp_required_field="can_detect_smoke", ufp_required_field="can_detect_co",
ufp_enabled="is_smoke_detection_on", ufp_enabled="is_co_detection_on",
ufp_event_obj="last_cmonx_detect_event", ufp_event_obj="last_cmonx_detect_event",
), ),
) )
@ -619,7 +610,7 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity):
@callback @callback
def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None:
super()._async_update_device_from_protect(device) super()._async_update_device_from_protect(device)
is_on = self.entity_description.get_is_on(self._event) is_on = self.entity_description.get_is_on(self.device, self._event)
self._attr_is_on: bool | None = is_on self._attr_is_on: bool | None = is_on
if not is_on: if not is_on:
self._event = None self._event = None

View File

@ -41,7 +41,7 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pyunifiprotect", "unifi_discovery"], "loggers": ["pyunifiprotect", "unifi_discovery"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pyunifiprotect==4.22.5", "unifi-discovery==1.1.7"], "requirements": ["pyunifiprotect==4.23.2", "unifi-discovery==1.1.7"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "Ubiquiti Networks", "manufacturer": "Ubiquiti Networks",

View File

@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription
from homeassistant.util import dt as dt_util
from .utils import get_nested_attr from .utils import get_nested_attr
@ -114,17 +113,10 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]):
return cast(Event, getattr(obj, self.ufp_event_obj, None)) return cast(Event, getattr(obj, self.ufp_event_obj, None))
return None return None
def get_is_on(self, event: Event | None) -> bool: def get_is_on(self, obj: T, event: Event | None) -> bool:
"""Return value if event is active.""" """Return value if event is active."""
if event is None:
return False
now = dt_util.utcnow() return event is not None and self.get_ufp_value(obj)
value = now > event.start
if value and event.end is not None and now > event.end:
value = False
return value
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -527,7 +527,7 @@ EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = (
name="License Plate Detected", name="License Plate Detected",
icon="mdi:car", icon="mdi:car",
translation_key="license_plate", translation_key="license_plate",
ufp_value="is_smart_detected", ufp_value="is_license_plate_currently_detected",
ufp_required_field="can_detect_license_plate", ufp_required_field="can_detect_license_plate",
ufp_event_obj="last_license_plate_detect_event", ufp_event_obj="last_license_plate_detect_event",
), ),
@ -781,7 +781,7 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity):
EventEntityMixin._async_update_device_from_protect(self, device) EventEntityMixin._async_update_device_from_protect(self, device)
event = self._event event = self._event
entity_description = self.entity_description entity_description = self.entity_description
is_on = entity_description.get_is_on(event) is_on = entity_description.get_is_on(self.device, self._event)
is_license_plate = ( is_license_plate = (
entity_description.ufp_event_obj == "last_license_plate_detect_event" entity_description.ufp_event_obj == "last_license_plate_detect_event"
) )

View File

@ -135,6 +135,16 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
ufp_set_method="set_osd_bitrate", ufp_set_method="set_osd_bitrate",
ufp_perm=PermRequired.WRITE, ufp_perm=PermRequired.WRITE,
), ),
ProtectSwitchEntityDescription(
key="color_night_vision",
name="Color Night Vision",
icon="mdi:light-flood-down",
entity_category=EntityCategory.CONFIG,
ufp_required_field="has_color_night_vision",
ufp_value="isp_settings.is_color_night_vision_enabled",
ufp_set_method="set_color_night_vision",
ufp_perm=PermRequired.WRITE,
),
ProtectSwitchEntityDescription( ProtectSwitchEntityDescription(
key="motion", key="motion",
name="Detections: Motion", name="Detections: Motion",
@ -167,17 +177,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
ufp_set_method="set_vehicle_detection", ufp_set_method="set_vehicle_detection",
ufp_perm=PermRequired.WRITE, ufp_perm=PermRequired.WRITE,
), ),
ProtectSwitchEntityDescription(
key="smart_face",
name="Detections: Face",
icon="mdi:human-greeting",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_face",
ufp_value="is_face_detection_on",
ufp_enabled="is_recording_enabled",
ufp_set_method="set_face_detection",
ufp_perm=PermRequired.WRITE,
),
ProtectSwitchEntityDescription( ProtectSwitchEntityDescription(
key="smart_package", key="smart_package",
name="Detections: Package", name="Detections: Package",
@ -202,7 +201,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
), ),
ProtectSwitchEntityDescription( ProtectSwitchEntityDescription(
key="smart_smoke", key="smart_smoke",
name="Detections: Smoke/CO", name="Detections: Smoke",
icon="mdi:fire", icon="mdi:fire",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_smoke", ufp_required_field="can_detect_smoke",
@ -212,13 +211,14 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
ufp_perm=PermRequired.WRITE, ufp_perm=PermRequired.WRITE,
), ),
ProtectSwitchEntityDescription( ProtectSwitchEntityDescription(
key="color_night_vision", key="smart_cmonx",
name="Color Night Vision", name="Detections: CO",
icon="mdi:light-flood-down", icon="mdi:molecule-co",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
ufp_required_field="has_color_night_vision", ufp_required_field="can_detect_co",
ufp_value="isp_settings.is_color_night_vision_enabled", ufp_value="is_co_detection_on",
ufp_set_method="set_color_night_vision", ufp_enabled="is_recording_enabled",
ufp_set_method="set_cmonx_detection",
ufp_perm=PermRequired.WRITE, ufp_perm=PermRequired.WRITE,
), ),
) )

View File

@ -186,9 +186,10 @@ class ValveEntity(Entity):
@final @final
@property @property
def state_attributes(self) -> dict[str, Any]: def state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes.""" """Return the state attributes."""
if not self.reports_position:
return None
return {ATTR_CURRENT_POSITION: self.current_valve_position} return {ATTR_CURRENT_POSITION: self.current_valve_position}
@property @property

View File

@ -1,6 +1,7 @@
"""Support for ZoneMinder.""" """Support for ZoneMinder."""
import logging import logging
from requests.exceptions import ConnectionError as RequestsConnectionError
import voluptuous as vol import voluptuous as vol
from zoneminder.zm import ZoneMinder from zoneminder.zm import ZoneMinder
@ -75,7 +76,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
) )
hass.data[DOMAIN][host_name] = zm_client hass.data[DOMAIN][host_name] = zm_client
success = zm_client.login() and success try:
success = zm_client.login() and success
except RequestsConnectionError as ex:
_LOGGER.error(
"ZoneMinder connection failure to %s: %s",
host_name,
ex,
)
def set_active_state(call: ServiceCall) -> None: def set_active_state(call: ServiceCall) -> None:
"""Set the ZoneMinder run state to the given state name.""" """Set the ZoneMinder run state to the given state name."""

View File

@ -8,6 +8,7 @@ from zoneminder.zm import ZoneMinder
from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -28,8 +29,9 @@ def setup_platform(
zm_client: ZoneMinder zm_client: ZoneMinder
for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): for zm_client in hass.data[ZONEMINDER_DOMAIN].values():
if not (monitors := zm_client.get_monitors()): if not (monitors := zm_client.get_monitors()):
_LOGGER.warning("Could not fetch monitors from ZoneMinder host: %s") raise PlatformNotReady(
return "Camera could not fetch any monitors from ZoneMinder"
)
for monitor in monitors: for monitor in monitors:
_LOGGER.info("Initializing camera %s", monitor.id) _LOGGER.info("Initializing camera %s", monitor.id)

View File

@ -1,9 +1,9 @@
{ {
"domain": "zoneminder", "domain": "zoneminder",
"name": "ZoneMinder", "name": "ZoneMinder",
"codeowners": ["@rohankapoorcom"], "codeowners": ["@rohankapoorcom", "@nabbi"],
"documentation": "https://www.home-assistant.io/integrations/zoneminder", "documentation": "https://www.home-assistant.io/integrations/zoneminder",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["zoneminder"], "loggers": ["zoneminder"],
"requirements": ["zm-py==0.5.2"] "requirements": ["zm-py==0.5.4"]
} }

View File

@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
) )
from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -77,7 +78,9 @@ def setup_platform(
zm_client: ZoneMinder zm_client: ZoneMinder
for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): for zm_client in hass.data[ZONEMINDER_DOMAIN].values():
if not (monitors := zm_client.get_monitors()): if not (monitors := zm_client.get_monitors()):
_LOGGER.warning("Could not fetch any monitors from ZoneMinder") raise PlatformNotReady(
"Sensor could not fetch any monitors from ZoneMinder"
)
for monitor in monitors: for monitor in monitors:
sensors.append(ZMSensorMonitors(monitor)) sensors.append(ZMSensorMonitors(monitor))

View File

@ -11,6 +11,7 @@ from zoneminder.zm import ZoneMinder
from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity
from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -42,8 +43,9 @@ def setup_platform(
zm_client: ZoneMinder zm_client: ZoneMinder
for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): for zm_client in hass.data[ZONEMINDER_DOMAIN].values():
if not (monitors := zm_client.get_monitors()): if not (monitors := zm_client.get_monitors()):
_LOGGER.warning("Could not fetch monitors from ZoneMinder") raise PlatformNotReady(
return "Switch could not fetch any monitors from ZoneMinder"
)
for monitor in monitors: for monitor in monitors:
switches.append(ZMSwitchMonitors(monitor, on_state, off_state)) switches.append(ZMSwitchMonitors(monitor, on_state, off_state))

View File

@ -16,7 +16,7 @@ from .helpers.deprecation import (
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024 MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 1 MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "2" PATCH_VERSION: Final = "3"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0)

View File

@ -13,6 +13,7 @@ import logging
import math import math
import sys import sys
from timeit import default_timer as timer from timeit import default_timer as timer
from types import FunctionType
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
@ -374,6 +375,9 @@ class CachedProperties(type):
# Check if an _attr_ class attribute exits and move it to __attr_. We check # Check if an _attr_ class attribute exits and move it to __attr_. We check
# __dict__ here because we don't care about _attr_ class attributes in parents. # __dict__ here because we don't care about _attr_ class attributes in parents.
if attr_name in cls.__dict__: if attr_name in cls.__dict__:
attr = getattr(cls, attr_name)
if isinstance(attr, (FunctionType, property)):
raise TypeError(f"Can't override {attr_name} in subclass")
setattr(cls, private_attr_name, getattr(cls, attr_name)) setattr(cls, private_attr_name, getattr(cls, attr_name))
annotations = cls.__annotations__ annotations = cls.__annotations__
if attr_name in annotations: if attr_name in annotations:

View File

@ -14,7 +14,7 @@ bcrypt==4.0.1
bleak-retry-connector==3.4.0 bleak-retry-connector==3.4.0
bleak==0.21.1 bleak==0.21.1
bluetooth-adapters==0.16.2 bluetooth-adapters==0.16.2
bluetooth-auto-recovery==1.2.3 bluetooth-auto-recovery==1.3.0
bluetooth-data-tools==1.19.0 bluetooth-data-tools==1.19.0
cached_ipaddress==0.3.0 cached_ipaddress==0.3.0
certifi>=2021.5.30 certifi>=2021.5.30
@ -24,10 +24,10 @@ dbus-fast==2.21.0
fnv-hash-fast==0.5.0 fnv-hash-fast==0.5.0
ha-av==10.1.1 ha-av==10.1.1
ha-ffmpeg==3.1.0 ha-ffmpeg==3.1.0
habluetooth==2.0.2 habluetooth==2.1.0
hass-nabucasa==0.75.1 hass-nabucasa==0.75.1
hassil==1.5.1 hassil==1.5.1
home-assistant-bluetooth==1.11.0 home-assistant-bluetooth==1.12.0
home-assistant-frontend==20240104.0 home-assistant-frontend==20240104.0
home-assistant-intents==2024.1.2 home-assistant-intents==2024.1.2
httpx==0.26.0 httpx==0.26.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2024.1.2" version = "2024.1.3"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
readme = "README.rst" readme = "README.rst"
@ -37,7 +37,7 @@ dependencies = [
# When bumping httpx, please check the version pins of # When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all # httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.26.0", "httpx==0.26.0",
"home-assistant-bluetooth==1.11.0", "home-assistant-bluetooth==1.12.0",
"ifaddr==0.2.0", "ifaddr==0.2.0",
"Jinja2==3.1.2", "Jinja2==3.1.2",
"lru-dict==1.3.0", "lru-dict==1.3.0",

View File

@ -15,7 +15,7 @@ bcrypt==4.0.1
certifi>=2021.5.30 certifi>=2021.5.30
ciso8601==2.3.0 ciso8601==2.3.0
httpx==0.26.0 httpx==0.26.0
home-assistant-bluetooth==1.11.0 home-assistant-bluetooth==1.12.0
ifaddr==0.2.0 ifaddr==0.2.0
Jinja2==3.1.2 Jinja2==3.1.2
lru-dict==1.3.0 lru-dict==1.3.0

View File

@ -167,7 +167,7 @@ afsapi==0.2.7
agent-py==0.0.23 agent-py==0.0.23
# homeassistant.components.geo_json_events # homeassistant.components.geo_json_events
aio-geojson-generic-client==0.3 aio-geojson-generic-client==0.4
# homeassistant.components.geonetnz_quakes # homeassistant.components.geonetnz_quakes
aio-geojson-geonetnz-quakes==0.15 aio-geojson-geonetnz-quakes==0.15
@ -215,7 +215,7 @@ aiobafi6==0.9.0
aiobotocore==2.6.0 aiobotocore==2.6.0
# homeassistant.components.comelit # homeassistant.components.comelit
aiocomelit==0.7.0 aiocomelit==0.7.3
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodiscover==1.6.0 aiodiscover==1.6.0
@ -356,7 +356,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0 aiosenz==1.0.0
# homeassistant.components.shelly # homeassistant.components.shelly
aioshelly==7.0.0 aioshelly==7.1.0
# homeassistant.components.skybell # homeassistant.components.skybell
aioskybell==22.7.0 aioskybell==22.7.0
@ -368,7 +368,7 @@ aioslimproto==2.3.3
aiosteamist==0.3.2 aiosteamist==0.3.2
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==3.3.0 aioswitcher==3.4.1
# homeassistant.components.syncthing # homeassistant.components.syncthing
aiosyncthing==0.5.1 aiosyncthing==0.5.1
@ -547,7 +547,7 @@ bleak==0.21.1
blebox-uniapi==2.2.0 blebox-uniapi==2.2.0
# homeassistant.components.blink # homeassistant.components.blink
blinkpy==0.22.4 blinkpy==0.22.5
# homeassistant.components.bitcoin # homeassistant.components.bitcoin
blockchain==1.4.4 blockchain==1.4.4
@ -566,7 +566,7 @@ bluemaestro-ble==0.2.3
bluetooth-adapters==0.16.2 bluetooth-adapters==0.16.2
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-auto-recovery==1.2.3 bluetooth-auto-recovery==1.3.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
# homeassistant.components.ld2410_ble # homeassistant.components.ld2410_ble
@ -998,7 +998,7 @@ ha-philipsjs==3.1.1
habitipy==0.2.0 habitipy==0.2.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
habluetooth==2.0.2 habluetooth==2.1.0
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.75.1 hass-nabucasa==0.75.1
@ -1240,7 +1240,7 @@ maxcube-api==0.4.3
mbddns==0.1.2 mbddns==0.1.2
# homeassistant.components.minecraft_server # homeassistant.components.minecraft_server
mcstatus==11.0.0 mcstatus==11.1.1
# homeassistant.components.meater # homeassistant.components.meater
meater-python==0.0.8 meater-python==0.0.8
@ -1548,7 +1548,7 @@ pushover_complete==1.1.1
pvo==2.1.1 pvo==2.1.1
# homeassistant.components.aosmith # homeassistant.components.aosmith
py-aosmith==1.0.1 py-aosmith==1.0.4
# homeassistant.components.canary # homeassistant.components.canary
py-canary==0.5.3 py-canary==0.5.3
@ -2280,7 +2280,7 @@ pytrydan==0.4.0
pyudev==0.23.2 pyudev==0.23.2
# homeassistant.components.unifiprotect # homeassistant.components.unifiprotect
pyunifiprotect==4.22.5 pyunifiprotect==4.23.2
# homeassistant.components.uptimerobot # homeassistant.components.uptimerobot
pyuptimerobot==22.2.0 pyuptimerobot==22.2.0
@ -2376,7 +2376,7 @@ renault-api==0.2.1
renson-endura-delta==1.7.1 renson-endura-delta==1.7.1
# homeassistant.components.reolink # homeassistant.components.reolink
reolink-aio==0.8.5 reolink-aio==0.8.6
# homeassistant.components.idteck_prox # homeassistant.components.idteck_prox
rfk101py==0.0.1 rfk101py==0.0.1
@ -2890,7 +2890,7 @@ zigpy-znp==0.12.1
zigpy==0.60.4 zigpy==0.60.4
# homeassistant.components.zoneminder # homeassistant.components.zoneminder
zm-py==0.5.2 zm-py==0.5.4
# homeassistant.components.zwave_js # homeassistant.components.zwave_js
zwave-js-server-python==0.55.3 zwave-js-server-python==0.55.3

View File

@ -146,7 +146,7 @@ afsapi==0.2.7
agent-py==0.0.23 agent-py==0.0.23
# homeassistant.components.geo_json_events # homeassistant.components.geo_json_events
aio-geojson-generic-client==0.3 aio-geojson-generic-client==0.4
# homeassistant.components.geonetnz_quakes # homeassistant.components.geonetnz_quakes
aio-geojson-geonetnz-quakes==0.15 aio-geojson-geonetnz-quakes==0.15
@ -194,7 +194,7 @@ aiobafi6==0.9.0
aiobotocore==2.6.0 aiobotocore==2.6.0
# homeassistant.components.comelit # homeassistant.components.comelit
aiocomelit==0.7.0 aiocomelit==0.7.3
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodiscover==1.6.0 aiodiscover==1.6.0
@ -329,7 +329,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0 aiosenz==1.0.0
# homeassistant.components.shelly # homeassistant.components.shelly
aioshelly==7.0.0 aioshelly==7.1.0
# homeassistant.components.skybell # homeassistant.components.skybell
aioskybell==22.7.0 aioskybell==22.7.0
@ -341,7 +341,7 @@ aioslimproto==2.3.3
aiosteamist==0.3.2 aiosteamist==0.3.2
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==3.3.0 aioswitcher==3.4.1
# homeassistant.components.syncthing # homeassistant.components.syncthing
aiosyncthing==0.5.1 aiosyncthing==0.5.1
@ -466,7 +466,7 @@ bleak==0.21.1
blebox-uniapi==2.2.0 blebox-uniapi==2.2.0
# homeassistant.components.blink # homeassistant.components.blink
blinkpy==0.22.4 blinkpy==0.22.5
# homeassistant.components.blue_current # homeassistant.components.blue_current
bluecurrent-api==1.0.6 bluecurrent-api==1.0.6
@ -478,7 +478,7 @@ bluemaestro-ble==0.2.3
bluetooth-adapters==0.16.2 bluetooth-adapters==0.16.2
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-auto-recovery==1.2.3 bluetooth-auto-recovery==1.3.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
# homeassistant.components.ld2410_ble # homeassistant.components.ld2410_ble
@ -803,7 +803,7 @@ ha-philipsjs==3.1.1
habitipy==0.2.0 habitipy==0.2.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
habluetooth==2.0.2 habluetooth==2.1.0
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.75.1 hass-nabucasa==0.75.1
@ -976,7 +976,7 @@ maxcube-api==0.4.3
mbddns==0.1.2 mbddns==0.1.2
# homeassistant.components.minecraft_server # homeassistant.components.minecraft_server
mcstatus==11.0.0 mcstatus==11.1.1
# homeassistant.components.meater # homeassistant.components.meater
meater-python==0.0.8 meater-python==0.0.8
@ -1195,7 +1195,7 @@ pushover_complete==1.1.1
pvo==2.1.1 pvo==2.1.1
# homeassistant.components.aosmith # homeassistant.components.aosmith
py-aosmith==1.0.1 py-aosmith==1.0.4
# homeassistant.components.canary # homeassistant.components.canary
py-canary==0.5.3 py-canary==0.5.3
@ -1726,7 +1726,7 @@ pytrydan==0.4.0
pyudev==0.23.2 pyudev==0.23.2
# homeassistant.components.unifiprotect # homeassistant.components.unifiprotect
pyunifiprotect==4.22.5 pyunifiprotect==4.23.2
# homeassistant.components.uptimerobot # homeassistant.components.uptimerobot
pyuptimerobot==22.2.0 pyuptimerobot==22.2.0
@ -1798,7 +1798,7 @@ renault-api==0.2.1
renson-endura-delta==1.7.1 renson-endura-delta==1.7.1
# homeassistant.components.reolink # homeassistant.components.reolink
reolink-aio==0.8.5 reolink-aio==0.8.6
# homeassistant.components.rflink # homeassistant.components.rflink
rflink==0.0.65 rflink==0.0.65

View File

@ -224,9 +224,20 @@ class ReportedProperties:
def assert_equal(self, namespace, name, value): def assert_equal(self, namespace, name, value):
"""Assert a property is equal to a given value.""" """Assert a property is equal to a given value."""
prop_set = None
prop_count = 0
for prop in self.properties: for prop in self.properties:
if prop["namespace"] == namespace and prop["name"] == name: if prop["namespace"] == namespace and prop["name"] == name:
assert prop["value"] == value assert prop["value"] == value
return prop prop_set = prop
prop_count += 1
if prop_count > 1:
pytest.fail(
f"property {namespace}:{name} more than once in {self.properties!r}"
)
if prop_set:
return prop_set
pytest.fail(f"property {namespace}:{name} not in {self.properties!r}") pytest.fail(f"property {namespace}:{name} not in {self.properties!r}")

View File

@ -1,6 +1,7 @@
"""Test Smart Home HTTP endpoints.""" """Test Smart Home HTTP endpoints."""
from http import HTTPStatus from http import HTTPStatus
import json import json
import logging
from typing import Any from typing import Any
import pytest import pytest
@ -44,11 +45,16 @@ async def do_http_discovery(config, hass, hass_client):
], ],
) )
async def test_http_api( async def test_http_api(
hass: HomeAssistant, hass_client: ClientSessionGenerator, config: dict[str, Any] hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
hass_client: ClientSessionGenerator,
config: dict[str, Any],
) -> None: ) -> None:
"""With `smart_home:` HTTP API is exposed.""" """With `smart_home:` HTTP API is exposed and debug log is redacted."""
response = await do_http_discovery(config, hass, hass_client) with caplog.at_level(logging.DEBUG):
response_data = await response.json() response = await do_http_discovery(config, hass, hass_client)
response_data = await response.json()
assert "'correlationToken': '**REDACTED**'" in caplog.text
# Here we're testing just the HTTP view glue -- details of discovery are # Here we're testing just the HTTP view glue -- details of discovery are
# covered in other tests. # covered in other tests.
@ -61,5 +67,4 @@ async def test_http_api_disabled(
"""Without `smart_home:`, the HTTP API is disabled.""" """Without `smart_home:`, the HTTP API is disabled."""
config = {"alexa": {}} config = {"alexa": {}}
response = await do_http_discovery(config, hass, hass_client) response = await do_http_discovery(config, hass, hass_client)
assert response.status == HTTPStatus.NOT_FOUND assert response.status == HTTPStatus.NOT_FOUND

View File

@ -54,10 +54,16 @@ async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, No
get_energy_use_fixture = load_json_object_fixture( get_energy_use_fixture = load_json_object_fixture(
"get_energy_use_data.json", DOMAIN "get_energy_use_data.json", DOMAIN
) )
get_all_device_info_fixture = load_json_object_fixture(
"get_all_device_info.json", DOMAIN
)
client_mock = MagicMock(AOSmithAPIClient) client_mock = MagicMock(AOSmithAPIClient)
client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) client_mock.get_devices = AsyncMock(return_value=get_devices_fixture)
client_mock.get_energy_use_data = AsyncMock(return_value=get_energy_use_fixture) client_mock.get_energy_use_data = AsyncMock(return_value=get_energy_use_fixture)
client_mock.get_all_device_info = AsyncMock(
return_value=get_all_device_info_fixture
)
return client_mock return client_mock

View File

@ -0,0 +1,247 @@
{
"devices": [
{
"alertSettings": {
"faultCode": {
"major": {
"email": true,
"sms": false
},
"minor": {
"email": false,
"sms": false
}
},
"operatingSetPoint": {
"email": false,
"sms": false
},
"tankTemperature": {
"highTemperature": {
"email": false,
"sms": false,
"value": 160
},
"lowTemperature": {
"email": false,
"sms": false,
"value": 120
}
}
},
"brand": "aosmith",
"deviceType": "NEXT_GEN_HEAT_PUMP",
"dsn": "dsn",
"hardware": {
"hasBluetooth": true,
"interface": "CONTROL_PANEL"
},
"id": "id",
"install": {
"address": "sample_address",
"city": "sample_city",
"country": "United States",
"date": "2023-09-29",
"email": "sample_email",
"group": "Residential",
"location": "Basement",
"phone": "sample_phone",
"postalCode": "sample_postal_code",
"professional": false,
"registeredOwner": "sample_owner",
"registrationDate": "2023-12-24",
"state": "sample_state"
},
"isRegistered": true,
"junctionId": "junctionId",
"lastUpdate": 1703386473737,
"model": "HPTS-50 200 202172000",
"name": "Water Heater",
"permissions": "USER",
"productId": "100350404",
"serial": "sample_serial",
"users": [
{
"contactId": "sample_contact_id",
"email": "sample_email",
"firstName": "sample_first_name",
"isSelf": true,
"lastName": "sample_last_name",
"permissions": "USER"
}
],
"data": {
"activeAlerts": [],
"alertHistory": [],
"isOnline": true,
"isWifi": true,
"lastUpdate": 1703138389000,
"signalStrength": null,
"heaterSsid": "sample_heater_ssid",
"ssid": "sample_ssid",
"temperatureSetpoint": 145,
"temperatureSetpointPending": false,
"temperatureSetpointPrevious": 145,
"temperatureSetpointMaximum": 145,
"error": "",
"modes": [
{
"mode": "HYBRID",
"controls": null
},
{
"mode": "HEAT_PUMP",
"controls": null
},
{
"mode": "ELECTRIC",
"controls": "SELECT_DAYS"
},
{
"mode": "VACATION",
"controls": "SELECT_DAYS"
}
],
"firmwareVersion": "2.14",
"hotWaterStatus": "HIGH",
"isAdvancedLoadUpMore": false,
"isCtaUcmPresent": false,
"isDemandResponsePaused": false,
"isEnrolled": false,
"mode": "HEAT_PUMP",
"modePending": false,
"vacationModeRemainingDays": 0,
"electricModeRemainingDays": 100,
"isLowes": false,
"canEditTimeOfUse": false,
"timeOfUseData": null,
"consumerScheduleData": null
}
}
],
"energy_use_data": {
"junctionId": {
"average": 2.4744000000000006,
"graphData": [
{
"date": "2023-11-26T04:00:00.000Z",
"kwh": 0.936
},
{
"date": "2023-11-27T04:00:00.000Z",
"kwh": 4.248
},
{
"date": "2023-11-28T04:00:00.000Z",
"kwh": 1.002
},
{
"date": "2023-11-29T04:00:00.000Z",
"kwh": 3.078
},
{
"date": "2023-11-30T04:00:00.000Z",
"kwh": 1.896
},
{
"date": "2023-12-01T04:00:00.000Z",
"kwh": 1.98
},
{
"date": "2023-12-02T04:00:00.000Z",
"kwh": 2.112
},
{
"date": "2023-12-03T04:00:00.000Z",
"kwh": 3.222
},
{
"date": "2023-12-04T04:00:00.000Z",
"kwh": 4.254
},
{
"date": "2023-12-05T04:00:00.000Z",
"kwh": 4.05
},
{
"date": "2023-12-06T04:00:00.000Z",
"kwh": 3.312
},
{
"date": "2023-12-07T04:00:00.000Z",
"kwh": 2.334
},
{
"date": "2023-12-08T04:00:00.000Z",
"kwh": 2.418
},
{
"date": "2023-12-09T04:00:00.000Z",
"kwh": 2.19
},
{
"date": "2023-12-10T04:00:00.000Z",
"kwh": 3.786
},
{
"date": "2023-12-11T04:00:00.000Z",
"kwh": 5.292
},
{
"date": "2023-12-12T04:00:00.000Z",
"kwh": 1.38
},
{
"date": "2023-12-13T04:00:00.000Z",
"kwh": 3.324
},
{
"date": "2023-12-14T04:00:00.000Z",
"kwh": 1.092
},
{
"date": "2023-12-15T04:00:00.000Z",
"kwh": 0.606
},
{
"date": "2023-12-16T04:00:00.000Z",
"kwh": 0
},
{
"date": "2023-12-17T04:00:00.000Z",
"kwh": 2.838
},
{
"date": "2023-12-18T04:00:00.000Z",
"kwh": 2.382
},
{
"date": "2023-12-19T04:00:00.000Z",
"kwh": 2.904
},
{
"date": "2023-12-20T04:00:00.000Z",
"kwh": 1.914
},
{
"date": "2023-12-21T04:00:00.000Z",
"kwh": 3.93
},
{
"date": "2023-12-22T04:00:00.000Z",
"kwh": 3.666
},
{
"date": "2023-12-23T04:00:00.000Z",
"kwh": 2.766
},
{
"date": "2023-12-24T04:00:00.000Z",
"kwh": 1.32
}
],
"lifetimeKwh": 203.259,
"startDate": "Nov 26"
}
}
}

View File

@ -0,0 +1,252 @@
# serializer version: 1
# name: test_diagnostics
dict({
'devices': list([
dict({
'alertSettings': dict({
'faultCode': dict({
'major': dict({
'email': '**REDACTED**',
'sms': False,
}),
'minor': dict({
'email': '**REDACTED**',
'sms': False,
}),
}),
'operatingSetPoint': dict({
'email': '**REDACTED**',
'sms': False,
}),
'tankTemperature': dict({
'highTemperature': dict({
'email': '**REDACTED**',
'sms': False,
'value': 160,
}),
'lowTemperature': dict({
'email': '**REDACTED**',
'sms': False,
'value': 120,
}),
}),
}),
'brand': 'aosmith',
'data': dict({
'activeAlerts': list([
]),
'alertHistory': list([
]),
'canEditTimeOfUse': False,
'consumerScheduleData': None,
'electricModeRemainingDays': 100,
'error': '',
'firmwareVersion': '2.14',
'heaterSsid': '**REDACTED**',
'hotWaterStatus': 'HIGH',
'isAdvancedLoadUpMore': False,
'isCtaUcmPresent': False,
'isDemandResponsePaused': False,
'isEnrolled': False,
'isLowes': False,
'isOnline': True,
'isWifi': True,
'lastUpdate': 1703138389000,
'mode': 'HEAT_PUMP',
'modePending': False,
'modes': list([
dict({
'controls': None,
'mode': 'HYBRID',
}),
dict({
'controls': None,
'mode': 'HEAT_PUMP',
}),
dict({
'controls': 'SELECT_DAYS',
'mode': 'ELECTRIC',
}),
dict({
'controls': 'SELECT_DAYS',
'mode': 'VACATION',
}),
]),
'signalStrength': None,
'ssid': '**REDACTED**',
'temperatureSetpoint': 145,
'temperatureSetpointMaximum': 145,
'temperatureSetpointPending': False,
'temperatureSetpointPrevious': 145,
'timeOfUseData': None,
'vacationModeRemainingDays': 0,
}),
'deviceType': 'NEXT_GEN_HEAT_PUMP',
'dsn': '**REDACTED**',
'hardware': dict({
'hasBluetooth': True,
'interface': 'CONTROL_PANEL',
}),
'id': '**REDACTED**',
'install': dict({
'address': '**REDACTED**',
'city': '**REDACTED**',
'country': 'United States',
'date': '2023-09-29',
'email': '**REDACTED**',
'group': 'Residential',
'location': 'Basement',
'phone': '**REDACTED**',
'postalCode': '**REDACTED**',
'professional': False,
'registeredOwner': '**REDACTED**',
'registrationDate': '2023-12-24',
'state': '**REDACTED**',
}),
'isRegistered': True,
'junctionId': 'junctionId',
'lastUpdate': 1703386473737,
'model': 'HPTS-50 200 202172000',
'name': 'Water Heater',
'permissions': 'USER',
'productId': '100350404',
'serial': '**REDACTED**',
'users': list([
dict({
'contactId': '**REDACTED**',
'email': '**REDACTED**',
'firstName': '**REDACTED**',
'isSelf': True,
'lastName': '**REDACTED**',
'permissions': 'USER',
}),
]),
}),
]),
'energy_use_data': dict({
'junctionId': dict({
'average': 2.4744000000000006,
'graphData': list([
dict({
'date': '2023-11-26T04:00:00.000Z',
'kwh': 0.936,
}),
dict({
'date': '2023-11-27T04:00:00.000Z',
'kwh': 4.248,
}),
dict({
'date': '2023-11-28T04:00:00.000Z',
'kwh': 1.002,
}),
dict({
'date': '2023-11-29T04:00:00.000Z',
'kwh': 3.078,
}),
dict({
'date': '2023-11-30T04:00:00.000Z',
'kwh': 1.896,
}),
dict({
'date': '2023-12-01T04:00:00.000Z',
'kwh': 1.98,
}),
dict({
'date': '2023-12-02T04:00:00.000Z',
'kwh': 2.112,
}),
dict({
'date': '2023-12-03T04:00:00.000Z',
'kwh': 3.222,
}),
dict({
'date': '2023-12-04T04:00:00.000Z',
'kwh': 4.254,
}),
dict({
'date': '2023-12-05T04:00:00.000Z',
'kwh': 4.05,
}),
dict({
'date': '2023-12-06T04:00:00.000Z',
'kwh': 3.312,
}),
dict({
'date': '2023-12-07T04:00:00.000Z',
'kwh': 2.334,
}),
dict({
'date': '2023-12-08T04:00:00.000Z',
'kwh': 2.418,
}),
dict({
'date': '2023-12-09T04:00:00.000Z',
'kwh': 2.19,
}),
dict({
'date': '2023-12-10T04:00:00.000Z',
'kwh': 3.786,
}),
dict({
'date': '2023-12-11T04:00:00.000Z',
'kwh': 5.292,
}),
dict({
'date': '2023-12-12T04:00:00.000Z',
'kwh': 1.38,
}),
dict({
'date': '2023-12-13T04:00:00.000Z',
'kwh': 3.324,
}),
dict({
'date': '2023-12-14T04:00:00.000Z',
'kwh': 1.092,
}),
dict({
'date': '2023-12-15T04:00:00.000Z',
'kwh': 0.606,
}),
dict({
'date': '2023-12-16T04:00:00.000Z',
'kwh': 0,
}),
dict({
'date': '2023-12-17T04:00:00.000Z',
'kwh': 2.838,
}),
dict({
'date': '2023-12-18T04:00:00.000Z',
'kwh': 2.382,
}),
dict({
'date': '2023-12-19T04:00:00.000Z',
'kwh': 2.904,
}),
dict({
'date': '2023-12-20T04:00:00.000Z',
'kwh': 1.914,
}),
dict({
'date': '2023-12-21T04:00:00.000Z',
'kwh': 3.93,
}),
dict({
'date': '2023-12-22T04:00:00.000Z',
'kwh': 3.666,
}),
dict({
'date': '2023-12-23T04:00:00.000Z',
'kwh': 2.766,
}),
dict({
'date': '2023-12-24T04:00:00.000Z',
'kwh': 1.32,
}),
]),
'lifetimeKwh': 203.259,
'startDate': 'Nov 26',
}),
}),
})
# ---

View File

@ -0,0 +1,23 @@
"""Tests for the diagnostics data provided by the A. O. Smith integration."""
from syrupy import SnapshotAssertion
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
assert (
await get_diagnostics_for_config_entry(hass, hass_client, init_integration)
== snapshot
)

View File

@ -55,7 +55,7 @@ def macos_adapter():
), patch( ), patch(
"bluetooth_adapters.systems.platform.system", "bluetooth_adapters.systems.platform.system",
return_value="Darwin", return_value="Darwin",
): ), patch("habluetooth.scanner.SYSTEM", "Darwin"):
yield yield
@ -65,7 +65,7 @@ def windows_adapter():
with patch( with patch(
"bluetooth_adapters.systems.platform.system", "bluetooth_adapters.systems.platform.system",
return_value="Windows", return_value="Windows",
): ), patch("habluetooth.scanner.SYSTEM", "Windows"):
yield yield
@ -81,7 +81,7 @@ def no_adapter_fixture():
), patch( ), patch(
"bluetooth_adapters.systems.platform.system", "bluetooth_adapters.systems.platform.system",
return_value="Linux", return_value="Linux",
), patch( ), patch("habluetooth.scanner.SYSTEM", "Linux"), patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.refresh", "bluetooth_adapters.systems.linux.LinuxAdapters.refresh",
), patch( ), patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.adapters", "bluetooth_adapters.systems.linux.LinuxAdapters.adapters",
@ -102,7 +102,7 @@ def one_adapter_fixture():
), patch( ), patch(
"bluetooth_adapters.systems.platform.system", "bluetooth_adapters.systems.platform.system",
return_value="Linux", return_value="Linux",
), patch( ), patch("habluetooth.scanner.SYSTEM", "Linux"), patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.refresh", "bluetooth_adapters.systems.linux.LinuxAdapters.refresh",
), patch( ), patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.adapters", "bluetooth_adapters.systems.linux.LinuxAdapters.adapters",

View File

@ -571,6 +571,7 @@ async def test_restart_takes_longer_than_watchdog_time(
assert "already restarting" in caplog.text assert "already restarting" in caplog.text
@pytest.mark.skipif("platform.system() != 'Darwin'")
async def test_setup_and_stop_macos( async def test_setup_and_stop_macos(
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None
) -> None: ) -> None:

View File

@ -76,16 +76,9 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]:
# Attributes that we mock with default values. # Attributes that we mock with default values.
mock_cloud.id_token = jwt.encode( mock_cloud.id_token = None
{ mock_cloud.access_token = None
"email": "hello@home-assistant.io", mock_cloud.refresh_token = None
"custom:sub-exp": "2018-01-03",
"cognito:username": "abcdefghjkl",
},
"test",
)
mock_cloud.access_token = "test_access_token"
mock_cloud.refresh_token = "test_refresh_token"
# Properties that we keep as properties. # Properties that we keep as properties.
@ -122,11 +115,31 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]:
When called, it should call the on_start callback. When called, it should call the on_start callback.
""" """
mock_cloud.id_token = jwt.encode(
{
"email": "hello@home-assistant.io",
"custom:sub-exp": "2018-01-03",
"cognito:username": "abcdefghjkl",
},
"test",
)
mock_cloud.access_token = "test_access_token"
mock_cloud.refresh_token = "test_refresh_token"
on_start_callback = mock_cloud.register_on_start.call_args[0][0] on_start_callback = mock_cloud.register_on_start.call_args[0][0]
await on_start_callback() await on_start_callback()
mock_cloud.login.side_effect = mock_login mock_cloud.login.side_effect = mock_login
async def mock_logout() -> None:
"""Mock logout."""
mock_cloud.id_token = None
mock_cloud.access_token = None
mock_cloud.refresh_token = None
await mock_cloud.stop()
await mock_cloud.client.logout_cleanups()
mock_cloud.logout.side_effect = mock_logout
yield mock_cloud yield mock_cloud

View File

@ -113,8 +113,8 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None:
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
on_start_callback = cloud.register_on_start.call_args[0][0] await cloud.login("test-user", "test-pass")
await on_start_callback() cloud.login.reset_mock()
async def test_google_actions_sync( async def test_google_actions_sync(

View File

@ -19,7 +19,7 @@ from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import Unauthorized from homeassistant.exceptions import Unauthorized
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import MockUser from tests.common import MockConfigEntry, MockUser
async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None:
@ -230,6 +230,7 @@ async def test_async_get_or_create_cloudhook(
"""Test async_get_or_create_cloudhook.""" """Test async_get_or_create_cloudhook."""
assert await async_setup_component(hass, "cloud", {"cloud": {}}) assert await async_setup_component(hass, "cloud", {"cloud": {}})
await hass.async_block_till_done() await hass.async_block_till_done()
await cloud.login("test-user", "test-pass")
webhook_id = "mock-webhook-id" webhook_id = "mock-webhook-id"
cloudhook_url = "https://cloudhook.nabu.casa/abcdefg" cloudhook_url = "https://cloudhook.nabu.casa/abcdefg"
@ -262,7 +263,7 @@ async def test_async_get_or_create_cloudhook(
async_create_cloudhook_mock.assert_not_called() async_create_cloudhook_mock.assert_not_called()
# Simulate logged out # Simulate logged out
cloud.id_token = None await cloud.logout()
# Not logged in # Not logged in
with pytest.raises(CloudNotAvailable): with pytest.raises(CloudNotAvailable):
@ -274,3 +275,18 @@ async def test_async_get_or_create_cloudhook(
# Not connected # Not connected
with pytest.raises(CloudNotConnected): with pytest.raises(CloudNotConnected):
await async_get_or_create_cloudhook(hass, webhook_id) await async_get_or_create_cloudhook(hass, webhook_id)
async def test_cloud_logout(
hass: HomeAssistant,
cloud: MagicMock,
) -> None:
"""Test cloud setup with existing config entry when user is logged out."""
assert cloud.is_logged_in is False
mock_config_entry = MockConfigEntry(domain=DOMAIN)
mock_config_entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {"cloud": {}})
await hass.async_block_till_done()
assert cloud.is_logged_in is False

View File

@ -42,6 +42,7 @@ async def test_cloud_system_health(
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
await cloud.login("test-user", "test-pass")
cloud.remote.snitun_server = "us-west-1" cloud.remote.snitun_server = "us-west-1"
cloud.remote.certificate_status = CertificateStatus.READY cloud.remote.certificate_status = CertificateStatus.READY

View File

@ -4,7 +4,7 @@ from http import HTTPStatus
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from hass_nabucasa.voice import MAP_VOICE, VoiceError from hass_nabucasa.voice import MAP_VOICE, VoiceError, VoiceTokenError
import pytest import pytest
import voluptuous as vol import voluptuous as vol
@ -189,3 +189,55 @@ async def test_get_tts_audio(
assert mock_process_tts.call_args.kwargs["language"] == "en-US" assert mock_process_tts.call_args.kwargs["language"] == "en-US"
assert mock_process_tts.call_args.kwargs["gender"] == "female" assert mock_process_tts.call_args.kwargs["gender"] == "female"
assert mock_process_tts.call_args.kwargs["output"] == "mp3" assert mock_process_tts.call_args.kwargs["output"] == "mp3"
@pytest.mark.parametrize(
("data", "expected_url_suffix"),
[
({"platform": DOMAIN}, DOMAIN),
({"engine_id": DOMAIN}, DOMAIN),
],
)
async def test_get_tts_audio_logged_out(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
cloud: MagicMock,
data: dict[str, Any],
expected_url_suffix: str,
) -> None:
"""Test cloud get tts audio when user is logged out."""
mock_process_tts = AsyncMock(
side_effect=VoiceTokenError("No token!"),
)
cloud.voice.process_tts = mock_process_tts
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
client = await hass_client()
url = "/api/tts_get_url"
data |= {"message": "There is someone at the door."}
req = await client.post(url, json=data)
assert req.status == HTTPStatus.OK
response = await req.json()
assert response == {
"url": (
"http://example.local:8123/api/tts_proxy/"
"42f18378fd4393d18c8dd11d03fa9563c1e54491"
f"_en-us_e09b5a0968_{expected_url_suffix}.mp3"
),
"path": (
"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
f"_en-us_e09b5a0968_{expected_url_suffix}.mp3"
),
}
await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door."
assert mock_process_tts.call_args.kwargs["language"] == "en-US"
assert mock_process_tts.call_args.kwargs["gender"] == "female"
assert mock_process_tts.call_args.kwargs["output"] == "mp3"

View File

@ -34,7 +34,8 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -
# ent3 = cover with simple tilt functions and no position # ent3 = cover with simple tilt functions and no position
# ent4 = cover with all tilt functions but no position # ent4 = cover with all tilt functions but no position
# ent5 = cover with all functions # ent5 = cover with all functions
ent1, ent2, ent3, ent4, ent5 = platform.ENTITIES # ent6 = cover with only open/close, but also reports opening/closing
ent1, ent2, ent3, ent4, ent5, ent6 = platform.ENTITIES
# Test init all covers should be open # Test init all covers should be open
assert is_open(hass, ent1) assert is_open(hass, ent1)
@ -42,6 +43,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -
assert is_open(hass, ent3) assert is_open(hass, ent3)
assert is_open(hass, ent4) assert is_open(hass, ent4)
assert is_open(hass, ent5) assert is_open(hass, ent5)
assert is_open(hass, ent6)
# call basic toggle services # call basic toggle services
await call_service(hass, SERVICE_TOGGLE, ent1) await call_service(hass, SERVICE_TOGGLE, ent1)
@ -49,13 +51,15 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -
await call_service(hass, SERVICE_TOGGLE, ent3) await call_service(hass, SERVICE_TOGGLE, ent3)
await call_service(hass, SERVICE_TOGGLE, ent4) await call_service(hass, SERVICE_TOGGLE, ent4)
await call_service(hass, SERVICE_TOGGLE, ent5) await call_service(hass, SERVICE_TOGGLE, ent5)
await call_service(hass, SERVICE_TOGGLE, ent6)
# entities without stop should be closed and with stop should be closing # entities should be either closed or closing, depending on if they report transitional states
assert is_closed(hass, ent1) assert is_closed(hass, ent1)
assert is_closing(hass, ent2) assert is_closing(hass, ent2)
assert is_closed(hass, ent3) assert is_closed(hass, ent3)
assert is_closed(hass, ent4) assert is_closed(hass, ent4)
assert is_closing(hass, ent5) assert is_closing(hass, ent5)
assert is_closing(hass, ent6)
# call basic toggle services and set different cover position states # call basic toggle services and set different cover position states
await call_service(hass, SERVICE_TOGGLE, ent1) await call_service(hass, SERVICE_TOGGLE, ent1)
@ -65,6 +69,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -
await call_service(hass, SERVICE_TOGGLE, ent4) await call_service(hass, SERVICE_TOGGLE, ent4)
set_cover_position(ent5, 15) set_cover_position(ent5, 15)
await call_service(hass, SERVICE_TOGGLE, ent5) await call_service(hass, SERVICE_TOGGLE, ent5)
await call_service(hass, SERVICE_TOGGLE, ent6)
# entities should be in correct state depending on the SUPPORT_STOP feature and cover position # entities should be in correct state depending on the SUPPORT_STOP feature and cover position
assert is_open(hass, ent1) assert is_open(hass, ent1)
@ -72,6 +77,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -
assert is_open(hass, ent3) assert is_open(hass, ent3)
assert is_open(hass, ent4) assert is_open(hass, ent4)
assert is_open(hass, ent5) assert is_open(hass, ent5)
assert is_opening(hass, ent6)
# call basic toggle services # call basic toggle services
await call_service(hass, SERVICE_TOGGLE, ent1) await call_service(hass, SERVICE_TOGGLE, ent1)
@ -79,6 +85,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -
await call_service(hass, SERVICE_TOGGLE, ent3) await call_service(hass, SERVICE_TOGGLE, ent3)
await call_service(hass, SERVICE_TOGGLE, ent4) await call_service(hass, SERVICE_TOGGLE, ent4)
await call_service(hass, SERVICE_TOGGLE, ent5) await call_service(hass, SERVICE_TOGGLE, ent5)
await call_service(hass, SERVICE_TOGGLE, ent6)
# entities should be in correct state depending on the SUPPORT_STOP feature and cover position # entities should be in correct state depending on the SUPPORT_STOP feature and cover position
assert is_closed(hass, ent1) assert is_closed(hass, ent1)
@ -86,6 +93,12 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -
assert is_closed(hass, ent3) assert is_closed(hass, ent3)
assert is_closed(hass, ent4) assert is_closed(hass, ent4)
assert is_opening(hass, ent5) assert is_opening(hass, ent5)
assert is_closing(hass, ent6)
# Without STOP but still reports opening/closing has a 4th possible toggle state
set_state(ent6, STATE_CLOSED)
await call_service(hass, SERVICE_TOGGLE, ent6)
assert is_opening(hass, ent6)
def call_service(hass, service, ent): def call_service(hass, service, ent):
@ -100,6 +113,11 @@ def set_cover_position(ent, position) -> None:
ent._values["current_cover_position"] = position ent._values["current_cover_position"] = position
def set_state(ent, state) -> None:
"""Set the state of a cover."""
ent._values["state"] = state
def is_open(hass, ent): def is_open(hass, ent):
"""Return if the cover is closed based on the statemachine.""" """Return if the cover is closed based on the statemachine."""
return hass.states.is_state(ent.entity_id, STATE_OPEN) return hass.states.is_state(ent.entity_id, STATE_OPEN)

View File

@ -227,3 +227,88 @@ async def test_no_next_event(
assert state is not None assert state is not None
assert state.state == "off" assert state.state == "off"
assert state.attributes == {"friendly_name": "Germany"} assert state.attributes == {"friendly_name": "Germany"}
async def test_language_not_exist(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test when language doesn't exist it will fallback to country default language."""
hass.config.language = "nb" # Norweigan language "Norks bokmål"
hass.config.country = "NO"
freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC))
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_COUNTRY: "NO"},
title="Norge",
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("calendar.norge")
assert state is not None
assert state.state == "on"
assert state.attributes == {
"friendly_name": "Norge",
"all_day": True,
"description": "",
"end_time": "2023-01-02 00:00:00",
"location": "Norge",
"message": "Første nyttårsdag",
"start_time": "2023-01-01 00:00:00",
}
response = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
"entity_id": "calendar.norge",
"end_date_time": dt_util.now(),
},
blocking=True,
return_response=True,
)
assert response == {
"calendar.norge": {
"events": [
{
"start": "2023-01-01",
"end": "2023-01-02",
"summary": "Første nyttårsdag",
"location": "Norge",
}
]
}
}
# Test with English as exist as optional language for Norway
hass.config.language = "en"
hass.config.country = "NO"
await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
response = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
"entity_id": "calendar.norge",
"end_date_time": dt_util.now(),
},
blocking=True,
return_response=True,
)
assert response == {
"calendar.norge": {
"events": [
{
"start": "2023-01-01",
"end": "2023-01-02",
"summary": "New Year's Day",
"location": "Norge",
}
]
}
}

View File

@ -150,7 +150,6 @@ async def test_remove_device_trigger(
}, },
) )
assert len(hass.data[DOMAIN].telegrams._jobs) == 1
await knx.receive_write("0/0/1", (0x03, 0x2F)) await knx.receive_write("0/0/1", (0x03, 0x2F))
assert len(calls) == 1 assert len(calls) == 1
assert calls.pop().data["catch_all"] == "telegram - 0/0/1" assert calls.pop().data["catch_all"] == "telegram - 0/0/1"
@ -161,8 +160,6 @@ async def test_remove_device_trigger(
{ATTR_ENTITY_ID: f"automation.{automation_name}"}, {ATTR_ENTITY_ID: f"automation.{automation_name}"},
blocking=True, blocking=True,
) )
assert len(hass.data[DOMAIN].telegrams._jobs) == 0
await knx.receive_write("0/0/1", (0x03, 0x2F)) await knx.receive_write("0/0/1", (0x03, 0x2F))
assert len(calls) == 0 assert len(calls) == 0

View File

@ -41,6 +41,7 @@ TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse(
version=JavaStatusVersion.build(TEST_JAVA_STATUS_RESPONSE_RAW["version"]), version=JavaStatusVersion.build(TEST_JAVA_STATUS_RESPONSE_RAW["version"]),
motd=Motd.parse(TEST_JAVA_STATUS_RESPONSE_RAW["description"], bedrock=False), motd=Motd.parse(TEST_JAVA_STATUS_RESPONSE_RAW["description"], bedrock=False),
icon=None, icon=None,
enforces_secure_chat=False,
latency=5, latency=5,
) )

View File

@ -115,6 +115,63 @@ async def test_controlling_state_via_topic(
assert state.state == "" assert state.state == ""
@pytest.mark.parametrize(
"hass_config",
[
{
mqtt.DOMAIN: {
text.DOMAIN: {
"name": "test",
"state_topic": "state-topic",
"command_topic": "command-topic",
"min": 5,
"max": 5,
}
}
}
],
)
async def test_forced_text_length(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a text entity that only allows a fixed length."""
await mqtt_mock_entry()
state = hass.states.get("text.test")
assert state.state == STATE_UNKNOWN
assert not state.attributes.get(ATTR_ASSUMED_STATE)
async_fire_mqtt_message(hass, "state-topic", "12345")
state = hass.states.get("text.test")
assert state.state == "12345"
caplog.clear()
# Text too long
async_fire_mqtt_message(hass, "state-topic", "123456")
state = hass.states.get("text.test")
assert state.state == "12345"
assert (
"ValueError: Entity text.test provides state 123456 "
"which is too long (maximum length 5)" in caplog.text
)
caplog.clear()
# Text too short
async_fire_mqtt_message(hass, "state-topic", "1")
state = hass.states.get("text.test")
assert state.state == "12345"
assert (
"ValueError: Entity text.test provides state 1 "
"which is too short (minimum length 5)" in caplog.text
)
# Valid update
async_fire_mqtt_message(hass, "state-topic", "54321")
state = hass.states.get("text.test")
assert state.state == "54321"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"hass_config", "hass_config",
[ [
@ -211,7 +268,7 @@ async def test_attribute_validation_max_greater_then_min(
) -> None: ) -> None:
"""Test the validation of min and max configuration attributes.""" """Test the validation of min and max configuration attributes."""
assert await mqtt_mock_entry() assert await mqtt_mock_entry()
assert "text length min must be >= max" in caplog.text assert "text length min must be <= max" in caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -0,0 +1,85 @@
"""Test the swiss_public_transport config flow."""
from unittest.mock import AsyncMock, patch
from homeassistant.components.swiss_public_transport.const import (
CONF_DESTINATION,
CONF_START,
DOMAIN,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
MOCK_DATA_STEP = {
CONF_START: "test_start",
CONF_DESTINATION: "test_destination",
}
CONNECTIONS = [
{
"departure": "2024-01-06T18:03:00+0100",
"number": 0,
"platform": 0,
"transfers": 0,
"duration": "10",
"delay": 0,
},
{
"departure": "2024-01-06T18:04:00+0100",
"number": 1,
"platform": 1,
"transfers": 0,
"duration": "10",
"delay": 0,
},
{
"departure": "2024-01-06T18:05:00+0100",
"number": 2,
"platform": 2,
"transfers": 0,
"duration": "10",
"delay": 0,
},
]
async def test_migration_1_to_2(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test successful setup."""
with patch(
"homeassistant.components.swiss_public_transport.OpendataTransport",
return_value=AsyncMock(),
) as mock:
mock().connections = CONNECTIONS
config_entry_faulty = MockConfigEntry(
domain=DOMAIN,
data=MOCK_DATA_STEP,
title="MIGRATION_TEST",
minor_version=1,
)
config_entry_faulty.add_to_hass(hass)
# Setup the config entry
await hass.config_entries.async_setup(config_entry_faulty.entry_id)
await hass.async_block_till_done()
assert entity_registry.async_is_registered(
entity_registry.entities.get_entity_id(
(Platform.SENSOR, DOMAIN, "test_start test_destination_departure")
)
)
# Check change in config entry
assert config_entry_faulty.minor_version == 2
assert config_entry_faulty.unique_id == "test_start test_destination"
# Check "None" is gone
assert not entity_registry.async_is_registered(
entity_registry.entities.get_entity_id(
(Platform.SENSOR, DOMAIN, "None_departure")
)
)

View File

@ -26,6 +26,10 @@ DUMMY_DEVICE_ID1 = "a123bc"
DUMMY_DEVICE_ID2 = "cafe12" DUMMY_DEVICE_ID2 = "cafe12"
DUMMY_DEVICE_ID3 = "bada77" DUMMY_DEVICE_ID3 = "bada77"
DUMMY_DEVICE_ID4 = "bbd164" DUMMY_DEVICE_ID4 = "bbd164"
DUMMY_DEVICE_KEY1 = "18"
DUMMY_DEVICE_KEY2 = "01"
DUMMY_DEVICE_KEY3 = "12"
DUMMY_DEVICE_KEY4 = "07"
DUMMY_DEVICE_NAME1 = "Plug 23BC" DUMMY_DEVICE_NAME1 = "Plug 23BC"
DUMMY_DEVICE_NAME2 = "Heater FE12" DUMMY_DEVICE_NAME2 = "Heater FE12"
DUMMY_DEVICE_NAME3 = "Breeze AB39" DUMMY_DEVICE_NAME3 = "Breeze AB39"
@ -67,6 +71,7 @@ DUMMY_PLUG_DEVICE = SwitcherPowerPlug(
DeviceType.POWER_PLUG, DeviceType.POWER_PLUG,
DeviceState.ON, DeviceState.ON,
DUMMY_DEVICE_ID1, DUMMY_DEVICE_ID1,
DUMMY_DEVICE_KEY1,
DUMMY_IP_ADDRESS1, DUMMY_IP_ADDRESS1,
DUMMY_MAC_ADDRESS1, DUMMY_MAC_ADDRESS1,
DUMMY_DEVICE_NAME1, DUMMY_DEVICE_NAME1,
@ -78,6 +83,7 @@ DUMMY_WATER_HEATER_DEVICE = SwitcherWaterHeater(
DeviceType.V4, DeviceType.V4,
DeviceState.ON, DeviceState.ON,
DUMMY_DEVICE_ID2, DUMMY_DEVICE_ID2,
DUMMY_DEVICE_KEY2,
DUMMY_IP_ADDRESS2, DUMMY_IP_ADDRESS2,
DUMMY_MAC_ADDRESS2, DUMMY_MAC_ADDRESS2,
DUMMY_DEVICE_NAME2, DUMMY_DEVICE_NAME2,
@ -91,6 +97,7 @@ DUMMY_SHUTTER_DEVICE = SwitcherShutter(
DeviceType.RUNNER, DeviceType.RUNNER,
DeviceState.ON, DeviceState.ON,
DUMMY_DEVICE_ID4, DUMMY_DEVICE_ID4,
DUMMY_DEVICE_KEY4,
DUMMY_IP_ADDRESS4, DUMMY_IP_ADDRESS4,
DUMMY_MAC_ADDRESS4, DUMMY_MAC_ADDRESS4,
DUMMY_DEVICE_NAME4, DUMMY_DEVICE_NAME4,
@ -102,6 +109,7 @@ DUMMY_THERMOSTAT_DEVICE = SwitcherThermostat(
DeviceType.BREEZE, DeviceType.BREEZE,
DeviceState.ON, DeviceState.ON,
DUMMY_DEVICE_ID3, DUMMY_DEVICE_ID3,
DUMMY_DEVICE_KEY3,
DUMMY_IP_ADDRESS3, DUMMY_IP_ADDRESS3,
DUMMY_MAC_ADDRESS3, DUMMY_MAC_ADDRESS3,
DUMMY_DEVICE_NAME3, DUMMY_DEVICE_NAME3,

View File

@ -25,6 +25,7 @@ async def test_diagnostics(
{ {
"auto_shutdown": "02:00:00", "auto_shutdown": "02:00:00",
"device_id": REDACTED, "device_id": REDACTED,
"device_key": REDACTED,
"device_state": { "device_state": {
"__type": "<enum 'DeviceState'>", "__type": "<enum 'DeviceState'>",
"repr": "<DeviceState.ON: ('01', 'on')>", "repr": "<DeviceState.ON: ('01', 'on')>",

View File

@ -0,0 +1,26 @@
[
{
"name": "Home",
"id": 123456,
"settings": {
"geoTrackingEnabled": false,
"specialOffersEnabled": false,
"onDemandLogRetrievalEnabled": false,
"pushNotifications": {
"lowBatteryReminder": true,
"awayModeReminder": true,
"homeModeReminder": true,
"openWindowReminder": true,
"energySavingsReportReminder": true,
"incidentDetection": true,
"energyIqReminder": false
}
},
"deviceMetadata": {
"platform": "Android",
"osVersion": "14",
"model": "Samsung",
"locale": "nl"
}
}
]

View File

@ -17,6 +17,7 @@ async def async_init_integration(
token_fixture = "tado/token.json" token_fixture = "tado/token.json"
devices_fixture = "tado/devices.json" devices_fixture = "tado/devices.json"
mobile_devices_fixture = "tado/mobile_devices.json"
me_fixture = "tado/me.json" me_fixture = "tado/me.json"
weather_fixture = "tado/weather.json" weather_fixture = "tado/weather.json"
home_state_fixture = "tado/home_state.json" home_state_fixture = "tado/home_state.json"
@ -70,6 +71,10 @@ async def async_init_integration(
"https://my.tado.com/api/v2/homes/1/devices", "https://my.tado.com/api/v2/homes/1/devices",
text=load_fixture(devices_fixture), text=load_fixture(devices_fixture),
) )
m.get(
"https://my.tado.com/api/v2/homes/1/mobileDevices",
text=load_fixture(mobile_devices_fixture),
)
m.get( m.get(
"https://my.tado.com/api/v2/devices/WR1/", "https://my.tado.com/api/v2/devices/WR1/",
text=load_fixture(device_wr1_fixture), text=load_fixture(device_wr1_fixture),

View File

@ -264,6 +264,26 @@ async def test_color_temp_light(
bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None)
bulb.set_color_temp.reset_mock() bulb.set_color_temp.reset_mock()
# Verify color temp is clamped to the valid range
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000},
blocking=True,
)
bulb.set_color_temp.assert_called_with(9000, brightness=None, transition=None)
bulb.set_color_temp.reset_mock()
# Verify color temp is clamped to the valid range
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1},
blocking=True,
)
bulb.set_color_temp.assert_called_with(4000, brightness=None, transition=None)
bulb.set_color_temp.reset_mock()
async def test_brightness_only_light(hass: HomeAssistant) -> None: async def test_brightness_only_light(hass: HomeAssistant) -> None:
"""Test a light.""" """Test a light."""

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