Merge branch 'dev' into AddClimate_MideaCCM15

This commit is contained in:
Oscar Calvo
2023-08-11 05:25:46 -06:00
committed by GitHub
22 changed files with 176 additions and 58 deletions

View File

@@ -637,7 +637,7 @@ class BluetoothManager:
else: else:
# We could write out every item in the typed dict here # We could write out every item in the typed dict here
# but that would be a bit inefficient and verbose. # but that would be a bit inefficient and verbose.
callback_matcher.update(matcher) # type: ignore[typeddict-item] callback_matcher.update(matcher)
callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True)
connectable = callback_matcher[CONNECTABLE] connectable = callback_matcher[CONNECTABLE]

View File

@@ -45,6 +45,7 @@ class BroadlinkLight(BroadlinkEntity, LightEntity):
"""Representation of a Broadlink light.""" """Representation of a Broadlink light."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = None
def __init__(self, device): def __init__(self, device):
"""Initialize the light.""" """Initialize the light."""

View File

@@ -217,6 +217,9 @@ def async_log_errors(
class DenonDevice(MediaPlayerEntity): class DenonDevice(MediaPlayerEntity):
"""Representation of a Denon Media Player Device.""" """Representation of a Denon Media Player Device."""
_attr_has_entity_name = True
_attr_name = None
def __init__( def __init__(
self, self,
receiver: DenonAVR, receiver: DenonAVR,
@@ -225,7 +228,6 @@ class DenonDevice(MediaPlayerEntity):
update_audyssey: bool, update_audyssey: bool,
) -> None: ) -> None:
"""Initialize the device.""" """Initialize the device."""
self._attr_name = receiver.name
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
assert config_entry.unique_id assert config_entry.unique_id
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
@@ -234,7 +236,7 @@ class DenonDevice(MediaPlayerEntity):
identifiers={(DOMAIN, config_entry.unique_id)}, identifiers={(DOMAIN, config_entry.unique_id)},
manufacturer=config_entry.data[CONF_MANUFACTURER], manufacturer=config_entry.data[CONF_MANUFACTURER],
model=config_entry.data[CONF_MODEL], model=config_entry.data[CONF_MODEL],
name=config_entry.title, name=receiver.name,
) )
self._attr_sound_mode_list = receiver.sound_mode_list self._attr_sound_mode_list = receiver.sound_mode_list

View File

@@ -24,7 +24,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@@ -117,3 +117,8 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
self._attr_native_value = time self._attr_native_value = time
super()._handle_coordinator_update() super()._handle_coordinator_update()
return return
@property
def available(self) -> bool:
"""Sensor only available when open."""
return super().available and self._attr_native_value is not None

View File

@@ -16,11 +16,12 @@ from .coordinator import MicroBotDataUpdateCoordinator
class MicroBotEntity(PassiveBluetoothCoordinatorEntity[MicroBotDataUpdateCoordinator]): class MicroBotEntity(PassiveBluetoothCoordinatorEntity[MicroBotDataUpdateCoordinator]):
"""Generic entity for all MicroBots.""" """Generic entity for all MicroBots."""
_attr_has_entity_name = True
def __init__(self, coordinator, config_entry): def __init__(self, coordinator, config_entry):
"""Initialise the entity.""" """Initialise the entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._address = self.coordinator.ble_device.address self._address = self.coordinator.ble_device.address
self._attr_name = "Push"
self._attr_unique_id = self._address self._attr_unique_id = self._address
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_BLUETOOTH, self._address)}, connections={(dr.CONNECTION_BLUETOOTH, self._address)},

View File

