diff --git a/CODEOWNERS b/CODEOWNERS index f277a7016fe9e3..a677b5ac081cbf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1109,6 +1109,8 @@ CLAUDE.md @home-assistant/core /tests/components/lyric/ @timmo001 /homeassistant/components/madvr/ @iloveicedgreentea /tests/components/madvr/ @iloveicedgreentea +/homeassistant/components/map_tiles/ @home-assistant/core +/tests/components/map_tiles/ @home-assistant/core /homeassistant/components/marantz_infrared/ @balloob /tests/components/marantz_infrared/ @balloob /homeassistant/components/mastodon/ @fabaff @andrew-codechimp diff --git a/homeassistant/components/alexa_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py index 59e84384f7a71d..09a061737a2a2e 100644 --- a/homeassistant/components/alexa_devices/diagnostics.py +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -1,14 +1,14 @@ """Diagnostics support for Alexa Devices integration.""" from dataclasses import asdict -from typing import Any +from typing import TYPE_CHECKING, Any from aioamazondevices.structures import AmazonDevice from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceEntry from .coordinator import AmazonConfigEntry @@ -48,13 +48,16 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: AmazonConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, entry: AmazonConfigEntry, device_entry: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" coordinator = entry.runtime_data - assert device_entry.serial_number + if TYPE_CHECKING: + # alexa_devices does not create child devices, and devices have a serial number + assert isinstance(device_entry, DeviceEntry) + assert device_entry.serial_number return build_device_data(coordinator.data[device_entry.serial_number]) diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index 149fccc0a84ea8..e891601c0dc501 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -24,12 +24,16 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import run_callback_threadsafe _KEY_INSTANCE = "configurator" +# The configurator integration is deprecated and can be removed in HA Core 2027.10 +_BREAKS_IN_HA_VERSION = "2027.10" + DATA_REQUESTS = "configurator_requests" ATTR_CONFIGURE_ID = "configure_id" @@ -54,6 +58,17 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +def _report_deprecation() -> None: + """Report use of the deprecated configurator integration.""" + report_usage( + "uses the deprecated configurator integration, which should be replaced " + "by a config flow", + breaks_in_ha_version=_BREAKS_IN_HA_VERSION, + core_behavior=ReportBehavior.LOG, + exclude_integrations={DOMAIN}, + ) + + @async_callback def async_request_config( hass: HomeAssistant, @@ -69,6 +84,38 @@ def async_request_config( ) -> str: """Create a new request for configuration. + Will return an ID to be used for subsequent calls. + """ + _report_deprecation() + return _async_request_config( + hass, + name, + callback=callback, + description=description, + description_image=description_image, + submit_caption=submit_caption, + fields=fields, + link_name=link_name, + link_url=link_url, + entity_picture=entity_picture, + ) + + +@async_callback +def _async_request_config( + hass: HomeAssistant, + name: str, + callback: ConfiguratorCallback | None = None, + description: str | None = None, + description_image: str | None = None, + submit_caption: str | None = None, + fields: list[dict[str, str]] | None = None, + link_name: str | None = None, + link_url: str | None = None, + entity_picture: str | None = None, +) -> str: + """Create a new request for configuration. + Will return an ID to be used for sequent calls. """ if description and link_name is not None and link_url is not None: @@ -97,8 +144,9 @@ def request_config(hass: HomeAssistant, *args: Any, **kwargs: Any) -> str: Will return an ID to be used for sequent calls. """ + _report_deprecation() return run_callback_threadsafe( - hass.loop, ft.partial(async_request_config, hass, *args, **kwargs) + hass.loop, ft.partial(_async_request_config, hass, *args, **kwargs) ).result() diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index 9326961c5d8cfb..bc4af4f28df3a5 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -6,7 +6,7 @@ import attr from homeassistant.core import callback -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry from .const import REDACTED @@ -65,7 +65,7 @@ def _device_entry_filter(a: attr.Attribute, _: Any) -> bool: @callback -def device_entry_as_dict(entry: DeviceEntry) -> dict[str, Any]: +def device_entry_as_dict(entry: AnyDeviceEntry) -> dict[str, Any]: """Convert a device registry entry to a dict for diagnostics. This excludes internal fields that should not be exposed in diagnostics. diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index bf1c8e62138c2b..5588f2f06c049e 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.12.0"], + "requirements": ["python-duco-connectivity==0.13.1"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/homeassistant/components/ecowitt/diagnostics.py b/homeassistant/components/ecowitt/diagnostics.py index e936eb92a9819b..1b7c35ce96e152 100644 --- a/homeassistant/components/ecowitt/diagnostics.py +++ b/homeassistant/components/ecowitt/diagnostics.py @@ -3,14 +3,14 @@ from typing import Any from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from . import EcowittConfigEntry from .const import DOMAIN async def async_get_device_diagnostics( - hass: HomeAssistant, entry: EcowittConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: EcowittConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" ecowitt = entry.runtime_data diff --git a/homeassistant/components/fibaro/diagnostics.py b/homeassistant/components/fibaro/diagnostics.py index b2c41e8ef8f12c..4d94d3cd71f16e 100644 --- a/homeassistant/components/fibaro/diagnostics.py +++ b/homeassistant/components/fibaro/diagnostics.py @@ -7,7 +7,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from . import CONF_IMPORT_PLUGINS, FibaroConfigEntry @@ -34,7 +34,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: FibaroConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: FibaroConfigEntry, device: AnyDeviceEntry ) -> Mapping[str, Any]: """Return diagnostics for a device.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 3dc2553190aa3e..932979620cf595 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -11,6 +11,7 @@ "file_upload", "http", "lovelace", + "map_tiles", "onboarding", "repairs", "search", diff --git a/homeassistant/components/fully_kiosk/diagnostics.py b/homeassistant/components/fully_kiosk/diagnostics.py index a83ffcc36dc413..9576fc38e8cb16 100644 --- a/homeassistant/components/fully_kiosk/diagnostics.py +++ b/homeassistant/components/fully_kiosk/diagnostics.py @@ -54,7 +54,7 @@ async def async_get_device_diagnostics( - hass: HomeAssistant, entry: FullyKioskConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, entry: FullyKioskConfigEntry, device: dr.AnyDeviceEntry ) -> dict[str, Any]: """Return device diagnostics.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/heos/diagnostics.py b/homeassistant/components/heos/diagnostics.py index bf33fc9bc150a0..2233dc3219b40d 100644 --- a/homeassistant/components/heos/diagnostics.py +++ b/homeassistant/components/heos/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import ATTR_PASSWORD, ATTR_USERNAME, DOMAIN from .coordinator import HeosConfigEntry @@ -66,7 +66,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: HeosConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: HeosConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index 364a109cf19de7..e0f29b9b32a54b 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -5,7 +5,7 @@ from aiohomeconnect.model import GetSetting, Status from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry @@ -53,7 +53,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: HomeConnectConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" ha_id = next( diff --git a/homeassistant/components/homee/diagnostics.py b/homeassistant/components/homee/diagnostics.py index 44f92c1a31d303..0978198574842a 100644 --- a/homeassistant/components/homee/diagnostics.py +++ b/homeassistant/components/homee/diagnostics.py @@ -11,7 +11,7 @@ CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from . import DOMAIN, HomeeConfigEntry @@ -42,7 +42,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: HomeeConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: HomeeConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" diff --git a/homeassistant/components/homekit_controller/diagnostics.py b/homeassistant/components/homekit_controller/diagnostics.py index a90eb9ea25f4cb..228d88e8dd6a71 100644 --- a/homeassistant/components/homekit_controller/diagnostics.py +++ b/homeassistant/components/homekit_controller/diagnostics.py @@ -1,6 +1,6 @@ """Diagnostics support for HomeKit Controller.""" -from typing import Any +from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics.characteristic_types import CharacteristicsTypes @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceEntry from .connection import HKDevice from .const import KNOWN_DEVICES @@ -33,9 +33,13 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: ConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" + if TYPE_CHECKING: + # homekit_controller does not create child devices + assert isinstance(device, DeviceEntry) + return _async_get_diagnostics(hass, entry, device) diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py index 89a04a4b143dd6..114353da50375c 100644 --- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -11,7 +11,7 @@ from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import REDACT_HUB_ADDRESS, REDACT_MAC_ADDRESS, REDACT_SERIAL_NUMBER from .model import PowerviewConfigEntry @@ -43,7 +43,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: PowerviewConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: PowerviewConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" data = _async_get_diagnostics(hass, entry) @@ -71,7 +71,9 @@ def _async_get_diagnostics( @callback -def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str, Any]: +def _async_device_as_dict( + hass: HomeAssistant, device: AnyDeviceEntry +) -> dict[str, Any]: """Represent a Powerview device as a dictionary.""" # Gather information how this device is represented in Home Assistant diff --git a/homeassistant/components/husqvarna_automower/diagnostics.py b/homeassistant/components/husqvarna_automower/diagnostics.py index bb0d7bb41432c6..9553c2a6c4d7f1 100644 --- a/homeassistant/components/husqvarna_automower/diagnostics.py +++ b/homeassistant/components/husqvarna_automower/diagnostics.py @@ -1,12 +1,12 @@ """Diagnostics support for Husqvarna Automower.""" import logging -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceEntry from . import AutomowerConfigEntry from .const import DOMAIN @@ -30,9 +30,13 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: AutomowerConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: AutomowerConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" + if TYPE_CHECKING: + # husqvarna_automower does not create child devices + assert isinstance(device, DeviceEntry) + coordinator = entry.runtime_data for identifier in device.identifiers: if identifier[0] == DOMAIN: diff --git a/homeassistant/components/iotawatt/const.py b/homeassistant/components/iotawatt/const.py index 01034ae2a703a1..a343ed2a5afe0c 100644 --- a/homeassistant/components/iotawatt/const.py +++ b/homeassistant/components/iotawatt/const.py @@ -5,7 +5,6 @@ import httpx DOMAIN = "iotawatt" -VOLT_AMPERE_REACTIVE = "VAR" VOLT_AMPERE_REACTIVE_HOURS = "VARh" CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 444e7e010ba179..4f874db65f4b36 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -21,6 +21,7 @@ UnitOfEnergy, UnitOfFrequency, UnitOfPower, + UnitOfReactivePower, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -29,7 +30,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .const import VOLT_AMPERE_REACTIVE_HOURS from .coordinator import IotawattConfigEntry, IotawattUpdater _LOGGER = logging.getLogger(__name__) @@ -87,9 +88,9 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): ), "VAR": IotaWattSensorEntityDescription( key="VAR", - native_unit_of_measurement=VOLT_AMPERE_REACTIVE, + native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:flash", + device_class=SensorDeviceClass.REACTIVE_POWER, entity_registry_enabled_default=False, ), "VARh": IotaWattSensorEntityDescription( diff --git a/homeassistant/components/lg_infrared/__init__.py b/homeassistant/components/lg_infrared/__init__.py index cb3535eada4893..0345ef85d3a5be 100644 --- a/homeassistant/components/lg_infrared/__init__.py +++ b/homeassistant/components/lg_infrared/__init__.py @@ -6,7 +6,13 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.EVENT, Platform.MEDIA_PLAYER] +PLATFORMS = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.EVENT, + Platform.MEDIA_PLAYER, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lg_infrared/climate.py b/homeassistant/components/lg_infrared/climate.py index 77a6d8b2864cff..12f5d772de4864 100644 --- a/homeassistant/components/lg_infrared/climate.py +++ b/homeassistant/components/lg_infrared/climate.py @@ -2,21 +2,26 @@ from typing import Any, override +from infrared_protocols.codes.lg.ac import LGACCode from infrared_protocols.commands.lg_ac import ( MAX_TEMP, MIN_TEMP, LgAcCommand, LgAcFanSpeed, + LgAcFixedCommand, LgAcMode, ) from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, + ATTR_SWING_HORIZONTAL_MODE, + ATTR_SWING_MODE, FAN_AUTO, FAN_HIGH, FAN_LOW, FAN_MEDIUM, + SWING_OFF, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -75,6 +80,51 @@ # Only these modes carry a temperature in the LG AC protocol frame. _TEMPERATURE_MODES = (LgAcMode.COOL, LgAcMode.HEAT) +SWING_LOWEST = "lowest" +SWING_LOW = "low" +SWING_MIDDLE_LOW = "middle_low" +SWING_MIDDLE_HIGH = "middle_high" +SWING_HIGH = "high" +SWING_HIGHEST = "highest" +SWING_OSCILLATE = "swing" + +SWING_LEFT = "left" +SWING_MIDDLE_LEFT = "middle_left" +SWING_MIDDLE = "middle" +SWING_MIDDLE_RIGHT = "middle_right" +SWING_RIGHT = "right" + +# Oscillates in one half +SWING_LEFT_HALF = "left_half" +SWING_RIGHT_HALF = "right_half" + +# The six vane positions plus off and the oscillating "swing" mode share the vertical +# swing dropdown. There is no true centre position on this ladder. +_HA_SWING_TO_LIB: dict[str, LGACCode] = { + SWING_OFF: LGACCode.SWING_V_OFF, + SWING_HIGHEST: LGACCode.SWING_V_HIGHEST, + SWING_HIGH: LGACCode.SWING_V_HIGH, + SWING_MIDDLE_HIGH: LGACCode.SWING_V_MIDDLE_HIGH, + SWING_MIDDLE_LOW: LGACCode.SWING_V_MIDDLE_LOW, + SWING_LOW: LGACCode.SWING_V_LOW, + SWING_LOWEST: LGACCode.SWING_V_LOWEST, + SWING_OSCILLATE: LGACCode.SWING_V_SWING, +} +_LIB_SWING_TO_HA: dict[LGACCode, str] = {v: k for k, v in _HA_SWING_TO_LIB.items()} + +_HA_SWING_H_TO_LIB: dict[str, LGACCode] = { + SWING_OFF: LGACCode.SWING_H_OFF, + SWING_LEFT: LGACCode.SWING_H_LEFT, + SWING_MIDDLE_LEFT: LGACCode.SWING_H_MIDDLE_LEFT, + SWING_MIDDLE: LGACCode.SWING_H_MIDDLE, + SWING_MIDDLE_RIGHT: LGACCode.SWING_H_MIDDLE_RIGHT, + SWING_RIGHT: LGACCode.SWING_H_RIGHT, + SWING_LEFT_HALF: LGACCode.SWING_H_MIDDLE_TO_LEFT, + SWING_RIGHT_HALF: LGACCode.SWING_H_MIDDLE_TO_RIGHT, + SWING_OSCILLATE: LGACCode.SWING_H_SWING, +} +_LIB_SWING_H_TO_HA: dict[LGACCode, str] = {v: k for k, v in _HA_SWING_H_TO_LIB.items()} + async def async_setup_entry( hass: HomeAssistant, @@ -116,6 +166,8 @@ class LgAcClimateEntity( FAN_MEDIUM_HIGH, FAN_HIGH, ] + _attr_swing_modes = list(_HA_SWING_TO_LIB) + _attr_swing_horizontal_modes = list(_HA_SWING_H_TO_LIB) def __init__(self, entry: ConfigEntry, emitter_entity_id: str) -> None: """Initialize LG AC climate entity.""" @@ -129,8 +181,14 @@ def __init__(self, entry: ConfigEntry, emitter_entity_id: str) -> None: self._attr_hvac_mode = HVACMode.OFF self._attr_target_temperature = float(MIN_TEMP) self._attr_fan_mode = FAN_AUTO + self._attr_swing_mode = SWING_OFF + self._attr_swing_horizontal_mode = SWING_OFF - self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE + ) # Without a temperature-carrying mode no target temperature can ever be sent. if any( _HA_MODE_TO_LIB[mode] in _TEMPERATURE_MODES @@ -156,6 +214,12 @@ async def async_added_to_hass(self) -> None: self._attr_fan_mode = fan_mode if (temperature := last_state.attributes.get(ATTR_TEMPERATURE)) is not None: self._attr_target_temperature = float(temperature) + if (swing := last_state.attributes.get(ATTR_SWING_MODE)) in _HA_SWING_TO_LIB: + self._attr_swing_mode = swing + if ( + swing_h := last_state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) + ) in _HA_SWING_H_TO_LIB: + self._attr_swing_horizontal_mode = swing_h @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -202,6 +266,24 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: self._attr_fan_mode = fan_mode self.async_write_ha_state() + @override + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the vertical swing mode. + + Each vane position is a self-contained fixed code, so it is sent directly + rather than folded into the current state frame. + """ + await self._send_command(_HA_SWING_TO_LIB[swing_mode].to_command()) + self._attr_swing_mode = swing_mode + self.async_write_ha_state() + + @override + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set the horizontal swing mode.""" + await self._send_command(_HA_SWING_H_TO_LIB[swing_horizontal_mode].to_command()) + self._attr_swing_horizontal_mode = swing_horizontal_mode + self.async_write_ha_state() + def _build_command(self, mode: LgAcMode, temp: int, fan_mode: str) -> LgAcCommand: """Build a command from a mode, a temperature and a fan mode. @@ -227,6 +309,7 @@ def _handle_signal(self, signal: InfraredReceivedSignal) -> None: """Update state from a physical remote signal.""" command = LgAcCommand.from_raw_timings(signal.timings) if command is None: + self._handle_fixed_signal(signal) return hvac_mode = _LIB_MODE_TO_HA[command.mode] @@ -241,3 +324,21 @@ def _handle_signal(self, signal: InfraredReceivedSignal) -> None: self._attr_target_temperature = float(command.temperature) self.async_write_ha_state() + + @callback + def _handle_fixed_signal(self, signal: InfraredReceivedSignal) -> None: + """Update the swing state from a fixed-code frame (e.g. a vane button).""" + command = LgAcFixedCommand.from_raw_timings(signal.timings) + if command is None: + return + try: + code = LGACCode(command.code) + except ValueError: + return + + if (swing := _LIB_SWING_TO_HA.get(code)) is not None: + self._attr_swing_mode = swing + self.async_write_ha_state() + elif (swing_h := _LIB_SWING_H_TO_HA.get(code)) is not None: + self._attr_swing_horizontal_mode = swing_h + self.async_write_ha_state() diff --git a/homeassistant/components/lg_infrared/icons.json b/homeassistant/components/lg_infrared/icons.json index 896d1eb4e9986b..5ca08923f9ee89 100644 --- a/homeassistant/components/lg_infrared/icons.json +++ b/homeassistant/components/lg_infrared/icons.json @@ -88,6 +88,47 @@ "up": { "default": "mdi:arrow-up" } + }, + "climate": { + "lg_ac": { + "state_attributes": { + "swing_horizontal_mode": { + "default": "mdi:circle-medium", + "state": { + "left": "mdi:chevron-double-left", + "left_half": "mdi:arrow-left", + "middle": "mdi:circle-small", + "middle_left": "mdi:chevron-left", + "middle_right": "mdi:chevron-right", + "off": "mdi:arrow-oscillating-off", + "right": "mdi:chevron-double-right", + "right_half": "mdi:arrow-right", + "swing": "mdi:arrow-expand-horizontal" + } + }, + "swing_mode": { + "default": "mdi:circle-medium", + "state": { + "high": "mdi:chevron-up", + "highest": "mdi:chevron-double-up", + "low": "mdi:chevron-down", + "lowest": "mdi:chevron-double-down", + "middle_high": "mdi:arrow-up-thin", + "middle_low": "mdi:arrow-down-thin", + "off": "mdi:arrow-oscillating-off", + "swing": "mdi:arrow-oscillating" + } + } + } + } + }, + "switch": { + "auto_clean": { + "default": "mdi:broom" + }, + "ion_generator": { + "default": "mdi:air-purifier" + } } } } diff --git a/homeassistant/components/lg_infrared/quality_scale.yaml b/homeassistant/components/lg_infrared/quality_scale.yaml index 268b4f9c53564c..90bc25e6bad0e5 100644 --- a/homeassistant/components/lg_infrared/quality_scale.yaml +++ b/homeassistant/components/lg_infrared/quality_scale.yaml @@ -93,10 +93,7 @@ rules: status: exempt comment: | This integration does not raise exceptions. - icon-translations: - status: exempt - comment: | - This integration does not use custom icons. + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/lg_infrared/strings.json b/homeassistant/components/lg_infrared/strings.json index 3dfea8c7405ded..f9fbc8740ff71c 100644 --- a/homeassistant/components/lg_infrared/strings.json +++ b/homeassistant/components/lg_infrared/strings.json @@ -144,6 +144,31 @@ "medium_low": "Medium low", "quiet": "Quiet" } + }, + "swing_horizontal_mode": { + "state": { + "left": "Left", + "left_half": "Left half", + "middle": "Middle", + "middle_left": "Middle left", + "middle_right": "Middle right", + "off": "Off", + "right": "Right", + "right_half": "Right half", + "swing": "Swing" + } + }, + "swing_mode": { + "state": { + "high": "High", + "highest": "Highest", + "low": "Low", + "lowest": "Lowest", + "middle_high": "Middle high", + "middle_low": "Middle low", + "off": "Off", + "swing": "Swing" + } } } } @@ -210,6 +235,14 @@ } } } + }, + "switch": { + "auto_clean": { + "name": "Auto clean" + }, + "ion_generator": { + "name": "Ion generator" + } } }, "selector": { diff --git a/homeassistant/components/lg_infrared/switch.py b/homeassistant/components/lg_infrared/switch.py new file mode 100644 index 00000000000000..071dfad2016444 --- /dev/null +++ b/homeassistant/components/lg_infrared/switch.py @@ -0,0 +1,111 @@ +"""Switch platform for LG IR integration — LG AC toggles with discrete codes.""" + +from dataclasses import dataclass +from typing import Any, override + +from infrared_protocols.codes.lg.ac import LGACCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + EntityCategory, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, LGDeviceType +from .entity import LgIrEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class LgAcSwitchEntityDescription(SwitchEntityDescription): + """Describes an LG AC switch backed by separate on and off IR codes.""" + + on_code: LGACCode + off_code: LGACCode + + +AC_SWITCH_DESCRIPTIONS: tuple[LgAcSwitchEntityDescription, ...] = ( + LgAcSwitchEntityDescription( + key="ion_generator", + translation_key="ion_generator", + on_code=LGACCode.ION_GENERATOR_ON, + off_code=LGACCode.ION_GENERATOR_OFF, + ), + LgAcSwitchEntityDescription( + key="auto_clean", + translation_key="auto_clean", + on_code=LGACCode.AUTO_CLEAN_ON, + off_code=LGACCode.AUTO_CLEAN_OFF, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LG AC switches from a config entry.""" + if entry.data[CONF_DEVICE_TYPE] != LGDeviceType.AC: + return + + emitter_entity_id = entry.data[CONF_INFRARED_ENTITY_ID] + async_add_entities( + LgAcSwitch(entry, emitter_entity_id, description) + for description in AC_SWITCH_DESCRIPTIONS + ) + + +class LgAcSwitch( + LgIrEntity, InfraredEmitterConsumerEntity, SwitchEntity, RestoreEntity +): + """An LG AC feature toggled by two discrete infrared codes.""" + + _attr_assumed_state = True + entity_description: LgAcSwitchEntityDescription + + def __init__( + self, + entry: ConfigEntry, + emitter_entity_id: str, + description: LgAcSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(entry, unique_id_suffix=description.key, device_name="LG AC") + self._infrared_emitter_entity_id = emitter_entity_id + self.entity_description = description + self._attr_is_on = False + + @override + async def async_added_to_hass(self) -> None: + """Restore the assumed state, as infrared cannot read it back from the AC.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state is not None and last_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._attr_is_on = last_state.state == STATE_ON + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the feature on.""" + await self._send_command(self.entity_description.on_code.to_command()) + self._attr_is_on = True + self.async_write_ha_state() + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the feature off.""" + await self._send_command(self.entity_description.off_code.to_command()) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/lunatone/sensor.py b/homeassistant/components/lunatone/sensor.py index 5ddc6492484fcc..38e454c445d468 100644 --- a/homeassistant/components/lunatone/sensor.py +++ b/homeassistant/components/lunatone/sensor.py @@ -205,8 +205,10 @@ def __init__( self._line_id = line_id line_unique_id = f"{config_entry_unique_id}-line{line_id}" + # Name must match the light platform, either of them may create the device self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, line_unique_id)}, + name=f"DALI Line {line_id}", ) self._attr_unique_id = f"{line_unique_id}-status" diff --git a/homeassistant/components/map_tiles/__init__.py b/homeassistant/components/map_tiles/__init__.py new file mode 100644 index 00000000000000..b576174e8789d1 --- /dev/null +++ b/homeassistant/components/map_tiles/__init__.py @@ -0,0 +1,78 @@ +"""The Map tiles integration. + +Serves the frontend's base map - OpenStreetMap vector tiles, their TileJSON, +glyphs and sprites, and raster tiles for devices that cannot render vector ones. + +A proxy is needed because the OSMF tile policy wants requests identified via +`User-Agent` or `Referer`, and a browser can send neither: both are forbidden +header names, and the default referrer (the page origin) would expose the +user's Nabu Casa installation URL. +""" + +from collections import deque +from datetime import datetime +import secrets +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType + +from .cache import MapTilesCache +from .const import DATA_ACCESS_TOKENS, DOMAIN, TOKEN_CHANGE_INTERVAL, TOKEN_SIZE +from .views import ( + MapTilesGlyphsView, + MapTilesRasterView, + MapTilesSpriteIndexView, + MapTilesSpriteSheetView, + MapTilesTileJsonView, + MapTilesVectorView, +) + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Map tiles integration.""" + # Leaflet asks for raster tiles with an , which can carry no header, so + # the token has to live in the URL. + access_tokens: deque[str] = deque([secrets.token_hex(TOKEN_SIZE)], maxlen=2) + hass.data[DATA_ACCESS_TOKENS] = access_tokens + + @callback + def _rotate_token(_now: datetime) -> None: + """Rotate the access token.""" + access_tokens.append(secrets.token_hex(TOKEN_SIZE)) + + async_track_time_interval( + hass, _rotate_token, TOKEN_CHANGE_INTERVAL, cancel_on_shutdown=True + ) + + cache = MapTilesCache(hass) + for view in ( + MapTilesTileJsonView, + MapTilesVectorView, + MapTilesRasterView, + MapTilesGlyphsView, + MapTilesSpriteIndexView, + MapTilesSpriteSheetView, + ): + hass.http.register_view(view(hass, cache)) + + websocket_api.async_register_command(hass, ws_access_token) + return True + + +@callback +@websocket_api.websocket_command({vol.Required("type"): "map_tiles/access_token"}) +def ws_access_token( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return the current map tiles access token.""" + connection.send_result(msg["id"], {"token": hass.data[DATA_ACCESS_TOKENS][-1]}) diff --git a/homeassistant/components/map_tiles/cache.py b/homeassistant/components/map_tiles/cache.py new file mode 100644 index 00000000000000..93d863e948d2fa --- /dev/null +++ b/homeassistant/components/map_tiles/cache.py @@ -0,0 +1,111 @@ +"""Cache for the Map tiles integration.""" + +import asyncio +from collections import OrderedDict +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import time +from typing import Any, Final + +from homeassistant.core import HomeAssistant + +from .const import CACHE_MAX_BYTES, DOMAIN, MAX_CONCURRENT_FETCHES + +# Approximate bookkeeping cost of one entry (key, tuple, Asset, timestamp and +# dict slot), charged so tiny bodies cannot grow the entry count without bound. +_ENTRY_OVERHEAD: Final = 300 + + +@dataclass(frozen=True, slots=True) +class Asset: + """An upstream response body plus the Content-Encoding it is stored in. + + Kept compressed as upstream sent it, so a dense vector tile is not + re-compressed for every client that requests it. + """ + + body: bytes + encoding: str | None + ttl: float | None = None + + +type FetchCallback = Callable[[], Coroutine[Any, Any, Asset | None]] + + +def _entry_size(key: str, asset: Asset) -> int: + """Return what an entry counts against the size ceiling.""" + return len(asset.body) + len(key) + _ENTRY_OVERHEAD + + +class MapTilesCache: + """A bounded in-memory cache of upstream responses, keyed by asset path. + + Entries are never dropped for being stale, so only the size ceiling evicts, + least recently used first. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the cache.""" + self._hass = hass + self._max_bytes = CACHE_MAX_BYTES + self._entries: OrderedDict[str, tuple[Asset, float]] = OrderedDict() + self._size = 0 + self._fetches: dict[str, asyncio.Task[Asset | None]] = {} + self._fetch_semaphore = asyncio.Semaphore(MAX_CONCURRENT_FETCHES) + + async def async_get(self, key: str, ttl: int, fetch: FetchCallback) -> Asset | None: + """Return the entry for key, fetching or refreshing it as needed. + + ttl is the fallback refresh interval, used when the stored asset carries + no upstream max-age of its own. + """ + if (entry := self._entries.get(key)) is None: + return await self._async_fetch(key, fetch) + + self._entries.move_to_end(key) + asset, stored_at = entry + if time.monotonic() - stored_at > (ttl if asset.ttl is None else asset.ttl): + # Serve the stale entry now and refresh in the background, so an + # upstream outage degrades to slightly old tiles, not to no map. + self._hass.async_create_background_task( + self._async_fetch(key, fetch), f"{DOMAIN} refresh {key}" + ) + return asset + + def _store(self, key: str, asset: Asset) -> None: + """Store an entry, evicting until back under the size ceiling.""" + if (previous := self._entries.pop(key, None)) is not None: + self._size -= _entry_size(key, previous[0]) + + self._entries[key] = (asset, time.monotonic()) + self._size += _entry_size(key, asset) + + while self._size > self._max_bytes and len(self._entries) > 1: + evicted_key, (evicted, _stored_at) = self._entries.popitem(last=False) + self._size -= _entry_size(evicted_key, evicted) + + async def _async_fetch(self, key: str, fetch: FetchCallback) -> Asset | None: + """Fetch key upstream, joining a fetch already in flight for it.""" + if (pending := self._fetches.get(key)) is None: + pending = self._hass.async_create_task( + self._async_fetch_and_store(key, fetch), f"{DOMAIN} fetch {key}" + ) + if not pending.done(): + self._fetches[key] = pending + pending.add_done_callback(lambda _task: self._fetches.pop(key, None)) + + # Shielded: one client navigating away must not cancel the fetch the + # others are waiting on. + return await asyncio.shield(pending) + + async def _async_fetch_and_store( + self, key: str, fetch: FetchCallback + ) -> Asset | None: + """Fetch key upstream and store what comes back.""" + # Bounds parallel upstream requests and the in-flight body memory they + # hold; the store afterwards is synchronous and needs no slot. + async with self._fetch_semaphore: + asset = await fetch() + if asset is not None: + self._store(key, asset) + return asset diff --git a/homeassistant/components/map_tiles/const.py b/homeassistant/components/map_tiles/const.py new file mode 100644 index 00000000000000..8add11bd3eab94 --- /dev/null +++ b/homeassistant/components/map_tiles/const.py @@ -0,0 +1,88 @@ +"""Constants for the Map tiles integration.""" + +from collections import deque +from datetime import timedelta +import re +from typing import Final + +from aiohttp import ClientTimeout + +from homeassistant.const import __version__ +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "map_tiles" +DATA_ACCESS_TOKENS: HassKey[deque[str]] = HassKey(DOMAIN) + +VECTOR_URL: Final = "https://vector.openstreetmap.org" +RASTER_URL: Final = "https://tile.openstreetmap.org" +TILEJSON_URL: Final = f"{VECTOR_URL}/shortbread_v1/tilejson.json" +UPSTREAM_TIMEOUT: Final = ClientTimeout(total=10) + +CONTACT: Final = "abuse@home-assistant.io" +UPSTREAM_HEADERS: Final = { + # OSM blocks referer-less browser requests; the accepted alternative is an + # identifying application `User-Agent`, which this proxy supplies because + # a browser cannot. + "User-Agent": ( + f"HomeAssistant/{__version__} (+https://www.home-assistant.io; {CONTACT})" + ), + # Pinned to gzip so cached bodies are in an encoding every client accepts; + # the session default advertises whichever codecs happen to be installed. + "Accept-Encoding": "gzip", +} + +# Fallback refresh intervals, used only when upstream sends no Cache-Control +# max-age to honor. Intervals, not lifetimes: an expired entry is never dropped, +# because it is what keeps the map up while upstream is unreachable. +TILE_TTL: Final = 7 * 24 * 60 * 60 +# Glyphs and sprites are pinned to an upstream release and never change. +ASSET_TTL: Final = 30 * 24 * 60 * 60 +# Short: the TileJSON is how upstream would announce a moved tile endpoint. +TILEJSON_TTL: Final = 60 * 60 + +# The OSMF asks consumers to cache tiles for at least a week; the max-age +# delegates that to the browser cache instead of this instance's memory. +TILE_MAX_AGE: Final = 7 * 24 * 60 * 60 +ASSET_MAX_AGE: Final = 30 * 24 * 60 * 60 +TILEJSON_MAX_AGE: Final = 5 * 60 + +# A server side cache is needed because the access token rotates, which changes +# every URL and empties every browser cache with it. In memory rather than on +# disk: Home Assistant runs on SD cards, and losing the working set on restart +# costs a few dozen requests. +CACHE_MAX_BYTES: Final = 32 * 1024 * 1024 + +# Far above any legitimate asset (tiles top out at a few hundred KB), so only a +# hostile or broken upstream hits them; they bound what a single response can +# make this process hold in memory, on the wire and after decompression. +MAX_FETCH_BYTES: Final = 8 * 1024 * 1024 +MAX_DECOMPRESSED_BYTES: Final = 32 * 1024 * 1024 + +# Bounds both in-flight body memory (this many concurrent fetches, each capped +# at MAX_FETCH_BYTES) and how many parallel requests reach the volunteer-run OSM +# servers at once. +MAX_CONCURRENT_FETCHES: Final = 16 + +# MapLibre overzooms above the source maxzoom, so nothing legitimate asks for a +# vector tile past z14. +VECTOR_MAX_ZOOM: Final = 14 +RASTER_MAX_ZOOM: Final = 19 + +# OSM's own TileJSON omits "contributors", which their guidelines ask for. +ATTRIBUTION: Final = ( + '© OpenStreetMap' + " contributors" +) + +FONTSTACK_RE: Final = re.compile( + r"^[A-Za-z0-9 _-]{1,64}(?:,[A-Za-z0-9 _-]{1,64}){0,7}$" +) +GLYPH_RANGE_RE: Final = re.compile(r"^\d{1,5}-\d{1,5}\.pbf$") +SPRITE_SET_RE: Final = re.compile(r"^[a-z0-9_-]{1,32}$") +SPRITE_NAME_RE: Final = re.compile(r"^sprites(?:@2x)?$") + +# Bytes of entropy per access token. +TOKEN_SIZE: Final = 32 + +# Two tokens are live at a time, so one stays valid for 30 to 60 minutes. +TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=30) diff --git a/homeassistant/components/map_tiles/manifest.json b/homeassistant/components/map_tiles/manifest.json new file mode 100644 index 00000000000000..c14e45b1c6d7f1 --- /dev/null +++ b/homeassistant/components/map_tiles/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "map_tiles", + "name": "Map tiles", + "codeowners": ["@home-assistant/core"], + "config_flow": false, + "dependencies": ["http", "websocket_api"], + "documentation": "https://www.home-assistant.io/integrations/map_tiles", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/map_tiles/views.py b/homeassistant/components/map_tiles/views.py new file mode 100644 index 00000000000000..e5b7880d190b77 --- /dev/null +++ b/homeassistant/components/map_tiles/views.py @@ -0,0 +1,343 @@ +"""HTTP views for the Map tiles integration.""" + +from functools import partial +import gzip +from http import HTTPStatus +import json +import logging +from typing import Final, override +import zlib + +from aiohttp import ClientError, hdrs, web + +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.json import json_bytes + +from .cache import Asset, MapTilesCache +from .const import ( + ASSET_MAX_AGE, + ASSET_TTL, + ATTRIBUTION, + DATA_ACCESS_TOKENS, + FONTSTACK_RE, + GLYPH_RANGE_RE, + MAX_DECOMPRESSED_BYTES, + MAX_FETCH_BYTES, + RASTER_MAX_ZOOM, + RASTER_URL, + SPRITE_NAME_RE, + SPRITE_SET_RE, + TILE_MAX_AGE, + TILE_TTL, + TILEJSON_MAX_AGE, + TILEJSON_TTL, + TILEJSON_URL, + UPSTREAM_HEADERS, + UPSTREAM_TIMEOUT, + VECTOR_MAX_ZOOM, + VECTOR_URL, +) + +_LOGGER = logging.getLogger(__name__) + +# A root-relative path works behind any reverse proxy; an absolute URL built +# from the request's Host header would not if the proxy does not forward it. +VECTOR_TILE_PATH = "/api/map_tiles/vector/{z}/{x}/{y}.mvt" + +# Cap coordinate length before int(), which is expensive on huge digit strings. +MAX_COORDINATE_DIGITS = 8 + +GZIP: Final = "gzip" + + +def _gzip_decompress(body: bytes) -> bytes: + """Decompress a gzip body, refusing pathological expansion.""" + decompressor = zlib.decompressobj(wbits=16 + zlib.MAX_WBITS) + decompressed = decompressor.decompress(body, MAX_DECOMPRESSED_BYTES) + if decompressor.unconsumed_tail: + raise ValueError("Decompressed body too large") + if not decompressor.eof or decompressor.unused_data: + raise ValueError("Malformed gzip body") + return decompressed + + +def _upstream_ttl(cache_control: str) -> float | None: + """Return upstream's max-age in seconds, or None when it sends none.""" + for directive in cache_control.split(","): + name, _, value = directive.strip().partition("=") + if name.lower() == "max-age" and value.isdigit(): + return float(value) + return None + + +class _MapTilesView(HomeAssistantView): + """Serve one class of map asset, from the cache or from upstream.""" + + requires_auth = False + + content_type: str + ttl: int + max_age: int + + def __init__(self, hass: HomeAssistant, cache: MapTilesCache) -> None: + """Initialize the view.""" + self._hass = hass + self._cache = cache + + def _authenticate(self, request: web.Request) -> None: + """Authenticate via the standard middleware or a map tiles query token.""" + access_tokens = self._hass.data[DATA_ACCESS_TOKENS] + if request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens: + return + if hdrs.AUTHORIZATION in request.headers: + # A real Bearer attempt, so let the ban middleware count it. + raise web.HTTPUnauthorized + # Most likely a query token that expired while a dashboard sat open, so + # 403 rather than banning the user's own IP over it. + raise web.HTTPForbidden + + async def _async_serve(self, key: str, url: str) -> web.Response: + """Serve an asset from the cache, fetching it upstream on a miss. + + A gzip-encoded asset is served compressed to every client; Accept-Encoding + is intentionally not checked, since every browser accepts gzip. There is + therefore no identity variant, and hence no Vary on Accept-Encoding. + """ + asset = await self._cache.async_get( + key, self.ttl, partial(self._async_fetch, url) + ) + if asset is None: + return web.Response(status=HTTPStatus.BAD_GATEWAY) + + headers = {hdrs.CACHE_CONTROL: f"private, max-age={self.max_age}"} + if asset.encoding: + headers[hdrs.CONTENT_ENCODING] = asset.encoding + + return web.Response( + body=asset.body, content_type=self.content_type, headers=headers + ) + + async def _async_fetch(self, url: str) -> Asset | None: + """Fetch url upstream, returning None on any upstream failure.""" + session = async_get_clientsession(self._hass) + # Keep the body in the encoding upstream sent, so a gzipped asset is + # cached compressed instead of re-compressed for every client. + try: + async with session.get( + url, + headers=UPSTREAM_HEADERS, + timeout=UPSTREAM_TIMEOUT, + auto_decompress=False, + ) as response: + if response.status >= HTTPStatus.BAD_REQUEST: + _LOGGER.debug("Upstream %s returned %s", url, response.status) + return None + # Accumulated in chunks so a hostile upstream cannot make this + # process buffer an arbitrarily large response. An empty body + # is a legitimate answer: a vector tile with nothing in it + # comes back as a short 200, not a 204 or a 404. + chunks: list[bytes] = [] + read = 0 + async for chunk in response.content.iter_chunked(64 * 1024): + read += len(chunk) + if read > MAX_FETCH_BYTES: + _LOGGER.warning( + "Upstream %s body exceeds %s bytes, refusing it", + url, + MAX_FETCH_BYTES, + ) + return None + chunks.append(chunk) + except (ClientError, TimeoutError) as err: + _LOGGER.debug("Upstream %s failed: %s", url, err) + return None + + body = b"".join(chunks) + ttl = _upstream_ttl(response.headers.get(hdrs.CACHE_CONTROL, "")) + return Asset(body, response.headers.get(hdrs.CONTENT_ENCODING), ttl) + + +class _MapTilesTileView(_MapTilesView): + """Serve map tiles.""" + + ttl = TILE_TTL + max_age = TILE_MAX_AGE + max_zoom: int + upstream: str + key_template: str + + async def get( + self, request: web.Request, z: str, x: str, y: str + ) -> web.StreamResponse: + """Handle a GET request for a tile.""" + self._authenticate(request) + + if any(len(part) > MAX_COORDINATE_DIGITS for part in (z, x, y)): + return web.Response(status=HTTPStatus.NOT_FOUND) + + zoom, column, row = int(z), int(x), int(y) + if zoom > self.max_zoom or column >= 2**zoom or row >= 2**zoom: + return web.Response(status=HTTPStatus.NOT_FOUND) + + coordinates = {"z": zoom, "x": column, "y": row} + return await self._async_serve( + self.key_template.format(**coordinates), + self.upstream.format(**coordinates), + ) + + +class MapTilesVectorView(_MapTilesTileView): + """Serve vector tiles.""" + + name = "api:map_tiles:vector" + url = "/api/map_tiles/vector/{z:[0-9]+}/{x:[0-9]+}/{y:[0-9]+}.mvt" + content_type = "application/vnd.mapbox-vector-tile" + max_zoom = VECTOR_MAX_ZOOM + upstream = f"{VECTOR_URL}/shortbread_v1/{{z}}/{{x}}/{{y}}.mvt" + key_template = "vector/{z}/{x}/{y}.mvt" + + +class MapTilesRasterView(_MapTilesTileView): + """Serve raster tiles, for devices that cannot render vector ones.""" + + name = "api:map_tiles:raster" + url = "/api/map_tiles/raster/{z:[0-9]+}/{x:[0-9]+}/{y:[0-9]+}.png" + content_type = "image/png" + max_zoom = RASTER_MAX_ZOOM + upstream = f"{RASTER_URL}/{{z}}/{{x}}/{{y}}.png" + key_template = "raster/{z}/{x}/{y}.png" + + +class MapTilesGlyphsView(_MapTilesView): + """Serve the SDF glyphs the map labels are drawn from.""" + + name = "api:map_tiles:glyphs" + url = "/api/map_tiles/fonts/{fontstack}/{glyph_range}" + content_type = "application/x-protobuf" + ttl = ASSET_TTL + max_age = ASSET_MAX_AGE + + async def get( + self, request: web.Request, fontstack: str, glyph_range: str + ) -> web.StreamResponse: + """Handle a GET request for a glyph range.""" + self._authenticate(request) + + if not FONTSTACK_RE.match(fontstack) or not GLYPH_RANGE_RE.match(glyph_range): + return web.Response(status=HTTPStatus.NOT_FOUND) + + return await self._async_serve( + f"fonts/{fontstack}/{glyph_range}", + f"{VECTOR_URL}/styles/shortbread/fonts/{fontstack}/{glyph_range}", + ) + + +class _MapTilesSpritesView(_MapTilesView): + """Serve the icon sprites the map symbols come from.""" + + ttl = ASSET_TTL + max_age = ASSET_MAX_AGE + extension: str + + async def get( + self, request: web.Request, sprite_set: str, name: str + ) -> web.StreamResponse: + """Handle a GET request for a sprite set.""" + self._authenticate(request) + + if not SPRITE_SET_RE.match(sprite_set) or not SPRITE_NAME_RE.match(name): + return web.Response(status=HTTPStatus.NOT_FOUND) + + path = f"sprites/{sprite_set}/{name}{self.extension}" + return await self._async_serve(path, f"{VECTOR_URL}/styles/shortbread/{path}") + + +class MapTilesSpriteIndexView(_MapTilesSpritesView): + """Serve the sprite index.""" + + name = "api:map_tiles:sprite_index" + url = "/api/map_tiles/sprites/{sprite_set}/{name}.json" + content_type = "application/json" + extension = ".json" + + +class MapTilesSpriteSheetView(_MapTilesSpritesView): + """Serve the sprite sheet.""" + + name = "api:map_tiles:sprite_sheet" + url = "/api/map_tiles/sprites/{sprite_set}/{name}.png" + content_type = "image/png" + extension = ".png" + + +class MapTilesTileJsonView(_MapTilesView): + """Serve the TileJSON, rewritten to point back at this instance.""" + + name = "api:map_tiles:tilejson" + url = "/api/map_tiles/tilejson.json" + content_type = "application/json" + ttl = TILEJSON_TTL + max_age = TILEJSON_MAX_AGE + + async def get(self, request: web.Request) -> web.StreamResponse: + """Handle a GET request for the TileJSON.""" + self._authenticate(request) + return await self._async_serve("tilejson.json", TILEJSON_URL) + + @override + async def _async_fetch(self, url: str) -> Asset | None: + """Fetch the upstream TileJSON and republish it as ours. + + The zoom range is taken from upstream (clamped to what we serve); the + attribution and the advertised tile endpoint are replaced with this + proxy's own. The tile endpoint is pinned, so the vector fetch URL does + not follow the upstream template. + """ + if (asset := await super()._async_fetch(url)) is None: + return None + return await self._hass.async_add_executor_job(self._rebuild, asset) + + def _rebuild(self, asset: Asset) -> Asset | None: + """Rewrite the upstream TileJSON to point back at this instance.""" + try: + tilejson = json.loads( + _gzip_decompress(asset.body) if asset.encoding else asset.body + ) + except ValueError, zlib.error: + _LOGGER.error("Upstream TileJSON is not valid JSON") + return None + + if not isinstance(tilejson, dict) or not tilejson.get("tiles"): + _LOGGER.error("Upstream TileJSON does not list any tiles") + return None + + try: + # Clamped to what the tile view will actually serve. + minzoom = max(int(tilejson.get("minzoom", 0)), 0) + maxzoom = min( + int(tilejson.get("maxzoom", VECTOR_MAX_ZOOM)), VECTOR_MAX_ZOOM + ) + except TypeError, ValueError, OverflowError: + _LOGGER.error("Upstream TileJSON zoom range is not a finite number") + return None + + # The only body built locally, so the only one this integration gzips. + # Kept on upstream's refresh cadence: it is how a moved endpoint arrives. + return Asset( + gzip.compress( + json_bytes( + { + **tilejson, + "tiles": [VECTOR_TILE_PATH], + "minzoom": minzoom, + "maxzoom": maxzoom, + "attribution": ATTRIBUTION, + } + ), + mtime=0, + ), + GZIP, + asset.ttl, + ) diff --git a/homeassistant/components/matter/ble_proxy.py b/homeassistant/components/matter/ble_proxy.py index f25f4e3ebaa606..4eca8f124121d2 100644 --- a/homeassistant/components/matter/ble_proxy.py +++ b/homeassistant/components/matter/ble_proxy.py @@ -25,6 +25,7 @@ from homeassistant.components.bluetooth import ( MONOTONIC_TIME, + BluetoothCallbackReplay, BluetoothScanningMode, async_ble_device_from_address, async_register_callback, @@ -77,6 +78,7 @@ def _on_advertisement( _on_advertisement, None, BluetoothScanningMode.PASSIVE, + replay=BluetoothCallbackReplay.NEWEST_FIRST, ) @override diff --git a/homeassistant/components/matter/diagnostics.py b/homeassistant/components/matter/diagnostics.py index 71e06282e1fc09..0702422372544a 100644 --- a/homeassistant/components/matter/diagnostics.py +++ b/homeassistant/components/matter/diagnostics.py @@ -53,7 +53,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: MatterConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, config_entry: MatterConfigEntry, device: dr.AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 4867b894a6c167..61af4d20091d3d 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -107,7 +107,7 @@ def node_from_ha_device_id(hass: HomeAssistant, ha_device_id: str) -> MatterNode @callback def get_node_from_device_entry( - hass: HomeAssistant, device: dr.DeviceEntry + hass: HomeAssistant, device: dr.AnyDeviceEntry ) -> MatterNode | None: """Return MatterNode from device entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/miele/diagnostics.py b/homeassistant/components/miele/diagnostics.py index 4c4f71cb168a55..c71f676c9799ba 100644 --- a/homeassistant/components/miele/diagnostics.py +++ b/homeassistant/components/miele/diagnostics.py @@ -1,13 +1,13 @@ """Diagnostics support for Miele.""" import hashlib -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from pymiele import completed_warnings from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceEntry from .coordinator import MieleConfigEntry @@ -69,9 +69,13 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: MieleConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: MieleConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" + if TYPE_CHECKING: + # miele does not create child devices + assert isinstance(device, DeviceEntry) + info = { "manufacturer": device.manufacturer, "model": device.model, diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 5ab4861201f4f1..8b1a643f716c7b 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from . import debug_info, is_connected @@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: ConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" return _async_get_diagnostics(hass, entry, device) @@ -37,7 +37,7 @@ async def async_get_device_diagnostics( def _async_get_diagnostics( hass: HomeAssistant, entry: ConfigEntry, - device: DeviceEntry | None = None, + device: AnyDeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" redacted_config = { @@ -69,7 +69,9 @@ def _async_get_diagnostics( @callback -def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str, Any]: +def _async_device_as_dict( + hass: HomeAssistant, device: AnyDeviceEntry +) -> dict[str, Any]: """Represent an MQTT device as a dictionary.""" # Gather information how this MQTT device is represented in Home Assistant diff --git a/homeassistant/components/nederlandse_spoorwegen/diagnostics.py b/homeassistant/components/nederlandse_spoorwegen/diagnostics.py index ee21cd3aae06c9..89a7a8fdeb2a82 100644 --- a/homeassistant/components/nederlandse_spoorwegen/diagnostics.py +++ b/homeassistant/components/nederlandse_spoorwegen/diagnostics.py @@ -1,11 +1,11 @@ """Diagnostics support for Nederlandse Spoorwegen.""" -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceEntry from .const import DOMAIN from .coordinator import NSConfigEntry @@ -51,9 +51,13 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: NSConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: NSConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a route.""" + if TYPE_CHECKING: + # nederlandse_spoorwegen does not create child devices + assert isinstance(device, DeviceEntry) + # Find the coordinator for this device coordinator = None subentry_id = None diff --git a/homeassistant/components/neopool/manifest.json b/homeassistant/components/neopool/manifest.json index fdb495967f6703..b27be9a9383160 100644 --- a/homeassistant/components/neopool/manifest.json +++ b/homeassistant/components/neopool/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["neopool_modbus"], "quality_scale": "silver", - "requirements": ["neopool-modbus==4.6.0"] + "requirements": ["neopool-modbus==4.6.2"] } diff --git a/homeassistant/components/nest/diagnostics.py b/homeassistant/components/nest/diagnostics.py index b3b5f7689c6ff9..a8c04c0b74dc7e 100644 --- a/homeassistant/components/nest/diagnostics.py +++ b/homeassistant/components/nest/diagnostics.py @@ -7,7 +7,7 @@ from homeassistant.components.camera import diagnostics as camera_diagnostics from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .types import NestConfigEntry @@ -41,7 +41,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( hass: HomeAssistant, config_entry: NestConfigEntry, - device: DeviceEntry, + device: AnyDeviceEntry, ) -> dict[str, Any]: """Return diagnostics for a device.""" nest_devices = config_entry.runtime_data.device_manager.devices diff --git a/homeassistant/components/onewire/diagnostics.py b/homeassistant/components/onewire/diagnostics.py index 01ec9a4de71ebd..ec5ca6dae210ab 100644 --- a/homeassistant/components/onewire/diagnostics.py +++ b/homeassistant/components/onewire/diagnostics.py @@ -1,7 +1,7 @@ """Diagnostics support for 1-Wire.""" from dataclasses import asdict -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST @@ -30,9 +30,12 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: OneWireConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: OneWireConfigEntry, device_entry: dr.AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" + if TYPE_CHECKING: + # onewire does not create child devices + assert isinstance(device_entry, dr.DeviceEntry) onewire_hub = entry.runtime_data diff --git a/homeassistant/components/overkiz/diagnostics.py b/homeassistant/components/overkiz/diagnostics.py index 28ea140a1e350e..48673c67fa97e9 100644 --- a/homeassistant/components/overkiz/diagnostics.py +++ b/homeassistant/components/overkiz/diagnostics.py @@ -1,12 +1,12 @@ """Provides diagnostics for Overkiz.""" -from typing import Any +from typing import TYPE_CHECKING, Any from pyoverkiz.enums import APIType from pyoverkiz.obfuscate import obfuscate_id from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceEntry from . import OverkizDataConfigEntry from .const import CONF_API_TYPE, CONF_HUB @@ -35,9 +35,13 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: OverkizDataConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: OverkizDataConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" + if TYPE_CHECKING: + # overkiz does not create child devices + assert isinstance(device, DeviceEntry) + client = entry.runtime_data.coordinator.client device_url = min(device.identifiers)[1] diff --git a/homeassistant/components/probe_plus/manifest.json b/homeassistant/components/probe_plus/manifest.json index 98a057c68448dd..dfdb0d06775bfe 100644 --- a/homeassistant/components/probe_plus/manifest.json +++ b/homeassistant/components/probe_plus/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["pyprobeplus==1.1.2"] + "requirements": ["pyprobeplus==2.1.0"] } diff --git a/homeassistant/components/probe_plus/sensor.py b/homeassistant/components/probe_plus/sensor.py index 16b5c318490101..a577d9626b3687 100644 --- a/homeassistant/components/probe_plus/sensor.py +++ b/homeassistant/components/probe_plus/sensor.py @@ -39,14 +39,14 @@ class ProbePlusSensorEntityDescription(SensorEntityDescription): key="probe_temperature", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda device: device.device_state.probe_temperature, + value_fn=lambda device: device.device_state.probe.temperature, device_class=SensorDeviceClass.TEMPERATURE, ), ProbePlusSensorEntityDescription( key="probe_battery", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda device: device.device_state.probe_battery, + value_fn=lambda device: device.device_state.probe.battery, device_class=SensorDeviceClass.BATTERY, ), ProbePlusSensorEntityDescription( @@ -62,7 +62,7 @@ class ProbePlusSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda device: device.device_state.probe_rssi, + value_fn=lambda device: device.device_state.probe.rssi, entity_registry_enabled_default=False, ), ProbePlusSensorEntityDescription( @@ -80,7 +80,7 @@ class ProbePlusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.VOLTAGE, - value_fn=lambda device: device.device_state.probe_voltage, + value_fn=lambda device: device.device_state.probe.voltage, entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/renault/diagnostics.py b/homeassistant/components/renault/diagnostics.py index 20f179bbd3b8f7..56c33b2b257a9e 100644 --- a/homeassistant/components/renault/diagnostics.py +++ b/homeassistant/components/renault/diagnostics.py @@ -4,7 +4,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from . import RenaultConfigEntry from .const import RenaultConfigurationKeys @@ -40,7 +40,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: RenaultConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: RenaultConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" vin = next(iter(device.identifiers))[1] diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 45ecabe601254a..c6318ceb2814b6 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -1,7 +1,6 @@ """Support for RESTful binary sensors.""" import logging -import ssl from typing import override from xml.parsers.expat import ExpatError @@ -13,33 +12,28 @@ BinarySensorEntity, ) from homeassistant.const import ( - CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_ICON, - CONF_NAME, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, - CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( - CONF_AVAILABILITY, - CONF_PICTURE, ManualTriggerEntity, ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import async_get_config_and_coordinator, create_rest_data_from_config from .const import DEFAULT_BINARY_SENSOR_NAME from .data import RestData -from .entity import RestEntity +from .entity import ( + RestEntity, + async_get_config_rest_data_and_coordinator, + async_get_trigger_entity_config, +) from .schema import BINARY_SENSOR_SCHEMA, RESOURCE_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -49,14 +43,6 @@ cv.has_at_least_one_key(CONF_RESOURCE, CONF_RESOURCE_TEMPLATE), ) -TRIGGER_ENTITY_OPTIONS = ( - CONF_AVAILABILITY, - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_PICTURE, - CONF_UNIQUE_ID, -) - async def async_setup_platform( hass: HomeAssistant, @@ -65,38 +51,13 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the REST binary sensor.""" - # Must update the sensor now (including fetching the rest resource) to - # ensure it's updating its state. - if discovery_info is not None: - conf, coordinator, rest = await async_get_config_and_coordinator( - hass, BINARY_SENSOR_DOMAIN, discovery_info - ) - else: - conf = config - coordinator = None - rest = create_rest_data_from_config(hass, conf) - await rest.async_update(log_errors=False) - - if rest.data is None: - if rest.last_exception: - if isinstance(rest.last_exception, ssl.SSLError): - _LOGGER.error( - "Error connecting %s failed with %s", - rest.url, - rest.last_exception, - ) - return - raise PlatformNotReady from rest.last_exception - raise PlatformNotReady - - name = conf.get(CONF_NAME) or Template(DEFAULT_BINARY_SENSOR_NAME, hass) - - trigger_entity_config = {CONF_NAME: name} - - for key in TRIGGER_ENTITY_OPTIONS: - if key not in conf: - continue - trigger_entity_config[key] = conf[key] + + conf, rest, coordinator = await async_get_config_rest_data_and_coordinator( + hass, config, BINARY_SENSOR_DOMAIN, discovery_info + ) + trigger_entity_config = async_get_trigger_entity_config( + hass, conf, DEFAULT_BINARY_SENSOR_NAME + ) async_add_entities( [ diff --git a/homeassistant/components/rest/entity.py b/homeassistant/components/rest/entity.py index b7389a67273b38..8a7f5112cc3789 100644 --- a/homeassistant/components/rest/entity.py +++ b/homeassistant/components/rest/entity.py @@ -1,22 +1,102 @@ """The base entity for the rest component.""" from abc import abstractmethod -from typing import Any, override +import logging +import ssl +from typing import override -from homeassistant.core import callback +from homeassistant.components.sensor import CONF_STATE_CLASS +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_NAME, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity import Entity from homeassistant.helpers.template import Template +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from . import async_get_config_and_coordinator, create_rest_data_from_config from .data import RestData +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_config_rest_data_and_coordinator( + hass: HomeAssistant, + config: ConfigType, + entity_domain: str, + discovery_info: DiscoveryInfoType | None = None, +) -> tuple[ConfigType, RestData, DataUpdateCoordinator[None] | None]: + """Get the config, rest data +/- coordinator for sub entity.""" + # Must update the sensor now (including fetching the rest resource) to + # ensure it's updating its state. + if discovery_info is not None: + conf, coordinator, rest = await async_get_config_and_coordinator( + hass, entity_domain, discovery_info + ) + else: + conf = config + coordinator = None + rest = create_rest_data_from_config(hass, conf) + await rest.async_update(log_errors=False) + + if rest.data is None: + if rest.last_exception: + if isinstance(rest.last_exception, ssl.SSLError): + _LOGGER.error( + "Error connecting %s failed with %s", + rest.url, + rest.last_exception, + ) + raise HomeAssistantError from rest.last_exception + raise PlatformNotReady from rest.last_exception + raise PlatformNotReady + + return conf, rest, coordinator + + +def async_get_trigger_entity_config( + hass: HomeAssistant, + config: ConfigType, + default_name: str, +) -> ConfigType: + """Get trigger entity config.""" + + trigger_entity_config = { + CONF_NAME: config.get(CONF_NAME, Template(default_name, hass)) + } + for key in TRIGGER_ENTITY_OPTIONS: + if key not in config: + continue + trigger_entity_config[key] = config[key] + return trigger_entity_config + class RestEntity(Entity): """A class for entities using DataUpdateCoordinator or rest data directly.""" def __init__( self, - coordinator: DataUpdateCoordinator[Any] | None, + coordinator: DataUpdateCoordinator[None] | None, rest: RestData, resource_template: Template | None, force_update: bool, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index e4ee56b286685e..08df21466e8bea 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -1,46 +1,38 @@ """Support for RESTful API sensors.""" import logging -import ssl from typing import Any, override from xml.parsers.expat import ExpatError import voluptuous as vol from homeassistant.components.sensor import ( - CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, ) from homeassistant.const import ( - CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_ICON, - CONF_NAME, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, - CONF_UNIQUE_ID, - CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( - CONF_AVAILABILITY, - CONF_PICTURE, ManualTriggerSensorEntity, ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -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 .entity import ( + RestEntity, + async_get_config_rest_data_and_coordinator, + async_get_trigger_entity_config, +) from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA from .util import parse_json_attributes @@ -51,16 +43,6 @@ cv.has_at_least_one_key(CONF_RESOURCE, CONF_RESOURCE_TEMPLATE), ) -TRIGGER_ENTITY_OPTIONS = ( - CONF_AVAILABILITY, - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_PICTURE, - CONF_UNIQUE_ID, - CONF_STATE_CLASS, - CONF_UNIT_OF_MEASUREMENT, -) - async def async_setup_platform( hass: HomeAssistant, @@ -69,39 +51,12 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the RESTful sensor.""" - # Must update the sensor now (including fetching the rest resource) to - # ensure it's updating its state. - if discovery_info is not None: - conf, coordinator, rest = await async_get_config_and_coordinator( - hass, SENSOR_DOMAIN, discovery_info - ) - else: - conf = config - coordinator = None - rest = create_rest_data_from_config(hass, conf) - await rest.async_update(log_errors=False) - - if rest.data is None: - if rest.last_exception: - if isinstance(rest.last_exception, ssl.SSLError): - _LOGGER.error( - "Error connecting %s failed with %s", - rest.url, - rest.last_exception, - ) - return - raise PlatformNotReady from rest.last_exception - raise PlatformNotReady - - name = conf.get(CONF_NAME) or Template(DEFAULT_SENSOR_NAME, hass) - - trigger_entity_config = {CONF_NAME: name} - - for key in TRIGGER_ENTITY_OPTIONS: - if key not in conf: - continue - trigger_entity_config[key] = conf[key] - + conf, rest, coordinator = await async_get_config_rest_data_and_coordinator( + hass, config, SENSOR_DOMAIN, discovery_info + ) + trigger_entity_config = async_get_trigger_entity_config( + hass, conf, DEFAULT_SENSOR_NAME + ) async_add_entities( [ RestSensor( diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index f329f41c0fac4b..e2e4b53b74868d 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -7,7 +7,7 @@ from pysmartthings import DeviceEvent from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from . import SmartThingsConfigEntry from .const import DOMAIN @@ -25,7 +25,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: SmartThingsConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" client = entry.runtime_data.client diff --git a/homeassistant/components/sofar/manifest.json b/homeassistant/components/sofar/manifest.json index 4572cc84be463c..e05b884e03fc73 100644 --- a/homeassistant/components/sofar/manifest.json +++ b/homeassistant/components/sofar/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["sofar-modbus==0.7.0"] + "requirements": ["sofar-modbus==0.7.1"] } diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index c51369082ea0c8..2b0ee88a00d563 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import DOMAIN from .helpers import SonosConfigEntry @@ -66,7 +66,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: SonosConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: SonosConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" uid = next( diff --git a/homeassistant/components/subaru/diagnostics.py b/homeassistant/components/subaru/diagnostics.py index 1db998302d0c80..465f4c30e5606a 100644 --- a/homeassistant/components/subaru/diagnostics.py +++ b/homeassistant/components/subaru/diagnostics.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import VEHICLE_VIN from .coordinator import SubaruConfigEntry @@ -40,7 +40,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: SubaruConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: SubaruConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" coordinator = config_entry.runtime_data.coordinator diff --git a/homeassistant/components/traccar_server/diagnostics.py b/homeassistant/components/traccar_server/diagnostics.py index 7ccab0883e78e7..a7ed55421e84f0 100644 --- a/homeassistant/components/traccar_server/diagnostics.py +++ b/homeassistant/components/traccar_server/diagnostics.py @@ -68,7 +68,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( hass: HomeAssistant, entry: TraccarServerConfigEntry, - device: dr.DeviceEntry, + device: dr.AnyDeviceEntry, ) -> dict[str, Any]: """Return device diagnostics.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 7de6e05d5164ff..f9e50c991ca94d 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import DOMAIN, DPCode from .coordinator import TuyaConfigEntry @@ -29,7 +29,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: TuyaConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: TuyaConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" return _async_get_diagnostics(hass, entry, device) @@ -39,7 +39,7 @@ async def async_get_device_diagnostics( def _async_get_diagnostics( hass: HomeAssistant, entry: TuyaConfigEntry, - device: DeviceEntry | None = None, + device: AnyDeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" manager = entry.runtime_data.manager diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 0f95516be25059..a51234a2fc922e 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -44,7 +44,7 @@ "iot_class": "cloud_push", "loggers": ["tuya_sharing"], "requirements": [ - "tuya-device-handlers==0.0.26", + "tuya-device-handlers==0.0.27", "tuya-device-sharing-sdk==0.2.15" ] } diff --git a/homeassistant/components/vesync/diagnostics.py b/homeassistant/components/vesync/diagnostics.py index c5dc0d069b3f9e..115bb075c005dc 100644 --- a/homeassistant/components/vesync/diagnostics.py +++ b/homeassistant/components/vesync/diagnostics.py @@ -7,7 +7,7 @@ from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import DOMAIN from .coordinator import VesyncConfigEntry @@ -37,7 +37,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: VesyncConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: VesyncConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" manager: VeSync = config_entry.runtime_data.manager diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index f45c0414562066..3e56f5e118de64 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -81,6 +81,7 @@ class WeatherEntityStateAttribute(StrEnum): VALID_UNITS_PRESSURE: set[str] = { UnitOfPressure.HPA, + UnitOfPressure.KPA, UnitOfPressure.MBAR, UnitOfPressure.INHG, UnitOfPressure.MMHG, diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py index 70d837b1dd2dc5..7deb5de2beb606 100644 --- a/homeassistant/components/weather/significant_change.py +++ b/homeassistant/components/weather/significant_change.py @@ -145,6 +145,8 @@ def async_check_significant_change( UnitOfPressure.MMHG, # 1hPa = 0.75mmHg ): absolute_change = 1.0 + elif unit == UnitOfPressure.KPA: # 1hPa = 0.1 kPa + absolute_change = 0.1 elif unit == UnitOfPressure.INHG: # 1hPa = 0.03inHg absolute_change = 0.05 diff --git a/homeassistant/components/zeversolar/diagnostics.py b/homeassistant/components/zeversolar/diagnostics.py index dc8730866a62de..cec95c45a86518 100644 --- a/homeassistant/components/zeversolar/diagnostics.py +++ b/homeassistant/components/zeversolar/diagnostics.py @@ -5,7 +5,7 @@ from zeversolar import ZeverSolarData from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .coordinator import ZeversolarConfigEntry @@ -34,7 +34,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ZeversolarConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: ZeversolarConfigEntry, device: AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 7c4b547f6fa037..4c304c9fbfd8d4 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -119,7 +119,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device: dr.AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" zha_device_proxy: ZHADeviceProxy = async_get_zha_device_proxy(hass, device.id) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 349ecfd4350924..85f51e2597da90 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -74,7 +74,7 @@ def get_device_entities( hass: HomeAssistant, node: Node, config_entry: ZwaveJSConfigEntry, - device: dr.DeviceEntry, + device: dr.AnyDeviceEntry, ) -> list[dict[str, Any]]: """Get entities for a device.""" entity_entries = er.async_entries_for_device( @@ -145,7 +145,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, device: dr.AnyDeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" client: Client = config_entry.runtime_data.client diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index ba9ea66cf7d669..931e8775bb7fcf 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -256,7 +256,7 @@ def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None def get_home_and_node_id_from_device_entry( - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> tuple[str, int] | None: """Get home ID and node ID for Z-Wave device registry entry. diff --git a/homeassistant/const.py b/homeassistant/const.py index 6e913ef931c9a2..6379cc6a5a6261 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -647,6 +647,7 @@ class UnitOfPressure(StrEnum): INHG = "inHg" INH2O = "inH₂O" PSI = "psi" + ATM = "atm" # Sound pressure units diff --git a/homeassistant/generated/sensor.json b/homeassistant/generated/sensor.json index a6214cf7d8e8e3..836e8b624f5f67 100644 --- a/homeassistant/generated/sensor.json +++ b/homeassistant/generated/sensor.json @@ -22,6 +22,7 @@ "yd\u00b2" ], "atmospheric_pressure": [ + "atm", "bar", "cbar", "hPa", @@ -199,6 +200,7 @@ "mm/h" ], "pressure": [ + "atm", "bar", "cbar", "hPa", @@ -356,6 +358,7 @@ "yd\u00b2" ], "atmospheric_pressure": [ + "atm", "bar", "cbar", "hPa", @@ -570,6 +573,7 @@ "mm/h" ], "pressure": [ + "atm", "bar", "cbar", "hPa", diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index a9e4267a40b36d..13c342860980a9 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -600,6 +600,7 @@ class PressureConverter(BaseUnitConverter): UnitOfPressure.PSI: 1 / 6894.757, UnitOfPressure.MMHG: 1 / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), + UnitOfPressure.ATM: 1 / 101325, } VALID_UNITS = { UnitOfPressure.MILLIPASCAL, @@ -613,6 +614,7 @@ class PressureConverter(BaseUnitConverter): UnitOfPressure.INH2O, UnitOfPressure.PSI, UnitOfPressure.MMHG, + UnitOfPressure.ATM, } diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 23f4f504bb0ce6..d3e9249a2fa109 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -386,6 +386,7 @@ def _deprecated_unit_system(value: str) -> str: ("pressure", UnitOfPressure.KPA): UnitOfPressure.PSI, ("pressure", UnitOfPressure.MMHG): UnitOfPressure.INHG, ("pressure", UnitOfPressure.INH2O): UnitOfPressure.PSI, + ("pressure", UnitOfPressure.ATM): UnitOfPressure.PSI, # Convert non-USCS radon concentration ( "radon", diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index eae640b333b528..63ce62dd5f32df 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -138,6 +138,7 @@ Every check has a code following the | `W7431` | [`home-assistant-options-flow-field-not-translated`](#w7431-home-assistant-options-flow-field-not-translated) | Options flow form field missing translation in `strings.json` | | `W7432` | [`home-assistant-subentry-flow-field-not-translated`](#w7432-home-assistant-subentry-flow-field-not-translated) | Subentry flow form field missing translation in `strings.json` | | `W7433` | [`home-assistant-missing-test-before-configure`](#w7433-home-assistant-missing-test-before-configure) | Config flow should test the connection before creating an entry | +| `W7434` | [`home-assistant-config-flow-menu-missing-step`](#w7434-home-assistant-config-flow-menu-missing-step) | `async_show_menu` option has no matching `async_step_*` method | | `W7435` | [`home-assistant-json-fixture`](#w7435-home-assistant-json-fixture) | Use a JSON fixture helper instead of parsing a loaded fixture | @@ -366,6 +367,25 @@ in config flows; they come automatically from the device or are set by the integration. +## `home_assistant_config_flow_menu_options` checker + +Validates that every option passed to +`self.async_show_menu(menu_options=...)` corresponds to an +`async_step_