mirror of
https://github.com/home-assistant/core.git
synced 2026-05-20 07:45:09 +02:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 709c750abe | |||
| 85014a469a | |||
| f6c11b5657 | |||
| e17e823459 | |||
| a41cf33ffd | |||
| 71425dd19f | |||
| eea08a0457 | |||
| 00132b4416 | |||
| 6b9efed899 | |||
| b0b6b46152 | |||
| 044ef25cb6 | |||
| b633fbcf07 | |||
| 7c9b6ad2a8 | |||
| 89d9fff1e9 | |||
| e0af3dfa99 | |||
| 4fb3ad102c | |||
| dc2ab012fa | |||
| 140fef6915 | |||
| 822a567ca9 | |||
| aa8904b0cd | |||
| e9f9194b7b | |||
| d0f4cba32c | |||
| beba530a9a | |||
| 5d3fd5a487 | |||
| bed6af2ef2 | |||
| 2b20b69928 | |||
| d5d50ac11a | |||
| ba5a62ec2a | |||
| 88ca0faea0 | |||
| a333f31d44 | |||
| 8854ad5765 |
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
Generated
+1
-9
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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",
|
||||
@@ -791,6 +791,7 @@ ignore = [
|
||||
"D406", # Section name should end with a newline
|
||||
"D407", # Section name underlining
|
||||
"D417", # Missing argument descriptions in docstring - to allow documenting only non-obvious parameters
|
||||
"E501", # line too long
|
||||
|
||||
"PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives
|
||||
"PLR0911", # Too many return statements ({returns} > {max_returns})
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user