@@ -24,6 +24,13 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
} }
}, },
"entity": {
"switch": {
"push": {
"name": "Push"
}
}
},
"services": { "services": {
"calibrate": { "calibrate": {
"name": "Calibrate", "name": "Calibrate",

View File

@@ -43,7 +43,7 @@ async def async_setup_entry(
class MicroBotBinarySwitch(MicroBotEntity, SwitchEntity): class MicroBotBinarySwitch(MicroBotEntity, SwitchEntity):
"""MicroBot switch class.""" """MicroBot switch class."""
_attr_has_entity_name = True _attr_translation_key = "push"
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch.""" """Turn on the switch."""

View File

@@ -48,7 +48,7 @@ class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEnti
BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = { BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = {
LitterRobot: ( LitterRobot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[LitterRobot]( RobotBinarySensorEntityDescription[LitterRobot](
key="sleeping", key="sleeping",
translation_key="sleeping", translation_key="sleeping",
@@ -66,7 +66,7 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, .
is_on_fn=lambda robot: robot.sleep_mode_enabled, is_on_fn=lambda robot: robot.sleep_mode_enabled,
), ),
), ),
Robot: ( Robot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[Robot]( RobotBinarySensorEntityDescription[Robot](
key="power_status", key="power_status",
translation_key="power_status", translation_key="power_status",

View File

@@ -48,7 +48,7 @@ class RobotSelectEntityDescription(
ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = {
LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( # type: ignore[type-abstract] # only used for isinstance check
key="cycle_delay", key="cycle_delay",
translation_key="cycle_delay", translation_key="cycle_delay",
icon="mdi:timer-outline", icon="mdi:timer-outline",

View File

@@ -66,7 +66,7 @@ class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity):
ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
LitterRobot: [ LitterRobot: [ # type: ignore[type-abstract] # only used for isinstance check
RobotSensorEntityDescription[LitterRobot]( RobotSensorEntityDescription[LitterRobot](
key="waste_drawer_level", key="waste_drawer_level",
translation_key="waste_drawer", translation_key="waste_drawer",

View File

@@ -75,6 +75,7 @@ FAN_INV_MODES = list(FAN_INV_MODE_MAP)
MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API
MIN_TEMP = 10 MIN_TEMP = 10
MAX_TEMP = 32 MAX_TEMP = 32
MIN_TEMP_RANGE = 1.66667
async def async_setup_entry( async def async_setup_entry(
@@ -313,6 +314,13 @@ class ThermostatEntity(ClimateEntity):
try: try:
if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL: if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL:
if low_temp and high_temp: if low_temp and high_temp:
if high_temp - low_temp < MIN_TEMP_RANGE:
# Ensure there is a minimum gap from the new temp. Pick
# the temp that is not changing as the one to move.
if abs(high_temp - self.target_temperature_high) < 0.01:
high_temp = low_temp + MIN_TEMP_RANGE
else:
low_temp = high_temp - MIN_TEMP_RANGE
await trait.set_range(low_temp, high_temp) await trait.set_range(low_temp, high_temp)
elif hvac_mode == HVACMode.COOL and temp: elif hvac_mode == HVACMode.COOL and temp:
await trait.set_cool(temp) await trait.set_cool(temp)

View File

@@ -12,8 +12,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@@ -5,7 +5,6 @@ import logging
import ssl import ssl
from typing import Any from typing import Any
from jsonpath import jsonpath
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@@ -39,13 +38,13 @@ from homeassistant.helpers.template_entity import (
) )
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.json import json_loads
from . import async_get_config_and_coordinator, create_rest_data_from_config from . import async_get_config_and_coordinator, create_rest_data_from_config
from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, DEFAULT_SENSOR_NAME from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, DEFAULT_SENSOR_NAME
from .data import RestData from .data import RestData
from .entity import RestEntity from .entity import RestEntity
from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA
from .util import parse_json_attributes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -163,32 +162,9 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity, SensorEntity):
value = self.rest.data_without_xml() value = self.rest.data_without_xml()
if self._json_attrs: if self._json_attrs:
if value: self._attr_extra_state_attributes = parse_json_attributes(
try: value, self._json_attrs, self._json_attrs_path
json_dict = json_loads(value)
if self._json_attrs_path is not None:
json_dict = jsonpath(json_dict, self._json_attrs_path)
# jsonpath will always store the result in json_dict[0]
# so the next line happens to work exactly as needed to
# find the result
if isinstance(json_dict, list):
json_dict = json_dict[0]
if isinstance(json_dict, dict):
attrs = {
k: json_dict[k] for k in self._json_attrs if k in json_dict
}
self._attr_extra_state_attributes = attrs
else:
_LOGGER.warning(
"JSON result was not a dictionary"
" or list with 0th element a dictionary"
) )
except ValueError:
_LOGGER.warning("REST result could not be parsed as JSON")
_LOGGER.debug("Erroneous JSON: %s", value)
else:
_LOGGER.warning("Empty reply found when expecting JSON data")
raw_value = value raw_value = value

View File

@@ -0,0 +1,40 @@
"""Helpers for RESTful API."""
import logging
from typing import Any
from jsonpath import jsonpath
from homeassistant.util.json import json_loads
_LOGGER = logging.getLogger(__name__)
def parse_json_attributes(
value: str | None, json_attrs: list[str], json_attrs_path: str | None
) -> dict[str, Any]:
"""Parse JSON attributes."""
if not value:
_LOGGER.warning("Empty reply found when expecting JSON data")
return {}
try:
json_dict = json_loads(value)
if json_attrs_path is not None:
json_dict = jsonpath(json_dict, json_attrs_path)
# jsonpath will always store the result in json_dict[0]
# so the next line happens to work exactly as needed to
# find the result
if isinstance(json_dict, list):
json_dict = json_dict[0]
if isinstance(json_dict, dict):
return {k: json_dict[k] for k in json_attrs if k in json_dict}
_LOGGER.warning(
"JSON result was not a dictionary or list with 0th element a dictionary"
)
except ValueError:
_LOGGER.warning("REST result could not be parsed as JSON")
_LOGGER.debug("Erroneous JSON: %s", value)
return {}

View File

@@ -618,8 +618,13 @@ class EntityPlatform:
**device_info, **device_info,
) )
except dev_reg.DeviceInfoError as exc: except dev_reg.DeviceInfoError as exc:
self.logger.error("Ignoring invalid device info: %s", str(exc)) self.logger.error(
device = None "%s: Not adding entity with invalid device info: %s",
self.platform_name,
str(exc),
)
entity.add_to_platform_abort()
return
else: else:
device = None device = None

View File

@@ -17,7 +17,7 @@ warn_unused_configs = true
warn_unused_ignores = true warn_unused_ignores = true
enable_error_code = ignore-without-code, redundant-self, truthy-iterable enable_error_code = ignore-without-code, redundant-self, truthy-iterable
disable_error_code = annotation-unchecked disable_error_code = annotation-unchecked
strict_concatenate = false extra_checks = false
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true
disallow_subclassing_any = true disallow_subclassing_any = true

View File

@@ -11,7 +11,7 @@ astroid==2.15.4
coverage==7.2.7 coverage==7.2.7
freezegun==1.2.2 freezegun==1.2.2
mock-open==1.4.0 mock-open==1.4.0
mypy==1.4.1 mypy==1.5.0
pre-commit==3.3.3 pre-commit==3.3.3
pydantic==1.10.12 pydantic==1.10.12
pylint==2.17.4 pylint==2.17.4

View File

@@ -51,8 +51,9 @@ GENERAL_SETTINGS: Final[dict[str, str]] = {
] ]
), ),
"disable_error_code": ", ".join(["annotation-unchecked"]), "disable_error_code": ", ".join(["annotation-unchecked"]),
# Strict_concatenate breaks passthrough ParamSpec typing # Impractical in real code
"strict_concatenate": "false", # E.g. this breaks passthrough ParamSpec typing with Concatenate
"extra_checks": "false",
} }
# This is basically the list of checks which is enabled for "strict=true". # This is basically the list of checks which is enabled for "strict=true".

View File

@@ -35,7 +35,7 @@
'entity_id': 'sensor.mock_title_valve_closing', 'entity_id': 'sensor.mock_title_valve_closing',
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'unknown', 'state': 'unavailable',
}) })
# --- # ---
# name: test_setup[98bd2a19-0b0e-421a-84e5-ddbf75dc6de4-raw0-sensor.mock_title_battery] # name: test_setup[98bd2a19-0b0e-421a-84e5-ddbf75dc6de4-raw0-sensor.mock_title_battery]

View File

@@ -758,6 +758,75 @@ async def test_thermostat_set_temperature_hvac_mode(
} }
@pytest.mark.parametrize(
("setpoint", "target_low", "target_high", "expected_params"),
[
(
{
"heatCelsius": 19.0,
"coolCelsius": 25.0,
},
19.0,
20.0,
# Cool is accepted and lowers heat by the min range
{"heatCelsius": 18.33333, "coolCelsius": 20.0},
),
(
{
"heatCelsius": 19.0,
"coolCelsius": 25.0,
},
24.0,
25.0,
# Cool is accepted and lowers heat by the min range
{"heatCelsius": 24.0, "coolCelsius": 25.66667},
),
],
)
async def test_thermostat_set_temperature_range_too_close(
hass: HomeAssistant,
setup_platform: PlatformSetup,
auth: FakeAuth,
create_device: CreateDevice,
setpoint: dict[str, Any],
target_low: float,
target_high: float,
expected_params: dict[str, Any],
) -> None:
"""Test setting an HVAC temperature range that is too small of a range."""
create_device.create(
{
"sdm.devices.traits.ThermostatHvac": {"status": "OFF"},
"sdm.devices.traits.ThermostatMode": {
"availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"],
"mode": "HEATCOOL",
},
"sdm.devices.traits.ThermostatTemperatureSetpoint": setpoint,
},
)
await setup_platform()
assert len(hass.states.async_all()) == 1
thermostat = hass.states.get("climate.my_thermostat")
assert thermostat is not None
assert thermostat.state == HVACMode.HEAT_COOL
# Move the target temp to be in too small of a range
await common.async_set_temperature(
hass,
target_temp_low=target_low,
target_temp_high=target_high,
)
await hass.async_block_till_done()
assert auth.method == "post"
assert auth.url == DEVICE_COMMAND
assert auth.json == {
"command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange",
"params": expected_params,
}
async def test_thermostat_set_heat_cool( async def test_thermostat_set_heat_cool(
hass: HomeAssistant, hass: HomeAssistant,
setup_platform: PlatformSetup, setup_platform: PlatformSetup,

View File

@@ -1853,23 +1853,27 @@ async def test_device_name_defaulting_config_entry(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("device_info"), ("device_info", "number_of_entities"),
[ [
# No identifiers # No identifiers
{}, ({}, 1), # Empty device info does not prevent the entity from being created
{"name": "bla"}, ({"name": "bla"}, 0),
{"default_name": "bla"}, ({"default_name": "bla"}, 0),
# Match multiple types # Match multiple types
(
{ {
"identifiers": {("hue", "1234")}, "identifiers": {("hue", "1234")},
"name": "bla", "name": "bla",
"default_name": "yo", "default_name": "yo",
}, },
0,
),
], ],
) )
async def test_device_type_error_checking( async def test_device_type_error_checking(
hass: HomeAssistant, hass: HomeAssistant,
device_info: dict, device_info: dict,
number_of_entities: int,
) -> None: ) -> None:
"""Test catching invalid device info.""" """Test catching invalid device info."""
@@ -1895,6 +1899,6 @@ async def test_device_type_error_checking(
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
assert len(dev_reg.devices) == 0 assert len(dev_reg.devices) == 0
# Entity should still be registered
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
assert ent_reg.async_get("test_domain.test_qwer") is not None assert len(ent_reg.entities) == number_of_entities
assert len(hass.states.async_all()) == number_of_entities