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_