mirror of
https://github.com/home-assistant/core.git
synced 2026-06-14 21:22:13 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a10c8ed3a | |||
| b3309ef169 | |||
| caaf5f9715 | |||
| 7ce7de3650 | |||
| 2c14c6be75 | |||
| e020f338ab | |||
| c85c2c4cd3 | |||
| c4e618e990 | |||
| 5efde60d21 | |||
| d9dc10ed81 | |||
| cb6ae03d21 | |||
| 915b78473c | |||
| 559006ba19 | |||
| bad2eed9fe | |||
| 9f1a079688 | |||
| 965a96b957 | |||
| d5791ae8b4 | |||
| 7b561934ea | |||
| cf60690fb7 |
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.3.1",
|
||||
"aiodiscover==3.3.2",
|
||||
"cached-ipaddress==1.1.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.6.0"],
|
||||
"requirements": ["eheimdigital==1.7.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"data_description_password": "Password for the FRITZ!Box.",
|
||||
"data_description_port": "Leave empty to use the default port.",
|
||||
"data_description_ssl": "Use SSL to connect to the FRITZ!Box.",
|
||||
"data_description_username": "Username for the FRITZ!Box.",
|
||||
"data_description_username": "Username for the FRITZ!Box. FRITZ!Powerline devices ignore this information and accept any value.",
|
||||
"data_feature_device_tracking": "Enable network device tracking"
|
||||
},
|
||||
"config": {
|
||||
|
||||
@@ -27,6 +27,7 @@ from homematicip.device import (
|
||||
PassageDetector,
|
||||
PresenceDetectorIndoor,
|
||||
RoomControlDeviceAnalog,
|
||||
RotaryHandleSensor,
|
||||
SmokeDetector,
|
||||
SoilMoistureSensorInterface,
|
||||
SwitchMeasuring,
|
||||
@@ -166,6 +167,7 @@ ILLUMINATION_DEVICE_ATTRIBUTES = {
|
||||
}
|
||||
|
||||
TILT_STATE_VALUES = ["neutral", "tilted", "non_neutral"]
|
||||
WINDOW_STATE_VALUES = ["open", "closed", "tilted"]
|
||||
|
||||
|
||||
def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
|
||||
@@ -204,6 +206,9 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
|
||||
RoomControlDeviceAnalog: lambda device: [
|
||||
HomematicipTemperatureSensor(hap, device),
|
||||
],
|
||||
RotaryHandleSensor: lambda device: [
|
||||
HomematicipWindowStateSensor(hap, device),
|
||||
],
|
||||
LightSensor: lambda device: [
|
||||
HomematicipIlluminanceSensor(hap, device),
|
||||
],
|
||||
@@ -498,6 +503,24 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity):
|
||||
return state_attr
|
||||
|
||||
|
||||
class HomematicipWindowStateSensor(HomematicipGenericEntity, SensorEntity):
|
||||
"""Representation of the HomematicIP rotary handle window state sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_options = WINDOW_STATE_VALUES
|
||||
_attr_translation_key = "window_state"
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device: RotaryHandleSensor) -> None:
|
||||
"""Initialize the window state sensor."""
|
||||
super().__init__(hap, device, feature_id="window_state")
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state."""
|
||||
window_state = getattr(self._device, "windowState", None)
|
||||
return window_state.lower() if window_state is not None else None
|
||||
|
||||
|
||||
class HomematicipFloorTerminalBlockMechanicChannelValve(
|
||||
HomematicipGenericEntity, SensorEntity
|
||||
):
|
||||
|
||||
@@ -98,6 +98,14 @@
|
||||
"non_neutral": "Non-neutral",
|
||||
"tilted": "Tilted"
|
||||
}
|
||||
},
|
||||
"window_state": {
|
||||
"name": "Window state",
|
||||
"state": {
|
||||
"closed": "[%key:common::state::closed%]",
|
||||
"open": "[%key:common::state::open%]",
|
||||
"tilted": "Tilted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
from huum.const import SaunaStatus
|
||||
|
||||
from homeassistant.components.number import NumberEntity
|
||||
from homeassistant.components.number import NumberDeviceClass, NumberEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -34,7 +34,9 @@ class HuumSteamer(HuumBaseEntity, NumberEntity):
|
||||
"""Representation of a steamer."""
|
||||
|
||||
_attr_translation_key = "humidity"
|
||||
_attr_native_max_value = 10
|
||||
_attr_device_class = NumberDeviceClass.HUMIDITY
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
_attr_native_max_value = 40
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_step = 1
|
||||
|
||||
@@ -47,7 +49,7 @@ class HuumSteamer(HuumBaseEntity, NumberEntity):
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
return self.coordinator.data.target_humidity
|
||||
return self.coordinator.data.humidity
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update the current value."""
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
from jvcprojector import Command, JvcProjector
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, NAME
|
||||
@@ -27,8 +27,12 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
|
||||
super().__init__(coordinator, command)
|
||||
|
||||
self._attr_unique_id = coordinator.unique_id
|
||||
# The config entry unique id is the device's formatted MAC address (set
|
||||
# from the projector's MAC in the config flow), so it doubles as the
|
||||
# network MAC connection.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)},
|
||||
name=NAME,
|
||||
model=self.device.model,
|
||||
manufacturer=MANUFACTURER,
|
||||
|
||||
@@ -99,6 +99,7 @@ SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
# support dry mode.
|
||||
(0x0001, 0x0108),
|
||||
(0x0001, 0x010A),
|
||||
(0x0001, 0x013F),
|
||||
(0x1209, 0x8000),
|
||||
(0x1209, 0x8001),
|
||||
(0x1209, 0x8002),
|
||||
@@ -138,6 +139,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
# support fan-only mode.
|
||||
(0x0001, 0x0108),
|
||||
(0x0001, 0x010A),
|
||||
(0x0001, 0x013F),
|
||||
(0x118C, 0x2022),
|
||||
(0x1209, 0x8000),
|
||||
(0x1209, 0x8001),
|
||||
|
||||
@@ -165,7 +165,6 @@ class MieleFan(MieleEntity, FanEntity):
|
||||
try:
|
||||
await self.api.send_action(self._device_id, {POWER_ON: True})
|
||||
except ClientResponseError as ex:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
@@ -183,7 +182,6 @@ class MieleFan(MieleEntity, FanEntity):
|
||||
try:
|
||||
await self.api.send_action(self._device_id, {POWER_OFF: True})
|
||||
except ClientResponseError as ex:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
@@ -57,6 +57,7 @@ class MyStromLight(LightEntity):
|
||||
self._attr_hs_color = 0, 0
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)},
|
||||
name=name,
|
||||
manufacturer=MANUFACTURER,
|
||||
sw_version=self._bulb.firmware,
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"cloudapp/QBUSMQTTGW/+/state"
|
||||
],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["qbusmqttapi==1.5.0"]
|
||||
"requirements": ["qbusmqttapi==1.5.1"]
|
||||
}
|
||||
|
||||
@@ -34,11 +34,7 @@ rules:
|
||||
docs-removal-instructions: todo
|
||||
test-before-setup: done
|
||||
docs-high-level-description: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
All config flow tests should finish with CREATE_ENTRY and ABORT to
|
||||
test they are able to recover from errors
|
||||
config-flow-test-coverage: done
|
||||
docs-actions: done
|
||||
runtime-data: done
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==5.14.1",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
"python-roborock==5.14.2",
|
||||
"vacuum-map-parser-roborock==0.1.5"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ PLATFORMS_BY_TYPE = {
|
||||
],
|
||||
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
|
||||
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
|
||||
SupportedModels.STANDING_FAN.value: [Platform.SENSOR],
|
||||
SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.S20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
@@ -207,6 +208,7 @@ CLASS_BY_DEVICE = {
|
||||
SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch,
|
||||
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
|
||||
SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan,
|
||||
SupportedModels.STANDING_FAN.value: switchbot.SwitchbotStandingFan,
|
||||
SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.S20_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
|
||||
@@ -71,6 +71,7 @@ class SupportedModels(StrEnum):
|
||||
LOCK_VISION = "lock_vision"
|
||||
LOCK_PRO_WIFI = "lock_pro_wifi"
|
||||
WEATHER_STATION = "weather_station"
|
||||
STANDING_FAN = "standing_fan"
|
||||
|
||||
|
||||
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -120,6 +121,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.LOCK_VISION_PRO: SupportedModels.LOCK_VISION_PRO,
|
||||
SwitchbotModel.LOCK_VISION: SupportedModels.LOCK_VISION,
|
||||
SwitchbotModel.LOCK_PRO_WIFI: SupportedModels.LOCK_PRO_WIFI,
|
||||
SwitchbotModel.STANDING_FAN: SupportedModels.STANDING_FAN,
|
||||
}
|
||||
|
||||
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientError
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.exceptions import (
|
||||
Forbidden,
|
||||
@@ -81,6 +82,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from e
|
||||
except ClientError as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
vehicles: list[TessieVehicleData] = []
|
||||
for vehicle in state_of_all_vehicles["results"]:
|
||||
@@ -124,13 +127,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
|
||||
|
||||
try:
|
||||
scopes = await tessie.scopes()
|
||||
except TeslaFleetError as e:
|
||||
except (TeslaFleetError, ClientError) as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
if Scope.ENERGY_DEVICE_DATA in scopes:
|
||||
try:
|
||||
products = (await tessie.products())["response"]
|
||||
except TeslaFleetError as e:
|
||||
except (TeslaFleetError, ClientError) as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
for product in products:
|
||||
@@ -154,7 +157,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
|
||||
except (InvalidToken, Forbidden, SubscriptionRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
except TeslaFleetError as e:
|
||||
raise ConfigEntryNotReady(e.message) from e
|
||||
raise ConfigEntryNotReady(getattr(e, "message", str(e))) from e
|
||||
except ClientError as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
powerwall = (
|
||||
product["components"]["battery"] or product["components"]["solar"]
|
||||
|
||||
@@ -67,7 +67,4 @@ class VistapoolLEDPulseButton(VistapoolEntity, ButtonEntity):
|
||||
translation_key="set_failed",
|
||||
translation_placeholders={"entity": self.entity_id},
|
||||
) from err
|
||||
# Optimistically reflect the just-written value so a rapid second press
|
||||
# doesn't read the stale off-state before the Firestore push round-trips.
|
||||
self.coordinator.data.setdefault("light", {})["status"] = 1
|
||||
self.coordinator.async_set_updated_data(self.coordinator.data)
|
||||
self.coordinator.apply_optimistic(_LIGHT_STATUS_PATH, 1)
|
||||
|
||||
@@ -81,3 +81,22 @@ class VistapoolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
def get_value(self, path: str, default: Any = None) -> Any:
|
||||
"""Get nested data using dot-notation path."""
|
||||
return AquariteClient.get_value(self.data, path, default)
|
||||
|
||||
def apply_optimistic(self, value_path: str, value: Any) -> None:
|
||||
"""Reflect a just-written value before the Firestore push round-trips.
|
||||
|
||||
Hayward's cloud takes several seconds to acknowledge a write back
|
||||
through Firestore, which would make the UI feel laggy. Writing into
|
||||
coordinator.data after a successful REST call gives entities instant
|
||||
feedback; the next snapshot from Firestore overwrites it harmlessly.
|
||||
"""
|
||||
keys = value_path.split(".")
|
||||
target: dict[str, Any] = self.data
|
||||
for key in keys[:-1]:
|
||||
child = target.get(key)
|
||||
if not isinstance(child, dict):
|
||||
child = {}
|
||||
target[key] = child
|
||||
target = child
|
||||
target[keys[-1]] = value
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
@@ -71,3 +71,4 @@ class VistapoolLight(VistapoolEntity, LightEntity):
|
||||
translation_key="set_failed",
|
||||
translation_placeholders={"entity": self.entity_id},
|
||||
) from err
|
||||
self.coordinator.apply_optimistic(_VALUE_PATH, value)
|
||||
|
||||
@@ -233,3 +233,4 @@ class VistapoolNumber(VistapoolEntity, NumberEntity):
|
||||
translation_key="set_failed",
|
||||
translation_placeholders={"entity": self.entity_id},
|
||||
) from err
|
||||
self.coordinator.apply_optimistic(self.entity_description.value_path, raw)
|
||||
|
||||
@@ -9,7 +9,6 @@ from pythonxbox.api.provider.people.models import Person
|
||||
from pythonxbox.api.provider.titlehub.models import Title
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
@@ -17,12 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import XboxConfigEntry
|
||||
from .entity import (
|
||||
XboxBaseEntity,
|
||||
XboxBaseEntityDescription,
|
||||
check_deprecated_entity,
|
||||
profile_pic,
|
||||
)
|
||||
from .entity import XboxBaseEntity, XboxBaseEntityDescription, profile_pic
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -31,9 +25,7 @@ class XboxBinarySensor(StrEnum):
|
||||
"""Xbox binary sensor."""
|
||||
|
||||
ONLINE = "online"
|
||||
IN_PARTY = "in_party"
|
||||
IN_GAME = "in_game"
|
||||
IN_MULTIPLAYER = "in_multiplayer"
|
||||
HAS_GAME_PASS = "has_game_pass"
|
||||
|
||||
|
||||
@@ -81,21 +73,11 @@ SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = (
|
||||
entity_picture_fn=profile_pic,
|
||||
attributes_fn=profile_attributes,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.IN_PARTY,
|
||||
is_on_fn=lambda _: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.IN_GAME,
|
||||
translation_key=XboxBinarySensor.IN_GAME,
|
||||
is_on_fn=in_game,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.IN_MULTIPLAYER,
|
||||
is_on_fn=lambda _: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.HAS_GAME_PASS,
|
||||
translation_key=XboxBinarySensor.HAS_GAME_PASS,
|
||||
@@ -118,9 +100,6 @@ async def async_setup_entry(
|
||||
[
|
||||
XboxBinarySensorEntity(coordinator, entry.unique_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if check_deprecated_entity(
|
||||
hass, entry.unique_id, description, BINARY_SENSOR_DOMAIN
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -130,9 +109,6 @@ async def async_setup_entry(
|
||||
XboxBinarySensorEntity(coordinator, subentry.unique_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if subentry.unique_id
|
||||
and check_deprecated_entity(
|
||||
hass, subentry.unique_id, description, BINARY_SENSOR_DOMAIN
|
||||
)
|
||||
and subentry.unique_id in coordinator.data.presence
|
||||
and subentry.subentry_type == "friend"
|
||||
],
|
||||
|
||||
@@ -9,8 +9,6 @@ from pythonxbox.api.provider.smartglass.models import ConsoleType, SmartglassCon
|
||||
from pythonxbox.api.provider.titlehub.models import Title
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -40,7 +38,6 @@ class XboxBaseEntityDescription(EntityDescription):
|
||||
attributes_fn: Callable[[Person, Title | None], Mapping[str, Any] | None] | None = (
|
||||
None
|
||||
)
|
||||
deprecated: bool | None = None
|
||||
|
||||
|
||||
class XboxBaseEntity(CoordinatorEntity[XboxPresenceCoordinator]):
|
||||
@@ -145,26 +142,6 @@ class XboxConsoleBaseEntity(CoordinatorEntity[XboxConsoleStatusCoordinator]):
|
||||
return self.coordinator.data.get(self._console.id) is not None
|
||||
|
||||
|
||||
def check_deprecated_entity(
|
||||
hass: HomeAssistant,
|
||||
xuid: str,
|
||||
entity_description: XboxBaseEntityDescription,
|
||||
entity_domain: str,
|
||||
) -> bool:
|
||||
"""Check for deprecated entity and remove it."""
|
||||
if not entity_description.deprecated:
|
||||
return True
|
||||
ent_reg = er.async_get(hass)
|
||||
if entity_id := ent_reg.async_get_entity_id(
|
||||
entity_domain,
|
||||
DOMAIN,
|
||||
f"{xuid}_{entity_description.key}",
|
||||
):
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def to_https(image_url: str) -> str:
|
||||
"""Convert image URLs to secure URLs."""
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from pythonxbox.api.provider.smartglass.models import SmartglassConsole, Storage
|
||||
from pythonxbox.api.provider.titlehub.models import Title
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
EntityCategory,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -27,13 +26,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import XboxConfigEntry, XboxConsolesCoordinator
|
||||
from .entity import (
|
||||
MAP_MODEL,
|
||||
XboxBaseEntity,
|
||||
XboxBaseEntityDescription,
|
||||
check_deprecated_entity,
|
||||
to_https,
|
||||
)
|
||||
from .entity import MAP_MODEL, XboxBaseEntity, XboxBaseEntityDescription, to_https
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -58,8 +51,6 @@ class XboxSensor(StrEnum):
|
||||
|
||||
STATUS = "status"
|
||||
GAMER_SCORE = "gamer_score"
|
||||
ACCOUNT_TIER = "account_tier"
|
||||
GOLD_TENURE = "gold_tenure"
|
||||
LAST_ONLINE = "last_online"
|
||||
FOLLOWING = "following"
|
||||
FOLLOWER = "follower"
|
||||
@@ -200,16 +191,6 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
|
||||
value_fn=lambda x, _: x.gamer_score,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
XboxSensorEntityDescription(
|
||||
key=XboxSensor.ACCOUNT_TIER,
|
||||
value_fn=lambda _, __: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxSensorEntityDescription(
|
||||
key=XboxSensor.GOLD_TENURE,
|
||||
value_fn=lambda _, __: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxSensorEntityDescription(
|
||||
key=XboxSensor.LAST_ONLINE,
|
||||
translation_key=XboxSensor.LAST_ONLINE,
|
||||
@@ -304,9 +285,6 @@ async def async_setup_entry(
|
||||
[
|
||||
XboxSensorEntity(presence, config_entry.unique_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if check_deprecated_entity(
|
||||
hass, config_entry.unique_id, description, SENSOR_DOMAIN
|
||||
)
|
||||
]
|
||||
)
|
||||
for subentry_id, subentry in config_entry.subentries.items():
|
||||
@@ -315,9 +293,6 @@ async def async_setup_entry(
|
||||
XboxSensorEntity(presence, subentry.unique_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if subentry.unique_id
|
||||
and check_deprecated_entity(
|
||||
hass, subentry.unique_id, description, SENSOR_DOMAIN
|
||||
)
|
||||
and subentry.unique_id in presence.data.presence
|
||||
and subentry.subentry_type == "friend"
|
||||
],
|
||||
|
||||
@@ -214,6 +214,10 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
|
||||
|
||||
def log(self, level: int, msg: str, *args, **kwargs):
|
||||
"""Log a message."""
|
||||
if not _LOGGER.isEnabledFor(level):
|
||||
# Avoid building the prefixed message and args tuple for disabled
|
||||
# levels; this runs for every entity event via _handle_entity_events.
|
||||
return
|
||||
msg = f"%s: {msg}"
|
||||
args = (self.entity_id, *args)
|
||||
_LOGGER.log(level, msg, *args, **kwargs)
|
||||
|
||||
@@ -432,15 +432,6 @@ async def async_set_credential(
|
||||
translation_key="no_available_credential_slots",
|
||||
translation_placeholders={"credential_type": cred_type_str},
|
||||
)
|
||||
elif not 1 <= credential_slot <= type_cap.number_of_credential_slots:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="credential_slot_out_of_range",
|
||||
translation_placeholders={
|
||||
"credential_type": cred_type_str,
|
||||
"max_slot": str(type_cap.number_of_credential_slots),
|
||||
},
|
||||
)
|
||||
|
||||
status = await node.access_control.set_credential(
|
||||
user_id, credential_type, credential_slot, credential_data
|
||||
|
||||
@@ -322,9 +322,6 @@
|
||||
"credential_rejected_wrong_uuid": {
|
||||
"message": "The device rejected the credential because the user unique identifier does not match."
|
||||
},
|
||||
"credential_slot_out_of_range": {
|
||||
"message": "Credential slot for {credential_type} must be between 1 and {max_slot}."
|
||||
},
|
||||
"credential_type_not_supported": {
|
||||
"message": "Credential type {credential_type} is not supported on this device"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Automatically generated by gen_requirements_all.py, do not edit
|
||||
|
||||
aiodhcpwatcher==1.2.7
|
||||
aiodiscover==3.3.1
|
||||
aiodiscover==3.3.2
|
||||
aiodns==4.0.4
|
||||
aiogithubapi==26.0.0
|
||||
aiohttp-asyncmdnsresolver==0.2.0
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Plugin to encourage correct use of DOMAIN constants in tests."""
|
||||
"""Plugin to prevent incorrect use of Platform enum in tests.
|
||||
|
||||
This plugin checks for common test helper functions and methods
|
||||
where a domain is expected, and ensures the argument is not a Platform.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -30,9 +34,6 @@ _METHOD_CHECKS: list[tuple[str, str, ArgumentCheckInfo]] = [
|
||||
("hass.states", "async_entity_ids", ArgumentCheckInfo(0, "domain_filter", True)),
|
||||
]
|
||||
|
||||
_DOMAIN_CONSTANTS: set[str] = {"DOMAIN", "domain"}
|
||||
_DOMAIN_SUFFIXES: tuple[str, ...] = ("_DOMAIN", "_domain")
|
||||
|
||||
|
||||
def _check_call_node_domain_arguments(node: nodes.Call) -> nodes.NodeNG | None:
|
||||
"""Ensure the call node arguments are valid domain constant or variable.
|
||||
@@ -81,10 +82,14 @@ def _check_call_node_domain_argument(
|
||||
return None
|
||||
|
||||
|
||||
def _check_domain_argument(arg_node: nodes.NodeNG, allow_iterable: bool) -> bool:
|
||||
def _check_domain_argument(arg_node: nodes.NodeNG, allow_iterable: bool = True) -> bool:
|
||||
"""Ensure the argument node is a domain constant or variable.
|
||||
|
||||
We allow:
|
||||
We currently only disallow `Platform.Xyz`
|
||||
|
||||
The plugin can be extended in the future (see #173374) to improve
|
||||
consistency and only allow certain patterns for domain arguments,
|
||||
such as:
|
||||
- x.DOMAIN/x.domain attribute (including *_DOMAIN/*_domain)
|
||||
- DOMAIN/domain name (including *_DOMAIN/*_domain)
|
||||
- string literals
|
||||
@@ -94,21 +99,8 @@ def _check_domain_argument(arg_node: nodes.NodeNG, allow_iterable: bool) -> bool
|
||||
"""
|
||||
match arg_node:
|
||||
case nodes.Attribute():
|
||||
if (
|
||||
attrname := arg_node.attrname
|
||||
) in _DOMAIN_CONSTANTS or attrname.endswith(_DOMAIN_SUFFIXES):
|
||||
return True
|
||||
case nodes.Const():
|
||||
if isinstance(arg_node.value, str):
|
||||
return True
|
||||
case nodes.Name():
|
||||
if (node_name := arg_node.name) in _DOMAIN_CONSTANTS or node_name.endswith(
|
||||
_DOMAIN_SUFFIXES
|
||||
):
|
||||
return True
|
||||
case nodes.Subscript():
|
||||
# Ignore subscripts like dict["key"]
|
||||
return True
|
||||
if arg_node.expr.as_string() == "Platform":
|
||||
return False
|
||||
case nodes.Tuple():
|
||||
if allow_iterable:
|
||||
return all(
|
||||
@@ -116,7 +108,7 @@ def _check_domain_argument(arg_node: nodes.NodeNG, allow_iterable: bool) -> bool
|
||||
for element in arg_node.elts
|
||||
)
|
||||
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class DomainConstantChecker(BaseChecker):
|
||||
|
||||
Generated
+5
-5
@@ -236,7 +236,7 @@ aiocomelit==2.0.3
|
||||
aiodhcpwatcher==1.2.7
|
||||
|
||||
# homeassistant.components.dhcp
|
||||
aiodiscover==3.3.1
|
||||
aiodiscover==3.3.2
|
||||
|
||||
# homeassistant.components.dnsip
|
||||
aiodns==4.0.4
|
||||
@@ -889,7 +889,7 @@ ecoaliface==0.4.0
|
||||
egauge-async==0.4.0
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.6.0
|
||||
eheimdigital==1.7.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.1
|
||||
@@ -2733,7 +2733,7 @@ python-rabbitair==0.0.8
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==5.14.1
|
||||
python-roborock==5.14.2
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.47
|
||||
@@ -2860,7 +2860,7 @@ pyzerproc==0.4.8
|
||||
qbittorrent-api==2026.5.1
|
||||
|
||||
# homeassistant.components.qbus
|
||||
qbusmqttapi==1.5.0
|
||||
qbusmqttapi==1.5.1
|
||||
|
||||
# homeassistant.components.qingping
|
||||
qingping-ble==1.1.5
|
||||
@@ -3287,7 +3287,7 @@ url-normalize==3.0.0
|
||||
uvcclient==0.12.1
|
||||
|
||||
# homeassistant.components.roborock
|
||||
vacuum-map-parser-roborock==0.1.4
|
||||
vacuum-map-parser-roborock==0.1.5
|
||||
|
||||
# homeassistant.components.vallox
|
||||
vallox-websocket-api==6.0.0
|
||||
|
||||
@@ -22,7 +22,7 @@ async def test_hmip_load_all_supported_devices(
|
||||
test_devices=None, test_groups=None
|
||||
)
|
||||
|
||||
assert len(mock_hap.hmip_device_by_entity_id) == 384
|
||||
assert len(mock_hap.hmip_device_by_entity_id) == 385
|
||||
|
||||
|
||||
async def test_hmip_remove_device(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for HomematicIP Cloud sensor."""
|
||||
|
||||
from homematicip.base.enums import ValveState
|
||||
from homematicip.base.enums import ValveState, WindowState
|
||||
|
||||
from homeassistant.components.homematicip_cloud import DOMAIN
|
||||
from homeassistant.components.homematicip_cloud.entity import (
|
||||
@@ -745,6 +745,38 @@ async def test_hmip_tilt_vibration_sensor_tilt_state(
|
||||
assert ha_state.attributes[ATTR_ACCELERATION_SENSOR_SECOND_TRIGGER_ANGLE] == 75
|
||||
|
||||
|
||||
async def test_hmip_rotary_handle_window_state_sensor(
|
||||
hass: HomeAssistant, default_mock_hap_factory: HomeFactory
|
||||
) -> None:
|
||||
"""Test HomematicipWindowStateSensor exposes the three-way state of HmIP-SRH."""
|
||||
entity_id = "sensor.fenstergriffsensor_window_state"
|
||||
entity_name = "Fenstergriffsensor Window state"
|
||||
device_model = "HmIP-SRH"
|
||||
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
|
||||
test_devices=["Fenstergriffsensor"]
|
||||
)
|
||||
|
||||
ha_state, hmip_device = get_and_check_entity_basics(
|
||||
hass, mock_hap, entity_id, entity_name, device_model
|
||||
)
|
||||
|
||||
assert ha_state.state == "tilted"
|
||||
|
||||
await async_manipulate_test_data(hass, hmip_device, "windowState", WindowState.OPEN)
|
||||
ha_state = hass.states.get(entity_id)
|
||||
assert ha_state.state == "open"
|
||||
|
||||
await async_manipulate_test_data(
|
||||
hass, hmip_device, "windowState", WindowState.CLOSED
|
||||
)
|
||||
ha_state = hass.states.get(entity_id)
|
||||
assert ha_state.state == "closed"
|
||||
|
||||
await async_manipulate_test_data(hass, hmip_device, "windowState", None)
|
||||
ha_state = hass.states.get(entity_id)
|
||||
assert ha_state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
async def test_hmip_tilt_vibration_sensor_tilt_angle(
|
||||
hass: HomeAssistant, default_mock_hap_factory: HomeFactory
|
||||
) -> None:
|
||||
|
||||
@@ -36,8 +36,7 @@ def mock_huum_client() -> Generator[AsyncMock]:
|
||||
target_temperature=80,
|
||||
config=3,
|
||||
light=1,
|
||||
humidity=0,
|
||||
target_humidity=5,
|
||||
humidity=5,
|
||||
sauna_config=SaunaConfig(
|
||||
child_lock="OFF",
|
||||
max_heating_time=3,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
'door_closed': True,
|
||||
'duration': None,
|
||||
'end_date': None,
|
||||
'humidity': 0,
|
||||
'humidity': 5,
|
||||
'is_private': None,
|
||||
'light': 1,
|
||||
'payment_end_date': None,
|
||||
@@ -29,7 +29,7 @@
|
||||
'start_date': None,
|
||||
'status': 232,
|
||||
'steamer_error': None,
|
||||
'target_humidity': 5,
|
||||
'target_humidity': None,
|
||||
'target_temperature': 80,
|
||||
'temperature': 30,
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 10,
|
||||
'max': 40,
|
||||
'min': 0,
|
||||
'mode': <NumberMode.AUTO: 'auto'>,
|
||||
'step': 1,
|
||||
@@ -29,7 +29,7 @@
|
||||
'object_id_base': 'Humidity',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_device_class': <NumberDeviceClass.HUMIDITY: 'humidity'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Humidity',
|
||||
'platform': 'huum',
|
||||
@@ -38,17 +38,19 @@
|
||||
'supported_features': 0,
|
||||
'translation_key': 'humidity',
|
||||
'unique_id': 'AABBCC112233',
|
||||
'unit_of_measurement': None,
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_number_entity[number.huum_sauna_humidity-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'humidity',
|
||||
'friendly_name': 'Huum sauna Humidity',
|
||||
'max': 10,
|
||||
'max': 40,
|
||||
'min': 0,
|
||||
'mode': <NumberMode.AUTO: 'auto'>,
|
||||
'step': 1,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.huum_sauna_humidity',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'e0:da:dc:0a:12:34',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'jvc_projector',
|
||||
'e0:da:dc:0a:12:34',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'JVC',
|
||||
'model': 'B2A2',
|
||||
'model_id': None,
|
||||
'name': 'JVC Projector',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from jvcprojector import JvcProjectorAuthError, JvcProjectorTimeoutError
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.jvc_projector.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -30,6 +31,20 @@ async def test_init(
|
||||
assert device.identifiers == {(DOMAIN, mac)}
|
||||
|
||||
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_device: AsyncMock,
|
||||
mock_integration: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the device registry entry, including the network MAC connection."""
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, format_mac(MOCK_MAC))}
|
||||
)
|
||||
assert device_entry == snapshot
|
||||
|
||||
|
||||
async def test_unload_config_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_device: AsyncMock,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry_bulb
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'60:01:94:03:76:eb',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'mystrom',
|
||||
'6001940376EB',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'myStrom',
|
||||
'model': None,
|
||||
'model_id': None,
|
||||
'name': 'myStrom Device',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '2.58.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -4,10 +4,12 @@ from unittest.mock import AsyncMock, PropertyMock, patch
|
||||
|
||||
from pymystrom.exceptions import MyStromConnectionError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.mystrom.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import (
|
||||
MyStromBulbMock,
|
||||
@@ -68,6 +70,19 @@ async def test_init_pir_and_unload(
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_device_registry_bulb(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the bulb device registry entry, including the network MAC connection."""
|
||||
await init_integration(hass, config_entry, 102)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_MAC)})
|
||||
assert device_entry == snapshot
|
||||
|
||||
|
||||
async def test_init_switch_and_unload(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
|
||||
@@ -277,10 +277,17 @@ async def test_controller_cannot_connect(
|
||||
) -> None:
|
||||
"""Test an error talking to the controller."""
|
||||
|
||||
# Controller response with a failure
|
||||
# Controller response with a failure, followed by successful responses
|
||||
responses.clear()
|
||||
responses.append(
|
||||
AiohttpClientMockResponse("POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
responses.extend(
|
||||
[
|
||||
AiohttpClientMockResponse(
|
||||
"POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE
|
||||
),
|
||||
mock_response(MODEL_AND_VERSION_RESPONSE),
|
||||
mock_response(SERIAL_RESPONSE),
|
||||
mock_json_response(WIFI_PARAMS_RESPONSE),
|
||||
]
|
||||
)
|
||||
|
||||
result = await complete_flow(hass)
|
||||
@@ -290,6 +297,19 @@ async def test_controller_cannot_connect(
|
||||
|
||||
assert not mock_setup.mock_calls
|
||||
|
||||
# Correct the form and enter the password again and setup completes
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: HOST, CONF_PASSWORD: PASSWORD},
|
||||
)
|
||||
assert result.get("type") is FlowResultType.CREATE_ENTRY
|
||||
assert result.get("title") == HOST
|
||||
assert "result" in result
|
||||
assert dict(result["result"].data) == CONFIG_ENTRY_DATA
|
||||
assert result["result"].unique_id == MAC_ADDRESS_UNIQUE_ID
|
||||
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_controller_invalid_auth(
|
||||
hass: HomeAssistant,
|
||||
@@ -347,20 +367,45 @@ async def test_controller_invalid_auth(
|
||||
async def test_controller_timeout(
|
||||
hass: HomeAssistant,
|
||||
mock_setup: Mock,
|
||||
responses: list[AiohttpClientMockResponse],
|
||||
) -> None:
|
||||
"""Test an error talking to the controller."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("step_id") == "user"
|
||||
assert not result.get("errors")
|
||||
assert "flow_id" in result
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rainbird.config_flow.asyncio.timeout",
|
||||
side_effect=TimeoutError,
|
||||
):
|
||||
result = await complete_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: HOST, CONF_PASSWORD: PASSWORD},
|
||||
)
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("step_id") == "user"
|
||||
assert result.get("errors") == {"base": "timeout_connect"}
|
||||
|
||||
assert not mock_setup.mock_calls
|
||||
|
||||
# Enter the password again and setup completes
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: HOST, CONF_PASSWORD: PASSWORD},
|
||||
)
|
||||
assert result.get("type") is FlowResultType.CREATE_ENTRY
|
||||
assert result.get("title") == HOST
|
||||
assert "result" in result
|
||||
assert dict(result["result"].data) == CONFIG_ENTRY_DATA
|
||||
assert result["result"].unique_id == MAC_ADDRESS_UNIQUE_ID
|
||||
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("responses", "config_entry_data"),
|
||||
|
||||
@@ -1532,3 +1532,27 @@ CONTACT_SENSOR_SERVICE_INFO = BluetoothServiceInfoBleak(
|
||||
connectable=False,
|
||||
tx_power=-127,
|
||||
)
|
||||
|
||||
STANDING_FAN_SERVICE_INFO = BluetoothServiceInfoBleak(
|
||||
name="WoStandingFan",
|
||||
manufacturer_data={2409: b"\xb0\xe9\xfe\x01\x02\x03~\xd3R9"},
|
||||
service_data={
|
||||
"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x11\x07\x60"
|
||||
},
|
||||
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
|
||||
address="AA:BB:CC:DD:EE:FF",
|
||||
rssi=-60,
|
||||
source="local",
|
||||
advertisement=generate_advertisement_data(
|
||||
local_name="WoStandingFan",
|
||||
manufacturer_data={2409: b"\xb0\xe9\xfe\x01\x02\x03~\xd3R9"},
|
||||
service_data={
|
||||
"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x11\x07\x60"
|
||||
},
|
||||
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
|
||||
),
|
||||
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoStandingFan"),
|
||||
time=0,
|
||||
connectable=True,
|
||||
tx_power=-127,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import Callable
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import switchbot
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
|
||||
from homeassistant.components.switchbot.const import (
|
||||
@@ -26,6 +27,7 @@ from . import (
|
||||
AIR_PURIFIER_US_SERVICE_INFO,
|
||||
HUBMINI_MATTER_SERVICE_INFO,
|
||||
LOCK_SERVICE_INFO,
|
||||
STANDING_FAN_SERVICE_INFO,
|
||||
WOCURTAIN_SERVICE_INFO,
|
||||
WOMETERTHPC_SERVICE_INFO,
|
||||
WOSENSORTH_SERVICE_INFO,
|
||||
@@ -327,3 +329,24 @@ async def test_migrate_deprecated_air_purifier_sensor_type_device_not_in_range(
|
||||
# sensor_type unchanged and entry not loaded; will retry when device advertises
|
||||
assert entry.data[CONF_SENSOR_TYPE] == DEPRECATED_SENSOR_TYPE_AIR_PURIFIER
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_standing_fan_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_entry_factory: Callable[[str], MockConfigEntry],
|
||||
) -> None:
|
||||
"""Test the Standing Fan is recognized and set up."""
|
||||
inject_bluetooth_service_info(hass, STANDING_FAN_SERVICE_INFO)
|
||||
|
||||
entry = mock_entry_factory(sensor_type="standing_fan")
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.switchbot.switchbot.SwitchbotStandingFan.get_basic_info",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert isinstance(entry.runtime_data.device, switchbot.SwitchbotStandingFan)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiohttp import ClientConnectionError, ClientError
|
||||
import pytest
|
||||
from tesla_fleet_api.exceptions import (
|
||||
InvalidRequest,
|
||||
InvalidToken,
|
||||
@@ -74,3 +76,78 @@ async def test_scopes_error(hass: HomeAssistant) -> None:
|
||||
):
|
||||
entry = await setup_platform(hass)
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("patch_target", "exception"),
|
||||
[
|
||||
pytest.param(
|
||||
None,
|
||||
ClientConnectionError(),
|
||||
id="list_vehicles-connection_error",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
ClientError(),
|
||||
id="list_vehicles-client_error",
|
||||
),
|
||||
pytest.param(
|
||||
"homeassistant.components.tessie.Tessie.scopes",
|
||||
ClientConnectionError(),
|
||||
id="scopes-connection_error",
|
||||
),
|
||||
pytest.param(
|
||||
"homeassistant.components.tessie.Tessie.scopes",
|
||||
ClientError(),
|
||||
id="scopes-client_error",
|
||||
),
|
||||
pytest.param(
|
||||
"homeassistant.components.tessie.Tessie.products",
|
||||
ClientConnectionError(),
|
||||
id="products-connection_error",
|
||||
),
|
||||
pytest.param(
|
||||
"homeassistant.components.tessie.Tessie.products",
|
||||
ClientError(),
|
||||
id="products-client_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_aiohttp_client_error_retries(
|
||||
hass: HomeAssistant,
|
||||
mock_get_state_of_all_vehicles: AsyncMock,
|
||||
patch_target: str | None,
|
||||
exception: ClientError,
|
||||
) -> None:
|
||||
"""Test that aiohttp.ClientError on any setup call triggers SETUP_RETRY.
|
||||
|
||||
Covers list_vehicles(), scopes(), and products() — all network calls that
|
||||
tesla_fleet_api does not wrap in TeslaFleetError.
|
||||
"""
|
||||
if patch_target is None:
|
||||
mock_get_state_of_all_vehicles.side_effect = exception
|
||||
entry = await setup_platform(hass)
|
||||
else:
|
||||
with patch(patch_target, side_effect=exception):
|
||||
entry = await setup_platform(hass)
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
pytest.param(ClientConnectionError(), id="connection_error"),
|
||||
pytest.param(ClientError(), id="client_error"),
|
||||
],
|
||||
)
|
||||
async def test_aiohttp_client_error_on_live_status_retries(
|
||||
hass: HomeAssistant,
|
||||
exception: ClientError,
|
||||
) -> None:
|
||||
"""Test that aiohttp.ClientError during live_status() triggers SETUP_RETRY."""
|
||||
with patch(
|
||||
"tesla_fleet_api.tessie.EnergySite.live_status",
|
||||
side_effect=exception,
|
||||
):
|
||||
entry = await setup_platform(hass)
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
@@ -103,6 +103,26 @@ async def test_setup_entry_subscribe_failure(
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_apply_optimistic_creates_missing_intermediate_dicts(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_vistapool_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test apply_optimistic walks through and creates missing intermediate dicts."""
|
||||
mock_vistapool_client.fetch_pool_data.return_value = {"existing": "scalar"}
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = next(iter(mock_config_entry.runtime_data.coordinators.values()))
|
||||
coordinator.apply_optimistic("filtration.intel.temp", 27)
|
||||
coordinator.apply_optimistic("existing.nested.key", 1)
|
||||
|
||||
assert coordinator.data["filtration"]["intel"]["temp"] == 27
|
||||
assert coordinator.data["existing"] == {"nested": {"key": 1}}
|
||||
|
||||
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
|
||||
@@ -98,6 +98,43 @@ async def test_light_set_value(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "initial_status", "initial_state", "expected_state"),
|
||||
[
|
||||
pytest.param(SERVICE_TURN_ON, 0, STATE_OFF, STATE_ON, id="turn_on"),
|
||||
pytest.param(SERVICE_TURN_OFF, 1, STATE_ON, STATE_OFF, id="turn_off"),
|
||||
],
|
||||
)
|
||||
async def test_light_optimistic_state(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_vistapool_client: AsyncMock,
|
||||
mock_pool_data: dict[str, Any],
|
||||
service: str,
|
||||
initial_status: int,
|
||||
initial_state: str,
|
||||
expected_state: str,
|
||||
) -> None:
|
||||
"""Test the entity state reflects the just-written value before the Firestore push."""
|
||||
mock_pool_data["light"] = {"status": initial_status}
|
||||
mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("light.my_pool_light").state == initial_state
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: "light.my_pool_light"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert hass.states.get("light.my_pool_light").state == expected_state
|
||||
|
||||
|
||||
async def test_light_set_value_raises_on_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
|
||||
@@ -232,6 +232,7 @@ async def test_number_set_value(
|
||||
mock_vistapool_client.set_value.assert_awaited_once_with(
|
||||
"ABCDEF1234567890", expected_path, expected_raw
|
||||
)
|
||||
assert hass.states.get(entity_id).state == str(float(user_value))
|
||||
value_arg = mock_vistapool_client.set_value.await_args.args[2]
|
||||
assert isinstance(value_arg, int)
|
||||
|
||||
|
||||
@@ -6,9 +6,6 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.xbox.binary_sensor import XboxBinarySensor
|
||||
from homeassistant.components.xbox.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,39 +40,3 @@ async def test_binary_sensors(
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "key"),
|
||||
[
|
||||
("gsr_ae_in_multiplayer", XboxBinarySensor.IN_MULTIPLAYER),
|
||||
("gsr_ae_in_party", XboxBinarySensor.IN_PARTY),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("xbox_live_client", "entity_registry_enabled_by_default")
|
||||
async def test_binary_sensor_deprecation_remove_disabled(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
entity_id: str,
|
||||
key: XboxBinarySensor,
|
||||
) -> None:
|
||||
"""Test we remove a deprecated binary sensor."""
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"271958441785640_{key}",
|
||||
suggested_object_id=entity_id,
|
||||
)
|
||||
|
||||
assert entity_registry is not None
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert entity_registry.async_get(f"binary_sensor.{entity_id}") is None
|
||||
|
||||
@@ -6,9 +6,6 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.xbox.const import DOMAIN
|
||||
from homeassistant.components.xbox.sensor import XboxSensor
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,39 +40,3 @@ async def test_sensors(
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "key"),
|
||||
[
|
||||
("gsr_ae_account_tier", XboxSensor.ACCOUNT_TIER),
|
||||
("gsr_ae_gold_tenure", XboxSensor.GOLD_TENURE),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("xbox_live_client", "entity_registry_enabled_by_default")
|
||||
async def test_sensor_deprecation_remove_entity(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
entity_id: str,
|
||||
key: XboxSensor,
|
||||
) -> None:
|
||||
"""Test we remove a deprecated sensor."""
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"271958441785640_{key}",
|
||||
suggested_object_id=entity_id,
|
||||
)
|
||||
|
||||
assert entity_registry is not None
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert entity_registry.async_get(f"sensor.{entity_id}") is None
|
||||
|
||||
@@ -877,48 +877,6 @@ async def test_set_credential_length_validation(
|
||||
api.set_credential.assert_not_called()
|
||||
|
||||
|
||||
async def test_set_credential_slot_out_of_range(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
client: MagicMock,
|
||||
lock_schlage_be469: Node,
|
||||
integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Explicit credential_slot above device capacity fails fast."""
|
||||
api = _mock_access_control(lock_schlage_be469)
|
||||
cred_caps = api.get_credential_capabilities_cached.return_value
|
||||
cred_caps.supported_credential_types[
|
||||
UserCredentialType.PIN_CODE
|
||||
].number_of_credential_slots = 5
|
||||
|
||||
with pytest.raises(HomeAssistantError) as exc:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_credential",
|
||||
{
|
||||
ATTR_ENTITY_ID: _lock_entity_id(
|
||||
entity_registry, device_registry, client, lock_schlage_be469
|
||||
),
|
||||
"user_id": 1,
|
||||
"credential_type": "pin_code",
|
||||
"credential_data": "1234",
|
||||
"credential_slot": 6,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
# The explicit slot exceeds the device-reported capacity, so the helper
|
||||
# rejects the call with the rendered upper bound and never writes.
|
||||
assert exc.value.translation_key == "credential_slot_out_of_range"
|
||||
assert exc.value.translation_placeholders == {
|
||||
"credential_type": "pin_code",
|
||||
"max_slot": "5",
|
||||
}
|
||||
api.set_credential.assert_not_called()
|
||||
|
||||
|
||||
async def test_delete_credential(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
|
||||
@@ -146,6 +146,72 @@ hass.services.unrelated("other")
|
||||
""",
|
||||
id="unrelated_method",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
async_setup_component(hass, OTHER, {})
|
||||
""",
|
||||
id="name_not_domain",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
async_setup_component(hass, sensor.OTHER, {})
|
||||
""",
|
||||
id="attribute_not_domain",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
async_setup_component(hass, 5, {})
|
||||
""",
|
||||
id="non_string_constant",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
async_mock_service(hass, OTHER, "service")
|
||||
""",
|
||||
id="async_mock_service_other",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
MockConfigEntry(domain=OTHER)
|
||||
""",
|
||||
id="mock_config_entry_kwarg_other",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.services.async_call(OTHER, "service")
|
||||
""",
|
||||
id="services_async_call_other",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.services.call(OTHER, "service")
|
||||
""",
|
||||
id="services_call_other",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.config_entries.flow.async_init(OTHER)
|
||||
""",
|
||||
id="flow_async_init_positional_other",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.config_entries.flow.async_init(handler=OTHER)
|
||||
""",
|
||||
id="flow_async_init_kwarg_other",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.states.async_entity_ids(OTHER)
|
||||
""",
|
||||
id="async_entity_ids_other",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.states.async_entity_ids((DOMAIN, OTHER))
|
||||
""",
|
||||
id="async_entity_ids_tuple_other",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_no_warning(
|
||||
@@ -167,80 +233,17 @@ def test_no_warning(
|
||||
[
|
||||
pytest.param(
|
||||
"""
|
||||
async_setup_component(hass, OTHER, {})
|
||||
async_setup_component(hass, Platform.Something, {})
|
||||
""",
|
||||
("OTHER", "async_setup_component"),
|
||||
id="name_not_domain",
|
||||
("Platform.Something", "async_setup_component"),
|
||||
id="attribute_platform",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
async_setup_component(hass, sensor.OTHER, {})
|
||||
hass.states.async_entity_ids((Platform.SENSOR, DOMAIN))
|
||||
""",
|
||||
("sensor.OTHER", "async_setup_component"),
|
||||
id="attribute_not_domain",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
async_setup_component(hass, 5, {})
|
||||
""",
|
||||
("5", "async_setup_component"),
|
||||
id="non_string_constant",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
async_mock_service(hass, OTHER, "service")
|
||||
""",
|
||||
("OTHER", "async_mock_service"),
|
||||
id="async_mock_service",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
MockConfigEntry(domain=OTHER)
|
||||
""",
|
||||
("OTHER", "MockConfigEntry"),
|
||||
id="mock_config_entry_kwarg",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.services.async_call(OTHER, "service")
|
||||
""",
|
||||
("OTHER", "hass.services.async_call"),
|
||||
id="services_async_call",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.services.call(OTHER, "service")
|
||||
""",
|
||||
("OTHER", "hass.services.call"),
|
||||
id="services_call",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.config_entries.flow.async_init(OTHER)
|
||||
""",
|
||||
("OTHER", "hass.config_entries.flow.async_init"),
|
||||
id="flow_async_init_positional",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.config_entries.flow.async_init(handler=OTHER)
|
||||
""",
|
||||
("OTHER", "hass.config_entries.flow.async_init"),
|
||||
id="flow_async_init_kwarg",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.states.async_entity_ids(OTHER)
|
||||
""",
|
||||
("OTHER", "hass.states.async_entity_ids"),
|
||||
id="async_entity_ids",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
hass.states.async_entity_ids((DOMAIN, OTHER))
|
||||
""",
|
||||
("(DOMAIN, OTHER)", "hass.states.async_entity_ids"),
|
||||
id="async_entity_ids_tuple",
|
||||
("(Platform.SENSOR, DOMAIN)", "hass.states.async_entity_ids"),
|
||||
id="attribute_platform_tuple",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user