Compare commits

..

14 Commits

Author SHA1 Message Date
Jan Bouwhuis 1e97e49cba Merge branch 'dev' into mqtt-subscribe-identifier 2026-05-12 18:18:34 +02:00
jbouwh 406a6dda9b remove local import 2026-05-12 10:00:20 +00:00
Jan Bouwhuis 7633cc7252 Merge branch 'dev' into mqtt-subscribe-identifier 2026-05-11 15:52:29 +02:00
jbouwh 5169b6554a Follow up on code review 2026-05-05 13:06:08 +00:00
Jan Bouwhuis 2d9cf87e44 Merge branch 'dev' into mqtt-subscribe-identifier 2026-05-05 14:37:06 +02:00
jbouwh b5f04ef502 Break up long line 2026-05-03 12:37:01 +00:00
jbouwh d073d4fe4a Cache subscriptions for topic and subscription_id 2026-05-03 12:33:37 +00:00
jbouwh 2e6d8e3aea Set subscription ID for restored subscriptiions 2026-05-02 20:46:45 +00:00
jbouwh 0d9f5a32f4 Fix packet type 2026-05-02 20:28:41 +00:00
jbouwh feea4925cd Improve testcase labels 2026-05-02 20:14:33 +00:00
Jan Bouwhuis 3be267a94c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-02 22:10:21 +02:00
jbouwh 9da3788c52 Expand unsubscribe race tests with wildcard subscriptions and multiple protocols 2026-05-02 13:31:18 +00:00
Jan Bouwhuis cb6a38b10f Merge branch 'dev' into mqtt-subscribe-identifier 2026-05-02 14:29:12 +02:00
jbouwh 03e22e1cb2 Set subscription identifier to allow filtering duplicate payloads with overlapping subscriptions 2026-05-02 11:40:36 +00:00
44 changed files with 410 additions and 1786 deletions
Generated
-2
View File
@@ -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"]
}
+1 -5
View File
@@ -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:
+2 -2
View File
@@ -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."""
+2 -2
View File
@@ -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."""
+90 -21
View File
@@ -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
+17
View File
@@ -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"]
}
+8 -43
View File
@@ -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,
)
-1
View File
@@ -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": {
+4 -7
View File
@@ -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
+3 -6
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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"
+190 -9
View File
@@ -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
+60 -53
View File
@@ -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(
+10 -119
View File
@@ -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.")
-101
View File
@@ -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
-7
View File
@@ -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
View File
@@ -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