Simplify esphome (#22868)
* Add ESPHome climate support * Adjust line length * Update .coveragerc * Update climate.py * Simplify esphome integration * Undo change * Update cover.pypull/23151/head
parent
10e8f4f70a
commit
3186109172
|
@ -1,6 +1,7 @@
|
|||
"""Support for esphome devices."""
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Callable, Tuple
|
||||
|
||||
import attr
|
||||
|
@ -520,6 +521,51 @@ async def platform_async_setup_entry(hass: HomeAssistantType,
|
|||
)
|
||||
|
||||
|
||||
def esphome_state_property(func):
|
||||
"""Wrap a state property of an esphome entity.
|
||||
|
||||
This checks if the state object in the entity is set, and
|
||||
prevents writing NAN values to the Home Assistant state machine.
|
||||
"""
|
||||
@property
|
||||
def _wrapper(self):
|
||||
if self._state is None:
|
||||
return None
|
||||
val = func(self)
|
||||
if isinstance(val, float) and math.isnan(val):
|
||||
# Home Assistant doesn't use NAN values in state machine
|
||||
# (not JSON serializable)
|
||||
return None
|
||||
return val
|
||||
return _wrapper
|
||||
|
||||
|
||||
class EsphomeEnumMapper:
|
||||
"""Helper class to convert between hass and esphome enum values."""
|
||||
|
||||
def __init__(self, func: Callable[[], Dict[int, str]]):
|
||||
"""Construct a EsphomeEnumMapper."""
|
||||
self._func = func
|
||||
|
||||
def from_esphome(self, value: int) -> str:
|
||||
"""Convert from an esphome int representation to a hass string."""
|
||||
return self._func()[value]
|
||||
|
||||
def from_hass(self, value: str) -> int:
|
||||
"""Convert from a hass string to a esphome int representation."""
|
||||
inverse = {v: k for k, v in self._func().items()}
|
||||
return inverse[value]
|
||||
|
||||
|
||||
def esphome_map_enum(func: Callable[[], Dict[int, str]]):
|
||||
"""Map esphome int enum values to hass string constants.
|
||||
|
||||
This class has to be used as a decorator. This ensures the aioesphomeapi
|
||||
import is only happening at runtime.
|
||||
"""
|
||||
return EsphomeEnumMapper(func)
|
||||
|
||||
|
||||
class EsphomeEntity(Entity):
|
||||
"""Define a generic esphome entity."""
|
||||
|
||||
|
@ -555,11 +601,11 @@ class EsphomeEntity(Entity):
|
|||
self.async_schedule_update_ha_state)
|
||||
)
|
||||
|
||||
async def _on_update(self):
|
||||
async def _on_update(self) -> None:
|
||||
"""Update the entity state when state or static info changed."""
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister callbacks."""
|
||||
for remove_callback in self._remove_callbacks:
|
||||
remove_callback()
|
||||
|
@ -608,7 +654,7 @@ class EsphomeEntity(Entity):
|
|||
return self._static_info.unique_id
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device registry information for this entity."""
|
||||
return {
|
||||
'connections': {(dr.CONNECTION_NETWORK_MAC,
|
||||
|
|
|
@ -38,7 +38,7 @@ class EsphomeBinarySensor(EsphomeEntity, BinarySensorDevice):
|
|||
return super()._state
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> Optional[bool]:
|
||||
"""Return true if the binary sensor is on."""
|
||||
if self._static_info.is_status_binary_sensor:
|
||||
# Status binary sensors indicated connected state.
|
||||
|
@ -49,12 +49,12 @@ class EsphomeBinarySensor(EsphomeEntity, BinarySensorDevice):
|
|||
return self._state.state
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
def device_class(self) -> str:
|
||||
"""Return the class of this device, from component DEVICE_CLASSES."""
|
||||
return self._static_info.device_class
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
if self._static_info.is_status_binary_sensor:
|
||||
return True
|
||||
|
|
|
@ -47,7 +47,7 @@ class EsphomeCamera(Camera, EsphomeEntity):
|
|||
def _state(self) -> Optional['CameraState']:
|
||||
return super()._state
|
||||
|
||||
async def _on_update(self):
|
||||
async def _on_update(self) -> None:
|
||||
"""Notify listeners of new image when update arrives."""
|
||||
await super()._on_update()
|
||||
async with self._image_cond:
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
"""Support for ESPHome climate devices."""
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from homeassistant.components.climate import ClimateDevice
|
||||
|
@ -13,7 +12,8 @@ from homeassistant.const import (
|
|||
ATTR_TEMPERATURE, PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE,
|
||||
STATE_OFF, TEMP_CELSIUS)
|
||||
|
||||
from . import EsphomeEntity, platform_async_setup_entry
|
||||
from . import EsphomeEntity, platform_async_setup_entry, \
|
||||
esphome_state_property, esphome_map_enum
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable=unused-import
|
||||
|
@ -35,18 +35,8 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
|||
)
|
||||
|
||||
|
||||
def _ha_climate_mode_to_esphome(mode: str) -> 'ClimateMode':
|
||||
# pylint: disable=redefined-outer-name
|
||||
from aioesphomeapi import ClimateMode # noqa
|
||||
return {
|
||||
STATE_OFF: ClimateMode.OFF,
|
||||
STATE_AUTO: ClimateMode.AUTO,
|
||||
STATE_COOL: ClimateMode.COOL,
|
||||
STATE_HEAT: ClimateMode.HEAT,
|
||||
}[mode]
|
||||
|
||||
|
||||
def _esphome_climate_mode_to_ha(mode: 'ClimateMode') -> str:
|
||||
@esphome_map_enum
|
||||
def _climate_modes():
|
||||
# pylint: disable=redefined-outer-name
|
||||
from aioesphomeapi import ClimateMode # noqa
|
||||
return {
|
||||
|
@ -54,7 +44,7 @@ def _esphome_climate_mode_to_ha(mode: 'ClimateMode') -> str:
|
|||
ClimateMode.AUTO: STATE_AUTO,
|
||||
ClimateMode.COOL: STATE_COOL,
|
||||
ClimateMode.HEAT: STATE_HEAT,
|
||||
}[mode]
|
||||
}
|
||||
|
||||
|
||||
class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
|
||||
|
@ -87,12 +77,12 @@ class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
|
|||
def operation_list(self) -> List[str]:
|
||||
"""Return the list of available operation modes."""
|
||||
return [
|
||||
_esphome_climate_mode_to_ha(mode)
|
||||
_climate_modes.from_esphome(mode)
|
||||
for mode in self._static_info.supported_modes
|
||||
]
|
||||
|
||||
@property
|
||||
def target_temperature_step(self):
|
||||
def target_temperature_step(self) -> float:
|
||||
"""Return the supported step of target temperature."""
|
||||
# Round to one digit because of floating point math
|
||||
return round(self._static_info.visual_temperature_step, 1)
|
||||
|
@ -120,61 +110,41 @@ class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
|
|||
features |= SUPPORT_AWAY_MODE
|
||||
return features
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def current_operation(self) -> Optional[str]:
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return _esphome_climate_mode_to_ha(self._state.mode)
|
||||
return _climate_modes.from_esphome(self._state.mode)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def current_temperature(self) -> Optional[float]:
|
||||
"""Return the current temperature."""
|
||||
if self._state is None:
|
||||
return None
|
||||
if math.isnan(self._state.current_temperature):
|
||||
return None
|
||||
return self._state.current_temperature
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def target_temperature(self) -> Optional[float]:
|
||||
"""Return the temperature we try to reach."""
|
||||
if self._state is None:
|
||||
return None
|
||||
if math.isnan(self._state.target_temperature):
|
||||
return None
|
||||
return self._state.target_temperature
|
||||
|
||||
@property
|
||||
def target_temperature_low(self):
|
||||
@esphome_state_property
|
||||
def target_temperature_low(self) -> Optional[float]:
|
||||
"""Return the lowbound target temperature we try to reach."""
|
||||
if self._state is None:
|
||||
return None
|
||||
if math.isnan(self._state.target_temperature_low):
|
||||
return None
|
||||
return self._state.target_temperature_low
|
||||
|
||||
@property
|
||||
def target_temperature_high(self):
|
||||
@esphome_state_property
|
||||
def target_temperature_high(self) -> Optional[float]:
|
||||
"""Return the highbound target temperature we try to reach."""
|
||||
if self._state is None:
|
||||
return None
|
||||
if math.isnan(self._state.target_temperature_high):
|
||||
return None
|
||||
return self._state.target_temperature_high
|
||||
|
||||
@property
|
||||
def is_away_mode_on(self):
|
||||
@esphome_state_property
|
||||
def is_away_mode_on(self) -> Optional[bool]:
|
||||
"""Return true if away mode is on."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.away
|
||||
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
"""Set new target temperature (and operation mode if set)."""
|
||||
data = {'key': self._static_info.key}
|
||||
if ATTR_OPERATION_MODE in kwargs:
|
||||
data['mode'] = _ha_climate_mode_to_esphome(
|
||||
data['mode'] = _climate_modes.from_hass(
|
||||
kwargs[ATTR_OPERATION_MODE])
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
data['target_temperature'] = kwargs[ATTR_TEMPERATURE]
|
||||
|
@ -184,14 +154,14 @@ class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
|
|||
data['target_temperature_high'] = kwargs[ATTR_TARGET_TEMP_HIGH]
|
||||
await self._client.climate_command(**data)
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode):
|
||||
async def async_set_operation_mode(self, operation_mode) -> None:
|
||||
"""Set new target operation mode."""
|
||||
await self._client.climate_command(
|
||||
key=self._static_info.key,
|
||||
mode=_ha_climate_mode_to_esphome(operation_mode),
|
||||
mode=_climate_modes.from_hass(operation_mode),
|
||||
)
|
||||
|
||||
async def async_turn_away_mode_on(self):
|
||||
async def async_turn_away_mode_on(self) -> None:
|
||||
"""Turn away mode on."""
|
||||
await self._client.climate_command(key=self._static_info.key,
|
||||
away=True)
|
||||
|
|
|
@ -9,7 +9,7 @@ from homeassistant.components.cover import (
|
|||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import EsphomeEntity, platform_async_setup_entry
|
||||
from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable=unused-import
|
||||
|
@ -51,7 +51,7 @@ class EsphomeCover(EsphomeEntity, CoverDevice):
|
|||
return flags
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
def device_class(self) -> str:
|
||||
"""Return the class of this device, from component DEVICE_CLASSES."""
|
||||
return self._static_info.device_class
|
||||
|
||||
|
@ -64,41 +64,35 @@ class EsphomeCover(EsphomeEntity, CoverDevice):
|
|||
def _state(self) -> Optional['CoverState']:
|
||||
return super()._state
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def is_closed(self) -> Optional[bool]:
|
||||
"""Return if the cover is closed or not."""
|
||||
if self._state is None:
|
||||
return None
|
||||
# Check closed state with api version due to a protocol change
|
||||
return self._state.is_closed(self._client.api_version)
|
||||
|
||||
@property
|
||||
def is_opening(self):
|
||||
@esphome_state_property
|
||||
def is_opening(self) -> bool:
|
||||
"""Return if the cover is opening or not."""
|
||||
from aioesphomeapi import CoverOperation
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.current_operation == CoverOperation.IS_OPENING
|
||||
|
||||
@property
|
||||
def is_closing(self):
|
||||
@esphome_state_property
|
||||
def is_closing(self) -> bool:
|
||||
"""Return if the cover is closing or not."""
|
||||
from aioesphomeapi import CoverOperation
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.current_operation == CoverOperation.IS_CLOSING
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def current_cover_position(self) -> Optional[float]:
|
||||
"""Return current position of cover. 0 is closed, 100 is open."""
|
||||
if self._state is None or not self._static_info.supports_position:
|
||||
if not self._static_info.supports_position:
|
||||
return None
|
||||
return self._state.position * 100.0
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def current_cover_tilt_position(self) -> Optional[float]:
|
||||
"""Return current position of cover tilt. 0 is closed, 100 is open."""
|
||||
if self._state is None or not self._static_info.supports_tilt:
|
||||
if not self._static_info.supports_tilt:
|
||||
return None
|
||||
return self._state.tilt * 100.0
|
||||
|
||||
|
|
|
@ -8,7 +8,8 @@ from homeassistant.components.fan import (
|
|||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import EsphomeEntity, platform_async_setup_entry
|
||||
from . import EsphomeEntity, platform_async_setup_entry, \
|
||||
esphome_state_property, esphome_map_enum
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable=unused-import
|
||||
|
@ -31,24 +32,15 @@ async def async_setup_entry(hass: HomeAssistantType,
|
|||
)
|
||||
|
||||
|
||||
def _ha_fan_speed_to_esphome(speed: str) -> 'FanSpeed':
|
||||
# pylint: disable=redefined-outer-name
|
||||
from aioesphomeapi import FanSpeed # noqa
|
||||
return {
|
||||
SPEED_LOW: FanSpeed.LOW,
|
||||
SPEED_MEDIUM: FanSpeed.MEDIUM,
|
||||
SPEED_HIGH: FanSpeed.HIGH,
|
||||
}[speed]
|
||||
|
||||
|
||||
def _esphome_fan_speed_to_ha(speed: 'FanSpeed') -> str:
|
||||
@esphome_map_enum
|
||||
def _fan_speeds():
|
||||
# pylint: disable=redefined-outer-name
|
||||
from aioesphomeapi import FanSpeed # noqa
|
||||
return {
|
||||
FanSpeed.LOW: SPEED_LOW,
|
||||
FanSpeed.MEDIUM: SPEED_MEDIUM,
|
||||
FanSpeed.HIGH: SPEED_HIGH,
|
||||
}[speed]
|
||||
}
|
||||
|
||||
|
||||
class EsphomeFan(EsphomeEntity, FanEntity):
|
||||
|
@ -69,7 +61,7 @@ class EsphomeFan(EsphomeEntity, FanEntity):
|
|||
return
|
||||
|
||||
await self._client.fan_command(
|
||||
self._static_info.key, speed=_ha_fan_speed_to_esphome(speed))
|
||||
self._static_info.key, speed=_fan_speeds.from_hass(speed))
|
||||
|
||||
async def async_turn_on(self, speed: Optional[str] = None,
|
||||
**kwargs) -> None:
|
||||
|
@ -79,7 +71,7 @@ class EsphomeFan(EsphomeEntity, FanEntity):
|
|||
return
|
||||
data = {'key': self._static_info.key, 'state': True}
|
||||
if speed is not None:
|
||||
data['speed'] = _ha_fan_speed_to_esphome(speed)
|
||||
data['speed'] = _fan_speeds.from_hass(speed)
|
||||
await self._client.fan_command(**data)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
|
@ -87,32 +79,26 @@ class EsphomeFan(EsphomeEntity, FanEntity):
|
|||
"""Turn off the fan."""
|
||||
await self._client.fan_command(key=self._static_info.key, state=False)
|
||||
|
||||
async def async_oscillate(self, oscillating: bool):
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Oscillate the fan."""
|
||||
await self._client.fan_command(key=self._static_info.key,
|
||||
oscillating=oscillating)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def is_on(self) -> Optional[bool]:
|
||||
"""Return true if the entity is on."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.state
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def speed(self) -> Optional[str]:
|
||||
"""Return the current speed."""
|
||||
if self._state is None:
|
||||
return None
|
||||
if not self._static_info.supports_speed:
|
||||
return None
|
||||
return _esphome_fan_speed_to_ha(self._state.speed)
|
||||
return _fan_speeds.from_esphome(self._state.speed)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def oscillating(self) -> None:
|
||||
"""Return the oscillation state."""
|
||||
if self._state is None:
|
||||
return None
|
||||
if not self._static_info.supports_oscillation:
|
||||
return None
|
||||
return self._state.oscillating
|
||||
|
|
|
@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
|
|||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
import homeassistant.util.color as color_util
|
||||
|
||||
from . import EsphomeEntity, platform_async_setup_entry
|
||||
from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable=unused-import
|
||||
|
@ -51,11 +51,9 @@ class EsphomeLight(EsphomeEntity, Light):
|
|||
def _state(self) -> Optional['LightState']:
|
||||
return super()._state
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def is_on(self) -> Optional[bool]:
|
||||
"""Return true if the switch is on."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.state
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
|
@ -88,42 +86,32 @@ class EsphomeLight(EsphomeEntity, Light):
|
|||
data['transition_length'] = kwargs[ATTR_TRANSITION]
|
||||
await self._client.light_command(**data)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def brightness(self) -> Optional[int]:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return round(self._state.brightness * 255)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def hs_color(self) -> Optional[Tuple[float, float]]:
|
||||
"""Return the hue and saturation color value [float, float]."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return color_util.color_RGB_to_hs(
|
||||
self._state.red * 255,
|
||||
self._state.green * 255,
|
||||
self._state.blue * 255)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def color_temp(self) -> Optional[float]:
|
||||
"""Return the CT color value in mireds."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.color_temperature
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def white_value(self) -> Optional[int]:
|
||||
"""Return the white value of this light between 0..255."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return round(self._state.white * 255)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def effect(self) -> Optional[str]:
|
||||
"""Return the current effect."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.effect
|
||||
|
||||
@property
|
||||
|
|
|
@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional
|
|||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import EsphomeEntity, platform_async_setup_entry
|
||||
from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable=unused-import
|
||||
|
@ -53,11 +53,9 @@ class EsphomeSensor(EsphomeEntity):
|
|||
"""Return the icon."""
|
||||
return self._static_info.icon
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def state(self) -> Optional[str]:
|
||||
"""Return the state of the entity."""
|
||||
if self._state is None:
|
||||
return None
|
||||
if math.isnan(self._state.state):
|
||||
return None
|
||||
return '{:.{prec}f}'.format(
|
||||
|
@ -85,9 +83,7 @@ class EsphomeTextSensor(EsphomeEntity):
|
|||
"""Return the icon."""
|
||||
return self._static_info.icon
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def state(self) -> Optional[str]:
|
||||
"""Return the state of the entity."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.state
|
||||
|
|
|
@ -6,7 +6,7 @@ from homeassistant.components.switch import SwitchDevice
|
|||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import EsphomeEntity, platform_async_setup_entry
|
||||
from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable=unused-import
|
||||
|
@ -50,17 +50,15 @@ class EsphomeSwitch(EsphomeEntity, SwitchDevice):
|
|||
"""Return true if we do optimistic updates."""
|
||||
return self._static_info.assumed_state
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
@esphome_state_property
|
||||
def is_on(self) -> Optional[bool]:
|
||||
"""Return true if the switch is on."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.state
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
"""Turn the entity on."""
|
||||
await self._client.switch_command(self._static_info.key, True)
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Turn the entity off."""
|
||||
await self._client.switch_command(self._static_info.key, False)
|
||||
|
|
Loading…
Reference in New Issue