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:
# We could write out every item in the typed dict here
# 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)
connectable = callback_matcher[CONNECTABLE]

View File

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

View File

@@ -217,6 +217,9 @@ def async_log_errors(
class DenonDevice(MediaPlayerEntity):
"""Representation of a Denon Media Player Device."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
receiver: DenonAVR,
@@ -225,7 +228,6 @@ class DenonDevice(MediaPlayerEntity):
update_audyssey: bool,
) -> None:
"""Initialize the device."""
self._attr_name = receiver.name
self._attr_unique_id = unique_id
assert config_entry.unique_id
self._attr_device_info = DeviceInfo(
@@ -234,7 +236,7 @@ class DenonDevice(MediaPlayerEntity):
identifiers={(DOMAIN, config_entry.unique_id)},
manufacturer=config_entry.data[CONF_MANUFACTURER],
model=config_entry.data[CONF_MODEL],
name=config_entry.title,
name=receiver.name,
)
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.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
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.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@@ -117,3 +117,8 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
self._attr_native_value = time
super()._handle_coordinator_update()
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]):
"""Generic entity for all MicroBots."""
_attr_has_entity_name = True
def __init__(self, coordinator, config_entry):
"""Initialise the entity."""
super().__init__(coordinator)
self._address = self.coordinator.ble_device.address
self._attr_name = "Push"
self._attr_unique_id = self._address
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_BLUETOOTH, self._address)},

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEnti
BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = {
LitterRobot: (
LitterRobot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[LitterRobot](
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,
),
),
Robot: (
Robot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[Robot](
key="power_status",
translation_key="power_status",

View File

@@ -48,7 +48,7 @@ class 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",
translation_key="cycle_delay",
icon="mdi:timer-outline",

View File

@@ -66,7 +66,7 @@ class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity):
ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
LitterRobot: [
LitterRobot: [ # type: ignore[type-abstract] # only used for isinstance check
RobotSensorEntityDescription[LitterRobot](
key="waste_drawer_level",
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
MIN_TEMP = 10
MAX_TEMP = 32
MIN_TEMP_RANGE = 1.66667
async def async_setup_entry(
@@ -313,6 +314,13 @@ class ThermostatEntity(ClimateEntity):
try:
if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL:
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)
elif hvac_mode == HVACMode.COOL and 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.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@@ -5,7 +5,6 @@ import logging
import ssl
from typing import Any
from jsonpath import jsonpath
import voluptuous as vol
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.update_coordinator import DataUpdateCoordinator
from homeassistant.util.json import json_loads
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 .data import RestData
from .entity import RestEntity
from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA
from .util import parse_json_attributes
_LOGGER = logging.getLogger(__name__)
@@ -163,32 +162,9 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity, SensorEntity):
value = self.rest.data_without_xml()
if self._json_attrs:
if value:
try:
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")
self._attr_extra_state_attributes = parse_json_attributes(
value, self._json_attrs, self._json_attrs_path
)
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,
)
except dev_reg.DeviceInfoError as exc:
self.logger.error("Ignoring invalid device info: %s", str(exc))
device = None
self.logger.error(
"%s: Not adding entity with invalid device info: %s",
self.platform_name,
str(exc),
)
entity.add_to_platform_abort()
return
else:
device = None

View File

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

View File

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

View File

@@ -51,8 +51,9 @@ GENERAL_SETTINGS: Final[dict[str, str]] = {
]
),
"disable_error_code": ", ".join(["annotation-unchecked"]),
# Strict_concatenate breaks passthrough ParamSpec typing
"strict_concatenate": "false",
# Impractical in real code
# 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".

View File

@@ -35,7 +35,7 @@
'entity_id': 'sensor.mock_title_valve_closing',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
'state': 'unavailable',
})
# ---
# 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(
hass: HomeAssistant,
setup_platform: PlatformSetup,

View File

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