diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index ce778e0309b..bd91c622316 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -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] diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index d42e2b76b99..796698c6a4c 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -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.""" diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 5674480d493..cad6656d01d 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -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 diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 6cda8c0b304..7bc683d245d 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -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 diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index eaa44d9d4fb..ebc83ae88af 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -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 diff --git a/homeassistant/components/keymitt_ble/entity.py b/homeassistant/components/keymitt_ble/entity.py index b61f8a3c24d..a9294bce239 100644 --- a/homeassistant/components/keymitt_ble/entity.py +++ b/homeassistant/components/keymitt_ble/entity.py @@ -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)}, diff --git a/homeassistant/components/keymitt_ble/strings.json b/homeassistant/components/keymitt_ble/strings.json index ab2d4ad9440..2a1f428603e 100644 --- a/homeassistant/components/keymitt_ble/strings.json +++ b/homeassistant/components/keymitt_ble/strings.json @@ -24,6 +24,13 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, + "entity": { + "switch": { + "push": { + "name": "Push" + } + } + }, "services": { "calibrate": { "name": "Calibrate", diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py index 3e5883ae5d0..4c9f0c335a7 100644 --- a/homeassistant/components/keymitt_ble/switch.py +++ b/homeassistant/components/keymitt_ble/switch.py @@ -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.""" diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 5308a3b4f83..0872c5c831d 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -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", diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 6fabd6ea526..7f2ea62f956 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -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", diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index ba601a0ba54..935bbaca595 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -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", diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 02874cab84c..0dcdec1cac1 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -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) diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index a890d022e0a..e6a165b36ee 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -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 diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 1a74735c670..f7743a853ad 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -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 diff --git a/homeassistant/components/rest/util.py b/homeassistant/components/rest/util.py new file mode 100644 index 00000000000..5625be3897a --- /dev/null +++ b/homeassistant/components/rest/util.py @@ -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 {} diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 9d6a1d0e1d2..c164e3b1052 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -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 diff --git a/mypy.ini b/mypy.ini index b3ab53bf8a9..1c47ad019a2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/requirements_test.txt b/requirements_test.txt index 79a26736b2b..73267ff5ab3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -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 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index ad4a0f64fe4..779d76078d6 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -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". diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr index 14135cb390c..8df37b40abc 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -35,7 +35,7 @@ 'entity_id': 'sensor.mock_title_valve_closing', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_setup[98bd2a19-0b0e-421a-84e5-ddbf75dc6de4-raw0-sensor.mock_title_battery] diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index 037894b43f5..c920eb5717d 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -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, diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 77914a49894..0bbfedb8926 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -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