Compare commits

...

31 Commits

Author SHA1 Message Date
Wendelin 709c750abe Format 2026-05-19 12:50:50 +02:00
Wendelin 85014a469a add comment 2026-05-19 10:55:19 +02:00
Wendelin f6c11b5657 Update tests 2026-05-19 10:53:06 +02:00
Wendelin e17e823459 Use str instead of string 2026-05-19 10:52:25 +02:00
Wendelin a41cf33ffd Add comment attributes to automation items 2026-05-18 13:14:33 +02:00
Paulus Schoutsen 71425dd19f Add buttons platform to Marantz IR Remote (PM6006) (#169627)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 12:46:09 +02:00
zhangluofeng eea08a0457 Add Xthings Cloud Switch (#170554) 2026-05-18 12:45:04 +02:00
Erik Montnemery 00132b4416 Remove source_type property from paj_gps device tracker entity (#171076) 2026-05-18 12:43:48 +02:00
Erik Montnemery 6b9efed899 Don't set _attr_source_type in nrgkick device tracker entity (#171075) 2026-05-18 12:43:39 +02:00
Erik Montnemery b0b6b46152 Remove source_type property from lojack device tracker entity (#171073) 2026-05-18 12:43:33 +02:00
Erik Montnemery 044ef25cb6 Remove source_type property from fressnapf_tracker device tracker entity (#171072) 2026-05-18 12:43:30 +02:00
bkobus-bbx b633fbcf07 Fix ValueError when turning on blebox light with brightness set to 0 (#170769)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-18 12:41:30 +02:00
Mick Vleeshouwer 7c9b6ad2a8 Fix controls for OpenCloseGate4T (rts:GateOpenerRTS4TComponent) in Overkiz (#170987) 2026-05-18 12:39:48 +02:00
Jan-Philipp Benecke 89d9fff1e9 Fix typo in lovelace action error message (#171074) 2026-05-18 13:38:27 +03:00
A. Gideonse e0af3dfa99 Add real-time control sensors to Indevolt (#170729)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 12:32:37 +02:00
renovate[bot] 4fb3ad102c Update cryptography to 48.0.0 (#170372) 2026-05-18 12:32:35 +02:00
Øyvind Matheson Wergeland dc2ab012fa End nobo_hub config flow tests in CREATE_ENTRY or ABORT (#170141) 2026-05-18 12:30:52 +02:00
Dougal Matthews 140fef6915 Add geo_location entity support to Prometheus exporter (#170721) 2026-05-18 12:27:41 +02:00
Franck Nijhof 822a567ca9 Return media_content_id as string in forked_daapd (#171059) 2026-05-18 12:26:45 +02:00
Sören aa8904b0cd Use config entry title for Avea light (#170978) 2026-05-18 12:26:09 +02:00
Franck Nijhof e9f9194b7b Fix swallowed exception in cast play_media for unsupported apps (#171064) 2026-05-18 12:22:22 +02:00
Jan-Philipp Benecke d0f4cba32c Reraise HomeAssistantError with translation in lovelace (#171053) 2026-05-18 11:53:22 +02:00
Erik Montnemery beba530a9a Remove source_type from autoskope device tracker entity (#171070) 2026-05-18 11:47:11 +02:00
AlCalzone 5d3fd5a487 Bump opensensemap-api to 0.4.1 (#171056) 2026-05-18 11:42:10 +02:00
Franck Nijhof bed6af2ef2 Fix swallowed exceptions in rest switch action handlers (#171069) 2026-05-18 11:38:06 +02:00
Mick Vleeshouwer 2b20b69928 Add tests for scene platform in Overkiz (#170993) 2026-05-18 11:35:33 +02:00
Jan Bouwhuis d5d50ac11a Set subscription identifier to allow matching duplicate payloads with overlapping subscriptions (#169604)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 11:27:05 +02:00
Mick Vleeshouwer ba5a62ec2a Replace redacted labels in test fixtures with meaningful names in overkiz (#170988) 2026-05-18 11:19:29 +02:00
Joakim Plate 88ca0faea0 Require service on fjaraskupan to detect it (#170363) 2026-05-18 11:00:05 +02:00
LG-ThinQ-Integration a333f31d44 Fix swallowed exceptions in lg_thinq action handlers (#171047)
Co-authored-by: YunseonPark-LGE <yunseon.park@lge.com>
2026-05-18 10:09:30 +02:00
James Nimmo 8854ad5765 Bump pyIntesishome to 1.8.8 (#171041) 2026-05-18 09:51:42 +02:00
84 changed files with 4506 additions and 1002 deletions
@@ -3,7 +3,7 @@
from autoskope_client.constants import MANUFACTURER
from autoskope_client.models import Vehicle
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -113,11 +113,6 @@ class AutoskopeDeviceTracker(
return float(vehicle.position.longitude)
return None
@property
def source_type(self) -> SourceType:
"""Return the source type of the device."""
return SourceType.GPS
@property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device in meters."""
+5 -3
View File
@@ -84,7 +84,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Avea light platform."""
async_add_entities([AveaLight(entry.runtime_data)], update_before_add=True)
async_add_entities(
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
)
def _discover_bulbs_for_import() -> list[dict[str, str]]:
@@ -169,10 +171,10 @@ class AveaLight(LightEntity):
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, light: avea.Bulb) -> None:
def __init__(self, light: avea.Bulb, entry_title: str) -> None:
"""Initialize an AveaLight."""
self._light = light
self._attr_name = light.name
self._attr_name = entry_title
self._attr_brightness = light.brightness
def turn_on(self, **kwargs: Any) -> None:
+4
View File
@@ -201,6 +201,10 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
else:
value = feature.apply_brightness(value, brightness)
if isinstance(value, (list, tuple)) and not any(value):
await self._feature.async_off()
return
try:
await self._feature.async_on(value)
except ValueError as exc:
@@ -718,9 +718,12 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
await self.hass.async_add_executor_job(
self._quick_play, app_name, app_data
)
# pylint: disable-next=home-assistant-action-swallowed-exception
except NotImplementedError:
_LOGGER.error("App %s not supported", app_name)
except NotImplementedError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="app_not_supported",
translation_placeholders={"app_name": app_name},
) from err
return
# Try the cast platforms
@@ -769,6 +772,8 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
media_id,
err,
)
# Fallback: if playlist parsing fails, forward the raw URL to the device
# pylint: disable-next=home-assistant-action-swallowed-exception
except PlaylistError as err:
_LOGGER.warning(
"[%s %s] Failed to parse playlist %s: %s",
@@ -18,6 +18,11 @@
}
}
},
"exceptions": {
"app_not_supported": {
"message": "App {app_name} is not supported"
}
},
"options": {
"error": {
"invalid_known_hosts": "[%key:component::cast::config::error::invalid_known_hosts%]"
@@ -3,7 +3,7 @@
from collections.abc import Callable
import logging
from fjaraskupan import Device
from fjaraskupan import UUID_SERVICE, Device
from homeassistant.components.bluetooth import (
BluetoothCallbackMatcher,
@@ -37,6 +37,7 @@ PLATFORMS = [
]
_LOGGER = logging.getLogger(__name__)
_UUID = str(UUID_SERVICE).lower()
async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry) -> bool:
@@ -44,39 +45,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry)
entry.runtime_data = {}
def detection_callback(
service_info: BluetoothServiceInfoBleak, change: BluetoothChange
def data_callback(
service_info: BluetoothServiceInfoBleak, change_: BluetoothChange
) -> None:
if change != BluetoothChange.ADVERTISEMENT:
if (data := entry.runtime_data.get(service_info.address)) is None:
_LOGGER.debug("Ignoring: %s", service_info)
return
if data := entry.runtime_data.get(service_info.address):
_LOGGER.debug("Update: %s", service_info)
data.detection_callback(service_info)
else:
_LOGGER.debug("Detected: %s", service_info)
device = Device(service_info.device.address)
device_info = DeviceInfo(
connections={(dr.CONNECTION_BLUETOOTH, service_info.address)},
identifiers={(DOMAIN, service_info.address)},
manufacturer="Fjäråskupan",
name="Fjäråskupan",
)
_LOGGER.debug("Update: %s", service_info)
data.detection_callback(service_info)
coordinator: FjaraskupanCoordinator = FjaraskupanCoordinator(
hass, entry, device, device_info
)
coordinator.detection_callback(service_info)
def detect_callback(
service_info: BluetoothServiceInfoBleak, change_: BluetoothChange
) -> None:
if service_info.address in entry.runtime_data:
return
entry.runtime_data[service_info.address] = coordinator
async_dispatcher_send(
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator
)
_LOGGER.debug("Detected: %s", service_info)
device = Device(service_info.device.address)
device_info = DeviceInfo(
connections={(dr.CONNECTION_BLUETOOTH, service_info.address)},
identifiers={(DOMAIN, service_info.address)},
manufacturer="Fjäråskupan",
name="Fjäråskupan",
)
coordinator: FjaraskupanCoordinator = FjaraskupanCoordinator(
hass, entry, device, device_info
)
coordinator.detection_callback(service_info)
entry.runtime_data[service_info.address] = coordinator
async_dispatcher_send(
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator
)
entry.async_on_unload(
async_register_callback(
hass,
detection_callback,
data_callback,
BluetoothCallbackMatcher(
manufacturer_id=20296,
manufacturer_data_start=[79, 68, 70, 74, 65, 82],
@@ -86,6 +93,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry)
)
)
entry.async_on_unload(
async_register_callback(
hass,
detect_callback,
BluetoothCallbackMatcher(
service_uuid=_UUID,
connectable=False,
),
BluetoothScanningMode.ACTIVE,
)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -1,6 +1,6 @@
"""Config flow for Fjäråskupan integration."""
from fjaraskupan import device_filter
from fjaraskupan import UUID_SERVICE
from homeassistant.components.bluetooth import async_discovered_service_info
from homeassistant.core import HomeAssistant
@@ -15,7 +15,8 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
service_infos = async_discovered_service_info(hass)
for service_info in service_infos:
if device_filter(service_info.device, service_info.advertisement):
uuids = service_info.service_uuids
if str(UUID_SERVICE) in uuids:
return True
return False
@@ -4,8 +4,7 @@
"bluetooth": [
{
"connectable": false,
"manufacturer_data_start": [79, 68, 70, 74, 65, 82],
"manufacturer_id": 20296
"service_uuid": "77a2bd49-1e5a-4961-bba1-21f34fa4bc7b"
}
],
"codeowners": ["@elupus"],
@@ -468,9 +468,11 @@ class ForkedDaapdMaster(MediaPlayerEntity):
return self._player["volume"] == 0
@property
def media_content_id(self):
def media_content_id(self) -> str | None:
"""Content ID of current playing media."""
return self._player["item_id"]
if (item_id := self._player["item_id"]) == 0:
return None
return str(item_id)
@property
def media_content_type(self):
@@ -1,6 +1,5 @@
"""Device tracker platform for fressnapf_tracker."""
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -61,11 +60,6 @@ class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
return self.coordinator.data.position.lng
return None
@property
def source_type(self) -> SourceType:
"""Return the source type, eg gps or router, of the device."""
return SourceType.GPS
@property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
@@ -49,6 +49,9 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
IndevoltSolar.DC_INPUT_POWER_3,
IndevoltSolar.DC_INPUT_POWER_4,
IndevoltConfig.READ_DISCHARGE_LIMIT,
IndevoltConfig.READ_REALTIME_COMMAND,
IndevoltConfig.READ_REALTIME_TARGET_SOC,
IndevoltConfig.READ_REALTIME_POWER_LIMIT,
IndevoltGrid.METER_POWER_GEN1,
IndevoltGrid.METER_CONNECTED,
IndevoltSolar.CUMULATIVE_PRODUCTION,
@@ -137,6 +140,9 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
IndevoltConfig.READ_INVERTER_INPUT_LIMIT,
IndevoltConfig.READ_FEEDIN_POWER_LIMIT,
IndevoltConfig.READ_DISCHARGE_LIMIT,
IndevoltConfig.READ_REALTIME_COMMAND,
IndevoltConfig.READ_REALTIME_TARGET_SOC,
IndevoltConfig.READ_REALTIME_POWER_LIMIT,
IndevoltBattery.MAIN_HEATING_STATE,
IndevoltBattery.PACK_1_HEATING_STATE,
IndevoltBattery.PACK_2_HEATING_STATE,
@@ -6,6 +6,7 @@ from typing import Final, cast
from indevolt_api import (
IndevoltBattery,
IndevoltConfig,
IndevoltEnergyMode,
IndevoltGrid,
IndevoltSolar,
IndevoltSystem,
@@ -43,6 +44,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription):
state_mapping: dict[str | int, str] = field(default_factory=dict)
generation: tuple[int, ...] = (1, 2)
energy_mode: IndevoltEnergyMode | None = None
SENSORS: Final = (
@@ -84,6 +86,28 @@ SENSORS: Final = (
translation_key="discharge_limit",
native_unit_of_measurement=PERCENTAGE,
),
# Real-time control state
IndevoltSensorEntityDescription(
key=IndevoltConfig.READ_REALTIME_COMMAND,
translation_key="realtime_command",
state_mapping={1000: "standby", 1001: "charging", 1002: "discharging"},
device_class=SensorDeviceClass.ENUM,
energy_mode=IndevoltEnergyMode.REAL_TIME_CONTROL,
),
IndevoltSensorEntityDescription(
key=IndevoltConfig.READ_REALTIME_TARGET_SOC,
translation_key="realtime_target_soc",
native_unit_of_measurement=PERCENTAGE,
energy_mode=IndevoltEnergyMode.REAL_TIME_CONTROL,
),
IndevoltSensorEntityDescription(
key=IndevoltConfig.READ_REALTIME_POWER_LIMIT,
translation_key="realtime_power_limit",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
energy_mode=IndevoltEnergyMode.REAL_TIME_CONTROL,
),
IndevoltSensorEntityDescription(
key=IndevoltSystem.INPUT_POWER,
translation_key="ac_input_power",
@@ -851,6 +875,16 @@ class IndevoltSensorEntity(IndevoltEntity, SensorEntity):
if description.device_class == SensorDeviceClass.ENUM:
self._attr_options = sorted(set(description.state_mapping.values()))
@property
def available(self) -> bool:
"""Return False when the device is not in the required energy mode."""
if self.entity_description.energy_mode is not None:
energy_mode = self.coordinator.data.get(IndevoltConfig.READ_ENERGY_MODE)
if energy_mode != self.entity_description.energy_mode:
return False
return super().available
@property
def native_value(self) -> str | int | float | None:
"""Return the current value of the sensor in its native unit."""
@@ -326,6 +326,20 @@
"rated_capacity": {
"name": "Rated capacity"
},
"realtime_command": {
"name": "Real-time mode",
"state": {
"charging": "[%key:common::state::charging%]",
"discharging": "[%key:common::state::discharging%]",
"standby": "[%key:common::state::standby%]"
}
},
"realtime_power_limit": {
"name": "Real-time power limit"
},
"realtime_target_soc": {
"name": "Real-time target SOC"
},
"serial_number": {
"name": "Serial number"
},
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["pyintesishome"],
"quality_scale": "legacy",
"requirements": ["pyintesishome==1.8.7"]
"requirements": ["pyintesishome==1.8.8"]
}
+3 -8
View File
@@ -181,14 +181,9 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
if percentage == 0:
await self.async_turn_off()
return
try:
value = percentage_to_ordered_list_item(
self._ordered_named_fan_speeds, percentage
)
# pylint: disable-next=home-assistant-action-swallowed-exception
except ValueError:
_LOGGER.exception("Failed to async_set_percentage")
return
value = percentage_to_ordered_list_item(
self._ordered_named_fan_speeds, percentage
)
_LOGGER.debug(
"[%s:%s] async_set_percentage. percentage=%s, value=%s",
@@ -1,6 +1,6 @@
"""Device tracker platform for LoJack integration."""
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -47,11 +47,6 @@ class LoJackDeviceTracker(CoordinatorEntity[LoJackCoordinator], TrackerEntity):
serial_number=self.coordinator.vehicle.vin,
)
@property
def source_type(self) -> SourceType:
"""Return the source type of the device."""
return SourceType.GPS
@property
def latitude(self) -> float | None:
"""Return the latitude of the device."""
@@ -127,10 +127,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Reload yaml resources."""
try:
conf = await async_hass_config_yaml(hass)
# pylint: disable-next=home-assistant-action-swallowed-exception
except HomeAssistantError as err:
_LOGGER.error(err)
return
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_reload",
) from err
integration = await async_get_integration(hass, DOMAIN)
@@ -1,5 +1,8 @@
{
"exceptions": {
"failed_to_reload": {
"message": "Failed to reload dashboard resources. Please check your configuration and try again."
},
"url_already_exists": {
"message": "The URL \"{url}\" is already in use. Please choose a different one."
}
@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
@dataclass
@@ -0,0 +1,80 @@
"""Button platform for Marantz IR integration.
Only commands that aren't already exposed by the media player live here:
speaker A/B, source-direct toggle, and loudness toggle.
"""
from dataclasses import dataclass
from infrared_protocols.codes.marantz.audio import MarantzAudioCode
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MarantzIrConfigEntry
from .const import CONF_INFRARED_EMITTER_ENTITY_ID, CONF_MODEL, MODELS
from .entity import MarantzIrEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class MarantzIrButtonEntityDescription(ButtonEntityDescription):
"""Describes Marantz IR button entity."""
command_code: MarantzAudioCode
BUTTON_DESCRIPTIONS: tuple[MarantzIrButtonEntityDescription, ...] = (
MarantzIrButtonEntityDescription(
key="speaker_ab",
translation_key="speaker_ab",
command_code=MarantzAudioCode.SPEAKER_AB,
),
MarantzIrButtonEntityDescription(
key="source_direct",
translation_key="source_direct",
command_code=MarantzAudioCode.SOURCE_DIRECT,
),
MarantzIrButtonEntityDescription(
key="loudness",
translation_key="loudness",
command_code=MarantzAudioCode.LOUDNESS,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MarantzIrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Marantz IR buttons from config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID]
model_codes = MODELS[entry.data[CONF_MODEL]].codes
async_add_entities(
MarantzIrButton(entry, infrared_entity_id, description)
for description in BUTTON_DESCRIPTIONS
if description.command_code in model_codes
)
class MarantzIrButton(MarantzIrEntity, ButtonEntity):
"""Marantz IR button entity."""
entity_description: MarantzIrButtonEntityDescription
def __init__(
self,
entry: MarantzIrConfigEntry,
infrared_entity_id: str,
description: MarantzIrButtonEntityDescription,
) -> None:
"""Initialize Marantz IR button."""
super().__init__(entry, infrared_entity_id, unique_id_suffix=description.key)
self.entity_description = description
async def async_press(self) -> None:
"""Press the button."""
await self._send_marantz_command(self.entity_description.command_code)
@@ -20,6 +20,17 @@
}
},
"entity": {
"button": {
"loudness": {
"name": "Loudness"
},
"source_direct": {
"name": "Source direct"
},
"speaker_ab": {
"name": "Speaker A/B"
}
},
"media_player": {
"receiver": {
"state_attributes": {
+107 -22
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
@@ -331,8 +330,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:
@@ -439,6 +439,7 @@ class MQTT:
_mqttc: AsyncMQTTClient
_last_subscribe: float
_mqtt_data: MqttData
_supports_subscription_identifiers: bool = False
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, conf: ConfigType
@@ -854,6 +855,9 @@ class MQTT:
) -> None:
"""Restore tracked subscriptions after reload."""
for subscription in subscriptions:
self._mqtt_data.subscription_id_generator.restore(
subscription.subscription_id, subscription.topic
)
self._async_track_subscription(subscription)
self._matching_subscriptions.cache_clear()
@@ -959,7 +963,17 @@ 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
else:
subscription_id = self._mqtt_data.subscription_id_generator.get_or_generate(
topic
)
subscription = Subscription(
topic, is_simple_match, matcher, job, qos, encoding, subscription_id
)
self._async_track_subscription(subscription)
self._matching_subscriptions.cache_clear()
@@ -978,15 +992,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
@@ -1012,33 +1026,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._supports_subscription_identifiers 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._supports_subscription_identifiers:
properties = mqtt.Properties(packetType=mqtt.PacketTypes.SUBSCRIBE) # type: ignore[no-untyped-call]
properties.SubscriptionIdentifier = (
self._mqtt_data.subscription_id_generator.get_subscription_id(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(
@@ -1069,6 +1110,10 @@ class MQTT:
await self._async_wait_for_mid_or_raise(mid, result)
# Remove stored subscription identifiers for topics that were just unsubscribed
for topic in topics:
self._mqtt_data.subscription_id_generator.release(topic)
async def _async_resubscribe_and_publish_birth_message(
self, birth_message: PublishMessage
) -> None:
@@ -1096,13 +1141,34 @@ class MQTT:
_userdata: None,
_connect_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None = None,
properties: mqtt.Properties | None = None,
) -> None:
"""On connect callback.
Resubscribe to all topics we were subscribed to and publish birth
message.
"""
if self.is_mqttv5:
# Check if the server explicitly disabled Subscription Identifiers
if (
properties is not None
and hasattr(properties, "SubscriptionIdentifierAvailable")
and properties.SubscriptionIdentifierAvailable == 0
):
_LOGGER.warning(
"Your MQTT broker reports it does not support "
"Subscription Identifiers, see "
"https://docs.oasis-open.org/"
"mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901092. "
"Please use a supported MQTT broker; got broker properties: %s",
properties,
)
self._supports_subscription_identifiers = False
else:
self._supports_subscription_identifiers = True
else:
self._supports_subscription_identifiers = False
if reason_code.is_failure:
# 24: Continue authentication
# 25: Re-authenticate
@@ -1167,9 +1233,15 @@ 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:
if topic in self._simple_subscriptions and (
identifiers is None or 1 in identifiers
):
# The subscription identifier is always 1 for simple subscriptions,
# so only include them when no identifiers are provided or 1 matches.
subscriptions.extend(self._simple_subscriptions[topic])
subscriptions.extend(
subscription
@@ -1177,6 +1249,7 @@ class MQTT:
# 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
@@ -1184,6 +1257,18 @@ class MQTT:
def _async_mqtt_on_message(
self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage
) -> None:
identifiers: tuple[int, ...] | None = None
if self._supports_subscription_identifiers:
# 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 TYPE_CHECKING:
assert msg.properties is not None
assert hasattr(msg.properties, "SubscriptionIdentifier")
with contextlib.suppress(AttributeError):
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
@@ -1200,16 +1285,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
+77 -1
View File
@@ -13,7 +13,11 @@ from paho.mqtt.client import MQTTMessage
from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, Platform
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
from homeassistant.exceptions import (
HomeAssistantError,
ServiceValidationError,
TemplateError,
)
from homeassistant.helpers import template
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
@@ -42,6 +46,77 @@ class PayloadSentinel(StrEnum):
DEFAULT = "default"
MAX_28BIT: int = 268435455
class SubscriptionID:
"""ID generator for wildcard subscriptions."""
_next_id: int = 2
_used_ids: set[int]
_available_ids: set[int]
_registered_subscriptions: dict[str, int] # topic, subscription_id
def __init__(self) -> None:
"""Initialize the Subscription Identifier generator."""
self._used_ids = set()
self._available_ids = set()
self._registered_subscriptions = {}
def _generate(self, topic: str) -> int:
"""Generate a new subscription ID."""
if self._available_ids:
subscription_id = self._available_ids.pop()
self._used_ids.add(subscription_id)
return subscription_id
subscription_id = self._next_id
if subscription_id > MAX_28BIT:
raise HomeAssistantError(
"MQTT Subscription ID limit reached. "
"Cannot generate more IDs to subscribe",
translation_domain=DOMAIN,
translation_key="mqtt_max_subscription_id_reached",
)
self._used_ids.add(subscription_id)
self._next_id += 1
self._registered_subscriptions[topic] = subscription_id
return subscription_id
def get_subscription_id(self, topic: str) -> int:
"""Get a registered subscription ID."""
return self._registered_subscriptions[topic]
def get_or_generate(self, topic: str) -> int:
"""Get an existing or generate a new subscription ID.
ID 0 is reserved.
ID 1 is used for non wildcard topics.
Generator starts at ID 2.
"""
if topic in self._registered_subscriptions:
return self._registered_subscriptions[topic]
return self._generate(topic)
def release(self, topic: str) -> None:
"""Release a Subscription Identifier to allow reuse."""
if (
(subscription_id := self._registered_subscriptions.pop(topic, None))
is not None
and subscription_id
and subscription_id in self._used_ids
):
self._used_ids.remove(subscription_id)
self._available_ids.add(subscription_id)
def restore(self, subscription_id: int | None, topic: str) -> None:
"""Restore a subscription."""
if subscription_id is None:
return
self._registered_subscriptions[topic] = subscription_id
self._used_ids.add(subscription_id)
_LOGGER = logging.getLogger(__name__)
ATTR_THIS = "this"
@@ -428,6 +503,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)
@@ -1099,6 +1099,9 @@
"mqtt_broker_error": {
"message": "Error talking to MQTT: {error_message}."
},
"mqtt_max_subscription_id_reached": {
"message": "MQTT Subscription ID limit reached. Cannot generate more IDs to subscribe."
},
"mqtt_message_expiry_interval_not_supported": {
"message": "Publishing to topic {topic} with a Message Expiry Interval is not supported for protocol version {protocol}."
},
@@ -6,10 +6,7 @@ rules:
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: done
comment: >
Tests driven to terminal CREATE_ENTRY or ABORT in PR #170141.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
@@ -2,7 +2,6 @@
from typing import Any, Final
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -42,7 +41,6 @@ class NRGkickDeviceTracker(NRGkickEntity, TrackerEntity):
"""Representation of a NRGkick GPS device tracker."""
_attr_translation_key = TRACKER_KEY
_attr_source_type = SourceType.GPS
def __init__(
self,
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["opensensemap_api"],
"quality_scale": "legacy",
"requirements": ["opensensemap-api==0.2.0"]
"requirements": ["opensensemap-api==0.4.1"]
}
+34
View File
@@ -201,6 +201,40 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
stop_command=OverkizCommand.STOP,
),
# Needs override since OpenCloseGate4T only supports the cycle command
# (rts:GateOpenerRTS4TComponent)
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.OPEN_CLOSE_GATE_4T,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.CYCLE,
close_command=OverkizCommand.CYCLE,
),
# Needs override since CyclicGarageDoor only supports the cycle command
# (io:CyclicGarageOpenerIOComponent)
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.CYCLIC_GARAGE_DOOR,
device_class=CoverDeviceClass.GARAGE,
open_command=OverkizCommand.CYCLE,
close_command=OverkizCommand.CYCLE,
),
# Needs override since CyclicSlidingGateOpener only supports the cycle command
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.CYCLIC_SLIDING_GATE_OPENER,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.CYCLE,
close_command=OverkizCommand.CYCLE,
),
# Needs override since CyclicSwingingGateOpener only supports the cycle command
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.CYCLIC_SWINGING_GATE_OPENER,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.CYCLE,
close_command=OverkizCommand.CYCLE,
),
# Needs override since SlidingDiscreteGateWithPedestrianPosition reports
# core:OpenClosedPedestrianState instead of core:OpenClosedState
# uiClass is Gate
@@ -5,7 +5,6 @@ Reads position data from PajGpsCoordinator and exposes it as a TrackerEntity.
import logging
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -71,8 +70,3 @@ class PajGPSDeviceTracker(PajGpsEntity, TrackerEntity):
"""Return the longitude of the device."""
tp = self.coordinator.data.positions.get(self._device_id)
return float(tp.lng) if tp and tp.lng is not None else None
@property
def source_type(self) -> SourceType:
"""Return the source type of the tracker."""
return SourceType.GPS
@@ -58,6 +58,8 @@ from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME,
ATTR_LATITUDE,
ATTR_LONGITUDE,
ATTR_MODE,
ATTR_TEMPERATURE,
ATTR_UNIT_OF_MEASUREMENT,
@@ -71,6 +73,7 @@ from homeassistant.const import (
STATE_OPENING,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfLength,
UnitOfTemperature,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State
@@ -104,7 +107,7 @@ from homeassistant.helpers.floor_registry import (
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.dt import as_timestamp
from homeassistant.util.unit_conversion import TemperatureConverter
from homeassistant.util.unit_conversion import DistanceConverter, TemperatureConverter
_LOGGER = logging.getLogger(__name__)
@@ -771,6 +774,33 @@ class PrometheusMetrics:
def _handle_person(self, state: State) -> None:
self._numeric_metric(state, "person", "person")
def _handle_geo_location(self, state: State) -> None:
labels = self._labels(state, {"source": state.attributes.get("source", "")})
if (value := self.state_as_number(state)) is not None:
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if unit is not None:
value = DistanceConverter.convert(value, unit, UnitOfLength.METERS)
self._metric(
"geo_location_distance_meters",
prometheus_client.Gauge,
"Distance of the geo location event from home in meters",
labels,
).set(value)
if (latitude := state.attributes.get(ATTR_LATITUDE)) is not None:
self._metric(
"geo_location_latitude_degrees",
prometheus_client.Gauge,
"Latitude of the geo location event in degrees",
labels,
).set(latitude)
if (longitude := state.attributes.get(ATTR_LONGITUDE)) is not None:
self._metric(
"geo_location_longitude_degrees",
prometheus_client.Gauge,
"Longitude of the geo location event in degrees",
labels,
).set(longitude)
def _handle_lock(self, state: State) -> None:
self._numeric_metric(state, "lock", "lock")
@@ -1,4 +1,15 @@
{
"exceptions": {
"error_communicating": {
"message": "Error communicating with {resource}"
},
"turn_off_failed": {
"message": "Failed to turn off {resource}"
},
"turn_on_failed": {
"message": "Failed to turn on {resource}"
}
},
"services": {
"reload": {
"description": "Reloads REST entities from the YAML-configuration.",
+32 -19
View File
@@ -27,7 +27,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
@@ -40,6 +40,8 @@ from homeassistant.helpers.trigger_template_entity import (
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
CONF_BODY_OFF = "body_off"
CONF_BODY_ON = "body_on"
@@ -163,16 +165,21 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity):
try:
req = await self.set_device_state(body_on_t)
except (TimeoutError, httpx.RequestError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_communicating",
translation_placeholders={"resource": self._resource},
) from err
if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES:
self._attr_is_on = True
else:
_LOGGER.error(
"Can't turn on %s. Is resource/endpoint offline?", self._resource
)
# pylint: disable-next=home-assistant-action-swallowed-exception
except TimeoutError, httpx.RequestError:
_LOGGER.error("Error while switching on %s", self._resource)
if not HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_on_failed",
translation_placeholders={"resource": self._resource},
)
self._attr_is_on = True
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
@@ -180,15 +187,21 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity):
try:
req = await self.set_device_state(body_off_t)
if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES:
self._attr_is_on = False
else:
_LOGGER.error(
"Can't turn off %s. Is resource/endpoint offline?", self._resource
)
# pylint: disable-next=home-assistant-action-swallowed-exception
except TimeoutError, httpx.RequestError:
_LOGGER.error("Error while switching off %s", self._resource)
except (TimeoutError, httpx.RequestError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_communicating",
translation_placeholders={"resource": self._resource},
) from err
if not HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_off_failed",
translation_placeholders={"resource": self._resource},
)
self._attr_is_on = False
async def set_device_state(self, body: Any) -> httpx.Response:
"""Send a state update to the device."""
@@ -17,4 +17,4 @@ CONF_INSTANCE_ID = "instance_id"
# Polling interval (seconds)
DEFAULT_SCAN_INTERVAL = 1800
PLATFORMS: list[Platform] = [Platform.LIGHT]
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SWITCH]
@@ -0,0 +1,48 @@
"""Switch platform for Xthings Cloud."""
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import XthingsCloudConfigEntry
from .entity import XthingsCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: XthingsCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch platform."""
coordinator = entry.runtime_data
entities = [
XthingsCloudSwitch(coordinator, device_id, device_data)
for device_id, device_data in coordinator.data.items()
if device_data["type"] in ("switch", "plug")
]
async_add_entities(entities)
class XthingsCloudSwitch(XthingsCloudEntity, SwitchEntity):
"""Xthings Cloud switch entity."""
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.device_data["status"]["on"]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on switch."""
if self.device_data["type"] == "plug":
await self.coordinator.client.async_plug_on(self._device_id)
else:
await self.coordinator.client.async_switch_on(self._device_id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off switch."""
if self.device_data["type"] == "plug":
await self.coordinator.client.async_plug_off(self._device_id)
else:
await self.coordinator.client.async_switch_off(self._device_id)
+1
View File
@@ -91,6 +91,7 @@ CONF_COMMAND_ON: Final = "command_on"
CONF_COMMAND_OPEN: Final = "command_open"
CONF_COMMAND_STATE: Final = "command_state"
CONF_COMMAND_STOP: Final = "command_stop"
CONF_COMMENT: Final = "comment"
CONF_CONDITION: Final = "condition"
CONF_CONDITIONS: Final = "conditions"
CONF_CONTINUE_ON_ERROR: Final = "continue_on_error"
+1 -9
View File
@@ -144,15 +144,7 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
{
"connectable": False,
"domain": "fjaraskupan",
"manufacturer_data_start": [
79,
68,
70,
74,
65,
82,
],
"manufacturer_id": 20296,
"service_uuid": "77a2bd49-1e5a-4961-bba1-21f34fa4bc7b",
},
{
"connectable": True,
@@ -38,6 +38,7 @@ from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_BELOW,
CONF_CHOOSE,
CONF_COMMENT,
CONF_CONDITION,
CONF_CONDITIONS,
CONF_CONTINUE_ON_ERROR,
@@ -1458,6 +1459,7 @@ SCRIPT_SCHEMA = vol.All(ensure_list, [script_action])
SCRIPT_ACTION_BASE_SCHEMA: VolDictType = {
vol.Optional(CONF_ALIAS): string,
vol.Remove(CONF_COMMENT): str, # Is only used in frontend
vol.Optional(CONF_CONTINUE_ON_ERROR): boolean,
vol.Optional(CONF_ENABLED): vol.Any(boolean, template),
}
@@ -1525,6 +1527,7 @@ NUMERIC_STATE_THRESHOLD_SCHEMA = vol.Any(
CONDITION_BASE_SCHEMA: VolDictType = {
vol.Optional(CONF_ALIAS): string,
vol.Remove(CONF_COMMENT): str, # Is only used in frontend
vol.Optional(CONF_ENABLED): vol.Any(boolean, template),
}
@@ -1859,6 +1862,7 @@ TRIGGER_BASE_SCHEMA = vol.Schema(
vol.Optional(CONF_ID): str,
vol.Optional(CONF_VARIABLES): SCRIPT_VARIABLES_SCHEMA,
vol.Optional(CONF_ENABLED): vol.Any(boolean, template),
vol.Remove(CONF_COMMENT): str, # Is only used in frontend
}
)
+1 -1
View File
@@ -29,7 +29,7 @@ cached-ipaddress==1.0.1
certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==47.0.0
cryptography==48.0.0
dbus-fast==4.0.4
file-read-backwards==2.0.0
fnv-hash-fast==2.0.2
+1 -1
View File
@@ -57,7 +57,7 @@ dependencies = [
"lru-dict==1.4.1",
"PyJWT==2.12.1",
# PyJWT has loose dependency. We want the latest one.
"cryptography==47.0.0",
"cryptography==48.0.0",
"Pillow==12.2.0",
"propcache==0.5.2",
"pyOpenSSL==26.2.0",
+1 -1
View File
@@ -21,7 +21,7 @@ bcrypt==5.0.0
certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==47.0.0
cryptography==48.0.0
fnv-hash-fast==2.0.2
ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
+2 -2
View File
@@ -1757,7 +1757,7 @@ openhomedevice==2.2.0
openrgb-python==0.3.6
# homeassistant.components.opensensemap
opensensemap-api==0.2.0
opensensemap-api==0.4.1
# homeassistant.components.enigma2
openwebifpy==4.3.1
@@ -2219,7 +2219,7 @@ pyinsteon==1.6.4
pyintelliclima==0.3.1
# homeassistant.components.intesishome
pyintesishome==1.8.7
pyintesishome==1.8.8
# homeassistant.components.ipma
pyipma==3.0.9
-1
View File
@@ -202,7 +202,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"nibe_heatpump": {"nibe": {"async-timeout"}},
"norway_air": {"pymetno": {"async-timeout"}},
"opengarage": {"open-garage": {"async-timeout"}},
"opensensemap": {"opensensemap-api": {"async-timeout"}},
"overkiz": {"pyoverkiz": {"backoff"}},
"prosegur": {"pyprosegur": {"backoff"}},
"pvpc_hourly_pricing": {"aiopvpc": {"async-timeout"}},
+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",
@@ -453,6 +456,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
@@ -465,6 +469,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
+2 -3
View File
@@ -15,7 +15,6 @@ from homeassistant.components.light import (
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import AVEA_DISCOVERY_INFO
@@ -26,7 +25,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed
def mock_bulb() -> MagicMock:
"""Return a mocked Avea bulb."""
bulb = MagicMock()
bulb.name = "Bedroom"
bulb.name = "Unknown"
bulb.brightness = 0
bulb.get_brightness.return_value = 0
return bulb
@@ -54,13 +53,13 @@ async def setup_integration(
async def test_init_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
setup_integration: MagicMock,
) -> None:
"""Test the initial state."""
state = hass.states.get("light.bedroom")
assert state is not None
assert state.state == STATE_OFF
assert state.name == "Bedroom"
assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS]
+42
View File
@@ -530,6 +530,48 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> Non
assert state.state == STATE_ON
async def test_wlightbox_turn_on_with_zero_brightness_turns_off(
wlightbox, hass: HomeAssistant
) -> None:
"""Test that setting brightness to 0 turns the light off instead of raising ValueError."""
feature_mock, entity_id = wlightbox
def initial_update():
feature_mock.is_on = True
feature_mock.rgbw_hex = "c1d2f3c7"
feature_mock.white_value = 0xC7
feature_mock.async_update = AsyncMock(side_effect=initial_update)
await async_setup_entity(hass, entity_id)
feature_mock.async_update = AsyncMock()
state = hass.states.get(entity_id)
assert state.state == STATE_ON
feature_mock.apply_brightness = MagicMock(return_value=[0, 0, 0, 0])
def turn_off():
feature_mock.is_on = False
feature_mock.white_value = 0x0
feature_mock.rgbw_hex = "00000000"
feature_mock.async_off = AsyncMock(side_effect=turn_off)
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
{"entity_id": entity_id, ATTR_BRIGHTNESS: 0},
blocking=True,
)
feature_mock.async_off.assert_called_once()
feature_mock.async_on.assert_not_called()
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None:
"""Test light off."""
+7 -2
View File
@@ -1263,9 +1263,14 @@ async def test_entity_play_media_cast_invalid(
# Play_media - media_type cast with unsupported app
quick_play_mock.side_effect = NotImplementedError()
await common.async_play_media(hass, "cast", '{"app_name": "unknown"}', entity_id)
with pytest.raises(HomeAssistantError) as exc_info:
await common.async_play_media(
hass, "cast", '{"app_name": "unknown"}', entity_id
)
assert exc_info.value.translation_domain == "cast"
assert exc_info.value.translation_key == "app_not_supported"
assert exc_info.value.translation_placeholders == {"app_name": "unknown"}
quick_play_mock.assert_called_once_with(ANY, "unknown", {})
assert "App unknown not supported" in caplog.text
async def test_entity_play_media_sign_URL(hass: HomeAssistant, quick_play_mock) -> None:
+19 -2
View File
@@ -1,15 +1,17 @@
"""Tests for the Fjäråskupan integration."""
from fjaraskupan import ANNOUNCE_MANUFACTURER, DEVICE_NAME
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
COOKER_SERVICE_INFO = BluetoothServiceInfoBleak(
name="COOKERHOOD_FJAR",
name=DEVICE_NAME,
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
manufacturer_data={},
service_uuids=[],
service_uuids=["77a2bd49-1e5a-4961-bba1-21f34fa4bc7b"],
service_data={},
source="local",
device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="COOKERHOOD_FJAR"),
@@ -18,3 +20,18 @@ COOKER_SERVICE_INFO = BluetoothServiceInfoBleak(
connectable=True,
tx_power=-127,
)
COOKER_SERVICE_INFO_DATA = BluetoothServiceInfoBleak(
name=DEVICE_NAME,
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
manufacturer_data={ANNOUNCE_MANUFACTURER: b"ODFJAR\x01\x02\x00\x00\x00\x30\x04"},
service_uuids=[],
service_data={},
source="local",
device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name=DEVICE_NAME),
advertisement=generate_advertisement_data(),
time=0,
connectable=True,
tx_power=-127,
)
@@ -10,7 +10,9 @@ from homeassistant.components.fjaraskupan.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import COOKER_SERVICE_INFO
from . import COOKER_SERVICE_INFO, COOKER_SERVICE_INFO_DATA
from tests.components.bluetooth import inject_bluetooth_service_info
@pytest.fixture(name="mock_setup_entry", autouse=True)
@@ -60,3 +62,36 @@ async def test_scan_no_devices(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_discovery(hass: HomeAssistant) -> None:
"""Test we get the form."""
inject_bluetooth_service_info(
hass,
COOKER_SERVICE_INFO,
)
await hass.async_block_till_done(wait_background_tasks=True)
result = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN)))
assert result["step_id"] == "confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Fjäråskupan"
assert result["data"] == {}
async def test_discovery_ignored_without_service(hass: HomeAssistant) -> None:
"""Test we don't start discovery on only manufacturer data since that can be invalid."""
inject_bluetooth_service_info(
hass,
COOKER_SERVICE_INFO_DATA,
)
await hass.async_block_till_done(wait_background_tasks=True)
result = next(
iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN)), None
)
assert result is None
+55 -6
View File
@@ -15,6 +15,17 @@ from tests.components.bluetooth import (
)
from tests.typing import WebSocketGenerator
MOCK_SERVICE_INFO_DISCOVERY = BluetoothServiceInfo(
address="11:11:11:11:11:11",
name=DEVICE_NAME,
service_uuids=["77a2bd49-1e5a-4961-bba1-21f34fa4bc7b"],
rssi=-60,
manufacturer_data={},
service_data={},
source="local",
)
MOCK_SERVICE_INFO = BluetoothServiceInfo(
address="11:11:11:11:11:11",
name=DEVICE_NAME,
@@ -38,6 +49,46 @@ async def test_setup(
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
inject_bluetooth_service_info(
hass,
MOCK_SERVICE_INFO_DISCOVERY,
)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, MOCK_SERVICE_INFO_DISCOVERY.address)}
)
assert device_entry is not None
assert device_entry.manufacturer == "Fjäråskupan"
assert device_entry.name == "Fjäråskupan"
state = hass.states.get("fan.fjaraskupan")
assert state
assert state.state == "off"
inject_bluetooth_service_info(
hass,
MOCK_SERVICE_INFO,
)
await hass.async_block_till_done()
state = hass.states.get("fan.fjaraskupan")
assert state
assert state.state == "on"
async def test_setup_ignore_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test setup does not create a device before we see service."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={},
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
inject_bluetooth_service_info(
hass,
MOCK_SERVICE_INFO,
@@ -45,11 +96,9 @@ async def test_setup(
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, MOCK_SERVICE_INFO.address)}
identifiers={(DOMAIN, MOCK_SERVICE_INFO_DISCOVERY.address)}
)
assert device_entry is not None
assert device_entry.manufacturer == "Fjäråskupan"
assert device_entry.name == "Fjäråskupan"
assert device_entry is None
async def test_remove_device(
@@ -67,11 +116,11 @@ async def test_remove_device(
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
inject_bluetooth_service_info(hass, MOCK_SERVICE_INFO)
inject_bluetooth_service_info(hass, MOCK_SERVICE_INFO_DISCOVERY)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, MOCK_SERVICE_INFO.address)}
identifiers={(DOMAIN, MOCK_SERVICE_INFO_DISCOVERY.address)}
)
assert device_entry
@@ -367,7 +367,7 @@ def test_master_state(hass: HomeAssistant) -> None:
assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORTED_FEATURES
assert not state.attributes[ATTR_MEDIA_VOLUME_MUTED]
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2
assert state.attributes[ATTR_MEDIA_CONTENT_ID] == 12322
assert state.attributes[ATTR_MEDIA_CONTENT_ID] == "12322"
assert state.attributes[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC
assert state.attributes[ATTR_MEDIA_DURATION] == 0.05
assert state.attributes[ATTR_MEDIA_POSITION] == 0.005
@@ -23,6 +23,9 @@
"6001": 1000,
"6002": 92,
"6105": 5,
"6107": 1000,
"6108": 80,
"6109": 200,
"6004": 0,
"6005": 0,
"6006": 277.16,
@@ -4,6 +4,9 @@
"7101": 1,
"142": 1.79,
"6105": 5,
"6107": 1000,
"6108": 80,
"6109": 200,
"2618": 1001,
"11009": 50.2,
"2101": 0,
@@ -27,6 +27,9 @@
'6007': 256.39,
'606': '1000',
'6105': 5,
'6107': 1000,
'6108': 80,
'6109': 200,
'667': 0,
'7101': 5,
'7120': 1000,
@@ -102,6 +105,9 @@
'6007': 338.07,
'606': '1001',
'6105': 5,
'6107': 1000,
'6108': 80,
'6109': 200,
'667': 0,
'680': 0,
'7101': 1,
@@ -1804,6 +1804,177 @@
'state': '0',
})
# ---
# name: test_sensor[1][sensor.bk1600_real_time_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'charging',
'discharging',
'standby',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.bk1600_real_time_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Real-time mode',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Real-time mode',
'platform': 'indevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'realtime_command',
'unique_id': 'BK1600-12345678_6107',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[1][sensor.bk1600_real_time_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'BK1600 Real-time mode',
'options': list([
'charging',
'discharging',
'standby',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.bk1600_real_time_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor[1][sensor.bk1600_real_time_power_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.bk1600_real_time_power_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Real-time power limit',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Real-time power limit',
'platform': 'indevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'realtime_power_limit',
'unique_id': 'BK1600-12345678_6109',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_sensor[1][sensor.bk1600_real_time_power_limit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'BK1600 Real-time power limit',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.bk1600_real_time_power_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor[1][sensor.bk1600_real_time_target_soc-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.bk1600_real_time_target_soc',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Real-time target SOC',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Real-time target SOC',
'platform': 'indevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'realtime_target_soc',
'unique_id': 'BK1600-12345678_6108',
'unit_of_measurement': '%',
})
# ---
# name: test_sensor[1][sensor.bk1600_real_time_target_soc-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'BK1600 Real-time target SOC',
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.bk1600_real_time_target_soc',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor[1][sensor.bk1600_total_ac_input_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -5865,6 +6036,177 @@
'state': '1.79',
})
# ---
# name: test_sensor[2][sensor.cms_sf2000_real_time_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'charging',
'discharging',
'standby',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cms_sf2000_real_time_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Real-time mode',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Real-time mode',
'platform': 'indevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'realtime_command',
'unique_id': 'SolidFlex2000-87654321_6107',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[2][sensor.cms_sf2000_real_time_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'CMS-SF2000 Real-time mode',
'options': list([
'charging',
'discharging',
'standby',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.cms_sf2000_real_time_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor[2][sensor.cms_sf2000_real_time_power_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cms_sf2000_real_time_power_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Real-time power limit',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Real-time power limit',
'platform': 'indevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'realtime_power_limit',
'unique_id': 'SolidFlex2000-87654321_6109',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_sensor[2][sensor.cms_sf2000_real_time_power_limit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'CMS-SF2000 Real-time power limit',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.cms_sf2000_real_time_power_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor[2][sensor.cms_sf2000_real_time_target_soc-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cms_sf2000_real_time_target_soc',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Real-time target SOC',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Real-time target SOC',
'platform': 'indevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'realtime_target_soc',
'unique_id': 'SolidFlex2000-87654321_6108',
'unit_of_measurement': '%',
})
# ---
# name: test_sensor[2][sensor.cms_sf2000_real_time_target_soc-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'CMS-SF2000 Real-time target SOC',
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.cms_sf2000_real_time_target_soc',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor[2][sensor.cms_sf2000_total_ac_input_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+58
View File
@@ -4,6 +4,7 @@ from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from indevolt_api import IndevoltConfig, IndevoltEnergyMode
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -57,6 +58,63 @@ async def test_sensor_availability(
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("generation", [2], indirect=True)
async def test_realtime_sensor_energy_mode_availability(
hass: HomeAssistant,
mock_indevolt: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test real-time sensors are only available in real-time control energy mode."""
with patch("homeassistant.components.indevolt.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
# Default fixture is not in RT mode (1), sensors should be unavailable
assert (
hass.states.get("sensor.cms_sf2000_real_time_mode").state == STATE_UNAVAILABLE
)
assert (
hass.states.get("sensor.cms_sf2000_real_time_target_soc").state
== STATE_UNAVAILABLE
)
assert (
hass.states.get("sensor.cms_sf2000_real_time_power_limit").state
== STATE_UNAVAILABLE
)
# Switch to RT mode (4), sensors should be available
mock_indevolt.fetch_data.return_value[IndevoltConfig.READ_ENERGY_MODE] = (
IndevoltEnergyMode.REAL_TIME_CONTROL
)
freezer.tick(delta=timedelta(seconds=SCAN_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("sensor.cms_sf2000_real_time_mode").state == "standby"
assert hass.states.get("sensor.cms_sf2000_real_time_target_soc").state == "80"
assert hass.states.get("sensor.cms_sf2000_real_time_power_limit").state == "200"
# Switch back to a non-RT mode (0), sensors become unavailable again
mock_indevolt.fetch_data.return_value[IndevoltConfig.READ_ENERGY_MODE] = (
IndevoltEnergyMode.OUTDOOR_PORTABLE
)
freezer.tick(delta=timedelta(seconds=SCAN_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.cms_sf2000_real_time_mode").state == STATE_UNAVAILABLE
)
assert (
hass.states.get("sensor.cms_sf2000_real_time_target_soc").state
== STATE_UNAVAILABLE
)
assert (
hass.states.get("sensor.cms_sf2000_real_time_power_limit").state
== STATE_UNAVAILABLE
)
# In individual tests, you can override the mock behavior
async def test_battery_pack_filtering(
hass: HomeAssistant,
@@ -0,0 +1,151 @@
# serializer version: 1
# name: test_entities[button.marantz_pm6006_integrated_amplifier_loudness-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.marantz_pm6006_integrated_amplifier_loudness',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Loudness',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Loudness',
'platform': 'marantz_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'loudness',
'unique_id': '01JTEST0000000000000000000_loudness',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.marantz_pm6006_integrated_amplifier_loudness-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Marantz PM6006 Integrated Amplifier Loudness',
}),
'context': <ANY>,
'entity_id': 'button.marantz_pm6006_integrated_amplifier_loudness',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.marantz_pm6006_integrated_amplifier_source_direct-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.marantz_pm6006_integrated_amplifier_source_direct',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Source direct',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Source direct',
'platform': 'marantz_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'source_direct',
'unique_id': '01JTEST0000000000000000000_source_direct',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.marantz_pm6006_integrated_amplifier_source_direct-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Marantz PM6006 Integrated Amplifier Source direct',
}),
'context': <ANY>,
'entity_id': 'button.marantz_pm6006_integrated_amplifier_source_direct',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.marantz_pm6006_integrated_amplifier_speaker_a_b-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.marantz_pm6006_integrated_amplifier_speaker_a_b',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Speaker A/B',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Speaker A/B',
'platform': 'marantz_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'speaker_ab',
'unique_id': '01JTEST0000000000000000000_speaker_ab',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.marantz_pm6006_integrated_amplifier_speaker_a_b-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Marantz PM6006 Integrated Amplifier Speaker A/B',
}),
'context': <ANY>,
'entity_id': 'button.marantz_pm6006_integrated_amplifier_speaker_a_b',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
@@ -0,0 +1,104 @@
"""Tests for the Marantz Infrared button platform."""
from infrared_protocols.codes.marantz.audio import MarantzAudioCode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .utils import check_availability_follows_ir_entity
from tests.common import MockConfigEntry, snapshot_platform
from tests.components.infrared.common import MockInfraredEmitterEntity
BUTTON_ENTITY_ID_SPEAKER_AB = "button.marantz_pm6006_integrated_amplifier_speaker_a_b"
BUTTON_ENTITY_ID_SOURCE_DIRECT = (
"button.marantz_pm6006_integrated_amplifier_source_direct"
)
BUTTON_ENTITY_ID_LOUDNESS = "button.marantz_pm6006_integrated_amplifier_loudness"
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.BUTTON]
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test all button entities are created with correct attributes."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
device_entry = device_registry.async_get_device(
identifiers={("marantz_infrared", mock_config_entry.entry_id)}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
@pytest.mark.parametrize(
("entity_id", "expected_code"),
[
(BUTTON_ENTITY_ID_SPEAKER_AB, MarantzAudioCode.SPEAKER_AB),
(BUTTON_ENTITY_ID_SOURCE_DIRECT, MarantzAudioCode.SOURCE_DIRECT),
(BUTTON_ENTITY_ID_LOUDNESS, MarantzAudioCode.LOUDNESS),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_button_press_sends_correct_code(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
entity_id: str,
expected_code: MarantzAudioCode,
) -> None:
"""Test pressing a button sends the correct IR code."""
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code
@pytest.mark.parametrize(
"model",
["sr_7300_receiver"],
indirect=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_only_supported_buttons_created(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that only buttons for codes supported by the model are created."""
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
button_entries = [e for e in entity_entries if e.domain == "button"]
assert len(button_entries) == 1
assert button_entries[0].translation_key == "speaker_ab"
@pytest.mark.usefixtures("init_integration")
async def test_button_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test button becomes unavailable when IR entity is unavailable."""
await check_availability_follows_ir_entity(hass, BUTTON_ENTITY_ID_LOUDNESS)
+254 -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] = []
@@ -1105,16 +1127,89 @@ async def test_unsubscribe_race(
# [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,
@@ -1182,7 +1277,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
@@ -1196,7 +1291,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)
@@ -1272,6 +1367,29 @@ async def test_logs_error_if_no_connect_broker(
assert "Unable to connect to the MQTT broker: Server unavailable" in caplog.text
@pytest.mark.parametrize(
"mqtt_config_entry_data", [{mqtt.CONF_BROKER: "mock-broker", CONF_PROTOCOL: "5"}]
)
async def test_logs_error_if_broker_does_not_support_subscription_identifiers(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
setup_with_birth_msg_client_mock: MqttMockPahoClient,
) -> None:
"""Test logging an error if the broker reports Subscription Identifiers are disabled."""
mqtt_client_mock = setup_with_birth_msg_client_mock
mqtt_client_mock.on_disconnect(Mock(), None, None, MockMqttReasonCode())
properties = paho_mqtt.Properties(paho_mqtt.PacketTypes.CONNACK)
properties.SubscriptionIdentifierAvailable = 0
mqtt_client_mock.on_connect(Mock(), None, None, MockMqttReasonCode(), properties)
await hass.async_block_till_done()
assert (
"Your MQTT broker reports it does not support Subscription Identifiers, see "
"https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901092. "
"Please use a supported MQTT broker; got broker properties: "
"[SubscriptionIdentifierAvailable : 0]" in caplog.text
)
@pytest.mark.parametrize(
"reason_code",
[
@@ -1392,7 +1510,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()
@@ -2389,3 +2507,130 @@ 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
async def test_subscriptions_id_generation(hass: HomeAssistant) -> None:
"""Test subscription identifier generation."""
generator = mqtt.models.SubscriptionID()
# Mock we are past the last subscriptions
generator._next_id = mqtt.models.MAX_28BIT - 2
new_id_1 = generator.get_or_generate("test1/#")
assert new_id_1 == mqtt.models.MAX_28BIT - 2
new_id_2 = generator.get_or_generate("test2/#")
assert new_id_2 == mqtt.models.MAX_28BIT - 1
new_id_3 = generator.get_or_generate("test3/#")
assert new_id_3 == mqtt.models.MAX_28BIT
with pytest.raises(HomeAssistantError) as exc:
generator.get_or_generate("test4/#")
assert exc.value.translation_domain == mqtt.DOMAIN
assert exc.value.translation_key == "mqtt_max_subscription_id_reached"
generator.release("test2/#")
new_id_4 = generator.get_or_generate("test4/#")
# Check we reused the ID
assert new_id_4 == mqtt.models.MAX_28BIT - 1
with pytest.raises(HomeAssistantError) as exc:
generator.get_or_generate("test5/#")
assert exc.value.translation_domain == mqtt.DOMAIN
assert exc.value.translation_key == "mqtt_max_subscription_id_reached"
generator.release("test1/#")
generator.release("test3/#")
new_id_5 = generator.get_or_generate("test5/#")
new_id_6 = generator.get_or_generate("test6/#")
assert {new_id_5, new_id_6} == {new_id_1, new_id_3}
with pytest.raises(HomeAssistantError) as exc:
generator.get_or_generate("test7/#")
assert exc.value.translation_domain == mqtt.DOMAIN
assert exc.value.translation_key == "mqtt_max_subscription_id_reached"
+220 -94
View File
@@ -177,8 +177,11 @@ async def test_configure_user_selected_manual(
mock_setup_entry.assert_awaited_once()
async def test_configure_invalid_serial_suffix(hass: HomeAssistant) -> None:
"""Test we handle invalid serial suffix error."""
async def test_configure_invalid_serial_suffix(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Invalid serial suffix surfaces an error; valid suffix recovers."""
with patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_discover_hubs",
return_value=[("1.1.1.1", "123456789")],
@@ -199,17 +202,36 @@ async def test_configure_invalid_serial_suffix(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_serial"}
async def test_configure_invalid_serial_undiscovered(hass: HomeAssistant) -> None:
"""Test we handle invalid serial error."""
with patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_discover_hubs",
return_value=[],
with (
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub",
return_value=True,
),
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.hub_info",
new_callable=PropertyMock,
create=True,
return_value={"name": "My Nobø Ecohub"},
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "manual"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"serial_suffix": "012"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
mock_setup_entry.assert_awaited_once()
async def test_configure_invalid_serial_undiscovered(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Invalid serial in the manual step surfaces an error; valid serial recovers."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "manual"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_IP_ADDRESS: "1.1.1.1", CONF_SERIAL: "123456789"},
@@ -218,17 +240,36 @@ async def test_configure_invalid_serial_undiscovered(hass: HomeAssistant) -> Non
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_serial"}
async def test_configure_invalid_ip_address(hass: HomeAssistant) -> None:
"""Test we handle invalid ip address error."""
with patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_discover_hubs",
return_value=[("1.1.1.1", "123456789")],
with (
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub",
return_value=True,
),
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.hub_info",
new_callable=PropertyMock,
create=True,
return_value={"name": "My Nobø Ecohub"},
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "manual"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_IP_ADDRESS: "1.1.1.1", CONF_SERIAL: "123456789012"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
mock_setup_entry.assert_awaited_once()
async def test_configure_invalid_ip_address(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Invalid IP surfaces an error; valid IP recovers."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "manual"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SERIAL: "123456789012", CONF_IP_ADDRESS: "ABCD"},
@@ -237,6 +278,26 @@ async def test_configure_invalid_ip_address(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_ip"}
with (
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub",
return_value=True,
),
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.hub_info",
new_callable=PropertyMock,
create=True,
return_value={"name": "My Nobø Ecohub"},
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SERIAL: "123456789012", CONF_IP_ADDRESS: "1.1.1.1"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
mock_setup_entry.assert_awaited_once()
@pytest.mark.parametrize(
("connect_outcome", "expected_error"),
@@ -248,10 +309,11 @@ async def test_configure_invalid_ip_address(hass: HomeAssistant) -> None:
)
async def test_configure_cannot_connect(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
connect_outcome: dict[str, object],
expected_error: str,
) -> None:
"""Connect failures map to distinct error keys.
"""Connect failures map to distinct error keys; retry recovers.
pynobo's async_connect_hub returns False on a successful TCP connect
followed by a handshake REJECT (serial mismatch) and raises OSError
@@ -285,6 +347,26 @@ async def test_configure_cannot_connect(
assert result["errors"] == {"base": expected_error}
mock_connect.assert_awaited_once_with("1.1.1.1", "123456789012")
with (
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub",
return_value=True,
),
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.hub_info",
new_callable=PropertyMock,
create=True,
return_value={"name": "My Nobø Ecohub"},
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"serial_suffix": "012"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
mock_setup_entry.assert_awaited_once()
async def test_dhcp_discovery_new_hub(
hass: HomeAssistant,
@@ -334,51 +416,18 @@ async def test_dhcp_discovery_new_hub(
mock_setup_entry.assert_awaited_once()
@pytest.mark.parametrize(
("stored_ip", "expected_type", "expected_reason", "expected_step", "expected_mac"),
[
# Matching IP + prefix → backfill MAC, abort already_configured.
(
"192.168.1.106",
FlowResultType.ABORT,
"already_configured",
None,
"7c830602644f",
),
# Mismatched IP (sibling hub in same production batch) → don't
# clobber, fall through to the selected step.
(
"192.168.1.100",
FlowResultType.FORM,
None,
"selected",
None,
),
],
ids=["matching_ip_backfills_mac", "mismatched_ip_does_not_clobber"],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_dhcp_discovery_backfill_requires_ip_match(
async def test_dhcp_discovery_backfill_aborts_when_ip_matches(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
stored_ip: str,
expected_type: FlowResultType,
expected_reason: str | None,
expected_step: str | None,
expected_mac: str | None,
) -> None:
"""MAC backfill requires both IP and prefix to match.
Two hubs from the same production batch share the 9-digit serial
prefix but have different IPs. Requiring IP match prevents a DHCP
packet from one hub clobbering a sibling entry's MAC.
"""
"""Matching IP + prefix backfills the MAC and aborts as already_configured."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="102000100098",
data={
CONF_SERIAL: "102000100098",
CONF_IP_ADDRESS: stored_ip,
CONF_IP_ADDRESS: "192.168.1.106",
CONF_MAC: None,
},
)
@@ -394,11 +443,69 @@ async def test_dhcp_discovery_backfill_requires_ip_match(
data=DHCP_DISCOVERY,
)
assert result["type"] is expected_type
assert result.get("reason") == expected_reason
assert result.get("step_id") == expected_step
assert config_entry.data[CONF_IP_ADDRESS] == stored_ip
assert config_entry.data[CONF_MAC] == expected_mac
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert config_entry.data[CONF_IP_ADDRESS] == "192.168.1.106"
assert config_entry.data[CONF_MAC] == "7c830602644f"
async def test_dhcp_discovery_backfill_proceeds_when_ip_mismatched(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
) -> None:
"""Mismatched IP (sibling hub from same production batch) doesn't clobber an existing entry's MAC.
Two hubs from the same production batch share the 9-digit serial
prefix but have different IPs. Requiring IP match prevents a DHCP
packet from one hub clobbering a sibling entry's MAC. The flow falls
through to the `selected` step so the user can configure the new hub.
"""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="102000100098",
data={
CONF_SERIAL: "102000100098",
CONF_IP_ADDRESS: "192.168.1.100",
CONF_MAC: None,
},
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_discover_hubs",
return_value={("192.168.1.106", "102000100")},
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "selected"
assert config_entry.data[CONF_IP_ADDRESS] == "192.168.1.100"
assert config_entry.data[CONF_MAC] is None
with (
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub",
return_value=True,
),
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.hub_info",
new_callable=PropertyMock,
create=True,
return_value={"name": "My Nobø Ecohub"},
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"serial_suffix": "099"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "102000100099"
@pytest.mark.usefixtures("mock_setup_entry")
@@ -433,40 +540,40 @@ async def test_dhcp_discovery_skips_broadcast_when_mac_known(
assert config_entry.data[CONF_MAC] == "7c830602644f"
@pytest.mark.parametrize(
("ignored_unique_id", "expected_type", "expected_reason", "expected_step"),
[
# Same MAC: rediscovery of a previously-ignored hub aborts.
(
"7c:83:06:02:64:4f",
FlowResultType.ABORT,
"already_configured",
None,
),
# Different MAC (sibling in same production batch): flow proceeds
# to the selected step. The 9-digit serial prefix would match,
# but using the MAC as unique_id prevents the false-shadowing.
(
"7c:83:06:99:99:99",
FlowResultType.FORM,
None,
"selected",
),
],
ids=["same_mac_aborts", "different_mac_proceeds"],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_dhcp_discovery_with_ignored_entry(
async def test_dhcp_discovery_aborts_when_ignored_mac_matches(
hass: HomeAssistant,
ignored_unique_id: str,
expected_type: FlowResultType,
expected_reason: str | None,
expected_step: str | None,
mock_setup_entry: AsyncMock,
) -> None:
"""Ignored entries match the discovery flow by MAC, not by serial prefix."""
"""Rediscovery of a previously-ignored hub aborts on matching MAC."""
ignored_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=ignored_unique_id,
unique_id="7c:83:06:02:64:4f",
source=config_entries.SOURCE_IGNORE,
)
ignored_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_dhcp_discovery_proceeds_when_ignored_mac_differs(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""A sibling hub (different MAC, same serial prefix) is not shadowed by an ignored entry.
The 9-digit serial prefix would match, but using the MAC as the
discovery flow's unique_id prevents the false-shadowing.
"""
ignored_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="7c:83:06:99:99:99",
source=config_entries.SOURCE_IGNORE,
)
ignored_entry.add_to_hass(hass)
@@ -481,9 +588,28 @@ async def test_dhcp_discovery_with_ignored_entry(
data=DHCP_DISCOVERY,
)
assert result["type"] is expected_type
assert result.get("reason") == expected_reason
assert result.get("step_id") == expected_step
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "selected"
with (
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub",
return_value=True,
),
patch(
"homeassistant.components.nobo_hub.config_flow.nobo.hub_info",
new_callable=PropertyMock,
create=True,
return_value={"name": "My Nobø Ecohub"},
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"serial_suffix": "098"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "102000100098"
@pytest.mark.usefixtures("mock_setup_entry")
@@ -0,0 +1,46 @@
[
{
"creationTime": 1763489130000,
"lastUpdateTime": 1763489130000,
"label": "Label 1",
"metadata": "a56b34aa-2f6f-4e24-b73a-c2ed23f0ac91",
"shortcut": false,
"notificationTypeMask": 0,
"notificationCondition": "NEVER",
"actions": [
{
"deviceURL": "io://1234-5678-1234/13880042",
"commands": [
{
"type": 1,
"name": "setHeatingLevel",
"parameters": ["eco"]
}
]
}
],
"oid": "0a0589bb-9471-4667-a2a9-4602beb2a2e8"
},
{
"creationTime": 1763214463000,
"lastUpdateTime": 1763214463000,
"label": "Label 2",
"metadata": "f6206578-b730-4f66-b322-e8d0a5625407",
"shortcut": false,
"notificationTypeMask": 0,
"notificationCondition": "NEVER",
"actions": [
{
"deviceURL": "io://1234-5678-1234/15581081",
"commands": [
{
"type": 1,
"name": "setHeatingLevel",
"parameters": ["comfort"]
}
]
}
],
"oid": "50d39fc3-9368-49c9-bcbf-c74f3ce1678a"
}
]
@@ -0,0 +1,26 @@
[
{
"creationTime": 1766929020000,
"lastUpdateTime": 1766929020000,
"label": "I'm arriving",
"metadata": "{\"tahoma\":{\"version\":1,\"icon\":\"at_home\",\"template\":\"arriving\"}}",
"shortcut": false,
"notificationTypeMask": 16,
"notificationCondition": "ON_ERROR",
"notificationText": "Your scene I'm arriving could not be played correctly due to an error in one of your products.",
"notificationTitle": "[$[setupLabel]] : Impossible to play the scene I'm arriving",
"actions": [
{
"deviceURL": "rts://1234-5678-1234/16756006",
"commands": [
{
"type": 1,
"name": "open",
"parameters": [1]
}
]
}
],
"oid": "d1b689e1-4087-473d-b726-d3b24770856f"
}
]
@@ -46,7 +46,7 @@
{
"creationTime": 1613675393000,
"lastUpdateTime": 1613675393000,
"label": "*",
"label": "Connexoon",
"deviceURL": "internal://1234-1234-6362/pod/0",
"shortcut": false,
"controllableName": "internal:PodMiniComponent",
@@ -142,7 +142,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "*"
"value": "Connexoon"
},
{
"name": "internal:LightingLedPodModeState",
@@ -47,7 +47,7 @@
{
"creationTime": 1740136137000,
"lastUpdateTime": 1740136137000,
"label": "** *(**)*",
"label": "TaHoma (HomeKit)",
"deviceURL": "homekit://1234-5678-5010/stack",
"shortcut": false,
"controllableName": "homekit:StackComponent",
@@ -70,12 +70,12 @@
{
"name": "homekit:SetupCode",
"type": 3,
"value": "**"
"value": "012-34-567"
},
{
"name": "homekit:SetupPayload",
"type": 3,
"value": "**:*/*/**"
"value": "X-HM://0000000000000"
}
],
"available": true,
@@ -89,7 +89,7 @@
{
"creationTime": 1740136137000,
"lastUpdateTime": 1740136137000,
"label": "**",
"label": "TaHoma",
"deviceURL": "internal://1234-5678-5010/pod/0",
"shortcut": false,
"controllableName": "internal:PodV3Component",
@@ -217,7 +217,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "**"
"value": "TaHoma"
}
],
"available": true,
@@ -231,7 +231,7 @@
{
"creationTime": 1740136137000,
"lastUpdateTime": 1740136137000,
"label": "** *(**/**)*",
"label": "TaHoma (WiFi/Ethernet)",
"deviceURL": "internal://1234-5678-5010/wifi/0",
"shortcut": false,
"controllableName": "internal:WifiComponent",
@@ -2985,7 +2985,7 @@
{
"creationTime": 1740139813000,
"lastUpdateTime": 1740139813000,
"label": "** *(**)*",
"label": "TaHoma (IO)",
"deviceURL": "io://1234-5678-5010/8508653",
"shortcut": false,
"controllableName": "io:StackComponent",
@@ -3043,7 +3043,7 @@
{
"creationTime": 1740136141000,
"lastUpdateTime": 1740136141000,
"label": "** ** **",
"label": "Somfy Protect Bridge",
"deviceURL": "ogp://1234-5678-5010/00000BE8",
"shortcut": false,
"controllableName": "ogp:Bridge",
@@ -3137,7 +3137,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "** ** **"
"value": "Somfy Protect Bridge"
}
],
"attributes": [
@@ -3183,7 +3183,7 @@
{
"creationTime": 1740136141000,
"lastUpdateTime": 1740136141000,
"label": "** ** **",
"label": "Somfy Protect Camera",
"deviceURL": "ogp://1234-5678-5010/0003FEF3",
"shortcut": false,
"controllableName": "ogp:Bridge",
@@ -3229,7 +3229,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "** ** **"
"value": "Somfy Protect Camera"
}
],
"attributes": [
@@ -3276,7 +3276,7 @@
{
"creationTime": 1740136141000,
"lastUpdateTime": 1740136141000,
"label": "** ** **",
"label": "Somfy Protect Sensor",
"deviceURL": "ogp://1234-5678-5010/039575E9",
"shortcut": false,
"controllableName": "ogp:Bridge",
@@ -3326,7 +3326,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "** ** **"
"value": "Somfy Protect Sensor"
}
],
"attributes": [
@@ -3369,7 +3369,7 @@
{
"creationTime": 1740136140000,
"lastUpdateTime": 1740136140000,
"label": "** ** **",
"label": "Somfy Protect Siren",
"deviceURL": "ogp://1234-5678-5010/09E45393",
"shortcut": false,
"controllableName": "ogp:Bridge",
@@ -3419,7 +3419,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "** ** **"
"value": "Somfy Protect Siren"
}
],
"attributes": [
@@ -3462,7 +3462,7 @@
{
"creationTime": 1740136137000,
"lastUpdateTime": 1740136137000,
"label": "** *(**)*",
"label": "TaHoma (Zigbee)",
"deviceURL": "zigbee://1234-5678-5010/65535",
"shortcut": false,
"controllableName": "zigbee:TransceiverV3_0Component",
@@ -46,7 +46,7 @@
{
"creationTime": 1527329167000,
"lastUpdateTime": 1527329167000,
"label": "* *",
"label": "Garden Camera",
"deviceURL": "camera://1234-1234-6233/00408cbef1e6",
"shortcut": false,
"controllableName": "camera:GenericCameraComponent",
@@ -80,7 +80,7 @@
{
"creationTime": 1606823644000,
"lastUpdateTime": 1606823644000,
"label": "* (*)",
"label": "TaHoma (HomeKit)",
"deviceURL": "homekit://1234-1234-6233/stack",
"shortcut": false,
"controllableName": "homekit:StackComponent",
@@ -103,12 +103,12 @@
{
"name": "homekit:SetupPayload",
"type": 3,
"value": "*://*"
"value": "X-HM://0000000000000"
},
{
"name": "homekit:SetupCode",
"type": 3,
"value": "*"
"value": "012-34-567"
}
],
"available": true,
@@ -122,7 +122,7 @@
{
"creationTime": 1501224146000,
"lastUpdateTime": 1501224146000,
"label": "*",
"label": "Alarm",
"deviceURL": "internal://1234-1234-6233/alarm/0",
"shortcut": false,
"controllableName": "internal:TSKAlarmComponent",
@@ -229,7 +229,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "* *"
"value": "Home Alarm"
},
{
"name": "internal:CurrentAlarmModeState",
@@ -263,7 +263,7 @@
{
"creationTime": 1501224054000,
"lastUpdateTime": 1501224054000,
"label": "* *",
"label": "TaHoma V2",
"deviceURL": "internal://1234-1234-6233/pod/0",
"shortcut": false,
"controllableName": "internal:PodV2Component",
@@ -362,7 +362,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "*"
"value": "TaHoma"
},
{
"name": "internal:BatteryStatusState",
@@ -2017,7 +2017,7 @@
{
"creationTime": 1501224336000,
"lastUpdateTime": 1501224336000,
"label": "* * *&*",
"label": "Pool Pump On&Off",
"deviceURL": "io://1234-1234-6233/16168460",
"shortcut": false,
"controllableName": "io:OnOffIOComponent",
@@ -2170,7 +2170,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "* * *&*"
"value": "Pool Pump On&Off"
},
{
"name": "core:PriorityLockTimerState",
@@ -2216,7 +2216,7 @@
{
"creationTime": 1600767469000,
"lastUpdateTime": 1600767469000,
"label": "* (*)",
"label": "TaHoma (IO)",
"deviceURL": "io://1234-1234-6233/1684749",
"shortcut": false,
"controllableName": "io:StackComponent",
@@ -2599,7 +2599,7 @@
{
"creationTime": 1624791003000,
"lastUpdateTime": 1624791003000,
"label": "*",
"label": "Protexiom",
"deviceURL": "io://1234-1234-6233/2155276",
"shortcut": false,
"controllableName": "io:AlarmIOComponent",
@@ -2755,7 +2755,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "*"
"value": "Protexiom"
},
{
"name": "core:PriorityLockTimerState",
@@ -3377,7 +3377,7 @@
{
"creationTime": 1544643386000,
"lastUpdateTime": 1544643386000,
"label": "* * *",
"label": "Garden Light Switch",
"deviceURL": "io://1234-1234-6233/6852535",
"shortcut": false,
"controllableName": "io:OnOffIOComponent",
@@ -3530,7 +3530,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "* * *"
"value": "Garden Light Switch"
},
{
"name": "core:PriorityLockTimerState",
@@ -4551,7 +4551,7 @@
{
"creationTime": 1576263695000,
"lastUpdateTime": 1576263695000,
"label": "* *",
"label": "Patio Light",
"deviceURL": "io://1234-1234-6233/9474368",
"shortcut": false,
"controllableName": "io:OnOffIOComponent",
@@ -4704,7 +4704,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "* *"
"value": "Patio Light"
},
{
"name": "core:PriorityLockTimerState",
@@ -5080,7 +5080,7 @@
{
"creationTime": 1593517355000,
"lastUpdateTime": 1593517355000,
"label": "* (*)",
"label": "Protect (Bridge)",
"deviceURL": "ogp://1234-1234-6233/00000BE8",
"shortcut": false,
"controllableName": "ogp:Bridge",
@@ -5162,7 +5162,7 @@
{
"creationTime": 1593517357000,
"lastUpdateTime": 1593517357000,
"label": "* * *",
"label": "Indoor Motion Sensor",
"deviceURL": "ogp://1234-1234-6233/039575E9",
"shortcut": false,
"controllableName": "ogp:Bridge",
@@ -5203,7 +5203,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "* * *"
"value": "Indoor Motion Sensor"
}
],
"attributes": [
@@ -5246,7 +5246,7 @@
{
"creationTime": 1593517356000,
"lastUpdateTime": 1593517356000,
"label": "* * *",
"label": "Outdoor Motion Sensor",
"deviceURL": "ogp://1234-1234-6233/09E45393",
"shortcut": false,
"controllableName": "ogp:Bridge",
@@ -5287,7 +5287,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "* * *"
"value": "Outdoor Motion Sensor"
}
],
"attributes": [
@@ -5330,7 +5330,7 @@
{
"creationTime": 1501225111000,
"lastUpdateTime": 1501225111000,
"label": "*é*é* *",
"label": "Télécommande Salon",
"deviceURL": "rtds://1234-1234-6233/124768",
"shortcut": false,
"controllableName": "rtds:RTDSRemoteControllerComponent",
@@ -5402,7 +5402,7 @@
{
"creationTime": 1501225074000,
"lastUpdateTime": 1501225074000,
"label": "*é*é* *",
"label": "Télécommande Bureau",
"deviceURL": "rtds://1234-1234-6233/169771",
"shortcut": false,
"controllableName": "rtds:RTDSRemoteControllerComponent",
@@ -5474,7 +5474,7 @@
{
"creationTime": 1501224458000,
"lastUpdateTime": 1501224458000,
"label": "*",
"label": "Hallway",
"deviceURL": "rtds://1234-1234-6233/232949",
"shortcut": false,
"controllableName": "rtds:RTDSMotionSensor",
@@ -5525,7 +5525,7 @@
{
"creationTime": 1501224970000,
"lastUpdateTime": 1501224970000,
"label": "É*",
"label": "Entrée",
"deviceURL": "rtds://1234-1234-6233/246258",
"shortcut": false,
"controllableName": "rtds:RTDSMotionSensor",
@@ -5576,7 +5576,7 @@
{
"creationTime": 1501224664000,
"lastUpdateTime": 1501224664000,
"label": "*",
"label": "Kitchen",
"deviceURL": "rtds://1234-1234-6233/288316",
"shortcut": false,
"controllableName": "rtds:RTDSMotionSensor",
@@ -5627,7 +5627,7 @@
{
"creationTime": 1501224830000,
"lastUpdateTime": 1501224830000,
"label": "*é*",
"label": "Fenêtre Garage",
"deviceURL": "rtds://1234-1234-6233/394765",
"shortcut": false,
"controllableName": "rtds:RTDSContactSensor",
@@ -5677,7 +5677,7 @@
{
"creationTime": 1501224801000,
"lastUpdateTime": 1501224801000,
"label": "*",
"label": "Porte",
"deviceURL": "rtds://1234-1234-6233/394781",
"shortcut": false,
"controllableName": "rtds:RTDSContactSensor",
@@ -5727,7 +5727,7 @@
{
"creationTime": 1501225017000,
"lastUpdateTime": 1501225017000,
"label": "*é* *",
"label": "Détecteur Fumée",
"deviceURL": "rtds://1234-1234-6233/711548",
"shortcut": false,
"controllableName": "rtds:RTDSSmokeSensor",
@@ -5778,7 +5778,7 @@
{
"creationTime": 1546191792000,
"lastUpdateTime": 1546191792000,
"label": "*",
"label": "Kitchen",
"deviceURL": "upnpcontrol://1234-1234-6233/uuid:RINCON_7828CA300AD801400",
"shortcut": false,
"controllableName": "upnpcontrol:SonosPlayOneComponent",
@@ -5938,7 +5938,7 @@
{
"creationTime": 1526325424000,
"lastUpdateTime": 1526325424000,
"label": "* *:*",
"label": "Living Room",
"deviceURL": "upnpcontrol://1234-1234-6233/uuid:RINCON_7828CA3011E801400",
"shortcut": false,
"controllableName": "upnpcontrol:SonosPlayOneComponent",
@@ -6098,7 +6098,7 @@
{
"creationTime": 1546191792000,
"lastUpdateTime": 1546191792000,
"label": "*",
"label": "Bedroom",
"deviceURL": "upnpcontrol://1234-1234-6233/uuid:RINCON_7828CACED56E01400",
"shortcut": false,
"controllableName": "upnpcontrol:SonosOneComponent",
@@ -6258,7 +6258,7 @@
{
"creationTime": 1526325424000,
"lastUpdateTime": 1526325424000,
"label": "*",
"label": "Lounge",
"deviceURL": "upnpcontrol://1234-1234-6233/uuid:RINCON_949F3E479FC001400",
"shortcut": false,
"controllableName": "upnpcontrol:SonosSubComponent",
@@ -6418,7 +6418,7 @@
{
"creationTime": 1510775207000,
"lastUpdateTime": 1510775207000,
"label": "*",
"label": "Studio",
"deviceURL": "upnpcontrol://1234-1234-6233/uuid:RINCON_B8E9372FDA1201400",
"shortcut": false,
"controllableName": "upnpcontrol:SonosPlayFiveComponent",
@@ -6573,7 +6573,7 @@
{
"creationTime": 1530352152000,
"lastUpdateTime": 1530352152000,
"label": "*",
"label": "Office",
"deviceURL": "upnpcontrol://1234-1234-6233/uuid:RINCON_B8E93744C18001400",
"shortcut": false,
"controllableName": "upnpcontrol:SonosPlayBaseComponent",
@@ -6733,7 +6733,7 @@
{
"creationTime": 1510775207000,
"lastUpdateTime": 1510775207000,
"label": "* * *",
"label": "Guest Room Speaker",
"deviceURL": "upnpcontrol://1234-1234-6233/uuid:RINCON_B8E937BB9E2E01400",
"shortcut": false,
"controllableName": "upnpcontrol:SonosPlayOneComponent",
@@ -7441,6 +7441,38 @@
"oid": "6ba9b1de-8037-41d7-9150-21f7d5f49a3f",
"uiClass": "Gate"
},
{
"creationTime": 1434967095000,
"lastUpdateTime": 1434967095000,
"label": "RTS Gate",
"deviceURL": "rts://1234-1234-6233/16730717",
"shortcut": false,
"controllableName": "rts:GateOpenerRTS4TComponent",
"definition": {
"commands": [
{
"commandName": "cycle",
"nparams": 1
}
],
"states": [],
"dataProperties": [],
"widgetName": "OpenCloseGate4T",
"uiProfiles": ["CyclicGateOpener", "Cyclic"],
"uiClass": "Gate",
"qualifiedName": "rts:GateOpenerRTS4TComponent",
"type": "ACTUATOR"
},
"states": [],
"attributes": [],
"available": true,
"enabled": true,
"placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3",
"widget": "OpenCloseGate4T",
"type": 1,
"oid": "a1b2c3d4-e5f6-7890-abcd-ef1234567891",
"uiClass": "Gate"
},
{
"creationTime": 1654894302000,
"lastUpdateTime": 1654894302000,
@@ -7724,6 +7756,385 @@
"oid": "2492f7ae-3711-4160-9dae-e8910b708ce1",
"uiClass": "GarageDoor"
},
{
"creationTime": 1685039520000,
"lastUpdateTime": 1685039520000,
"label": "Cyclic Garage Door",
"deviceURL": "io://1234-1234-6233/6416929",
"shortcut": false,
"controllableName": "io:CyclicGarageOpenerIOComponent",
"definition": {
"commands": [
{
"commandName": "advancedRefresh",
"nparams": 2
},
{
"commandName": "cycle",
"nparams": 0
},
{
"commandName": "delayedStopIdentify",
"nparams": 1
},
{
"commandName": "getName",
"nparams": 0
},
{
"commandName": "identify",
"nparams": 0
},
{
"commandName": "setName",
"nparams": 1
},
{
"commandName": "startIdentify",
"nparams": 0
},
{
"commandName": "stopIdentify",
"nparams": 0
},
{
"commandName": "wink",
"nparams": 1
},
{
"commandName": "pairOneWayController",
"nparams": 2
},
{
"commandName": "setConfigState",
"nparams": 1
},
{
"commandName": "unpairAllOneWayControllers",
"nparams": 0
},
{
"commandName": "unpairOneWayController",
"nparams": 2
}
],
"states": [
{
"type": "DiscreteState",
"values": ["good", "low", "normal", "verylow"],
"qualifiedName": "core:DiscreteRSSILevelState"
},
{
"type": "DataState",
"qualifiedName": "core:NameState"
},
{
"type": "ContinuousState",
"qualifiedName": "core:PriorityLockTimerState"
},
{
"type": "ContinuousState",
"qualifiedName": "core:RSSILevelState"
},
{
"type": "DiscreteState",
"values": ["available", "unavailable"],
"qualifiedName": "core:StatusState"
},
{
"type": "DiscreteState",
"values": [
"comfortLevel1",
"comfortLevel2",
"comfortLevel3",
"comfortLevel4",
"environmentProtection",
"humanProtection",
"userLevel1",
"userLevel2"
],
"qualifiedName": "io:PriorityLockLevelState"
},
{
"type": "DiscreteState",
"values": [
"LSC",
"SAAC",
"SFC",
"UPS",
"externalGateway",
"localUser",
"myself",
"rain",
"security",
"temperature",
"timer",
"user",
"wind"
],
"qualifiedName": "io:PriorityLockOriginatorState"
}
],
"dataProperties": [
{
"value": "500",
"qualifiedName": "core:identifyInterval"
}
],
"widgetName": "CyclicGarageDoor",
"uiProfiles": ["CyclicGarageOpener", "Cyclic"],
"uiClass": "GarageDoor",
"qualifiedName": "io:CyclicGarageOpenerIOComponent",
"type": "ACTUATOR"
},
"states": [
{
"name": "core:NameState",
"type": 3,
"value": "Cyclic Garage Door"
},
{
"name": "core:PriorityLockTimerState",
"type": 1,
"value": 0
},
{
"name": "core:StatusState",
"type": 3,
"value": "available"
},
{
"name": "core:DiscreteRSSILevelState",
"type": 3,
"value": "normal"
},
{
"name": "core:RSSILevelState",
"type": 2,
"value": 72.0
}
],
"attributes": [
{
"name": "core:Manufacturer",
"type": 3,
"value": "Somfy"
},
{
"name": "core:FirmwareRevision",
"type": 3,
"value": "5127170A01"
}
],
"available": true,
"enabled": true,
"placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3",
"widget": "CyclicGarageDoor",
"type": 1,
"oid": "42a91c8c-2f55-4442-9af8-fa93862fbeff",
"uiClass": "GarageDoor"
},
{
"creationTime": 1711109596000,
"lastUpdateTime": 1711109596000,
"label": "Swinging Gate",
"deviceURL": "io://1234-1234-8983/1959462",
"shortcut": false,
"controllableName": "io:CyclicSwingingGateOpenerIOComponent",
"definition": {
"commands": [
{
"commandName": "addLockLevel",
"nparams": 2
},
{
"commandName": "advancedRefresh",
"nparams": 2
},
{
"commandName": "cycle",
"nparams": 0
},
{
"commandName": "delayedStopIdentify",
"nparams": 1
},
{
"commandName": "getName",
"nparams": 0
},
{
"commandName": "identify",
"nparams": 0
},
{
"commandName": "removeLockLevel",
"nparams": 1
},
{
"commandName": "resetLockLevels",
"nparams": 0
},
{
"commandName": "setName",
"nparams": 1
},
{
"commandName": "startIdentify",
"nparams": 0
},
{
"commandName": "stopIdentify",
"nparams": 0
},
{
"commandName": "wink",
"nparams": 1
},
{
"commandName": "pairOneWayController",
"nparams": 2
},
{
"commandName": "setConfigState",
"nparams": 1
},
{
"commandName": "unpairAllOneWayControllers",
"nparams": 0
},
{
"commandName": "unpairOneWayController",
"nparams": 2
}
],
"states": [
{
"eventBased": true,
"type": "DataState",
"qualifiedName": "core:CommandLockLevelsState"
},
{
"type": "DiscreteState",
"values": ["good", "low", "normal", "verylow"],
"qualifiedName": "core:DiscreteRSSILevelState"
},
{
"type": "DataState",
"qualifiedName": "core:NameState"
},
{
"type": "ContinuousState",
"qualifiedName": "core:PriorityLockTimerState"
},
{
"type": "ContinuousState",
"qualifiedName": "core:RSSILevelState"
},
{
"type": "DiscreteState",
"values": ["available", "unavailable"],
"qualifiedName": "core:StatusState"
},
{
"type": "DiscreteState",
"values": [
"comfortLevel1",
"comfortLevel2",
"comfortLevel3",
"comfortLevel4",
"environmentProtection",
"humanProtection",
"userLevel1",
"userLevel2"
],
"qualifiedName": "io:PriorityLockLevelState"
},
{
"type": "DiscreteState",
"values": [
"LSC",
"SAAC",
"SFC",
"UPS",
"externalGateway",
"localUser",
"myself",
"rain",
"security",
"temperature",
"timer",
"user",
"wind"
],
"qualifiedName": "io:PriorityLockOriginatorState"
}
],
"dataProperties": [
{
"value": "500",
"qualifiedName": "core:identifyInterval"
}
],
"widgetName": "CyclicSwingingGateOpener",
"uiProfiles": ["CyclicGateOpener", "Cyclic"],
"uiClass": "Gate",
"qualifiedName": "io:CyclicSwingingGateOpenerIOComponent",
"type": "ACTUATOR"
},
"states": [
{
"name": "core:NameState",
"type": 3,
"value": "Swinging Gate"
},
{
"name": "core:PriorityLockTimerState",
"type": 1,
"value": 0
},
{
"name": "core:CommandLockLevelsState",
"type": 3,
"value": "[]",
"lastUpdateTime": 1742634864000
},
{
"name": "core:StatusState",
"type": 3,
"value": "available"
},
{
"name": "core:DiscreteRSSILevelState",
"type": 3,
"value": "low"
},
{
"name": "core:RSSILevelState",
"type": 2,
"value": 32.0
}
],
"attributes": [
{
"name": "core:FirmwareRevision",
"type": 3,
"value": "5127170B02"
},
{
"name": "core:Manufacturer",
"type": 3,
"value": "Somfy"
}
],
"available": true,
"enabled": true,
"placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3",
"widget": "CyclicSwingingGateOpener",
"type": 1,
"oid": "83371762-4f7c-4e1b-846b-d5b7f8e9aa53",
"uiClass": "Gate"
},
{
"creationTime": 1521964729000,
"lastUpdateTime": 1521964729000,
@@ -28,7 +28,7 @@
{
"type": 3,
"name": "core:NameState",
"value": "*"
"value": "Connexoon"
},
{
"type": 3,
@@ -41,7 +41,7 @@
"value": "192.168.150.8"
}
],
"label": "*",
"label": "Connexoon",
"subsystemId": 0,
"attributes": [],
"enabled": true,
@@ -381,7 +381,7 @@
"synced": true,
"type": 5,
"states": [],
"label": "* (*)",
"label": "Connexoon (IO)",
"subsystemId": 0,
"attributes": [
{
@@ -3,7 +3,7 @@
{
"subsystemId": 0,
"synced": true,
"label": "**",
"label": "TaHoma",
"states": [
{
"name": "core:CountryCodeState",
@@ -23,7 +23,7 @@
{
"name": "core:NameState",
"type": 3,
"value": "**"
"value": "TaHoma"
}
],
"attributes": [],
@@ -120,7 +120,7 @@
{
"subsystemId": 0,
"synced": true,
"label": "** *(**/**)*",
"label": "TaHoma (WiFi/Ethernet)",
"states": [
{
"name": "internal:WifiModeState",
@@ -3331,7 +3331,7 @@
{
"subsystemId": 0,
"synced": true,
"label": "** *(**)*",
"label": "TaHoma (IO)",
"states": [],
"attributes": [],
"available": true,
@@ -5488,7 +5488,7 @@
{
"deviceURL": "zigbee://1234-5678-6508/65535",
"synced": true,
"label": "** *(**)*",
"label": "TaHoma (Zigbee)",
"states": [],
"attributes": [],
"available": true,
@@ -667,7 +667,7 @@
"widgetName": "Pod"
},
"controllableName": "internal:PodV3Component",
"label": "**",
"label": "TaHoma",
"enabled": true,
"type": 1,
"subsystemId": 0,
@@ -677,7 +677,7 @@
{
"type": 3,
"name": "core:NameState",
"value": "**"
"value": "TaHoma"
},
{
"type": 3,
@@ -864,7 +864,7 @@
]
},
"controllableName": "zigbee:OnOffComponent",
"label": "**",
"label": "Zigbee Plug",
"enabled": true,
"type": 1,
"subsystemId": 0,
@@ -1005,7 +1005,7 @@
]
},
"controllableName": "zigbee:StackV3Component",
"label": "** *(**/**)*",
"label": "Zigbee Plug",
"enabled": true,
"type": 5,
"subsystemId": 0,
@@ -1025,7 +1025,7 @@
"states": []
},
"controllableName": "zigbee:TransceiverV3_0Component",
"label": "** *(**)*",
"label": "TaHoma (Zigbee)",
"enabled": true,
"type": 5,
"subsystemId": 0,
@@ -1070,7 +1070,7 @@
"widgetName": "Wifi"
},
"controllableName": "internal:WifiComponent",
"label": "** *(**/**)*",
"label": "TaHoma (WiFi/Ethernet)",
"enabled": true,
"type": 1,
"subsystemId": 0,
@@ -1154,7 +1154,7 @@
]
},
"controllableName": "zigbee:ZigbeeNetworkNode",
"label": "**",
"label": "Zigbee Node",
"enabled": true,
"type": 6,
"subsystemId": 0,
@@ -1259,7 +1259,7 @@
]
},
"controllableName": "zigbee:StackV3Component",
"label": "** *(**/**)*",
"label": "Zigbee Node",
"enabled": true,
"type": 5,
"subsystemId": 0,
@@ -1322,7 +1322,7 @@
]
},
"controllableName": "zigbee:ZigbeeNetworkNode",
"label": "** *(**/**)*",
"label": "TaHoma (Zigbee/ZDO)",
"enabled": true,
"type": 6,
"subsystemId": 0,
@@ -1715,7 +1715,7 @@
"widgetName": "IOStack"
},
"controllableName": "io:StackComponent",
"label": "** *(**)*",
"label": "TaHoma (IO)",
"enabled": true,
"type": 5,
"subsystemId": 0,
@@ -882,7 +882,7 @@
"synced": true,
"type": 5,
"states": [],
"label": "** *(**)*",
"label": "TaHoma (IO)",
"subsystemId": 0,
"attributes": [
{
@@ -947,7 +947,7 @@
{
"type": 3,
"name": "core:NameState",
"value": "** **"
"value": "TaHoma Alarm"
},
{
"type": 3,
@@ -970,7 +970,7 @@
"value": 30
}
],
"label": "**",
"label": "TaHoma",
"subsystemId": 0,
"attributes": [],
"enabled": true,
@@ -1339,7 +1339,7 @@
{
"type": 3,
"name": "core:NameState",
"value": "**"
"value": "TaHoma"
},
{
"type": 3,
@@ -1367,7 +1367,7 @@
"value": "online"
}
],
"label": "** **",
"label": "TaHoma Premium",
"subsystemId": 0,
"attributes": [],
"enabled": true,
@@ -1478,7 +1478,7 @@
{
"type": 3,
"name": "core:NameState",
"value": "** **"
"value": "Hallway Light"
},
{
"type": 3,
@@ -1496,7 +1496,7 @@
"value": 60
}
],
"label": "** **",
"label": "Hallway Light",
"subsystemId": 0,
"attributes": [
{
File diff suppressed because it is too large Load Diff
@@ -1242,6 +1242,59 @@
'state': 'closed',
})
# ---
# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.cyclic_garage_door-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.cyclic_garage_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <CoverDeviceClass.GARAGE: 'garage'>,
'original_icon': None,
'original_name': None,
'platform': 'overkiz',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 3>,
'translation_key': None,
'unique_id': 'io://1234-1234-6233/6416929',
'unit_of_measurement': None,
})
# ---
# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.cyclic_garage_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'garage',
'friendly_name': 'Cyclic Garage Door',
'is_closed': None,
'supported_features': <CoverEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'cover.cyclic_garage_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.dining_room_shutter-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -1993,6 +2046,60 @@
'state': 'closed',
})
# ---
# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.rts_gate-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.rts_gate',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <CoverDeviceClass.GATE: 'gate'>,
'original_icon': None,
'original_name': None,
'platform': 'overkiz',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 3>,
'translation_key': None,
'unique_id': 'rts://1234-1234-6233/16730717',
'unit_of_measurement': None,
})
# ---
# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.rts_gate-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'assumed_state': True,
'device_class': 'gate',
'friendly_name': 'RTS Gate',
'is_closed': None,
'supported_features': <CoverEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'cover.rts_gate',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.side_garage_door-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -2154,6 +2261,59 @@
'state': 'closed',
})
# ---
# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.swinging_gate-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.swinging_gate',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <CoverDeviceClass.GATE: 'gate'>,
'original_icon': None,
'original_name': None,
'platform': 'overkiz',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 3>,
'translation_key': None,
'unique_id': 'io://1234-1234-8983/1959462',
'unit_of_measurement': None,
})
# ---
# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.swinging_gate-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'gate',
'friendly_name': 'Swinging Gate',
'is_closed': None,
'supported_features': <CoverEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'cover.swinging_gate',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_cover_entities_snapshot[local_somfy_connexoon_europe.json][cover.terrace_awning-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -0,0 +1,151 @@
# serializer version: 1
# name: test_scene_entities_snapshot[scenarios/cozytouch.json][scene.label_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'scene',
'entity_category': None,
'entity_id': 'scene.label_1',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Label 1',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Label 1',
'platform': 'overkiz',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '0a0589bb-9471-4667-a2a9-4602beb2a2e8',
'unit_of_measurement': None,
})
# ---
# name: test_scene_entities_snapshot[scenarios/cozytouch.json][scene.label_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Label 1',
}),
'context': <ANY>,
'entity_id': 'scene.label_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_scene_entities_snapshot[scenarios/cozytouch.json][scene.label_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'scene',
'entity_category': None,
'entity_id': 'scene.label_2',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Label 2',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Label 2',
'platform': 'overkiz',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '50d39fc3-9368-49c9-bcbf-c74f3ce1678a',
'unit_of_measurement': None,
})
# ---
# name: test_scene_entities_snapshot[scenarios/cozytouch.json][scene.label_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Label 2',
}),
'context': <ANY>,
'entity_id': 'scene.label_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_scene_entities_snapshot[scenarios/tahoma_switch.json][scene.i_m_arriving-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'scene',
'entity_category': None,
'entity_id': 'scene.i_m_arriving',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': "I'm arriving",
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': "I'm arriving",
'platform': 'overkiz',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'd1b689e1-4087-473d-b726-d3b24770856f',
'unit_of_measurement': None,
})
# ---
# name: test_scene_entities_snapshot[scenarios/tahoma_switch.json][scene.i_m_arriving-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': "I'm arriving",
}),
'context': <ANY>,
'entity_id': 'scene.i_m_arriving',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+29
View File
@@ -129,6 +129,21 @@ PARTIAL_GARAGE_DOOR = FixtureDevice(
"io://1234-1234-6233/7433515",
"cover.partial_garage_door",
)
RTS_GATE_4T = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"rts://1234-1234-6233/16730717",
"cover.rts_gate",
)
CYCLIC_GARAGE_DOOR = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-6233/6416929",
"cover.cyclic_garage_door",
)
CYCLIC_SWINGING_GATE = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-8983/1959462",
"cover.swinging_gate",
)
SLIDING_DISCRETE_GATE = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-6233/16730051",
@@ -187,6 +202,9 @@ async def test_cover_entities_snapshot(
(DYNAMIC_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(DYNAMIC_GARAGE_DOOR_OGP, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(DYNAMIC_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(RTS_GATE_4T, SERVICE_OPEN_COVER, "cycle", [0], CoverState.OPENING),
(CYCLIC_GARAGE_DOOR, SERVICE_OPEN_COVER, "cycle", None, CoverState.OPENING),
(CYCLIC_SWINGING_GATE, SERVICE_OPEN_COVER, "cycle", None, CoverState.OPENING),
(SLIDING_DISCRETE_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(PARTIAL_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(
@@ -211,6 +229,11 @@ async def test_cover_entities_snapshot(
CoverState.CLOSING,
),
(DYNAMIC_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
# Cycle command is used for both open and close; device reports OPENING
# since the RTS protocol has no directional feedback.
(RTS_GATE_4T, SERVICE_CLOSE_COVER, "cycle", [0], CoverState.OPENING),
(CYCLIC_GARAGE_DOOR, SERVICE_CLOSE_COVER, "cycle", None, CoverState.OPENING),
(CYCLIC_SWINGING_GATE, SERVICE_CLOSE_COVER, "cycle", None, CoverState.OPENING),
(SLIDING_DISCRETE_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
(PARTIAL_GARAGE_DOOR, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
(
@@ -318,6 +341,9 @@ async def test_cover_entities_snapshot(
"open-dynamic-garage-door",
"open-dynamic-garage-door-ogp",
"open-dynamic-gate",
"open-rts-gate-4t",
"open-cyclic-garage-door",
"open-cyclic-swinging-gate",
"open-sliding-discrete-gate",
"open-partial-garage-door",
"open-up-down-bioclimatic-pergola",
@@ -330,6 +356,9 @@ async def test_cover_entities_snapshot(
"close-dynamic-garage-door",
"close-dynamic-garage-door-ogp",
"close-dynamic-gate",
"close-rts-gate-4t",
"close-cyclic-garage-door",
"close-cyclic-swinging-gate",
"close-sliding-discrete-gate",
"close-partial-garage-door",
"close-up-down-bioclimatic-pergola",
+128
View File
@@ -0,0 +1,128 @@
"""Tests for the Overkiz scene platform."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import humps
from pyoverkiz.models import Scenario
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.overkiz.const import DOMAIN
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import MockOverkizClient, SetupOverkizIntegration
from tests.common import load_json_array_fixture, snapshot_platform
SCENARIO_FIXTURES = [
"scenarios/cozytouch.json",
"scenarios/tahoma_switch.json",
]
def load_scenarios_fixture(fixture: str) -> list[Scenario]:
"""Load scenario fixture and return Scenario objects."""
data = load_json_array_fixture(fixture, DOMAIN)
return [Scenario(**humps.decamelize(s)) for s in data]
@pytest.fixture(autouse=True)
def fixture_platforms() -> Generator[None]:
"""Limit platforms to scene only."""
with patch("homeassistant.components.overkiz.PLATFORMS", [Platform.SCENE]):
yield
@pytest.mark.parametrize("fixture", SCENARIO_FIXTURES)
async def test_scene_entities_snapshot(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
entity_registry: er.EntityRegistry,
mock_client: MockOverkizClient,
snapshot: SnapshotAssertion,
fixture: str,
) -> None:
"""Test scene entities via snapshot for each fixture."""
scenarios = load_scenarios_fixture(fixture)
mock_client.get_scenarios = AsyncMock(return_value=scenarios)
config_entry = await setup_overkiz_integration()
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
async def test_scene_activate(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
) -> None:
"""Test activating a scene calls execute_scenario with the correct OID."""
scenarios = load_scenarios_fixture("scenarios/tahoma_switch.json")
mock_client.get_scenarios = AsyncMock(return_value=scenarios)
mock_client.execute_scenario = AsyncMock(return_value="exec-1")
await setup_overkiz_integration()
await hass.services.async_call(
SCENE_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "scene.i_m_arriving"},
blocking=True,
)
mock_client.execute_scenario.assert_awaited_once_with(
"d1b689e1-4087-473d-b726-d3b24770856f"
)
async def test_scene_activate_multiple(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
) -> None:
"""Test activating different scenes uses the correct OID for each."""
scenarios = load_scenarios_fixture("scenarios/cozytouch.json")
mock_client.get_scenarios = AsyncMock(return_value=scenarios)
mock_client.execute_scenario = AsyncMock(return_value="exec-1")
await setup_overkiz_integration()
await hass.services.async_call(
SCENE_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "scene.label_1"},
blocking=True,
)
mock_client.execute_scenario.assert_awaited_once_with(
"0a0589bb-9471-4667-a2a9-4602beb2a2e8"
)
mock_client.execute_scenario.reset_mock()
await hass.services.async_call(
SCENE_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "scene.label_2"},
blocking=True,
)
mock_client.execute_scenario.assert_awaited_once_with(
"50d39fc3-9368-49c9-bcbf-c74f3ce1678a"
)
async def test_no_scenes_when_empty(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
) -> None:
"""Test no scene entities are created when there are no scenarios."""
await setup_overkiz_integration()
states = hass.states.async_entity_ids(SCENE_DOMAIN)
assert len(states) == 0
+65
View File
@@ -19,6 +19,7 @@ from homeassistant.components import (
cover,
device_tracker,
fan,
geo_location,
humidifier,
input_boolean,
input_number,
@@ -90,6 +91,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfEnergy,
UnitOfLength,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
@@ -1341,6 +1343,39 @@ async def test_person(
).withValue(0.0).assert_in_metrics(body)
@pytest.mark.parametrize("namespace", [""])
async def test_geo_location(
client: ClientSessionGenerator,
geo_location_entities: dict[str, er.RegistryEntry],
) -> None:
"""Test prometheus metrics for geo_location."""
body = await generate_latest_metrics(client)
EntityMetric(
metric_name="geo_location_distance_meters",
domain="geo_location",
friendly_name="Earthquake",
entity="geo_location.earthquake",
source="usgs_earthquakes",
).withValue(25500.0).assert_in_metrics(body)
EntityMetric(
metric_name="geo_location_latitude_degrees",
domain="geo_location",
friendly_name="Earthquake",
entity="geo_location.earthquake",
source="usgs_earthquakes",
).withValue(34.05).assert_in_metrics(body)
EntityMetric(
metric_name="geo_location_longitude_degrees",
domain="geo_location",
friendly_name="Earthquake",
entity="geo_location.earthquake",
source="usgs_earthquakes",
).withValue(-118.25).assert_in_metrics(body)
@pytest.mark.parametrize("namespace", [""])
async def test_counter(
client: ClientSessionGenerator, counter_entities: dict[str, er.RegistryEntry]
@@ -2756,6 +2791,36 @@ async def device_tracker_fixture(
return data
@pytest.fixture(name="geo_location_entities")
async def geo_location_fixture(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> dict[str, er.RegistryEntry]:
"""Simulate geo_location entities."""
data = {}
geo_location_1 = entity_registry.async_get_or_create(
domain=geo_location.DOMAIN,
platform="test",
unique_id="geo_location_1",
suggested_object_id="earthquake",
original_name="Earthquake",
)
set_state_with_entry(
hass,
geo_location_1,
25.5,
{
"source": "usgs_earthquakes",
"latitude": 34.05,
"longitude": -118.25,
"unit_of_measurement": UnitOfLength.KILOMETERS,
},
)
data["geo_location_1"] = geo_location_1
await hass.async_block_till_done()
return data
@pytest.fixture(name="counter_entities")
async def counter_fixture(
hass: HomeAssistant, entity_registry: er.EntityRegistry
+39 -33
View File
@@ -41,6 +41,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.trigger_template_entity import CONF_PICTURE
from homeassistant.setup import async_setup_component
@@ -308,18 +309,20 @@ async def test_turn_on_status_not_ok(hass: HomeAssistant) -> None:
await _async_setup_test_switch(hass)
route = respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.foo"},
blocking=True,
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.foo"},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "turn_on_failed"
last_call = route.calls[-1]
last_request: httpx.Request = last_call.request
assert last_request.content.decode() == "ON"
assert hass.states.get("switch.foo").state == STATE_UNKNOWN
@respx.mock
@@ -328,15 +331,16 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None:
await _async_setup_test_switch(hass)
respx.post(RESOURCE).mock(side_effect=httpx.TimeoutException(""))
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.foo"},
blocking=True,
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.foo"},
blocking=True,
)
assert hass.states.get("switch.foo").state == STATE_UNKNOWN
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "error_communicating"
@respx.mock
@@ -370,20 +374,21 @@ async def test_turn_off_status_not_ok(hass: HomeAssistant) -> None:
await _async_setup_test_switch(hass)
route = respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "switch.foo"},
blocking=True,
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "switch.foo"},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "turn_off_failed"
last_call = route.calls[-1]
last_request: httpx.Request = last_call.request
assert last_request.content.decode() == "OFF"
assert hass.states.get("switch.foo").state == STATE_UNKNOWN
@respx.mock
async def test_turn_off_timeout(hass: HomeAssistant) -> None:
@@ -391,15 +396,16 @@ async def test_turn_off_timeout(hass: HomeAssistant) -> None:
await _async_setup_test_switch(hass)
respx.post(RESOURCE).mock(side_effect=httpx.TimeoutException(""))
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "switch.foo"},
blocking=True,
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "switch.foo"},
blocking=True,
)
assert hass.states.get("switch.foo").state == STATE_UNKNOWN
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "error_communicating"
@respx.mock
@@ -48,6 +48,8 @@ def device_fixtures() -> list[str]:
"XT-LT050",
"XT-LT100",
"XT-LT200",
"XT-PL50",
"XT-PL100",
]
@@ -0,0 +1,11 @@
{
"id": "dev_plug_002",
"name": "Smart Plug 100",
"type": "plug",
"model": "XT-PL100",
"version": "1.0.0",
"online": true,
"status": {
"on": false
}
}
@@ -0,0 +1,11 @@
{
"id": "dev_plug_001",
"name": "Smart Plug 50",
"type": "plug",
"model": "XT-PL50",
"version": "1.0.0",
"online": true,
"status": {
"on": true
}
}
@@ -92,3 +92,65 @@
'via_device_id': None,
})
# ---
# name: test_devices[XT-PL50]
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_plug_001',
),
}),
'labels': set({
}),
'manufacturer': 'Xthings',
'model': 'XT-PL50',
'model_id': None,
'name': 'Smart Plug 50',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '1.0.0',
'via_device_id': None,
})
# ---
# name: test_devices[XT-PL100]
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_plug_002',
),
}),
'labels': set({
}),
'manufacturer': 'Xthings',
'model': 'XT-PL100',
'model_id': None,
'name': 'Smart Plug 100',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '1.0.0',
'via_device_id': None,
})
# ---
@@ -0,0 +1,101 @@
# serializer version: 1
# name: test_switches[switch.smart_plug_50-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.smart_plug_50',
'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_plug_001',
'unit_of_measurement': None,
})
# ---
# name: test_switches[switch.smart_plug_50-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Smart Plug 50',
}),
'context': <ANY>,
'entity_id': 'switch.smart_plug_50',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[switch.smart_plug_100-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.smart_plug_100',
'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_plug_002',
'unit_of_measurement': None,
})
# ---
# name: test_switches[switch.smart_plug_100-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Smart Plug 100',
}),
'context': <ANY>,
'entity_id': 'switch.smart_plug_100',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+7 -3
View File
@@ -1,6 +1,6 @@
"""Tests for Xthings Cloud light platform."""
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -18,6 +18,7 @@ from homeassistant.const import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -35,9 +36,12 @@ async def test_lights(
snapshot: SnapshotAssertion,
) -> None:
"""Test light entities are created correctly."""
await setup_integration(hass, mock_config_entry)
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.LIGHT]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)
@pytest.mark.parametrize(
@@ -0,0 +1,136 @@
"""Tests for Xthings Cloud switch platform."""
from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
Platform,
)
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_switches(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: AsyncMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test switch entities are created correctly."""
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)
@pytest.mark.parametrize(
("entity_id", "device_id", "device_type", "service", "method"),
[
(
"switch.smart_plug_50",
"dev_plug_001",
"plug",
SERVICE_TURN_ON,
"async_plug_on",
),
(
"switch.smart_plug_50",
"dev_plug_001",
"plug",
SERVICE_TURN_OFF,
"async_plug_off",
),
(
"switch.smart_plug_100",
"dev_plug_002",
"switch",
SERVICE_TURN_ON,
"async_switch_on",
),
(
"switch.smart_plug_100",
"dev_plug_002",
"switch",
SERVICE_TURN_OFF,
"async_switch_off",
),
],
)
async def test_turn_on_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: AsyncMock,
entity_id: str,
device_id: str,
device_type: str,
service: str,
method: str,
) -> None:
"""Test turning on and off a device."""
get_device_by_id(mock_api_client, device_id)["type"] = device_type
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
getattr(mock_api_client, method).assert_called_once_with(device_id)
async def test_plug_unavailable_when_offline(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: AsyncMock,
) -> None:
"""Test plug shows unavailable when device is offline."""
get_device_by_id(mock_api_client, "dev_plug_001")["online"] = False
await setup_integration(hass, mock_config_entry)
state = hass.states.get("switch.smart_plug_50")
assert state is not None
assert state.state == STATE_UNAVAILABLE
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("switch.smart_plug_100")
assert state is not None
assert state.state == STATE_OFF
mock_websocket.call_args[1]["on_device_status"](
"dev_plug_002",
{
"on": True,
},
)
state = hass.states.get("switch.smart_plug_100")
assert state is not None
assert state.state == STATE_ON
+1 -1
View File
@@ -1081,7 +1081,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
+53
View File
@@ -1,6 +1,7 @@
"""Test config validators."""
from collections import OrderedDict
from collections.abc import Callable
from datetime import date, datetime, timedelta
import enum
from functools import partial
@@ -2031,3 +2032,55 @@ def test_stop_action_schema_error_false_with_response() -> None:
# no error with response_variable should work
config = schema({"stop": "Done", "response_variable": "result"})
assert config["response_variable"] == "result"
_COMMENT_SCHEMA_PARAMS = [
pytest.param(
cv.TRIGGER_BASE_SCHEMA,
{"platform": "event"},
id="trigger_base",
),
pytest.param(
cv.CONDITION_SCHEMA,
{"condition": "state", "entity_id": "sun.sun", "state": "above_horizon"},
id="condition",
),
pytest.param(
cv.script_action,
{"action": "test.foo"},
id="script_action",
),
]
@pytest.mark.parametrize(("validator", "base_config"), _COMMENT_SCHEMA_PARAMS)
@pytest.mark.usefixtures("hass")
def test_base_schemas_accept_comment(
validator: Callable[[dict[str, Any]], dict[str, Any]],
base_config: dict[str, Any],
) -> None:
"""Test that the comment field is accepted and stripped from the output."""
validated = validator({**base_config, "comment": "Single line"})
assert "comment" not in validated
@pytest.mark.parametrize(("validator", "base_config"), _COMMENT_SCHEMA_PARAMS)
@pytest.mark.parametrize(
"invalid_comment",
[
pytest.param(None, id="none"),
pytest.param(42, id="int"),
pytest.param(True, id="bool"),
pytest.param([], id="list"),
pytest.param({}, id="dict"),
],
)
@pytest.mark.usefixtures("hass")
def test_base_schemas_reject_invalid_comment(
validator: Callable[[dict[str, Any]], dict[str, Any]],
base_config: dict[str, Any],
invalid_comment: Any,
) -> None:
"""Test that script, condition, trigger base schemas reject non-string comments."""
with pytest.raises(vol.Invalid):
validator({**base_config, "comment": invalid_comment})