mirror of
https://github.com/home-assistant/core.git
synced 2026-05-13 00:13:49 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e97e49cba | |||
| 406a6dda9b | |||
| 7633cc7252 | |||
| 5169b6554a | |||
| 2d9cf87e44 | |||
| b5f04ef502 | |||
| d073d4fe4a | |||
| 2e6d8e3aea | |||
| 0d9f5a32f4 | |||
| feea4925cd | |||
| 3be267a94c | |||
| 9da3788c52 | |||
| cb6a38b10f | |||
| 03e22e1cb2 |
Generated
-2
@@ -2026,8 +2026,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG
|
||||
/homeassistant/components/xiaomi_tv/ @simse
|
||||
/homeassistant/components/xmpp/ @fabaff @flowolf
|
||||
/homeassistant/components/xthings_cloud/ @XthingsJacobs
|
||||
/tests/components/xthings_cloud/ @XthingsJacobs
|
||||
/homeassistant/components/yale/ @bdraco
|
||||
/tests/components/yale/ @bdraco
|
||||
/homeassistant/components/yale_smart_alarm/ @gjohansson-ST
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.24.1",
|
||||
"aioesphomeapi==44.21.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.3"
|
||||
],
|
||||
|
||||
@@ -104,6 +104,7 @@ class InvalidStreamException(HomeAssistantError):
|
||||
|
||||
def build_schema(
|
||||
is_options_flow: bool = False,
|
||||
show_advanced_options: bool = False,
|
||||
) -> vol.Schema:
|
||||
"""Create schema for camera config setup."""
|
||||
rtsp_options = [
|
||||
@@ -140,7 +141,8 @@ def build_schema(
|
||||
}
|
||||
if is_options_flow:
|
||||
advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool
|
||||
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool
|
||||
if show_advanced_options:
|
||||
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool
|
||||
|
||||
return vol.Schema(spec)
|
||||
|
||||
@@ -467,7 +469,10 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
build_schema(True),
|
||||
build_schema(
|
||||
True,
|
||||
self.show_advanced_options,
|
||||
),
|
||||
user_input or self.config_entry.options,
|
||||
),
|
||||
errors=errors,
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
"rtsp_transport": "RTSP transport protocol",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"description": "These options are only needed for special cases.",
|
||||
"name": "More options"
|
||||
"description": "Advanced settings are only needed for special cases. Leave them unchanged unless you know what you are doing.",
|
||||
"name": "Advanced settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioharmony", "slixmpp"],
|
||||
"requirements": ["aioharmony==1.0.3"],
|
||||
"requirements": ["aioharmony==0.5.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:myharmony-com:device:harmony:1",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pyintesishome"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyintesishome==1.8.7"]
|
||||
"requirements": ["pyintesishome==1.8.0"]
|
||||
}
|
||||
|
||||
@@ -1089,11 +1089,7 @@ class MieleStatusSensor(MieleSensor):
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return (
|
||||
StateStatus(self.device.state_status).name
|
||||
if self._device_id in self.coordinator.data.devices
|
||||
else None
|
||||
)
|
||||
return StateStatus(self.device.state_status).name
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -188,9 +188,9 @@ class MielePowerSwitch(MieleSwitch):
|
||||
def available(self) -> bool:
|
||||
"""Return the availability of the entity."""
|
||||
|
||||
return super().available and (
|
||||
return (
|
||||
self.action.power_off_enabled or self.action.power_on_enabled
|
||||
)
|
||||
) and super().available
|
||||
|
||||
async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None:
|
||||
"""Set switch to mode."""
|
||||
|
||||
@@ -179,9 +179,9 @@ class MieleVacuum(MieleEntity, StateVacuumEntity):
|
||||
def available(self) -> bool:
|
||||
"""Return the availability of the entity."""
|
||||
|
||||
return super().available and (
|
||||
return (
|
||||
self.action.power_off_enabled or self.action.power_on_enabled
|
||||
)
|
||||
) and super().available
|
||||
|
||||
async def send(self, device_id: str, action: dict[str, Any]) -> None:
|
||||
"""Send action to the device."""
|
||||
|
||||
@@ -110,7 +110,6 @@ TIMEOUT_ACK = 10
|
||||
SUBSCRIBE_TIMEOUT = 10
|
||||
RECONNECT_INTERVAL_SECONDS = 10
|
||||
|
||||
MAX_WILDCARD_SUBSCRIBES_PER_CALL = 1
|
||||
MAX_SUBSCRIBES_PER_CALL = 500
|
||||
MAX_UNSUBSCRIBES_PER_CALL = 500
|
||||
|
||||
@@ -330,8 +329,9 @@ class Subscription:
|
||||
is_simple_match: bool
|
||||
complex_matcher: Callable[[str], bool] | None
|
||||
job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None]
|
||||
qos: int = 0
|
||||
encoding: str | None = "utf-8"
|
||||
qos: int
|
||||
encoding: str | None
|
||||
subscription_id: int
|
||||
|
||||
|
||||
class MqttClientSetup:
|
||||
@@ -479,6 +479,7 @@ class MQTT:
|
||||
|
||||
self._max_qos: defaultdict[str, int] = defaultdict(int) # topic, max qos
|
||||
self._pending_subscriptions: dict[str, int] = {} # topic, qos
|
||||
self._registered_subscriptions: dict[str, int] = {} # topic, subscription_id
|
||||
self._unsubscribe_debouncer = EnsureJobAfterCooldown(
|
||||
UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes
|
||||
)
|
||||
@@ -853,6 +854,9 @@ class MQTT:
|
||||
) -> None:
|
||||
"""Restore tracked subscriptions after reload."""
|
||||
for subscription in subscriptions:
|
||||
self._registered_subscriptions[subscription.topic] = (
|
||||
subscription.subscription_id
|
||||
)
|
||||
self._async_track_subscription(subscription)
|
||||
self._matching_subscriptions.cache_clear()
|
||||
|
||||
@@ -958,7 +962,19 @@ class MQTT:
|
||||
is_simple_match = not ("+" in topic or "#" in topic)
|
||||
matcher = None if is_simple_match else _matcher_for_topic(topic)
|
||||
|
||||
subscription = Subscription(topic, is_simple_match, matcher, job, qos, encoding)
|
||||
if is_simple_match:
|
||||
subscription_id = 1
|
||||
elif topic in self._registered_subscriptions:
|
||||
subscription_id = self._registered_subscriptions[topic]
|
||||
else:
|
||||
subscription_id = self._registered_subscriptions[topic] = (
|
||||
self._mqtt_data.subscription_id_generator.generate()
|
||||
)
|
||||
|
||||
subscription = Subscription(
|
||||
topic, is_simple_match, matcher, job, qos, encoding, subscription_id
|
||||
)
|
||||
|
||||
self._async_track_subscription(subscription)
|
||||
self._matching_subscriptions.cache_clear()
|
||||
|
||||
@@ -977,15 +993,15 @@ class MQTT:
|
||||
del self._retained_topics[subscription]
|
||||
# Only unsubscribe if currently connected
|
||||
if self.connected:
|
||||
self._async_unsubscribe(subscription.topic)
|
||||
self._async_unsubscribe(subscription.topic, subscription.subscription_id)
|
||||
|
||||
@callback
|
||||
def _async_unsubscribe(self, topic: str) -> None:
|
||||
def _async_unsubscribe(self, topic: str, subscription_id: int) -> None:
|
||||
"""Unsubscribe from a topic."""
|
||||
if self.is_active_subscription(topic):
|
||||
if self._max_qos[topic] == 0:
|
||||
return
|
||||
subs = self._matching_subscriptions(topic)
|
||||
subs = self._matching_subscriptions(topic, (subscription_id,))
|
||||
self._max_qos[topic] = max(sub.qos for sub in subs)
|
||||
# Other subscriptions on topic remaining - don't unsubscribe.
|
||||
return
|
||||
@@ -1011,33 +1027,60 @@ class MQTT:
|
||||
#
|
||||
# Since we do not know if a published value is retained we need to
|
||||
# (re)subscribe, to ensure retained messages are replayed
|
||||
|
||||
if not self._pending_subscriptions:
|
||||
return
|
||||
|
||||
# Split out the wildcard subscriptions, we subscribe to them one by one
|
||||
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
pending_subscriptions: dict[str, int] = self._pending_subscriptions
|
||||
pending_wildcard_subscriptions = {
|
||||
subscription.topic: pending_subscriptions.pop(subscription.topic)
|
||||
for subscription in self._wildcard_subscriptions
|
||||
if subscription.topic in pending_subscriptions
|
||||
}
|
||||
subscribe_chain = chunked_or_all(
|
||||
pending_subscriptions.items(), MAX_SUBSCRIBES_PER_CALL
|
||||
)
|
||||
if self.is_mqttv5 and pending_subscriptions:
|
||||
bulk_properties = mqtt.Properties(packetType=mqtt.PacketTypes.SUBSCRIBE) # type: ignore[no-untyped-call]
|
||||
bulk_properties.SubscriptionIdentifier = 1
|
||||
else:
|
||||
bulk_properties = None
|
||||
|
||||
self._pending_subscriptions = {}
|
||||
|
||||
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
for topic, qos in pending_wildcard_subscriptions.items():
|
||||
if self.is_mqttv5:
|
||||
properties = mqtt.Properties(packetType=mqtt.PacketTypes.SUBSCRIBE) # type: ignore[no-untyped-call]
|
||||
properties.SubscriptionIdentifier = self._registered_subscriptions[
|
||||
topic
|
||||
]
|
||||
else:
|
||||
properties = None
|
||||
|
||||
for chunk in chain(
|
||||
chunked_or_all(
|
||||
pending_wildcard_subscriptions.items(), MAX_WILDCARD_SUBSCRIBES_PER_CALL
|
||||
),
|
||||
chunked_or_all(pending_subscriptions.items(), MAX_SUBSCRIBES_PER_CALL),
|
||||
):
|
||||
result, mid = self._mqttc.subscribe(topic, qos, properties=properties)
|
||||
if debug_enabled:
|
||||
_LOGGER.debug(
|
||||
"Subscribing with mid: %s to topic %s "
|
||||
"with qos: %s and properties: %s",
|
||||
mid,
|
||||
topic,
|
||||
qos,
|
||||
properties,
|
||||
)
|
||||
self._last_subscribe = time.monotonic()
|
||||
|
||||
await self._async_wait_for_mid_or_raise(mid, result)
|
||||
async_dispatcher_send(
|
||||
self.hass, MQTT_PROCESSED_SUBSCRIPTIONS, [(topic, qos)]
|
||||
)
|
||||
|
||||
for chunk in subscribe_chain:
|
||||
chunk_list = list(chunk)
|
||||
if not chunk_list:
|
||||
continue
|
||||
|
||||
result, mid = self._mqttc.subscribe(chunk_list)
|
||||
result, mid = self._mqttc.subscribe(chunk_list, properties=bulk_properties)
|
||||
|
||||
if debug_enabled:
|
||||
_LOGGER.debug(
|
||||
@@ -1068,6 +1111,10 @@ class MQTT:
|
||||
|
||||
await self._async_wait_for_mid_or_raise(mid, result)
|
||||
|
||||
# Flush subscription identifiers if they are available
|
||||
for topic in topics:
|
||||
self._registered_subscriptions.pop(topic, None)
|
||||
|
||||
async def _async_resubscribe_and_publish_birth_message(
|
||||
self, birth_message: PublishMessage
|
||||
) -> None:
|
||||
@@ -1166,16 +1213,27 @@ class MQTT:
|
||||
)
|
||||
|
||||
@lru_cache(None) # pylint: disable=method-cache-max-size-none
|
||||
def _matching_subscriptions(self, topic: str) -> list[Subscription]:
|
||||
def _matching_subscriptions(
|
||||
self, topic: str, identifiers: tuple[int, ...] | None
|
||||
) -> list[Subscription]:
|
||||
subscriptions: list[Subscription] = []
|
||||
if topic in self._simple_subscriptions:
|
||||
subscriptions.extend(self._simple_subscriptions[topic])
|
||||
simple_subscriptions_for_topic = self._simple_subscriptions[topic]
|
||||
if identifiers is None:
|
||||
subscriptions.extend(simple_subscriptions_for_topic)
|
||||
else:
|
||||
subscriptions.extend(
|
||||
subscription
|
||||
for subscription in simple_subscriptions_for_topic
|
||||
if subscription.subscription_id in identifiers
|
||||
)
|
||||
subscriptions.extend(
|
||||
subscription
|
||||
for subscription in self._wildcard_subscriptions
|
||||
# mypy doesn't know that complex_matcher is always set when
|
||||
# is_simple_match is False
|
||||
if subscription.complex_matcher(topic) # type: ignore[misc]
|
||||
and (identifiers is None or subscription.subscription_id in identifiers)
|
||||
)
|
||||
return subscriptions
|
||||
|
||||
@@ -1183,6 +1241,17 @@ class MQTT:
|
||||
def _async_mqtt_on_message(
|
||||
self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage
|
||||
) -> None:
|
||||
identifiers: tuple[int,] | None = None
|
||||
if self.is_mqttv5:
|
||||
# It is possible we have multiple messages if there
|
||||
# are overlapping wildcard subscriptions.
|
||||
# So we assigned all wildcard subscriptions with a
|
||||
# unique SubscriptionIdentifier. Simple subscriptions are assigned
|
||||
# with SubscriptionIdentifier 1.
|
||||
if msg.properties is not None and hasattr(
|
||||
msg.properties, "SubscriptionIdentifier"
|
||||
):
|
||||
identifiers = tuple(msg.properties.SubscriptionIdentifier)
|
||||
try:
|
||||
# msg.topic is a property that decodes the topic to a string
|
||||
# every time it is accessed. Save the result to avoid
|
||||
@@ -1199,16 +1268,16 @@ class MQTT:
|
||||
)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Received%s message on %s (qos=%s): %s",
|
||||
"Received%s message on %s (qos=%s) IDs=%s: %s",
|
||||
" retained" if msg.retain else "",
|
||||
topic,
|
||||
msg.qos,
|
||||
identifiers,
|
||||
msg.payload[0:8192],
|
||||
)
|
||||
subscriptions = self._matching_subscriptions(topic)
|
||||
msg_cache_by_subscription_topic: dict[str, ReceiveMessage] = {}
|
||||
|
||||
for subscription in subscriptions:
|
||||
for subscription in self._matching_subscriptions(topic, identifiers):
|
||||
if msg.retain:
|
||||
retained_topics = self._retained_topics[subscription]
|
||||
# Skip if the subscription already received a retained message
|
||||
|
||||
@@ -42,6 +42,22 @@ class PayloadSentinel(StrEnum):
|
||||
DEFAULT = "default"
|
||||
|
||||
|
||||
class SubscriptionID:
|
||||
"""ID generator for wildcard subscriptions."""
|
||||
|
||||
_id: int = 1
|
||||
|
||||
def generate(self) -> int:
|
||||
"""Generate a new subscription ID.
|
||||
|
||||
ID 0 is reserved.
|
||||
ID 1 is used for non wildcard topics.
|
||||
Generator starts at ID 2.
|
||||
"""
|
||||
self._id = self._id + 1
|
||||
return self._id
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_THIS = "this"
|
||||
@@ -421,6 +437,7 @@ class MqttData:
|
||||
state_write_requests: EntityTopicState = field(default_factory=EntityTopicState)
|
||||
subscriptions_to_restore: set[Subscription] = field(default_factory=set)
|
||||
tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict)
|
||||
subscription_id_generator: SubscriptionID = field(default_factory=SubscriptionID)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-pooldose==0.9.1"]
|
||||
"requirements": ["python-pooldose==0.9.0"]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"""The Risco integration."""
|
||||
|
||||
from asyncio import CancelledError
|
||||
import logging
|
||||
|
||||
from pyrisco import (
|
||||
CannotConnectError,
|
||||
OperationError,
|
||||
RiscoCloud,
|
||||
RiscoLocal,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from pyrisco import CannotConnectError, RiscoCloud, RiscoLocal, UnauthorizedError
|
||||
from pyrisco.common import Partition, System, Zone
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -30,7 +23,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_COMMUNICATION_DELAY,
|
||||
CONF_CONCURRENCY,
|
||||
DEFAULT_CONCURRENCY,
|
||||
DOMAIN,
|
||||
@@ -50,8 +42,6 @@ PLATFORMS = [
|
||||
Platform.SWITCH,
|
||||
]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
# pyrisco exposes timeout context as message text for this case.
|
||||
CLOCK_TIMEOUT_ERROR_FRAGMENT = "Timeout in command: CLOCK"
|
||||
|
||||
|
||||
def is_local(entry: ConfigEntry) -> bool:
|
||||
@@ -78,11 +68,7 @@ async def _async_setup_local_entry(
|
||||
data = entry.data
|
||||
concurrency = entry.options.get(CONF_CONCURRENCY, DEFAULT_CONCURRENCY)
|
||||
risco = RiscoLocal(
|
||||
data[CONF_HOST],
|
||||
data[CONF_PORT],
|
||||
data[CONF_PIN],
|
||||
communication_delay=data.get(CONF_COMMUNICATION_DELAY, 0),
|
||||
concurrency=concurrency,
|
||||
data[CONF_HOST], data[CONF_PORT], data[CONF_PIN], concurrency=concurrency
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -90,26 +76,14 @@ async def _async_setup_local_entry(
|
||||
except CannotConnectError as error:
|
||||
raise ConfigEntryNotReady from error
|
||||
except UnauthorizedError:
|
||||
_LOGGER.exception("Failed to authenticate with local Risco panel")
|
||||
_LOGGER.exception("Failed to login to Risco cloud")
|
||||
return False
|
||||
|
||||
async def _error(error: Exception) -> None:
|
||||
if isinstance(error, OperationError) and CLOCK_TIMEOUT_ERROR_FRAGMENT in str(
|
||||
error
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Risco keep-alive timeout for entry %s (host: %s)",
|
||||
entry.title,
|
||||
data.get(CONF_HOST, "unknown"),
|
||||
)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Error in Risco library",
|
||||
exc_info=error,
|
||||
)
|
||||
if isinstance(error, ConnectionResetError) and not hass.is_stopping:
|
||||
_LOGGER.debug("Disconnected from panel. Reloading integration")
|
||||
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
|
||||
_LOGGER.error("Error in Risco library", exc_info=error)
|
||||
if isinstance(error, ConnectionResetError) and not hass.is_stopping:
|
||||
_LOGGER.debug("Disconnected from panel. Reloading integration")
|
||||
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
|
||||
|
||||
entry.async_on_unload(risco.add_error_handler(_error))
|
||||
|
||||
@@ -185,16 +159,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: RiscoConfigEntry) -> bo
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok and (local_data := entry.runtime_data.local_data):
|
||||
try:
|
||||
await local_data.system.disconnect()
|
||||
except CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Failed to disconnect from local Risco panel for entry %s (host: %s)",
|
||||
entry.title,
|
||||
entry.data.get(CONF_HOST, "unknown"),
|
||||
)
|
||||
await local_data.system.disconnect()
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Xthings Cloud integration for Home Assistant."""
|
||||
|
||||
from ha_xthings_cloud import XthingsCloudApiClient
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_TOKEN, PLATFORMS
|
||||
from .coordinator import XthingsCloudConfigEntry, XthingsCloudCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: XthingsCloudConfigEntry
|
||||
) -> bool:
|
||||
"""Set up config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
client = XthingsCloudApiClient(session, token=entry.data[CONF_TOKEN])
|
||||
|
||||
coordinator = XthingsCloudCoordinator(hass, client, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await coordinator.async_start_websocket()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: XthingsCloudConfigEntry
|
||||
) -> bool:
|
||||
"""Unload config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await coordinator.async_stop_websocket()
|
||||
return unload_ok
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Config flow for Xthings Cloud."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ha_xthings_cloud import (
|
||||
XthingsCloudApiClient,
|
||||
XthingsCloudApiError,
|
||||
XthingsCloudAuthError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
|
||||
|
||||
from .const import (
|
||||
CONF_EMAIL,
|
||||
CONF_PASSWORD,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_TOKEN,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
ERROR_CODE_MAP: dict[int, str] = {
|
||||
20001: "token_invalid",
|
||||
21001: "email_empty",
|
||||
21002: "email_invalid",
|
||||
21004: "email_not_found",
|
||||
21011: "password_empty",
|
||||
21014: "password_wrong",
|
||||
21021: "user_disabled",
|
||||
21022: "user_not_logged_in",
|
||||
21023: "user_not_activated",
|
||||
20011: "token_invalid",
|
||||
20012: "token_expired",
|
||||
22001: "device_not_found",
|
||||
22003: "device_offline",
|
||||
}
|
||||
|
||||
|
||||
def _error_from_exception(err: XthingsCloudApiError) -> str:
|
||||
"""Return translation key from error code."""
|
||||
return ERROR_CODE_MAP.get(err.code, "unknown")
|
||||
|
||||
|
||||
class XthingsCloudConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Xthings Cloud config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user input step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
instance_id = await async_get_instance_id(self.hass)
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = XthingsCloudApiClient(session)
|
||||
try:
|
||||
token_data = await client.async_login(
|
||||
user_input[CONF_EMAIL],
|
||||
user_input[CONF_PASSWORD],
|
||||
client_id=instance_id,
|
||||
)
|
||||
except XthingsCloudAuthError as err:
|
||||
errors["base"] = _error_from_exception(err)
|
||||
except XthingsCloudApiError as err:
|
||||
errors["base"] = (
|
||||
_error_from_exception(err) if err.code else "cannot_connect"
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error during login")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(token_data["user_id"])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL],
|
||||
data={
|
||||
CONF_EMAIL: user_input[CONF_EMAIL],
|
||||
CONF_TOKEN: token_data["token"],
|
||||
CONF_REFRESH_TOKEN: token_data["refresh_token"],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Constants for Xthings Cloud integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "xthings_cloud"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
CONF_EMAIL = "email"
|
||||
CONF_PASSWORD = "password"
|
||||
CONF_TOKEN = "token"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
CONF_CLIENT_ID = "client_id"
|
||||
CONF_INSTANCE_ID = "instance_id"
|
||||
|
||||
# Polling interval (seconds)
|
||||
DEFAULT_SCAN_INTERVAL = 1800
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
@@ -1,128 +0,0 @@
|
||||
"""DataUpdateCoordinator for Xthings Cloud."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from ha_xthings_cloud import (
|
||||
XthingsCloudApiClient,
|
||||
XthingsCloudApiError,
|
||||
XthingsCloudAuthError,
|
||||
XthingsCloudWebSocket,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_REFRESH_TOKEN, CONF_TOKEN, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
|
||||
|
||||
type XthingsCloudConfigEntry = ConfigEntry["XthingsCloudCoordinator"]
|
||||
|
||||
|
||||
class XthingsCloudCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Xthings Cloud data update coordinator."""
|
||||
|
||||
config_entry: XthingsCloudConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: XthingsCloudApiClient,
|
||||
entry: XthingsCloudConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
config_entry=entry,
|
||||
)
|
||||
self.client = client
|
||||
self.websocket: XthingsCloudWebSocket | None = None
|
||||
|
||||
async def _async_ensure_token_valid(self) -> None:
|
||||
"""Ensure the token is valid, refresh if expired.
|
||||
|
||||
Raises ConfigEntryAuthFailed if refresh fails.
|
||||
"""
|
||||
if not self.client.is_token_expired():
|
||||
return
|
||||
try:
|
||||
token_data = await self.client.async_refresh_token(
|
||||
self.config_entry.data[CONF_REFRESH_TOKEN]
|
||||
)
|
||||
except XthingsCloudAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Token expired and refresh failed, re-authentication required"
|
||||
) from err
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={
|
||||
**self.config_entry.data,
|
||||
CONF_TOKEN: token_data["token"],
|
||||
CONF_REFRESH_TOKEN: token_data["refresh_token"],
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch latest device data from cloud."""
|
||||
await self._async_ensure_token_valid()
|
||||
try:
|
||||
devices = await self.client.async_get_devices()
|
||||
except XthingsCloudAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Invalid token, re-authentication required"
|
||||
) from err
|
||||
except XthingsCloudApiError as err:
|
||||
raise UpdateFailed(f"Failed to fetch data: {err}") from err
|
||||
return {device["id"]: device for device in devices}
|
||||
|
||||
async def async_start_websocket(self) -> None:
|
||||
"""Start WebSocket connection."""
|
||||
if self.websocket:
|
||||
return
|
||||
session = async_get_clientsession(self.hass)
|
||||
token = self.config_entry.data[CONF_TOKEN]
|
||||
self.websocket = XthingsCloudWebSocket(
|
||||
session=session,
|
||||
token=token,
|
||||
on_device_status=self._handle_ws_device_status,
|
||||
on_token_expired=self._handle_ws_token_expired,
|
||||
)
|
||||
await self.websocket.async_start()
|
||||
|
||||
async def async_stop_websocket(self) -> None:
|
||||
"""Stop WebSocket connection."""
|
||||
if self.websocket:
|
||||
await self.websocket.async_stop()
|
||||
self.websocket = None
|
||||
|
||||
def _handle_ws_device_status(
|
||||
self, device_uuid: str, status: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle WebSocket device status update."""
|
||||
if not self.data or device_uuid not in self.data:
|
||||
LOGGER.debug(
|
||||
"WebSocket received status for unknown device: %s", device_uuid
|
||||
)
|
||||
return
|
||||
device_data = self.data[device_uuid]
|
||||
device_data.setdefault("status", {}).update(status)
|
||||
LOGGER.debug("WebSocket updated device status: %s", device_uuid)
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def _handle_ws_token_expired(self) -> None:
|
||||
"""Handle WebSocket auth expiry, refresh token."""
|
||||
try:
|
||||
await self._async_ensure_token_valid()
|
||||
except ConfigEntryAuthFailed:
|
||||
LOGGER.error("WebSocket token refresh failed")
|
||||
return
|
||||
new_token = self.config_entry.data[CONF_TOKEN]
|
||||
self.client.token = new_token
|
||||
if self.websocket:
|
||||
self.websocket.token = new_token
|
||||
LOGGER.info("WebSocket token refreshed successfully")
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Base entity for Xthings Cloud."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import XthingsCloudCoordinator
|
||||
|
||||
|
||||
class XthingsCloudEntity(CoordinatorEntity[XthingsCloudCoordinator]):
|
||||
"""Xthings Cloud base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: XthingsCloudCoordinator,
|
||||
device_id: str,
|
||||
device_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._attr_unique_id = device_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=device_data["name"],
|
||||
manufacturer="Xthings",
|
||||
model=device_data["model"],
|
||||
sw_version=device_data.get("version"),
|
||||
)
|
||||
|
||||
@property
|
||||
def device_data(self) -> dict[str, Any]:
|
||||
"""Return current device data."""
|
||||
return self.coordinator.data[self._device_id]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether device is available (online)."""
|
||||
return (
|
||||
super().available
|
||||
and self._device_id in self.coordinator.data
|
||||
and self.device_data["online"]
|
||||
)
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Light platform for Xthings Cloud."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_HS_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import XthingsCloudConfigEntry, XthingsCloudCoordinator
|
||||
from .entity import XthingsCloudEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: XthingsCloudConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up light platform."""
|
||||
coordinator = entry.runtime_data
|
||||
entities = [
|
||||
XthingsCloudLight(coordinator, device_id, device_data)
|
||||
for device_id, device_data in coordinator.data.items()
|
||||
if device_data["type"] == "light"
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class XthingsCloudLight(XthingsCloudEntity, LightEntity):
|
||||
"""Xthings Cloud light entity."""
|
||||
|
||||
_attr_min_color_temp_kelvin = 2000
|
||||
_attr_max_color_temp_kelvin = 6500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: XthingsCloudCoordinator,
|
||||
device_id: str,
|
||||
device_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the light entity."""
|
||||
super().__init__(coordinator, device_id, device_data)
|
||||
# Determine supported color modes from device status
|
||||
status = device_data["status"]
|
||||
modes: set[ColorMode] = set()
|
||||
if "hue" in status or "saturation" in status:
|
||||
modes.add(ColorMode.HS)
|
||||
if "temperature" in status:
|
||||
modes.add(ColorMode.COLOR_TEMP)
|
||||
if not modes and "brightness" in status:
|
||||
modes.add(ColorMode.BRIGHTNESS)
|
||||
if not modes:
|
||||
modes.add(ColorMode.ONOFF)
|
||||
self._attr_supported_color_modes = modes
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return current color mode."""
|
||||
status = self.device_data["status"]
|
||||
color_type = status.get("color_type")
|
||||
modes = self._attr_supported_color_modes or set()
|
||||
if color_type == 0 and ColorMode.HS in modes:
|
||||
return ColorMode.HS
|
||||
if color_type == 1 and ColorMode.COLOR_TEMP in modes:
|
||||
return ColorMode.COLOR_TEMP
|
||||
if ColorMode.HS in modes:
|
||||
return ColorMode.HS
|
||||
if ColorMode.COLOR_TEMP in modes:
|
||||
return ColorMode.COLOR_TEMP
|
||||
if ColorMode.BRIGHTNESS in modes:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the light is on."""
|
||||
return self.device_data["status"]["on"]
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return brightness (0-255)."""
|
||||
level = self.device_data["status"].get("brightness")
|
||||
if level is not None:
|
||||
return round(level * 255 / 100)
|
||||
return None
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
"""Return the HS color value."""
|
||||
status = self.device_data["status"]
|
||||
hue = status.get("hue")
|
||||
saturation = status.get("saturation")
|
||||
if hue is not None and saturation is not None:
|
||||
return (hue, saturation)
|
||||
return None
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the color temperature in Kelvin."""
|
||||
return self.device_data["status"].get("temperature")
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on light."""
|
||||
client = self.coordinator.client
|
||||
has_color = ATTR_HS_COLOR in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs
|
||||
has_brightness = ATTR_BRIGHTNESS in kwargs
|
||||
# Only send on command when no color/brightness adjustment
|
||||
if not has_color and not has_brightness:
|
||||
await client.async_brite_on(self._device_id)
|
||||
# Adjust brightness (standalone, no color change)
|
||||
if has_brightness and not has_color:
|
||||
brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
|
||||
await client.async_brite_brightness(self._device_id, brightness)
|
||||
# Adjust HS color
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
hue, saturation = kwargs[ATTR_HS_COLOR]
|
||||
status = self.device_data["status"]
|
||||
lightness = status.get("lightness", 50)
|
||||
cur_brightness = status.get("brightness", 100)
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
lightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
|
||||
cur_brightness = lightness
|
||||
await client.async_brite_color(
|
||||
self._device_id,
|
||||
{
|
||||
"colortype": 0,
|
||||
"hue": round(hue),
|
||||
"saturation": round(saturation),
|
||||
"lightness": lightness,
|
||||
"brightness": cur_brightness,
|
||||
},
|
||||
)
|
||||
# Adjust color temperature
|
||||
if ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
status = self.device_data["status"]
|
||||
cur_brightness = status.get("brightness", 100)
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
cur_brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
|
||||
await client.async_brite_color(
|
||||
self._device_id,
|
||||
{
|
||||
"colortype": 1,
|
||||
"temperature": kwargs[ATTR_COLOR_TEMP_KELVIN],
|
||||
"brightness": cur_brightness,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off light."""
|
||||
await self.coordinator.client.async_brite_off(self._device_id)
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "xthings_cloud",
|
||||
"name": "Xthings Cloud",
|
||||
"codeowners": ["@XthingsJacobs"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/xthings_cloud",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["ha_xthings_cloud"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["ha-xthings-cloud==1.0.5"]
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No service actions implemented.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No service actions implemented.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: No event-based entity setup.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
config-entry-unloading: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: Offloaded to coordinator.
|
||||
entity-unavailable:
|
||||
status: done
|
||||
comment: Offloaded to coordinator.
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: No service actions implemented.
|
||||
reauthentication-flow: todo
|
||||
parallel-updates: todo
|
||||
test-coverage: todo
|
||||
integration-owner: done
|
||||
docs-installation-parameters: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No options flow.
|
||||
|
||||
# Gold
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: Entity uses has_entity_name with name set to None.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: No platform with device classes.
|
||||
devices: done
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: No diagnostic or configuration entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: No entities disabled by default.
|
||||
discovery: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Single config entry, devices managed by coordinator.
|
||||
diagnostics: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Devices are fetched from cloud on each update.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No discoverable entities.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair issues implemented.
|
||||
docs-use-cases: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-data-update: done
|
||||
docs-known-limitations: done
|
||||
docs-troubleshooting: done
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: No automation examples needed.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"device_not_found": "Device not found.",
|
||||
"device_offline": "Device is offline.",
|
||||
"email_empty": "Email cannot be empty.",
|
||||
"email_invalid": "Invalid email format.",
|
||||
"email_not_found": "Email does not exist.",
|
||||
"password_empty": "Password cannot be empty.",
|
||||
"password_wrong": "Incorrect password.",
|
||||
"token_expired": "Token expired, please log in again.",
|
||||
"token_invalid": "Invalid token, please log in again.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"user_disabled": "This account has been disabled.",
|
||||
"user_not_activated": "This account has not been activated.",
|
||||
"user_not_logged_in": "Session expired, please log in again."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "The email address used to register your Xthings Cloud account.",
|
||||
"password": "Your Xthings Cloud account password."
|
||||
},
|
||||
"description": "Please enter your Xthings Cloud account credentials.",
|
||||
"title": "Xthings Cloud Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,5 @@ class ZeversolarEntity(
|
||||
identifiers={(DOMAIN, coordinator.data.serial_number)},
|
||||
name="Zeversolar Sensor",
|
||||
manufacturer="Zeversolar",
|
||||
hw_version=coordinator.data.hardware_version,
|
||||
sw_version=coordinator.data.software_version,
|
||||
serial_number=coordinator.data.serial_number,
|
||||
)
|
||||
|
||||
Generated
-1
@@ -847,7 +847,6 @@ FLOWS = {
|
||||
"xiaomi_aqara",
|
||||
"xiaomi_ble",
|
||||
"xiaomi_miio",
|
||||
"xthings_cloud",
|
||||
"yale",
|
||||
"yale_smart_alarm",
|
||||
"yalexs_ble",
|
||||
|
||||
@@ -8069,12 +8069,6 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"xthings_cloud": {
|
||||
"name": "Xthings Cloud",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
"yale": {
|
||||
"name": "Yale (non-US/Canada)",
|
||||
"integrations": {
|
||||
|
||||
Generated
+4
-7
@@ -254,7 +254,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==44.24.1
|
||||
aioesphomeapi==44.21.0
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -276,7 +276,7 @@ aiogithubapi==26.0.0
|
||||
aioguardian==2026.01.1
|
||||
|
||||
# homeassistant.components.harmony
|
||||
aioharmony==1.0.3
|
||||
aioharmony==0.5.3
|
||||
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.4.3
|
||||
@@ -1200,9 +1200,6 @@ ha-philipsjs==3.2.4
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
ha-silabs-firmware-client==0.3.0
|
||||
|
||||
# homeassistant.components.xthings_cloud
|
||||
ha-xthings-cloud==1.0.5
|
||||
|
||||
# homeassistant.components.habitica
|
||||
habiticalib==0.4.7
|
||||
|
||||
@@ -2213,7 +2210,7 @@ pyinsteon==1.6.4
|
||||
pyintelliclima==0.3.1
|
||||
|
||||
# homeassistant.components.intesishome
|
||||
pyintesishome==1.8.7
|
||||
pyintesishome==1.8.0
|
||||
|
||||
# homeassistant.components.ipma
|
||||
pyipma==3.0.9
|
||||
@@ -2684,7 +2681,7 @@ python-overseerr==0.9.0
|
||||
python-picnic-api2==1.3.4
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.9.1
|
||||
python-pooldose==0.9.0
|
||||
|
||||
# homeassistant.components.hr_energy_qube
|
||||
python-qube-heatpump==1.10.0
|
||||
|
||||
Generated
+3
-6
@@ -245,7 +245,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==44.24.1
|
||||
aioesphomeapi==44.21.0
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -264,7 +264,7 @@ aiogithubapi==26.0.0
|
||||
aioguardian==2026.01.1
|
||||
|
||||
# homeassistant.components.harmony
|
||||
aioharmony==1.0.3
|
||||
aioharmony==0.5.3
|
||||
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.4.3
|
||||
@@ -1076,9 +1076,6 @@ ha-philipsjs==3.2.4
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
ha-silabs-firmware-client==0.3.0
|
||||
|
||||
# homeassistant.components.xthings_cloud
|
||||
ha-xthings-cloud==1.0.5
|
||||
|
||||
# homeassistant.components.habitica
|
||||
habiticalib==0.4.7
|
||||
|
||||
@@ -2295,7 +2292,7 @@ python-overseerr==0.9.0
|
||||
python-picnic-api2==1.3.4
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.9.1
|
||||
python-pooldose==0.9.0
|
||||
|
||||
# homeassistant.components.hr_energy_qube
|
||||
python-qube-heatpump==1.10.0
|
||||
|
||||
+6
-1
@@ -23,7 +23,7 @@ import os
|
||||
import pathlib
|
||||
import time
|
||||
from types import FrameType, ModuleType
|
||||
from typing import Any, Literal, NoReturn
|
||||
from typing import TYPE_CHECKING, Any, Literal, NoReturn
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from aiohttp.test_utils import unused_port as get_test_instance_port
|
||||
@@ -122,6 +122,9 @@ from .testing_config.custom_components.test_constant_deprecation import (
|
||||
import_deprecated_constant,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
__all__ = [
|
||||
"async_get_device_automation_capabilities",
|
||||
"get_test_instance_port",
|
||||
@@ -452,6 +455,7 @@ def async_fire_mqtt_message(
|
||||
payload: bytes | str,
|
||||
qos: int = 0,
|
||||
retain: bool = False,
|
||||
properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""Fire the MQTT message."""
|
||||
from homeassistant.components.mqtt import MqttData # noqa: PLC0415
|
||||
@@ -464,6 +468,7 @@ def async_fire_mqtt_message(
|
||||
msg.qos = qos
|
||||
msg.retain = retain
|
||||
msg.timestamp = time.monotonic()
|
||||
msg.properties = properties
|
||||
|
||||
mqtt_data: MqttData = hass.data["mqtt"]
|
||||
assert mqtt_data.client
|
||||
|
||||
@@ -949,7 +949,9 @@ async def test_options_use_wallclock_as_timestamps(
|
||||
) -> None:
|
||||
"""Test the use_wallclock_as_timestamps option flow."""
|
||||
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
result = await hass.config_entries.options.async_init(
|
||||
config_entry.entry_id, context={"show_advanced_options": True}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
@@ -1071,12 +1072,33 @@ async def test_not_calling_subscribe_when_unsubscribed_within_cooldown(
|
||||
assert not mqtt_client_mock.subscribe.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mqtt_config_entry_data",
|
||||
[
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1",
|
||||
},
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1.1",
|
||||
},
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "5",
|
||||
},
|
||||
],
|
||||
ids=["v3.1", "v3.1.1", "v5"],
|
||||
)
|
||||
async def test_unsubscribe_race(
|
||||
hass: HomeAssistant,
|
||||
mock_debouncer: asyncio.Event,
|
||||
setup_with_birth_msg_client_mock: MqttMockPahoClient,
|
||||
) -> None:
|
||||
"""Test not calling unsubscribe() when other subscribers are active."""
|
||||
"""Test not calling unsubscribe() when other subscribers are active.
|
||||
|
||||
Testing with simple topics.
|
||||
"""
|
||||
mqtt_client_mock = setup_with_birth_msg_client_mock
|
||||
calls_a: list[ReceiveMessage] = []
|
||||
calls_b: list[ReceiveMessage] = []
|
||||
@@ -1104,16 +1126,89 @@ async def test_unsubscribe_race(
|
||||
# We allow either calls [subscribe, unsubscribe, subscribe], [subscribe, subscribe] or
|
||||
# when both subscriptions were combined [subscribe]
|
||||
expected_calls_1 = [
|
||||
call.subscribe([("test/state", 0)]),
|
||||
call.subscribe([("test/state", 0)], properties=ANY),
|
||||
call.unsubscribe("test/state"),
|
||||
call.subscribe([("test/state", 0)]),
|
||||
call.subscribe([("test/state", 0)], properties=ANY),
|
||||
]
|
||||
expected_calls_2 = [
|
||||
call.subscribe([("test/state", 0)]),
|
||||
call.subscribe([("test/state", 0)]),
|
||||
call.subscribe([("test/state", 0)], properties=ANY),
|
||||
call.subscribe([("test/state", 0)], properties=ANY),
|
||||
]
|
||||
expected_calls_3 = [
|
||||
call.subscribe([("test/state", 0)]),
|
||||
call.subscribe([("test/state", 0)], properties=ANY),
|
||||
]
|
||||
assert mqtt_client_mock.mock_calls in (
|
||||
expected_calls_1,
|
||||
expected_calls_2,
|
||||
expected_calls_3,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mqtt_config_entry_data",
|
||||
[
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1",
|
||||
},
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1.1",
|
||||
},
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "5",
|
||||
},
|
||||
],
|
||||
ids=["v3.1", "v3.1.1", "v5"],
|
||||
)
|
||||
@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE])
|
||||
async def test_wildcard_unsubscribe_race(
|
||||
hass: HomeAssistant,
|
||||
mock_debouncer: asyncio.Event,
|
||||
setup_with_birth_msg_client_mock: MqttMockPahoClient,
|
||||
) -> None:
|
||||
"""Test not calling unsubscribe() when other subscribers are active.
|
||||
|
||||
Testing with wildcard topics.
|
||||
"""
|
||||
mqtt_client_mock = setup_with_birth_msg_client_mock
|
||||
calls_a: list[ReceiveMessage] = []
|
||||
calls_b: list[ReceiveMessage] = []
|
||||
|
||||
@callback
|
||||
def _callback_a(msg: ReceiveMessage) -> None:
|
||||
calls_a.append(msg)
|
||||
|
||||
@callback
|
||||
def _callback_b(msg: ReceiveMessage) -> None:
|
||||
calls_b.append(msg)
|
||||
|
||||
mqtt_client_mock.reset_mock()
|
||||
|
||||
mock_debouncer.clear()
|
||||
unsub = await mqtt.async_subscribe(hass, "test/#", _callback_a)
|
||||
unsub()
|
||||
await mqtt.async_subscribe(hass, "test/#", _callback_b)
|
||||
await mock_debouncer.wait()
|
||||
|
||||
async_fire_mqtt_message(hass, "test/state", "online")
|
||||
assert not calls_a
|
||||
assert calls_b
|
||||
|
||||
# We allow either calls [subscribe, unsubscribe, subscribe], [subscribe, subscribe] or
|
||||
# when both subscriptions were combined [subscribe]
|
||||
expected_calls_1 = [
|
||||
call.subscribe("test/#", 0, properties=ANY),
|
||||
call.unsubscribe("test/#"),
|
||||
call.subscribe("test/#", 0, properties=ANY),
|
||||
]
|
||||
expected_calls_2 = [
|
||||
call.subscribe("test/#", 0, properties=ANY),
|
||||
call.subscribe("test/#", 0, properties=ANY),
|
||||
]
|
||||
expected_calls_3 = [
|
||||
call.subscribe("test/#", 0, properties=ANY),
|
||||
]
|
||||
assert mqtt_client_mock.mock_calls in (
|
||||
expected_calls_1,
|
||||
@@ -1181,7 +1276,7 @@ async def test_restore_all_active_subscriptions_on_reconnect(
|
||||
|
||||
# the subscription with the highest QoS should survive
|
||||
expected = [
|
||||
call([("test/state", 2)]),
|
||||
call([("test/state", 2)], properties=None),
|
||||
]
|
||||
assert mqtt_client_mock.subscribe.mock_calls == expected
|
||||
|
||||
@@ -1195,7 +1290,7 @@ async def test_restore_all_active_subscriptions_on_reconnect(
|
||||
# wait for cooldown
|
||||
await mock_debouncer.wait()
|
||||
|
||||
expected.append(call([("test/state", 1)]))
|
||||
expected.append(call([("test/state", 1)], properties=None))
|
||||
for expected_call in expected:
|
||||
assert mqtt_client_mock.subscribe.hass_call(expected_call)
|
||||
|
||||
@@ -1387,7 +1482,7 @@ async def test_subscribe_error(
|
||||
mqtt_client_mock = setup_with_birth_msg_client_mock
|
||||
mqtt_client_mock.reset_mock()
|
||||
# simulate client is not connected error before subscribing
|
||||
mqtt_client_mock.subscribe.side_effect = lambda *args: (4, None)
|
||||
mqtt_client_mock.subscribe.side_effect = lambda *args, **kwargs: (4, None)
|
||||
await mqtt.async_subscribe(hass, "some-topic", record_calls)
|
||||
while mqtt_client_mock.subscribe.call_count == 0:
|
||||
await hass.async_block_till_done()
|
||||
@@ -2384,3 +2479,89 @@ async def test_loop_write_failure(
|
||||
|
||||
# Cleanup. Server is closed earlier already.
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mqtt_config_entry_data", "mqtt_config_entry_options"),
|
||||
[
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "5",
|
||||
},
|
||||
ENTRY_DEFAULT_BIRTH_MESSAGE,
|
||||
),
|
||||
],
|
||||
ids=["v5"],
|
||||
)
|
||||
async def test_overlapping_subscriptions_only_processed_once(
|
||||
hass: HomeAssistant,
|
||||
setup_with_birth_msg_client_mock: MqttMockPahoClient,
|
||||
) -> None:
|
||||
"""Test messages are only processed once per subscription in case of overlap.
|
||||
|
||||
Overlapping subscriptions are only supported with MQTTv5
|
||||
"""
|
||||
mqtt_client_mock = setup_with_birth_msg_client_mock
|
||||
assert mqtt_client_mock.connect.call_count == 1
|
||||
|
||||
mock_subscribe: MagicMock = mqtt_client_mock.subscribe
|
||||
mock_subscribe.reset_mock()
|
||||
|
||||
# We create 3 sensors:
|
||||
# - 2 with an overlapping wildcard subscription
|
||||
# - 1 with an overlapping simple subscription
|
||||
config1 = json.dumps(
|
||||
{
|
||||
"name": "test1",
|
||||
"default_entity_id": "sensor.test1",
|
||||
"unique_id": "test1_veryunique",
|
||||
"state_topic": "test/+/status",
|
||||
}
|
||||
)
|
||||
config2 = json.dumps(
|
||||
{
|
||||
"name": "test2",
|
||||
"default_entity_id": "sensor.test2",
|
||||
"unique_id": "test2_veryunique",
|
||||
"state_topic": "test/#",
|
||||
}
|
||||
)
|
||||
config3 = json.dumps(
|
||||
{
|
||||
"name": "test3",
|
||||
"default_entity_id": "sensor.test3",
|
||||
"unique_id": "test3_veryunique",
|
||||
"state_topic": "test/bla/status",
|
||||
}
|
||||
)
|
||||
|
||||
async_fire_mqtt_message(hass, "homeassistant/sensor/config1/config", config1)
|
||||
async_fire_mqtt_message(hass, "homeassistant/sensor/config2/config", config2)
|
||||
async_fire_mqtt_message(hass, "homeassistant/sensor/config3/config", config3)
|
||||
while len(mock_subscribe.mock_calls) < 3:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
message_identifiers = [
|
||||
mock_call[2]["properties"].SubscriptionIdentifier[0]
|
||||
for mock_call in mock_subscribe.mock_calls
|
||||
]
|
||||
|
||||
assert hass.states.get("sensor.test1") is not None
|
||||
assert hass.states.get("sensor.test2") is not None
|
||||
assert hass.states.get("sensor.test3") is not None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.mqtt.entity.MqttEntity.async_write_ha_state"
|
||||
) as mock_async_ha_write_state:
|
||||
# Simulate the broker sends a publish message at topic "test/bla/status"
|
||||
# That matches all three subscriptions
|
||||
for message_identifier in message_identifiers:
|
||||
properties = paho_mqtt.Properties(paho_mqtt.PacketTypes.PUBLISH)
|
||||
properties.SubscriptionIdentifier = message_identifier
|
||||
async_fire_mqtt_message(
|
||||
hass, "test/bla/status", "bla", properties=properties
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
# Each sensor should receive one update, so we should have 3 state write calls
|
||||
assert len(mock_async_ha_write_state.mock_calls) == 3
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Tests for the Nobø Ecohub integration setup."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pynobo import nobo as pynobo_nobo
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.nobo_hub import async_setup_entry
|
||||
from homeassistant.components.nobo_hub.const import (
|
||||
CONF_OVERRIDE_TYPE,
|
||||
CONF_SERIAL,
|
||||
@@ -13,6 +14,7 @@ from homeassistant.components.nobo_hub.const import (
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .conftest import SERIAL, STORED_IP
|
||||
@@ -22,6 +24,25 @@ from tests.common import MockConfigEntry
|
||||
NEW_IP = "192.168.1.55"
|
||||
|
||||
|
||||
def _spec_hub(connect_exc: BaseException | None = None) -> MagicMock:
|
||||
"""Build a minimal spec'd pynobo hub for rediscovery tests."""
|
||||
hub = MagicMock(spec=pynobo_nobo)
|
||||
if connect_exc is not None:
|
||||
hub.connect.side_effect = connect_exc
|
||||
hub.hub_serial = SERIAL
|
||||
hub.hub_info = {
|
||||
"name": "My Eco Hub",
|
||||
"serial": SERIAL,
|
||||
"software_version": "115",
|
||||
"hardware_version": "hw",
|
||||
}
|
||||
hub.zones = {}
|
||||
hub.components = {}
|
||||
hub.week_profiles = {}
|
||||
hub.overrides = {}
|
||||
return hub
|
||||
|
||||
|
||||
async def test_setup_uses_stored_ip(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -41,71 +62,57 @@ async def test_setup_uses_stored_ip(
|
||||
async def test_setup_rediscovery_updates_ip(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_nobo_class: MagicMock,
|
||||
) -> None:
|
||||
"""A failed direct connect falls back to rediscovery and persists the new IP."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
failing_hub = MagicMock(spec=pynobo_nobo)
|
||||
failing_hub.connect.side_effect = OSError("Unreachable")
|
||||
mock_nobo_class.side_effect = [failing_hub, mock_nobo_class.return_value]
|
||||
mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)}
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_cls:
|
||||
mock_cls.side_effect = [
|
||||
_spec_hub(connect_exc=OSError("Unreachable")),
|
||||
_spec_hub(),
|
||||
]
|
||||
mock_cls.async_discover_hubs.return_value = {(NEW_IP, SERIAL)}
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert mock_config_entry.data[CONF_IP_ADDRESS] == NEW_IP
|
||||
assert mock_nobo_class.call_count == 2
|
||||
assert mock_nobo_class.call_args_list[0].kwargs["ip"] == STORED_IP
|
||||
assert mock_nobo_class.call_args_list[1].kwargs["ip"] == NEW_IP
|
||||
assert mock_cls.call_count == 2
|
||||
assert mock_cls.call_args_list[0].kwargs["ip"] == STORED_IP
|
||||
assert mock_cls.call_args_list[1].kwargs["ip"] == NEW_IP
|
||||
|
||||
|
||||
async def test_setup_retries_when_rediscovery_finds_nothing(
|
||||
@pytest.mark.parametrize(
|
||||
("discovered_hubs", "second_exc", "expected_placeholders"),
|
||||
[
|
||||
(set(), None, {"serial": SERIAL, "ip": STORED_IP}),
|
||||
(
|
||||
{(NEW_IP, SERIAL)},
|
||||
OSError("Unreachable"),
|
||||
{"serial": SERIAL, "ip": NEW_IP},
|
||||
),
|
||||
],
|
||||
ids=["rediscovery_empty", "rediscovered_ip_fails"],
|
||||
)
|
||||
async def test_setup_rediscovery_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_nobo_class: MagicMock,
|
||||
discovered_hubs: set[tuple[str, str]],
|
||||
second_exc: BaseException | None,
|
||||
expected_placeholders: dict[str, str],
|
||||
) -> None:
|
||||
"""Setup retries with cannot_connect when the stored IP fails and rediscovery is empty."""
|
||||
"""Setup raises cannot_connect when rediscovery can't recover."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
failing_hub = MagicMock(spec=pynobo_nobo)
|
||||
failing_hub.connect.side_effect = OSError("Unreachable")
|
||||
mock_nobo_class.side_effect = [failing_hub]
|
||||
mock_nobo_class.async_discover_hubs.return_value = set()
|
||||
with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_cls:
|
||||
mock_cls.side_effect = [
|
||||
_spec_hub(connect_exc=OSError("Unreachable")),
|
||||
_spec_hub(connect_exc=second_exc),
|
||||
]
|
||||
mock_cls.async_discover_hubs.return_value = discovered_hubs
|
||||
with pytest.raises(ConfigEntryNotReady) as exc_info:
|
||||
await async_setup_entry(hass, mock_config_entry)
|
||||
|
||||
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert mock_config_entry.error_reason_translation_key == "cannot_connect"
|
||||
assert mock_config_entry.error_reason_translation_placeholders == {
|
||||
"serial": SERIAL,
|
||||
"ip": STORED_IP,
|
||||
}
|
||||
|
||||
|
||||
async def test_setup_retries_when_rediscovered_ip_also_fails(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_nobo_class: MagicMock,
|
||||
) -> None:
|
||||
"""Setup retries with cannot_connect when both the stored and rediscovered IPs fail."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
first_failing_hub = MagicMock(spec=pynobo_nobo)
|
||||
first_failing_hub.connect.side_effect = OSError("Unreachable")
|
||||
second_failing_hub = MagicMock(spec=pynobo_nobo)
|
||||
second_failing_hub.connect.side_effect = OSError("Unreachable")
|
||||
mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub]
|
||||
mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)}
|
||||
|
||||
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert mock_config_entry.error_reason_translation_key == "cannot_connect"
|
||||
assert mock_config_entry.error_reason_translation_placeholders == {
|
||||
"serial": SERIAL,
|
||||
"ip": NEW_IP,
|
||||
}
|
||||
assert exc_info.value.translation_key == "cannot_connect"
|
||||
assert exc_info.value.translation_placeholders == expected_placeholders
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -1,139 +1,30 @@
|
||||
"""Tests for the Risco integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyrisco import OperationError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.risco.const import (
|
||||
CONF_COMMUNICATION_DELAY,
|
||||
DEFAULT_CONCURRENCY,
|
||||
DOMAIN,
|
||||
TYPE_LOCAL,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_error_handler() -> MagicMock:
|
||||
def mock_error_handler():
|
||||
"""Create a mock for add_error_handler."""
|
||||
with patch("homeassistant.components.risco.RiscoLocal.add_error_handler") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
async def test_connection_reset(
|
||||
hass: HomeAssistant,
|
||||
two_zone_local: dict[int, Any],
|
||||
mock_error_handler: MagicMock,
|
||||
setup_risco_local: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
hass: HomeAssistant, two_zone_local, mock_error_handler, setup_risco_local
|
||||
) -> None:
|
||||
"""Test that ConnectionResetError triggers a reload but generic errors do not."""
|
||||
"""Test config entry reload on connection reset."""
|
||||
|
||||
callback = mock_error_handler.call_args.args[0]
|
||||
assert callback is not None
|
||||
|
||||
local_data = setup_risco_local.runtime_data.local_data
|
||||
disconnect_mock = cast(AsyncMock, local_data.system.disconnect)
|
||||
connect_mock = cast(AsyncMock, local_data.system.connect)
|
||||
with patch.object(hass.config_entries, "async_reload") as reload_mock:
|
||||
await callback(Exception())
|
||||
reload_mock.assert_not_awaited()
|
||||
|
||||
caplog.set_level(logging.DEBUG, logger="homeassistant.components.risco")
|
||||
|
||||
# Generic error should not trigger reload — unload/setup not invoked again
|
||||
await callback(Exception())
|
||||
await hass.async_block_till_done()
|
||||
disconnect_mock.assert_not_called()
|
||||
assert connect_mock.call_count == 1
|
||||
|
||||
# ConnectionResetError should trigger a reload
|
||||
await callback(ConnectionResetError())
|
||||
await hass.async_block_till_done()
|
||||
disconnect_mock.assert_awaited_once()
|
||||
assert connect_mock.call_count == 2
|
||||
assert "Disconnected from panel. Reloading integration" in caplog.text
|
||||
|
||||
|
||||
async def test_unload_handles_disconnect_error(
|
||||
hass: HomeAssistant,
|
||||
two_zone_local: dict[int, Any],
|
||||
setup_risco_local: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test unload succeeds when local disconnect errors out."""
|
||||
with patch(
|
||||
"homeassistant.components.risco.RiscoLocal.disconnect",
|
||||
side_effect=RuntimeError("disconnect failed"),
|
||||
) as disconnect_mock:
|
||||
assert await hass.config_entries.async_unload(setup_risco_local.entry_id)
|
||||
disconnect_mock.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_local_setup_uses_stored_communication_delay(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test local setup passes stored communication delay to the client."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_TYPE: TYPE_LOCAL,
|
||||
CONF_HOST: "test-host",
|
||||
CONF_PORT: 5004,
|
||||
CONF_PIN: "1234",
|
||||
CONF_COMMUNICATION_DELAY: 2,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.risco.RiscoLocal", autospec=True
|
||||
) as risco_local,
|
||||
patch.object(
|
||||
hass.config_entries,
|
||||
"async_forward_entry_setups",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
risco_local.return_value.connect = AsyncMock()
|
||||
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
risco_local.assert_called_once_with(
|
||||
"test-host",
|
||||
5004,
|
||||
"1234",
|
||||
communication_delay=2,
|
||||
concurrency=DEFAULT_CONCURRENCY,
|
||||
)
|
||||
|
||||
|
||||
async def test_clock_operation_error_is_downgraded(
|
||||
hass: HomeAssistant,
|
||||
two_zone_local: dict[int, Any],
|
||||
mock_error_handler: MagicMock,
|
||||
setup_risco_local: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test CLOCK keep-alive operation errors warn without triggering reload."""
|
||||
callback = mock_error_handler.call_args.args[0]
|
||||
assert callback is not None
|
||||
|
||||
local_data = setup_risco_local.runtime_data.local_data
|
||||
disconnect_mock = cast(AsyncMock, local_data.system.disconnect)
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="homeassistant.components.risco")
|
||||
await callback(OperationError("Timeout in command: CLOCK"))
|
||||
|
||||
disconnect_mock.assert_not_awaited()
|
||||
assert "Error in Risco library" not in caplog.text
|
||||
|
||||
expected_warning = (
|
||||
"Risco keep-alive timeout for entry "
|
||||
f"{setup_risco_local.title} (host: "
|
||||
f"{setup_risco_local.data.get(CONF_HOST, 'unknown')})"
|
||||
)
|
||||
assert expected_warning in caplog.text
|
||||
await callback(ConnectionResetError())
|
||||
reload_mock.assert_awaited_once()
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Tests for the Xthings Cloud integration."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Fixture for setting up the integration."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
def get_device_by_id(mock_api_client: AsyncMock, device_id: str) -> dict[str, Any]:
|
||||
"""Helper for getting the device."""
|
||||
for device in mock_api_client.async_get_devices.return_value:
|
||||
if device["id"] == device_id:
|
||||
return device
|
||||
raise ValueError(f"Device with ID {device_id} not found in mock API client.")
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Fixtures for Xthings Cloud tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.xthings_cloud.const import (
|
||||
CONF_EMAIL,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_TOKEN,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
from .const import MOCK_EMAIL, MOCK_REFRESH_TOKEN, MOCK_TOKEN, MOCK_USER_ID
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=MOCK_EMAIL,
|
||||
data={
|
||||
CONF_EMAIL: MOCK_EMAIL,
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
CONF_REFRESH_TOKEN: MOCK_REFRESH_TOKEN,
|
||||
},
|
||||
unique_id=MOCK_USER_ID,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.xthings_cloud.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_fixtures() -> list[str]:
|
||||
"""Fixtures for Xthings Cloud devices."""
|
||||
return [
|
||||
"XT-LT050",
|
||||
"XT-LT100",
|
||||
"XT-LT200",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client(
|
||||
device_fixtures: list[str], mock_websocket: AsyncMock
|
||||
) -> Generator[AsyncMock]:
|
||||
"""Mock the XthingsCloudApiClient."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.xthings_cloud.config_flow.XthingsCloudApiClient",
|
||||
autospec=True,
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"homeassistant.components.xthings_cloud.XthingsCloudApiClient",
|
||||
new=mock_cls,
|
||||
),
|
||||
):
|
||||
client = mock_cls.return_value
|
||||
client.async_login.return_value = {
|
||||
"token": MOCK_TOKEN,
|
||||
"refresh_token": MOCK_REFRESH_TOKEN,
|
||||
"user_id": MOCK_USER_ID,
|
||||
"client_id": "mock_client_id",
|
||||
}
|
||||
client.async_get_devices.return_value = [
|
||||
load_json_object_fixture(f"{device_fixture}.json", DOMAIN)
|
||||
for device_fixture in device_fixtures
|
||||
]
|
||||
client.is_token_expired.return_value = False
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket() -> Generator[AsyncMock]:
|
||||
"""Mock the XthingsCloudWebSocket."""
|
||||
with patch(
|
||||
"homeassistant.components.xthings_cloud.coordinator.XthingsCloudWebSocket",
|
||||
autospec=True,
|
||||
) as mock_ws_cls:
|
||||
yield mock_ws_cls
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_instance_id() -> Generator[None]:
|
||||
"""Mock the instance ID."""
|
||||
with patch(
|
||||
"homeassistant.components.xthings_cloud.config_flow.async_get_instance_id",
|
||||
return_value="mock_instance_id",
|
||||
):
|
||||
yield
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Constants for Xthings Cloud integration tests."""
|
||||
|
||||
MOCK_EMAIL = "test@example.com"
|
||||
MOCK_PASSWORD = "test_password"
|
||||
MOCK_TOKEN = "mock_token"
|
||||
MOCK_REFRESH_TOKEN = "mock_refresh_token"
|
||||
MOCK_USER_ID = "02c7badf2b3d44d953b48b579eb9eeb5"
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "dev_light_003",
|
||||
"name": "Porch Light",
|
||||
"type": "light",
|
||||
"model": "XT-LT050",
|
||||
"version": "1.0.0",
|
||||
"online": true,
|
||||
"status": {
|
||||
"on": true
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"id": "dev_light_002",
|
||||
"name": "Hallway Light",
|
||||
"type": "light",
|
||||
"model": "XT-LT100",
|
||||
"version": "1.0.0",
|
||||
"online": true,
|
||||
"status": {
|
||||
"on": false,
|
||||
"brightness": 50
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"id": "dev_light_001",
|
||||
"name": "Bedroom Light",
|
||||
"type": "light",
|
||||
"model": "XT-LT200",
|
||||
"version": "2.0.1",
|
||||
"online": true,
|
||||
"status": {
|
||||
"on": true,
|
||||
"brightness": 75,
|
||||
"color_type": 0,
|
||||
"hue": 150,
|
||||
"saturation": 80,
|
||||
"lightness": 54,
|
||||
"temperature": 4000
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_devices[XT-LT050]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'xthings_cloud',
|
||||
'dev_light_003',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Xthings',
|
||||
'model': 'XT-LT050',
|
||||
'model_id': None,
|
||||
'name': 'Porch Light',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.0.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_devices[XT-LT100]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'xthings_cloud',
|
||||
'dev_light_002',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Xthings',
|
||||
'model': 'XT-LT100',
|
||||
'model_id': None,
|
||||
'name': 'Hallway Light',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.0.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_devices[XT-LT200]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'xthings_cloud',
|
||||
'dev_light_001',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Xthings',
|
||||
'model': 'XT-LT200',
|
||||
'model_id': None,
|
||||
'name': 'Bedroom Light',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '2.0.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1,200 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_lights[light.bedroom_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max_color_temp_kelvin': 6500,
|
||||
'min_color_temp_kelvin': 2000,
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.COLOR_TEMP: 'color_temp'>,
|
||||
<ColorMode.HS: 'hs'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': None,
|
||||
'entity_id': 'light.bedroom_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'xthings_cloud',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'dev_light_001',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.bedroom_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'brightness': 191,
|
||||
'color_mode': <ColorMode.HS: 'hs'>,
|
||||
'color_temp_kelvin': None,
|
||||
'friendly_name': 'Bedroom Light',
|
||||
'hs_color': tuple(
|
||||
150,
|
||||
80,
|
||||
),
|
||||
'max_color_temp_kelvin': 6500,
|
||||
'min_color_temp_kelvin': 2000,
|
||||
'rgb_color': tuple(
|
||||
51,
|
||||
255,
|
||||
153,
|
||||
),
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.COLOR_TEMP: 'color_temp'>,
|
||||
<ColorMode.HS: 'hs'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
'xy_color': tuple(
|
||||
0.174,
|
||||
0.53,
|
||||
),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.bedroom_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.hallway_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': None,
|
||||
'entity_id': 'light.hallway_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'xthings_cloud',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'dev_light_002',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.hallway_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'brightness': None,
|
||||
'color_mode': None,
|
||||
'friendly_name': 'Hallway Light',
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.hallway_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.porch_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': None,
|
||||
'entity_id': 'light.porch_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'xthings_cloud',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'dev_light_003',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.porch_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'color_mode': <ColorMode.ONOFF: 'onoff'>,
|
||||
'friendly_name': 'Porch Light',
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.porch_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Tests for Xthings Cloud config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from ha_xthings_cloud import XthingsCloudApiError, XthingsCloudAuthError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.xthings_cloud.const import (
|
||||
CONF_EMAIL,
|
||||
CONF_PASSWORD,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_TOKEN,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .const import (
|
||||
MOCK_EMAIL,
|
||||
MOCK_PASSWORD,
|
||||
MOCK_REFRESH_TOKEN,
|
||||
MOCK_TOKEN,
|
||||
MOCK_USER_ID,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful user login flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: MOCK_EMAIL, CONF_PASSWORD: MOCK_PASSWORD},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == MOCK_EMAIL
|
||||
assert result["result"].unique_id == MOCK_USER_ID
|
||||
assert result["data"] == {
|
||||
CONF_EMAIL: MOCK_EMAIL,
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
CONF_REFRESH_TOKEN: MOCK_REFRESH_TOKEN,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(XthingsCloudAuthError("Auth failed", code=21014), "password_wrong"),
|
||||
(XthingsCloudApiError("API error", code=22001), "device_not_found"),
|
||||
(XthingsCloudApiError("Connection failed", code=0), "cannot_connect"),
|
||||
(RuntimeError("unexpected"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_user_flow_error_and_recover(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
side_effect: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test user flow shows error then recovers on retry."""
|
||||
mock_api_client.async_login.side_effect = side_effect
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: MOCK_EMAIL, CONF_PASSWORD: MOCK_PASSWORD},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == expected_error
|
||||
|
||||
# Recover: repatch to succeed
|
||||
mock_api_client.async_login.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: MOCK_EMAIL, CONF_PASSWORD: MOCK_PASSWORD},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test user flow aborts if same account already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: MOCK_EMAIL, CONF_PASSWORD: MOCK_PASSWORD},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Tests for the Xthings Cloud integration."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.xthings_cloud.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_devices(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_api_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test all devices."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
for device in mock_api_client.async_get_devices.return_value:
|
||||
device_entry = device_registry.async_get_device({(DOMAIN, device["id"])})
|
||||
|
||||
assert device_entry is not None
|
||||
assert device_entry == snapshot(name=device["model"])
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Tests for Xthings Cloud light platform."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_HS_COLOR,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
ColorMode,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import get_device_by_id, setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
async def test_lights(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test light entities are created correctly."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "method"),
|
||||
[
|
||||
(SERVICE_TURN_ON, "async_brite_on"),
|
||||
(SERVICE_TURN_OFF, "async_brite_off"),
|
||||
],
|
||||
)
|
||||
async def test_light_turn_on_off(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
service: str,
|
||||
method: str,
|
||||
) -> None:
|
||||
"""Test turning on and off a light."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: "light.bedroom_light"},
|
||||
blocking=True,
|
||||
)
|
||||
getattr(mock_api_client, method).assert_called_once_with("dev_light_001")
|
||||
|
||||
|
||||
async def test_light_turn_on_brightness(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning on with brightness."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.hallway_light",
|
||||
ATTR_BRIGHTNESS: 128,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_api_client.async_brite_brightness.assert_called_once_with(
|
||||
"dev_light_002", round(128 * 100 / 255)
|
||||
)
|
||||
mock_api_client.async_brite_on.assert_not_called()
|
||||
|
||||
|
||||
async def test_light_turn_on_hs_color(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning on with HS color."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.bedroom_light",
|
||||
ATTR_HS_COLOR: (200, 90),
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_api_client.async_brite_color.assert_called_once_with(
|
||||
"dev_light_001",
|
||||
{
|
||||
"colortype": 0,
|
||||
"hue": 200,
|
||||
"saturation": 90,
|
||||
"lightness": 54,
|
||||
"brightness": 75,
|
||||
},
|
||||
)
|
||||
mock_api_client.async_brite_on.assert_not_called()
|
||||
|
||||
|
||||
async def test_light_turn_on_color_temp(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning on with color temperature."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.bedroom_light",
|
||||
ATTR_COLOR_TEMP_KELVIN: 3000,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_api_client.async_brite_color.assert_called_once_with(
|
||||
"dev_light_001",
|
||||
{
|
||||
"colortype": 1,
|
||||
"temperature": 3000,
|
||||
"brightness": 75,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_light_turn_on_hs_color_with_brightness(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning on with HS color and brightness."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.bedroom_light",
|
||||
ATTR_HS_COLOR: (100, 50),
|
||||
ATTR_BRIGHTNESS: 200,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
expected_level = round(200 * 100 / 255)
|
||||
mock_api_client.async_brite_color.assert_called_once_with(
|
||||
"dev_light_001",
|
||||
{
|
||||
"colortype": 0,
|
||||
"hue": 100,
|
||||
"saturation": 50,
|
||||
"lightness": expected_level,
|
||||
"brightness": expected_level,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_light_color_temp_with_brightness(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test color temp with brightness override."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.bedroom_light",
|
||||
ATTR_COLOR_TEMP_KELVIN: 5000,
|
||||
ATTR_BRIGHTNESS: 180,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_api_client.async_brite_color.assert_called_once_with(
|
||||
"dev_light_001",
|
||||
{
|
||||
"colortype": 1,
|
||||
"temperature": 5000,
|
||||
"brightness": round(180 * 100 / 255),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_light_unavailable_when_offline(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test light shows unavailable when device is offline."""
|
||||
get_device_by_id(mock_api_client, "dev_light_001")["online"] = False
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("light.bedroom_light")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_light_color_mode_color_temp(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test color mode is COLOR_TEMP when color_type is 1."""
|
||||
get_device_by_id(mock_api_client, "dev_light_001")["status"]["color_type"] = 1
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("light.bedroom_light")
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP
|
||||
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4000
|
||||
|
||||
|
||||
async def test_updating_state(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
mock_websocket: AsyncMock,
|
||||
) -> None:
|
||||
"""Test updating state."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("light.bedroom_light")
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_BRIGHTNESS] == 191
|
||||
|
||||
mock_websocket.call_args[1]["on_device_status"](
|
||||
"dev_light_001",
|
||||
{
|
||||
"on": True,
|
||||
"brightness": 100,
|
||||
"color_type": 0,
|
||||
"hue": 150,
|
||||
"saturation": 80,
|
||||
"lightness": 54,
|
||||
"temperature": 4000,
|
||||
},
|
||||
)
|
||||
|
||||
state = hass.states.get("light.bedroom_light")
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_BRIGHTNESS] == 255
|
||||
+1
-1
@@ -1071,7 +1071,7 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]:
|
||||
)
|
||||
return FakeInfo(mid)
|
||||
|
||||
def _subscribe(topic, qos=0):
|
||||
def _subscribe(topic_or_list, qos=0, **kwargs):
|
||||
mid = get_mid()
|
||||
hass.loop.call_soon(
|
||||
mock_client.on_subscribe, Mock(), 0, mid, [MockMqttReasonCode()], None
|
||||
|
||||
Reference in New Issue
Block a user