Merge branch 'dev' into kitchen-sink-via-device

This commit is contained in:
Artur Pragacz
2026-08-21 16:10:34 +02:00
committed by GitHub
839 changed files with 47718 additions and 5408 deletions
+37 -6
View File
@@ -55,10 +55,12 @@ env:
# and some queries that work on MariaDB do not work on MySQL
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mariadb:11.4.9','mysql:8.0.32']"
# 12 is the oldest supported version
# - 12.14 is the latest (as of 9 Feb 2023)
# 15 is the latest version
# - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
# - 12.22 is the latest (as of 19 Aug 2026)
# 15 is a supported version
# - 15.19 is the latest (as of 19 Aug 2026)
# 18 is the latest version
# - 18.6 is the latest (as of 19 Aug 2026)
POSTGRESQL_VERSIONS: "['postgres:12.22','postgres:15.19', 'postgres:18.6']"
UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_VERSION: 1
SQLALCHEMY_WARN_20: 1
@@ -281,7 +283,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +304,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
with:
extra-args: --all-files zizmor
@@ -472,6 +474,32 @@ jobs:
run: |
uv run --no-project python -m script.gen_copilot_instructions validate
gen-recorder-db-versions:
name: Check recorder database versions
runs-on: ubuntu-24.04
permissions:
contents: read
needs:
- info
# Only run on push to the dev branch; this job reaches out to endoflife.date, and
# we do not want a new MariaDB/MySQL release to fail CI on PR runs or the rc/master
# branches.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
id: python
uses: ./.github/actions/setup-uv-python
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Check MariaDB and MySQL versions are up to date
run: |
uv run --no-project python -m script.gen_recorder_db_versions validate
dependency-review:
name: Dependency review
runs-on: ubuntu-24.04
@@ -1064,6 +1092,9 @@ jobs:
- 5432:5432
env:
POSTGRES_PASSWORD: password
# 18+ images default PGDATA to /var/lib/postgresql/<major>/docker,
# which is not covered by the tmpfs below
PGDATA: /var/lib/postgresql/data
options: >-
--health-cmd="pg_isready -hlocalhost -Upostgres"
--health-interval=5s --health-timeout=2s --health-retries=3
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:python"
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.1
rev: v0.16.3
hooks:
- id: ruff-check
args:
+1
View File
@@ -216,6 +216,7 @@ homeassistant.components.filter.*
homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.*
homeassistant.components.flow_it.*
homeassistant.components.flux_led.*
homeassistant.components.folder_watcher.*
homeassistant.components.forecast_solar.*
Generated
+4 -2
View File
@@ -578,6 +578,8 @@ CLAUDE.md @home-assistant/core
/tests/components/flipr/ @cnico
/homeassistant/components/flo/ @dmulcahey
/tests/components/flo/ @dmulcahey
/homeassistant/components/flow_it/ @albertogeniola
/tests/components/flow_it/ @albertogeniola
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss @Marcello17
@@ -1143,8 +1145,8 @@ CLAUDE.md @home-assistant/core
/tests/components/metoffice/ @MrHarcombe @avee87
/homeassistant/components/microbees/ @microBeesTech
/tests/components/microbees/ @microBeesTech
/homeassistant/components/midea/ @chemelli74 @rokam @wuwentao @caibinqing
/tests/components/midea/ @chemelli74 @rokam @wuwentao @caibinqing
/homeassistant/components/midea/ @chemelli74 @rokam @caibinqing
/tests/components/midea/ @chemelli74 @rokam @caibinqing
/homeassistant/components/miele/ @astrandb
/tests/components/miele/ @astrandb
/homeassistant/components/mikrotik/ @engrbm87 @chemelli74
@@ -7,6 +7,7 @@ from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
from .coordinator import (
@@ -27,7 +28,10 @@ PLATFORMS = [
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Set up Actron Air integration from a config entry."""
api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN])
api = ActronAirAPI(
refresh_token=entry.data[CONF_API_TOKEN],
session=async_get_clientsession(hass),
)
systems: list[ActronAirSystemInfo] = []
try:
@@ -14,6 +14,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
@@ -37,7 +38,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
if self._api is None:
LOGGER.debug("Initiating device authorization")
self._api = ActronAirAPI()
self._api = ActronAirAPI(session=async_get_clientsession(self.hass))
try:
device_code_response = await self._api.request_device_code()
except ActronAirAuthError as err:
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.12"]
"requirements": ["actron-neo-api==0.5.14"]
}
@@ -74,5 +74,5 @@ rules:
# Platinum
async-dependency: done
inject-websession: todo
inject-websession: done
strict-typing: done
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["airgradient==0.9.2"],
"requirements": ["airgradient==0.10.0"],
"zeroconf": ["_airgradient._tcp.local."]
}
@@ -29,7 +29,7 @@ PARALLEL_UPDATES = 1
class AirGradientNumberEntityDescription(NumberEntityDescription):
"""Describes AirGradient number entity."""
value_fn: Callable[[Config], int]
value_fn: Callable[[Config], int | None]
set_value_fn: Callable[[AirGradientClient, int], Awaitable[None]]
@@ -64,7 +64,9 @@ DISPLAY_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = (
translation_key="display_pm_standard",
options=list(PM_STANDARD_REVERSE),
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: PM_STANDARD.get(config.pm_standard),
value_fn=lambda config: (
PM_STANDARD.get(config.pm_standard) if config.pm_standard else None
),
set_value_fn=lambda client, value: client.set_pm_standard(
PM_STANDARD_REVERSE[value]
),
@@ -100,7 +102,7 @@ ABC_DAYS = [
]
def _get_value(value: int, values: list[str]) -> str | None:
def _get_value(value: int | None, values: list[str]) -> str | None:
str_value = str(value)
return str_value if str_value in values else None
@@ -210,7 +210,9 @@ CONFIG_DISPLAY_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...
device_class=SensorDeviceClass.ENUM,
options=list(PM_STANDARD_REVERSE),
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: PM_STANDARD.get(config.pm_standard),
value_fn=lambda config: (
PM_STANDARD.get(config.pm_standard) if config.pm_standard else None
),
),
AirGradientConfigSensorEntityDescription(
key="display_brightness",
@@ -29,7 +29,7 @@ PARALLEL_UPDATES = 1
class AirGradientSwitchEntityDescription(SwitchEntityDescription):
"""Describes AirGradient switch entity."""
value_fn: Callable[[Config], bool]
value_fn: Callable[[Config], bool | None]
set_value_fn: Callable[[AirGradientClient, bool], Awaitable[None]]
@@ -98,7 +98,7 @@ class AirGradientSwitch(AirGradientEntity, SwitchEntity):
@property
@override
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return the state of the switch."""
return self.entity_description.value_fn(self.coordinator.data.config)
+218 -73
View File
@@ -10,7 +10,6 @@ from homeassistant.components import (
fan,
humidifier,
input_number,
light,
media_player,
number,
remote,
@@ -20,29 +19,64 @@ from homeassistant.components import (
)
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelEntityStateAttribute,
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
ClimateEntityCapabilityAttribute,
ClimateEntityStateAttribute,
HVACMode,
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverEntityStateAttribute,
)
from homeassistant.components.fan import (
DOMAIN as FAN_DOMAIN,
FanEntityCapabilityAttribute,
FanEntityStateAttribute,
)
from homeassistant.components.humidifier import (
DOMAIN as HUMIDIFIER_DOMAIN,
HumidifierEntityCapabilityAttribute,
HumidifierEntityStateAttribute,
)
from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN
from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.light import LightEntityStateAttribute
from homeassistant.components.lock import LockState
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
from homeassistant.components.media_player import (
MediaPlayerEntityCapabilityAttribute,
MediaPlayerEntityStateAttribute,
)
from homeassistant.components.number import (
DOMAIN as NUMBER_DOMAIN,
NumberEntityCapabilityAttribute,
)
from homeassistant.components.remote import (
DOMAIN as REMOTE_DOMAIN,
RemoteEntityStateAttribute,
)
from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.components.vacuum import (
DOMAIN as VACUUM_DOMAIN,
VacuumEntityCapabilityAttribute,
VacuumEntityStateAttribute,
)
from homeassistant.components.valve import (
DOMAIN as VALVE_DOMAIN,
ValveEntityStateAttribute,
)
from homeassistant.components.water_heater import (
DOMAIN as WATER_HEATER_DOMAIN,
WaterHeaterCapabilityAttribute,
WaterHeaterStateAttribute,
)
from homeassistant.const import (
ATTR_CODE_FORMAT,
ATTR_SUPPORTED_FEATURES,
ATTR_TEMPERATURE,
ATTR_UNIT_OF_MEASUREMENT,
PERCENTAGE,
STATE_IDLE,
STATE_OFF,
@@ -51,6 +85,7 @@ from homeassistant.const import (
STATE_PLAYING,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
EntityStateAttribute,
UnitOfLength,
UnitOfMass,
UnitOfTemperature,
@@ -103,7 +138,9 @@ UNIT_TO_CATALOG_TAG = {
def get_resource_by_unit_of_measurement(entity: State) -> str:
"""Translate the unit of measurement to an Alexa Global Catalog keyword."""
unit: str = entity.attributes.get("unit_of_measurement", "preset")
unit: str = entity.attributes.get(
EntityStateAttribute.UNIT_OF_MEASUREMENT, "preset"
)
return UNIT_TO_CATALOG_TAG.get(unit, AlexaGlobalCatalog.SETTING_PRESET)
@@ -619,7 +656,9 @@ class AlexaBrightnessController(AlexaCapability):
"""Read and return a property."""
if name != "brightness":
raise UnsupportedProperty(name)
if brightness := self.entity.attributes.get("brightness"):
if brightness := self.entity.attributes.get(
LightEntityStateAttribute.BRIGHTNESS
):
return round(brightness / 255.0 * 100)
return 0
@@ -676,9 +715,17 @@ class AlexaColorController(AlexaCapability):
raise UnsupportedProperty(name)
hue_saturation: tuple[float, float] | None
if (hue_saturation := self.entity.attributes.get(light.ATTR_HS_COLOR)) is None:
if (
hue_saturation := self.entity.attributes.get(
LightEntityStateAttribute.HS_COLOR
)
) is None:
hue_saturation = (0, 0)
if (brightness := self.entity.attributes.get(light.ATTR_BRIGHTNESS)) is None:
if (
brightness := self.entity.attributes.get(
LightEntityStateAttribute.BRIGHTNESS
)
) is None:
brightness = 0
return {
@@ -774,7 +821,9 @@ class AlexaSpeaker(AlexaCapability):
"""Return what properties this entity supports."""
properties = [{"name": "volume"}]
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if supported & media_player.MediaPlayerEntityFeature.VOLUME_MUTE:
properties.append({"name": "muted"})
@@ -795,14 +844,16 @@ class AlexaSpeaker(AlexaCapability):
"""Read and return a property."""
if name == "volume":
current_level = self.entity.attributes.get(
media_player.ATTR_MEDIA_VOLUME_LEVEL
MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL
)
if current_level is not None:
return round(float(current_level) * 100)
if name == "muted":
return bool(
self.entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED)
self.entity.attributes.get(
MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED
)
)
return None
@@ -871,7 +922,9 @@ class AlexaPlaybackController(AlexaCapability):
Supported Operations: FastForward, Next, Pause, Play, Previous, Rewind,
StartOver, Stop
"""
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported_features = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
operations: dict[
cover.CoverEntityFeature | media_player.MediaPlayerEntityFeature, str
@@ -929,7 +982,10 @@ class AlexaInputController(AlexaCapability):
def inputs(self) -> list[dict[str, str]] | None:
"""Return the list of valid supported inputs."""
source_list: list[Any] = (
self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or []
self.entity.attributes.get(
MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST
)
or []
)
return AlexaInputController.get_valid_inputs(source_list)
@@ -1008,15 +1064,20 @@ class AlexaTemperatureSensor(AlexaCapability):
raise UnsupportedProperty(name)
unit: str = self.entity.attributes.get(
ATTR_UNIT_OF_MEASUREMENT, self.hass.config.units.temperature_unit
EntityStateAttribute.UNIT_OF_MEASUREMENT,
self.hass.config.units.temperature_unit,
)
temp: str | None = self.entity.state
if self.entity.domain == CLIMATE_DOMAIN:
unit = self.hass.config.units.temperature_unit
temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE)
temp = self.entity.attributes.get(
ClimateEntityStateAttribute.CURRENT_TEMPERATURE
)
elif self.entity.domain == WATER_HEATER_DOMAIN:
unit = self.hass.config.units.temperature_unit
temp = self.entity.attributes.get(water_heater.ATTR_CURRENT_TEMPERATURE)
temp = self.entity.attributes.get(
WaterHeaterStateAttribute.CURRENT_TEMPERATURE
)
if temp is None or temp in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return None
@@ -1197,7 +1258,9 @@ class AlexaThermostatController(AlexaCapability):
def properties_supported(self) -> list[dict[str, str]]:
"""Return what properties this entity supports."""
properties = [{"name": "thermostatMode"}]
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if self.entity.domain == CLIMATE_DOMAIN:
if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
properties.append({"name": "lowerSetpoint"})
@@ -1230,7 +1293,7 @@ class AlexaThermostatController(AlexaCapability):
if name == "thermostatMode":
if self.entity.domain == WATER_HEATER_DOMAIN:
return None
preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE)
preset = self.entity.attributes.get(ClimateEntityStateAttribute.PRESET_MODE)
mode: dict[str, str] | str | None
if preset in API_THERMOSTAT_PRESETS:
@@ -1251,11 +1314,17 @@ class AlexaThermostatController(AlexaCapability):
unit = self.hass.config.units.temperature_unit
if name == "targetSetpoint":
temp = self.entity.attributes.get(ATTR_TEMPERATURE)
temp = self.entity.attributes.get(
ClimateEntityStateAttribute.TARGET_TEMPERATURE
)
elif name == "lowerSetpoint":
temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)
temp = self.entity.attributes.get(
ClimateEntityStateAttribute.TARGET_TEMP_LOW
)
elif name == "upperSetpoint":
temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)
temp = self.entity.attributes.get(
ClimateEntityStateAttribute.TARGET_TEMP_HIGH
)
else:
raise UnsupportedProperty(name)
@@ -1285,14 +1354,19 @@ class AlexaThermostatController(AlexaCapability):
if self.entity.domain == WATER_HEATER_DOMAIN:
return None
hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []
hvac_modes = (
self.entity.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES)
or []
)
supported_modes: list[str] = [
API_THERMOSTAT_MODES[mode]
for mode in hvac_modes
if mode in API_THERMOSTAT_MODES
]
preset_modes = self.entity.attributes.get(climate.ATTR_PRESET_MODES)
preset_modes = self.entity.attributes.get(
ClimateEntityCapabilityAttribute.PRESET_MODES
)
if preset_modes:
for mode in preset_modes:
thermostat_mode = API_THERMOSTAT_PRESETS.get(mode)
@@ -1426,8 +1500,10 @@ class AlexaSecurityPanelController(AlexaCapability):
@override
def configuration(self) -> dict[str, Any] | None:
"""Return configuration object with supported authorization types."""
code_format = self.entity.attributes.get(ATTR_CODE_FORMAT)
supported = self.entity.attributes[ATTR_SUPPORTED_FEATURES]
code_format = self.entity.attributes.get(
AlarmControlPanelEntityStateAttribute.CODE_FORMAT
)
supported = self.entity.attributes[EntityStateAttribute.SUPPORTED_FEATURES]
configuration = {}
supported_arm_states = [{"value": "DISARMED"}]
@@ -1518,38 +1594,50 @@ class AlexaModeController(AlexaCapability):
# Fan Direction
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_DIRECTION}":
mode = self.entity.attributes.get(fan.ATTR_DIRECTION, None)
mode = self.entity.attributes.get(FanEntityStateAttribute.DIRECTION, None)
if mode in (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE, STATE_UNKNOWN):
return f"{fan.ATTR_DIRECTION}.{mode}"
# Fan preset_mode
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}":
mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None)
if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, ()):
mode = self.entity.attributes.get(FanEntityStateAttribute.PRESET_MODE, None)
if mode in self.entity.attributes.get(
FanEntityCapabilityAttribute.PRESET_MODES, ()
):
return f"{fan.ATTR_PRESET_MODE}.{mode}"
# Humidifier mode
if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}":
mode = self.entity.attributes.get(humidifier.ATTR_MODE)
mode = self.entity.attributes.get(HumidifierEntityStateAttribute.MODE)
modes: list[str] = (
self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) or []
self.entity.attributes.get(
HumidifierEntityCapabilityAttribute.AVAILABLE_MODES
)
or []
)
if mode in modes:
return f"{humidifier.ATTR_MODE}.{mode}"
# Remote Activity
if self.instance == f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}":
activity = self.entity.attributes.get(remote.ATTR_CURRENT_ACTIVITY, None)
if activity in self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST, []):
activity = self.entity.attributes.get(
RemoteEntityStateAttribute.CURRENT_ACTIVITY, None
)
if activity in self.entity.attributes.get(
RemoteEntityStateAttribute.ACTIVITY_LIST, []
):
return f"{remote.ATTR_ACTIVITY}.{activity}"
# Water heater operation mode
if self.instance == f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
operation_mode = self.entity.attributes.get(
water_heater.ATTR_OPERATION_MODE
WaterHeaterStateAttribute.OPERATION_MODE
)
operation_modes: list[str] = (
self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) or []
self.entity.attributes.get(
WaterHeaterCapabilityAttribute.OPERATION_LIST
)
or []
)
if operation_mode in operation_modes:
return f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}"
@@ -1612,7 +1700,10 @@ class AlexaModeController(AlexaCapability):
self._resource = AlexaModeResource(
[AlexaGlobalCatalog.SETTING_PRESET], False
)
preset_modes = self.entity.attributes.get(fan.ATTR_PRESET_MODES) or []
preset_modes = (
self.entity.attributes.get(FanEntityCapabilityAttribute.PRESET_MODES)
or []
)
for preset_mode in preset_modes:
self._resource.add_mode(
f"{fan.ATTR_PRESET_MODE}.{preset_mode}", [preset_mode]
@@ -1628,7 +1719,12 @@ class AlexaModeController(AlexaCapability):
# Humidifier modes
if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}":
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
modes = self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) or []
modes = (
self.entity.attributes.get(
HumidifierEntityCapabilityAttribute.AVAILABLE_MODES
)
or []
)
for mode in modes:
self._resource.add_mode(f"{humidifier.ATTR_MODE}.{mode}", [mode])
# Humidifiers or Fans with a single mode completely break Alexa discovery,
@@ -1643,7 +1739,10 @@ class AlexaModeController(AlexaCapability):
if self.instance == f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
operation_modes = (
self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) or []
self.entity.attributes.get(
WaterHeaterCapabilityAttribute.OPERATION_LIST
)
or []
)
for operation_mode in operation_modes:
self._resource.add_mode(
@@ -1664,7 +1763,10 @@ class AlexaModeController(AlexaCapability):
# Use the mode controller for a remote because the input controller
# only allows a preset of names as an input.
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
activities = (
self.entity.attributes.get(RemoteEntityStateAttribute.ACTIVITY_LIST)
or []
)
for activity in activities:
self._resource.add_mode(
f"{remote.ATTR_ACTIVITY}.{activity}", [activity]
@@ -1698,7 +1800,9 @@ class AlexaModeController(AlexaCapability):
# Valve position resources
if self.instance == f"{VALVE_DOMAIN}.state":
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported_features = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
self._resource = AlexaModeResource(
["Preset", AlexaGlobalCatalog.SETTING_PRESET], False
)
@@ -1727,7 +1831,9 @@ class AlexaModeController(AlexaCapability):
@override
def semantics(self) -> dict[str, Any] | None:
"""Build and return semantics object."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
# Cover Position
if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
@@ -1871,24 +1977,32 @@ class AlexaRangeController(AlexaCapability):
# Cover Position
if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION)
return self.entity.attributes.get(
CoverEntityStateAttribute.CURRENT_POSITION
)
# Cover Tilt
if self.instance == f"{COVER_DOMAIN}.tilt":
return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION)
return self.entity.attributes.get(
CoverEntityStateAttribute.CURRENT_TILT_POSITION
)
# Fan speed percentage
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}":
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if supported and fan.FanEntityFeature.SET_SPEED:
return self.entity.attributes.get(fan.ATTR_PERCENTAGE)
return self.entity.attributes.get(FanEntityStateAttribute.PERCENTAGE)
return 100 if self.entity.state == fan.STATE_ON else 0
# Humidifier target humidity
if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}":
# If the humidifier is turned off the target humidity attribute is not set.
# We return 0 to make clear we do not know the current value.
return self.entity.attributes.get(humidifier.ATTR_HUMIDITY, 0)
return self.entity.attributes.get(
HumidifierEntityStateAttribute.HUMIDITY, 0
)
# Input Number Value
if self.instance == f"{INPUT_NUMBER_DOMAIN}.{input_number.ATTR_VALUE}":
@@ -1900,14 +2014,18 @@ class AlexaRangeController(AlexaCapability):
# Vacuum Fan Speed
if self.instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
speed_list = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED_LIST)
speed = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED)
speed_list = self.entity.attributes.get(
VacuumEntityCapabilityAttribute.FAN_SPEED_LIST
)
speed = self.entity.attributes.get(VacuumEntityStateAttribute.FAN_SPEED)
if speed_list is not None and speed is not None:
return next((i for i, v in enumerate(speed_list) if v == speed), None)
# Valve Position
if self.instance == f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}":
return self.entity.attributes.get(valve.ATTR_CURRENT_POSITION)
return self.entity.attributes.get(
ValveEntityStateAttribute.CURRENT_POSITION
)
return None
@@ -1925,7 +2043,9 @@ class AlexaRangeController(AlexaCapability):
# Fan Speed Percentage Resources
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}":
percentage_step = self.entity.attributes.get(fan.ATTR_PERCENTAGE_STEP)
percentage_step = self.entity.attributes.get(
FanEntityStateAttribute.PERCENTAGE_STEP
)
self._resource = AlexaPresetResource(
labels=["Percentage", AlexaGlobalCatalog.SETTING_FAN_SPEED],
min_value=0,
@@ -1941,8 +2061,12 @@ class AlexaRangeController(AlexaCapability):
if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}":
self._resource = AlexaPresetResource(
labels=["Humidity", "Percentage", "Target humidity"],
min_value=self.entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10),
max_value=self.entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90),
min_value=self.entity.attributes.get(
HumidifierEntityCapabilityAttribute.MIN_HUMIDITY, 10
),
max_value=self.entity.attributes.get(
HumidifierEntityCapabilityAttribute.MAX_HUMIDITY, 90
),
precision=1,
unit=AlexaGlobalCatalog.UNIT_PERCENT,
)
@@ -1975,7 +2099,7 @@ class AlexaRangeController(AlexaCapability):
min_value = float(self.entity.attributes[input_number.ATTR_MIN])
max_value = float(self.entity.attributes[input_number.ATTR_MAX])
precision = float(self.entity.attributes.get(input_number.ATTR_STEP, 1))
unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
unit = self.entity.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
self._resource = AlexaPresetResource(
["Value", get_resource_by_unit_of_measurement(self.entity)],
@@ -1994,10 +2118,16 @@ class AlexaRangeController(AlexaCapability):
# Number Value
if self.instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}":
min_value = float(self.entity.attributes[number.ATTR_MIN])
max_value = float(self.entity.attributes[number.ATTR_MAX])
precision = float(self.entity.attributes.get(number.ATTR_STEP, 1))
unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
min_value = float(
self.entity.attributes[NumberEntityCapabilityAttribute.MIN]
)
max_value = float(
self.entity.attributes[NumberEntityCapabilityAttribute.MAX]
)
precision = float(
self.entity.attributes.get(NumberEntityCapabilityAttribute.STEP, 1)
)
unit = self.entity.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
self._resource = AlexaPresetResource(
["Value", get_resource_by_unit_of_measurement(self.entity)],
@@ -2016,7 +2146,9 @@ class AlexaRangeController(AlexaCapability):
# Vacuum Fan Speed Resources
if self.instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
speed_list = self.entity.attributes[vacuum.ATTR_FAN_SPEED_LIST]
speed_list = self.entity.attributes[
VacuumEntityCapabilityAttribute.FAN_SPEED_LIST
]
max_value = len(speed_list) - 1
self._resource = AlexaPresetResource(
labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED],
@@ -2050,7 +2182,9 @@ class AlexaRangeController(AlexaCapability):
@override
def semantics(self) -> dict[str, Any] | None:
"""Build and return semantics object."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
# Cover Position
if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
@@ -2111,8 +2245,12 @@ class AlexaRangeController(AlexaCapability):
lower_labels = [AlexaSemantics.ACTION_LOWER]
raise_labels = [AlexaSemantics.ACTION_RAISE]
self._semantics = AlexaSemantics()
min_value = self.entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10)
max_value = self.entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90)
min_value = self.entity.attributes.get(
HumidifierEntityCapabilityAttribute.MIN_HUMIDITY, 10
)
max_value = self.entity.attributes.get(
HumidifierEntityCapabilityAttribute.MAX_HUMIDITY, 90
)
self._semantics.add_action_to_directive(
lower_labels, "SetRangeValue", {"rangeValue": min_value}
@@ -2217,7 +2355,9 @@ class AlexaToggleController(AlexaCapability):
# Fan Oscillating
if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_OSCILLATING}":
is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING))
is_on = bool(
self.entity.attributes.get(FanEntityStateAttribute.OSCILLATING)
)
return "ON" if is_on else "OFF"
# Stop Valve
@@ -2531,7 +2671,9 @@ class AlexaEqualizerController(AlexaCapability):
if name != "mode":
raise UnsupportedProperty(name)
sound_mode = self.entity.attributes.get(media_player.ATTR_SOUND_MODE)
sound_mode = self.entity.attributes.get(
MediaPlayerEntityStateAttribute.SOUND_MODE
)
if sound_mode and sound_mode.upper() in self.VALID_SOUND_MODES:
return sound_mode.upper()
@@ -2542,7 +2684,10 @@ class AlexaEqualizerController(AlexaCapability):
"""Return the sound modes supported in the configurations object."""
configurations = None
supported_sound_modes = self.get_valid_inputs(
self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or []
self.entity.attributes.get(
MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST
)
or []
)
if supported_sound_modes:
configurations = {"modes": {"supported": supported_sound_modes}}
+101 -38
View File
@@ -22,27 +22,46 @@ from homeassistant.components import (
)
from homeassistant.components.alarm_control_panel import (
DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
AlarmControlPanelEntityStateAttribute,
)
from homeassistant.components.alert import DOMAIN as ALERT_DOMAIN
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
ClimateEntityCapabilityAttribute,
)
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.fan import (
DOMAIN as FAN_DOMAIN,
FanEntityCapabilityAttribute,
)
from homeassistant.components.group import DOMAIN as GROUP_DOMAIN
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.humidifier import (
DOMAIN as HUMIDIFIER_DOMAIN,
HumidifierEntityCapabilityAttribute,
)
from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN
from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN
from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN,
LightEntityCapabilityAttribute,
)
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.media_player import (
DOMAIN as MEDIA_PLAYER_DOMAIN,
MediaPlayerEntityCapabilityAttribute,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
from homeassistant.components.remote import (
DOMAIN as REMOTE_DOMAIN,
RemoteEntityStateAttribute,
)
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
@@ -50,13 +69,14 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.components.water_heater import (
DOMAIN as WATER_HEATER_DOMAIN,
WaterHeaterCapabilityAttribute,
)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_SUPPORTED_FEATURES,
ATTR_UNIT_OF_MEASUREMENT,
CONF_DESCRIPTION,
CONF_NAME,
EntityStateAttribute,
UnitOfTemperature,
__version__,
)
@@ -444,7 +464,7 @@ class SwitchCapabilities(AlexaEntity):
if self.entity.domain == INPUT_BOOLEAN_DOMAIN:
return [DisplayCategory.OTHER]
device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS)
device_class = self.entity.attributes.get(EntityStateAttribute.DEVICE_CLASS)
if device_class == switch.SwitchDeviceClass.OUTLET:
return [DisplayCategory.SMARTPLUG]
@@ -494,12 +514,19 @@ class ClimateCapabilities(AlexaEntity):
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
# If we support two modes, one being off, we allow turning on too.
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported_features = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if (
(
self.entity.domain == CLIMATE_DOMAIN
and climate.HVACMode.OFF
in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or [])
in (
self.entity.attributes.get(
ClimateEntityCapabilityAttribute.HVAC_MODES
)
or []
)
)
or (
self.entity.domain == CLIMATE_DOMAIN
@@ -533,7 +560,9 @@ class ClimateCapabilities(AlexaEntity):
supported_features
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
)
and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST)
and self.entity.attributes.get(
WaterHeaterCapabilityAttribute.OPERATION_LIST
)
):
yield AlexaModeController(
self.entity,
@@ -550,7 +579,7 @@ class CoverCapabilities(AlexaEntity):
@override
def default_display_categories(self) -> list[str]:
"""Return the display categories for this entity."""
device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS)
device_class = self.entity.attributes.get(EntityStateAttribute.DEVICE_CLASS)
if device_class in (cover.CoverDeviceClass.GARAGE, cover.CoverDeviceClass.GATE):
return [DisplayCategory.GARAGE_DOOR]
if device_class == cover.CoverDeviceClass.DOOR:
@@ -573,14 +602,16 @@ class CoverCapabilities(AlexaEntity):
@override
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS)
device_class = self.entity.attributes.get(EntityStateAttribute.DEVICE_CLASS)
if device_class not in (
cover.CoverDeviceClass.GARAGE,
cover.CoverDeviceClass.GATE,
):
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if supported & cover.CoverEntityFeature.SET_POSITION:
yield AlexaRangeController(
self.entity, instance=f"{COVER_DOMAIN}.{cover.ATTR_POSITION}"
@@ -609,7 +640,9 @@ class EventCapabilities(AlexaEntity):
def default_display_categories(self) -> list[str] | None:
"""Return the display categories for this entity."""
attrs = self.entity.attributes
device_class: event.EventDeviceClass | None = attrs.get(ATTR_DEVICE_CLASS)
device_class: event.EventDeviceClass | None = attrs.get(
EntityStateAttribute.DEVICE_CLASS
)
if device_class == event.EventDeviceClass.DOORBELL:
return [DisplayCategory.DOORBELL]
return None
@@ -637,7 +670,9 @@ class LightCapabilities(AlexaEntity):
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
color_modes = self.entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES)
color_modes = self.entity.attributes.get(
LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES
)
if light.brightness_supported(color_modes):
yield AlexaBrightnessController(self.entity)
if light.color_supported(color_modes):
@@ -663,14 +698,16 @@ class FanCapabilities(AlexaEntity):
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
force_range_controller = True
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if supported & fan.FanEntityFeature.OSCILLATE:
yield AlexaToggleController(
self.entity, instance=f"{FAN_DOMAIN}.{fan.ATTR_OSCILLATING}"
)
force_range_controller = False
if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get(
fan.ATTR_PRESET_MODES
FanEntityCapabilityAttribute.PRESET_MODES
):
yield AlexaModeController(
self.entity, instance=f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}"
@@ -709,12 +746,16 @@ class RemoteCapabilities(AlexaEntity):
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
activities = (
self.entity.attributes.get(RemoteEntityStateAttribute.ACTIVITY_LIST) or []
)
if (
activities
and (supported & remote.RemoteEntityFeature.ACTIVITY)
and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
and self.entity.attributes.get(RemoteEntityStateAttribute.ACTIVITY_LIST)
):
yield AlexaModeController(
self.entity, instance=f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}"
@@ -736,10 +777,14 @@ class HumidifierCapabilities(AlexaEntity):
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if (
supported & humidifier.HumidifierEntityFeature.MODES
) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES):
) and self.entity.attributes.get(
HumidifierEntityCapabilityAttribute.AVAILABLE_MODES
):
yield AlexaModeController(
self.entity, instance=f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}"
)
@@ -775,7 +820,7 @@ class MediaPlayerCapabilities(AlexaEntity):
@override
def default_display_categories(self) -> list[str]:
"""Return the display categories for this entity."""
device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS)
device_class = self.entity.attributes.get(EntityStateAttribute.DEVICE_CLASS)
if device_class == media_player.MediaPlayerDeviceClass.SPEAKER:
return [DisplayCategory.SPEAKER]
@@ -786,7 +831,9 @@ class MediaPlayerCapabilities(AlexaEntity):
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if supported & media_player.MediaPlayerEntityFeature.VOLUME_SET:
yield AlexaSpeaker(self.entity)
elif supported & media_player.MediaPlayerEntityFeature.VOLUME_STEP:
@@ -808,7 +855,9 @@ class MediaPlayerCapabilities(AlexaEntity):
if supported & media_player.MediaPlayerEntityFeature.SELECT_SOURCE:
inputs = AlexaInputController.get_valid_inputs(
self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, [])
self.entity.attributes.get(
MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST, []
)
)
if len(inputs) > 0:
yield AlexaInputController(self.entity)
@@ -825,7 +874,10 @@ class MediaPlayerCapabilities(AlexaEntity):
and domain != "denonavr"
):
inputs = AlexaEqualizerController.get_valid_inputs(
self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or []
self.entity.attributes.get(
MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST
)
or []
)
if len(inputs) > 0:
yield AlexaEqualizerController(self.entity)
@@ -889,7 +941,7 @@ class SensorCapabilities(AlexaEntity):
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
attrs = self.entity.attributes
if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in {
if attrs.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) in {
UnitOfTemperature.FAHRENHEIT,
UnitOfTemperature.CELSIUS,
}:
@@ -947,7 +999,7 @@ class BinarySensorCapabilities(AlexaEntity):
def get_type(self) -> str | None:
"""Return the type of binary sensor."""
attrs = self.entity.attributes
if attrs.get(ATTR_DEVICE_CLASS) in (
if attrs.get(EntityStateAttribute.DEVICE_CLASS) in (
binary_sensor.BinarySensorDeviceClass.DOOR,
binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR,
binary_sensor.BinarySensorDeviceClass.OPENING,
@@ -955,11 +1007,14 @@ class BinarySensorCapabilities(AlexaEntity):
):
return self.TYPE_CONTACT
if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.BinarySensorDeviceClass.MOTION:
if (
attrs.get(EntityStateAttribute.DEVICE_CLASS)
== binary_sensor.BinarySensorDeviceClass.MOTION
):
return self.TYPE_MOTION
if (
attrs.get(ATTR_DEVICE_CLASS)
attrs.get(EntityStateAttribute.DEVICE_CLASS)
== binary_sensor.BinarySensorDeviceClass.PRESENCE
):
return self.TYPE_PRESENCE
@@ -979,7 +1034,9 @@ class AlarmControlPanelCapabilities(AlexaEntity):
@override
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
if not self.entity.attributes.get("code_arm_required"):
if not self.entity.attributes.get(
AlarmControlPanelEntityStateAttribute.CODE_ARM_REQUIRED
):
yield AlexaSecurityPanelController(self.hass, self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
@@ -1050,7 +1107,9 @@ class VacuumCapabilities(AlexaEntity):
@override
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if (
(supported & vacuum.VacuumEntityFeature.TURN_ON)
or (supported & vacuum.VacuumEntityFeature.START)
@@ -1087,7 +1146,9 @@ class ValveCapabilities(AlexaEntity):
@override
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if supported & valve.ValveEntityFeature.SET_POSITION:
yield AlexaRangeController(
self.entity, instance=f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}"
@@ -1115,7 +1176,9 @@ class CameraCapabilities(AlexaEntity):
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
if self._check_requirements():
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = self.entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
if supported & camera.CameraEntityFeature.STREAM:
yield AlexaCameraStreamController(self.entity)
+131 -51
View File
@@ -27,24 +27,58 @@ from homeassistant.components import (
water_heater,
)
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
ClimateEntityCapabilityAttribute,
ClimateEntityStateAttribute,
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverEntityStateAttribute,
)
from homeassistant.components.fan import (
DOMAIN as FAN_DOMAIN,
FanEntityCapabilityAttribute,
FanEntityStateAttribute,
)
from homeassistant.components.group import DOMAIN as GROUP_DOMAIN
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.humidifier import (
DOMAIN as HUMIDIFIER_DOMAIN,
HumidifierEntityCapabilityAttribute,
HumidifierEntityStateAttribute,
)
from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
from homeassistant.components.light import (
LightEntityCapabilityAttribute,
LightEntityStateAttribute,
)
from homeassistant.components.media_player import (
DOMAIN as MEDIA_PLAYER_DOMAIN,
MediaPlayerEntityCapabilityAttribute,
MediaPlayerEntityStateAttribute,
)
from homeassistant.components.number import (
DOMAIN as NUMBER_DOMAIN,
NumberEntityCapabilityAttribute,
)
from homeassistant.components.remote import (
DOMAIN as REMOTE_DOMAIN,
RemoteEntityStateAttribute,
)
from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.components.vacuum import (
DOMAIN as VACUUM_DOMAIN,
VacuumEntityCapabilityAttribute,
VacuumEntityStateAttribute,
)
from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.components.water_heater import (
DOMAIN as WATER_HEATER_DOMAIN,
WaterHeaterCapabilityAttribute,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE,
ATTR_SUPPORTED_FEATURES,
ATTR_TEMPERATURE,
SERVICE_ALARM_ARM_AWAY,
SERVICE_ALARM_ARM_HOME,
@@ -65,6 +99,7 @@ from homeassistant.const import (
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
EntityStateAttribute,
UnitOfTemperature,
)
from homeassistant.helpers import network
@@ -205,7 +240,7 @@ async def async_api_turn_on(
elif domain == REMOTE_DOMAIN:
service = remote.SERVICE_TURN_ON
elif domain == VACUUM_DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0)
if (
not supported & vacuum.VacuumEntityFeature.TURN_ON
and supported & vacuum.VacuumEntityFeature.START
@@ -214,7 +249,7 @@ async def async_api_turn_on(
elif domain == TIMER_DOMAIN:
service = timer.SERVICE_START
elif domain == MEDIA_PLAYER_DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0)
power_features = (
media_player.MediaPlayerEntityFeature.TURN_ON
| media_player.MediaPlayerEntityFeature.TURN_OFF
@@ -258,7 +293,7 @@ async def async_api_turn_off(
elif domain == HUMIDIFIER_DOMAIN:
service = humidifier.SERVICE_TURN_OFF
elif domain == VACUUM_DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0)
if (
not supported & vacuum.VacuumEntityFeature.TURN_OFF
and supported & vacuum.VacuumEntityFeature.RETURN_HOME
@@ -267,7 +302,7 @@ async def async_api_turn_off(
elif domain == TIMER_DOMAIN:
service = timer.SERVICE_CANCEL
elif domain == MEDIA_PLAYER_DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0)
power_features = (
media_player.MediaPlayerEntityFeature.TURN_ON
| media_player.MediaPlayerEntityFeature.TURN_OFF
@@ -391,8 +426,10 @@ async def async_api_decrease_color_temp(
) -> AlexaResponse:
"""Process a decrease color temperature request."""
entity = directive.entity
current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN])
min_kelvin = int(entity.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN])
current = int(entity.attributes[LightEntityStateAttribute.COLOR_TEMP_KELVIN])
min_kelvin = int(
entity.attributes[LightEntityCapabilityAttribute.MIN_COLOR_TEMP_KELVIN]
)
value = max(min_kelvin, current - 500)
await hass.services.async_call(
@@ -415,8 +452,10 @@ async def async_api_increase_color_temp(
) -> AlexaResponse:
"""Process an increase color temperature request."""
entity = directive.entity
current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN])
max_kelvin = int(entity.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN])
current = int(entity.attributes[LightEntityStateAttribute.COLOR_TEMP_KELVIN])
max_kelvin = int(
entity.attributes[LightEntityCapabilityAttribute.MAX_COLOR_TEMP_KELVIN]
)
value = min(max_kelvin, current + 500)
await hass.services.async_call(
@@ -604,7 +643,10 @@ async def async_api_select_input(
# Attempt to map the ALL UPPERCASE payload name to a source.
# Strips trailing 1 to match single input devices.
source_list = entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or []
source_list = (
entity.attributes.get(MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST)
or []
)
for source in source_list:
formatted_source = (
source.lower().replace("-", "").replace("_", "").replace(" ", "")
@@ -651,7 +693,9 @@ async def async_api_adjust_volume(
volume_delta = int(directive.payload["volume"])
entity = directive.entity
current_level = entity.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL]
current_level = entity.attributes[
MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL
]
# read current state
try:
@@ -782,7 +826,9 @@ async def async_api_stop(
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
if entity.domain == COVER_DOMAIN:
supported: int = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported: int = entity.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES, 0
)
feature_services: dict[int, str] = {
cover.CoverEntityFeature.STOP.value: cover.SERVICE_STOP_COVER,
cover.CoverEntityFeature.STOP_TILT.value: cover.SERVICE_STOP_COVER_TILT,
@@ -875,7 +921,7 @@ async def async_api_set_target_temp(
domain = entity.domain
min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]]
max_temp = entity.attributes["max_temp"]
max_temp = entity.attributes[ClimateEntityCapabilityAttribute.MAX_TEMP]
unit = hass.config.units.temperature_unit
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
@@ -953,8 +999,12 @@ async def async_api_adjust_target_temp(
response = directive.response()
current_target_temp_high = entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)
current_target_temp_low = entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)
current_target_temp_high = entity.attributes.get(
ClimateEntityStateAttribute.TARGET_TEMP_HIGH
)
current_target_temp_low = entity.attributes.get(
ClimateEntityStateAttribute.TARGET_TEMP_LOW
)
if current_target_temp_high is not None and current_target_temp_low is not None:
target_temp_high = float(current_target_temp_high) + temp_delta
if target_temp_high < min_temp or target_temp_high > max_temp:
@@ -985,7 +1035,9 @@ async def async_api_adjust_target_temp(
}
)
else:
current_target_temp: str | None = entity.attributes.get(ATTR_TEMPERATURE)
current_target_temp: str | None = entity.attributes.get(
ClimateEntityStateAttribute.TARGET_TEMPERATURE
)
if current_target_temp is None:
raise AlexaUnsupportedThermostatTargetStateError(
"The current target temperature is not set, "
@@ -1037,7 +1089,9 @@ async def async_api_set_thermostat_mode(
ha_preset = next((k for k, v in API_THERMOSTAT_PRESETS.items() if v == mode), None)
if ha_preset:
presets = entity.attributes.get(climate.ATTR_PRESET_MODES) or []
presets = (
entity.attributes.get(ClimateEntityCapabilityAttribute.PRESET_MODES) or []
)
if ha_preset not in presets:
msg = f"The requested thermostat mode {ha_preset} is not supported"
@@ -1047,7 +1101,9 @@ async def async_api_set_thermostat_mode(
data[climate.ATTR_PRESET_MODE] = ha_preset
elif mode == "CUSTOM":
operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) or []
operation_list = (
entity.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES) or []
)
custom_mode = directive.payload["thermostatMode"]["customName"]
custom_mode = next(
(k for k, v in API_THERMOSTAT_MODES_CUSTOM.items() if v == custom_mode),
@@ -1063,7 +1119,9 @@ async def async_api_set_thermostat_mode(
data[climate.ATTR_HVAC_MODE] = custom_mode
else:
operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) or []
operation_list = (
entity.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES) or []
)
ha_modes: dict[str, str] = {
k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode
}
@@ -1221,7 +1279,9 @@ async def async_api_set_mode(
# Fan preset_mode
elif instance == f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}":
preset_mode = mode.split(".")[1]
preset_modes: list[str] | None = entity.attributes.get(fan.ATTR_PRESET_MODES)
preset_modes: list[str] | None = entity.attributes.get(
FanEntityCapabilityAttribute.PRESET_MODES
)
if (
preset_mode != PRESET_MODE_NA
and preset_modes
@@ -1236,7 +1296,9 @@ async def async_api_set_mode(
# Humidifier mode
elif instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}":
mode = mode.split(".")[1]
modes: list[str] | None = entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES)
modes: list[str] | None = entity.attributes.get(
HumidifierEntityCapabilityAttribute.AVAILABLE_MODES
)
if mode != PRESET_MODE_NA and modes and mode in modes:
service = humidifier.SERVICE_SET_MODE
data[humidifier.ATTR_MODE] = mode
@@ -1247,7 +1309,9 @@ async def async_api_set_mode(
# Remote Activity
elif instance == f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}":
activity = mode.split(".")[1]
activities: list[str] | None = entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
activities: list[str] | None = entity.attributes.get(
RemoteEntityStateAttribute.ACTIVITY_LIST
)
if activity != PRESET_MODE_NA and activities and activity in activities:
service = remote.SERVICE_TURN_ON
data[remote.ATTR_ACTIVITY] = activity
@@ -1259,7 +1323,7 @@ async def async_api_set_mode(
elif instance == f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
operation_mode = mode.split(".")[1]
operation_modes: list[str] | None = entity.attributes.get(
water_heater.ATTR_OPERATION_LIST
WaterHeaterCapabilityAttribute.OPERATION_LIST
)
if (
operation_mode != PRESET_MODE_NA
@@ -1432,7 +1496,7 @@ async def async_api_set_range(
service = None
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
range_value = directive.payload["rangeValue"]
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0)
# Cover Position
if instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
@@ -1485,14 +1549,14 @@ async def async_api_set_range(
elif instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}":
range_value = float(range_value)
service = number.SERVICE_SET_VALUE
min_value = float(entity.attributes[number.ATTR_MIN])
max_value = float(entity.attributes[number.ATTR_MAX])
min_value = float(entity.attributes[NumberEntityCapabilityAttribute.MIN])
max_value = float(entity.attributes[NumberEntityCapabilityAttribute.MAX])
data[number.ATTR_VALUE] = min(max_value, max(min_value, range_value))
# Vacuum Fan Speed
elif instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
service = vacuum.SERVICE_SET_FAN_SPEED
speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST]
speed_list = entity.attributes[VacuumEntityCapabilityAttribute.FAN_SPEED_LIST]
speed = next(
(v for i, v in enumerate(speed_list) if i == int(range_value)), None
)
@@ -1555,7 +1619,9 @@ async def async_api_adjust_range(
if instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}":
range_delta = int(range_delta * 20) if range_delta_default else int(range_delta)
service = SERVICE_SET_COVER_POSITION
if not (current := entity.attributes.get(cover.ATTR_CURRENT_POSITION)):
if not (
current := entity.attributes.get(CoverEntityStateAttribute.CURRENT_POSITION)
):
msg = f"Unable to determine {entity.entity_id} current position"
raise AlexaInvalidValueError(msg)
position = response_value = min(100, max(0, range_delta + current))
@@ -1584,14 +1650,16 @@ async def async_api_adjust_range(
# Fan speed percentage
elif instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}":
percentage_step = entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 20
percentage_step = (
entity.attributes.get(FanEntityStateAttribute.PERCENTAGE_STEP) or 20
)
range_delta = (
int(range_delta * percentage_step)
if range_delta_default
else int(range_delta)
)
service = fan.SERVICE_SET_PERCENTAGE
if not (current := entity.attributes.get(fan.ATTR_PERCENTAGE)):
if not (current := entity.attributes.get(FanEntityStateAttribute.PERCENTAGE)):
msg = f"Unable to determine {entity.entity_id} current fan speed"
raise AlexaInvalidValueError(msg)
percentage = response_value = min(100, max(0, range_delta + current))
@@ -1609,11 +1677,17 @@ async def async_api_adjust_range(
else int(range_delta)
)
service = humidifier.SERVICE_SET_HUMIDITY
if not (current := entity.attributes.get(humidifier.ATTR_HUMIDITY)):
if not (
current := entity.attributes.get(HumidifierEntityStateAttribute.HUMIDITY)
):
msg = f"Unable to determine {entity.entity_id} current target humidity"
raise AlexaInvalidValueError(msg)
min_value = entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10)
max_value = entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90)
min_value = entity.attributes.get(
HumidifierEntityCapabilityAttribute.MIN_HUMIDITY, 10
)
max_value = entity.attributes.get(
HumidifierEntityCapabilityAttribute.MAX_HUMIDITY, 90
)
percentage = response_value = min(
max_value, max(min_value, range_delta + current)
)
@@ -1635,8 +1709,8 @@ async def async_api_adjust_range(
elif instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}":
range_delta = float(range_delta)
service = number.SERVICE_SET_VALUE
min_value = float(entity.attributes[number.ATTR_MIN])
max_value = float(entity.attributes[number.ATTR_MAX])
min_value = float(entity.attributes[NumberEntityCapabilityAttribute.MIN])
max_value = float(entity.attributes[NumberEntityCapabilityAttribute.MAX])
current = float(entity.state)
data[number.ATTR_VALUE] = response_value = min(
max_value, max(min_value, range_delta + current)
@@ -1646,8 +1720,8 @@ async def async_api_adjust_range(
elif instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
range_delta = int(range_delta)
service = vacuum.SERVICE_SET_FAN_SPEED
speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST]
current_speed = entity.attributes[vacuum.ATTR_FAN_SPEED]
speed_list = entity.attributes[VacuumEntityCapabilityAttribute.FAN_SPEED_LIST]
current_speed = entity.attributes[VacuumEntityStateAttribute.FAN_SPEED]
current_speed_index = next(
(i for i, v in enumerate(speed_list) if v == current_speed), 0
)
@@ -1798,14 +1872,18 @@ async def async_api_seek(
entity = directive.entity
position_delta = int(directive.payload["deltaPositionMilliseconds"])
current_position = entity.attributes.get(media_player.ATTR_MEDIA_POSITION)
current_position = entity.attributes.get(
MediaPlayerEntityStateAttribute.MEDIA_POSITION
)
if not current_position:
msg = f"{entity} did not return the current media position."
raise AlexaVideoActionNotPermittedForContentError(msg)
seek_position = max(int(current_position) + int(position_delta / 1000), 0)
media_duration = entity.attributes.get(media_player.ATTR_MEDIA_DURATION)
media_duration = entity.attributes.get(
MediaPlayerEntityStateAttribute.MEDIA_DURATION
)
if media_duration and 0 < int(media_duration) < seek_position:
seek_position = media_duration
@@ -1845,7 +1923,9 @@ async def async_api_set_eq_mode(
entity = directive.entity
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
sound_mode_list = entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST)
sound_mode_list = entity.attributes.get(
MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST
)
if sound_mode_list and mode.lower() in sound_mode_list:
data[media_player.ATTR_SOUND_MODE] = mode.lower()
else:
@@ -1946,7 +2026,7 @@ async def async_api_initialize_camera_stream(
stream_source = await camera.async_request_stream(hass, entity.entity_id, fmt="hls")
state = hass.states.get(entity.entity_id)
assert state
camera_image = state.attributes[ATTR_ENTITY_PICTURE]
camera_image = state.attributes[EntityStateAttribute.ENTITY_PICTURE]
try:
external_url = network.get_url(
@@ -132,8 +132,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
device_registry = dr.async_get(hass)
self.previous_devices: set[str] = {
identifier
for device in device_registry.devices.get_devices_for_config_entry_id(
entry.entry_id
for device in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
)
if device.entry_type != dr.DeviceEntryType.SERVICE
for identifier_domain, identifier in device.identifiers
@@ -113,6 +113,7 @@ class AmbientNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id=CONF_USER, data_schema=schema, errors=errors or {}
)
@@ -763,7 +763,7 @@ def _device_payload(device_entry: dr.AnyDeviceEntry) -> dict[str, Any]:
}
async def _async_snapshot_payload(hass: HomeAssistant) -> dict:
async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
"""Return detailed information about entities and devices for a snapshot."""
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
@@ -774,7 +774,7 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict:
removed_devices: set[str] = set()
# Get device list
for device_entry in (*dev_reg.devices.values(), *dev_reg.child_devices.values()):
for device_entry in (*dev_reg.devices, *dev_reg.child_devices):
config_entry = hass.config_entries.async_get_entry(device_entry.config_entry_id)
if config_entry is None:
@@ -883,7 +883,10 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict:
continue
resolved_device = dev_reg.async_get(device_id)
assert resolved_device is not None
if resolved_device is None:
# The device was removed while we were awaiting above
removed_devices.add(device_id)
continue
device_id_mapping[device_id] = (integration_domain, len(devices_info))
@@ -2,7 +2,7 @@
import asyncio
from collections.abc import Iterable
from typing import Any, override
from typing import Any, Final, override
from homeassistant.components.remote import (
ATTR_ACTIVITY,
@@ -16,14 +16,35 @@ from homeassistant.components.remote import (
RemoteEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_APP_NAME
from .const import CONF_APP_NAME, DOMAIN
from .entity import AndroidTVRemoteBaseEntity
from .helpers import AndroidTVRemoteConfigEntry
PARALLEL_UPDATES = 0
PREFIX_SEPARATOR: Final[str] = ":"
# Only direction prefixes are stripped; other colon conventions (e.g. text:) pass through to the library unchanged.
VALID_PREFIXES: Final[frozenset[str]] = frozenset(
{
"SHORT",
"START_LONG",
"END_LONG",
}
)
def _parse_command(single_command: str) -> tuple[str, str | None]:
"""Split an optional prefix from the key code."""
prefix, separator, rest = single_command.partition(PREFIX_SEPARATOR)
if separator:
normalized = prefix.upper()
if normalized in VALID_PREFIXES:
return rest, normalized
return single_command, None
async def async_setup_entry(
hass: HomeAssistant,
@@ -105,10 +126,24 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
for _ in range(num_repeats):
for single_command in command:
key_code, direction = _parse_command(single_command)
if direction is not None:
if not key_code:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="empty_key_code",
translation_placeholders={"command": single_command},
)
if hold_secs:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="direction_prefix_with_hold_secs",
translation_placeholders={"command": single_command},
)
if hold_secs:
self._send_key_command(single_command, "START_LONG")
self._send_key_command(key_code, "START_LONG")
await asyncio.sleep(hold_secs)
self._send_key_command(single_command, "END_LONG")
self._send_key_command(key_code, "END_LONG")
else:
self._send_key_command(single_command, "SHORT")
self._send_key_command(key_code, direction or "SHORT")
await asyncio.sleep(delay_secs)
@@ -56,6 +56,12 @@
"connection_closed": {
"message": "Connection to the Android TV device is closed"
},
"direction_prefix_with_hold_secs": {
"message": "Command \"{command}\" combines a direction prefix with hold_secs; specify only one"
},
"empty_key_code": {
"message": "Command \"{command}\" is missing a key code after the direction prefix"
},
"invalid_channel": {
"message": "Channel must be numeric: {media_id}"
},
+2
View File
@@ -33,6 +33,7 @@ SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [
key="cook_time",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
translation_key="cook_time",
device_class=SensorDeviceClass.DURATION,
value_fn=lambda data: data.cook_time,
@@ -62,6 +63,7 @@ SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [
AnovaSensorEntityDescription(
key="cook_time_remaining",
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
translation_key="cook_time_remaining",
device_class=SensorDeviceClass.DURATION,
value_fn=lambda data: data.cook_time_remaining,
@@ -0,0 +1 @@
"""Virtual integration: Ariston."""
@@ -0,0 +1,6 @@
{
"domain": "ariston",
"name": "Ariston",
"integration_type": "virtual",
"supported_by": "midea"
}
@@ -21,10 +21,9 @@ def async_describe_events(
@callback
def async_describe_logbook_event(event: Event) -> dict[str, str]:
"""Describe logbook event."""
device: dr.DeviceEntry | None = None
device_name: str = "Unknown device"
device_name = "Unknown device"
device = device_registry.devices[event.data[ATTR_DEVICE_ID]]
device = device_registry.async_get(event.data[ATTR_DEVICE_ID])
if device:
device_name = device.name_by_user or device.name or "Unknown device"
@@ -131,6 +131,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
f"Invalid Assist satellite entity id: {satellite_entity_id}"
)
satellite_entity.async_set_context(call.context)
ask_question_args = {
"question": call.data.get("question"),
"question_media_id": call.data.get("question_media_id"),
@@ -7,7 +7,6 @@ import contextlib
from dataclasses import dataclass, field
from enum import StrEnum
import logging
import time
from typing import Any, Literal, final, override
from hassil import Intents, recognize
@@ -442,6 +441,8 @@ class AssistSatelliteEntity(entity.Entity):
start_stage: PipelineStage = PipelineStage.STT,
end_stage: PipelineStage = PipelineStage.TTS,
wake_word_phrase: str | None = None,
*,
context: Context | None = None,
) -> None:
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
await self._cancel_running_pipeline()
@@ -485,15 +486,8 @@ class AssistSatelliteEntity(entity.Entity):
device_id = self.registry_entry.device_id if self.registry_entry else None
# Refresh context if necessary
if (
(self._context is None)
or (self._context_set is None)
or ((time.time() - self._context_set) > entity.CONTEXT_RECENT_TIME_SECONDS)
):
self.async_set_context(Context())
assert self._context is not None
context = context or Context()
self.async_set_context(context)
# Set entity state based on pipeline events
self._run_has_tts = False
@@ -511,7 +505,7 @@ class AssistSatelliteEntity(entity.Entity):
self.hass,
async_pipeline_from_audio_stream(
self.hass,
context=self._context,
context=context,
event_callback=self._internal_on_pipeline_event,
stt_metadata=stt.SpeechMetadata(
language="", # set in async_pipeline_from_audio_stream
+3 -1
View File
@@ -92,7 +92,9 @@ async def async_setup_august(
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: AugustConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant,
config_entry: AugustConfigEntry,
device_entry: dr.AnyDeviceEntry,
) -> bool:
"""Remove august config entry from a device if its no longer present."""
return not any(
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.10", "yalexs-ble==3.3.1"]
"requirements": ["yalexs==9.2.10", "yalexs-ble==4.0.1"]
}
+166 -12
View File
@@ -1,7 +1,9 @@
"""Helpers to resolve client ID/secret."""
from html.parser import HTMLParser
from http import HTTPStatus
from ipaddress import ip_address
import json
import logging
from typing import override
from urllib.parse import ParseResult, urljoin, urlparse
@@ -14,6 +16,9 @@ from homeassistant.util.network import is_local
_LOGGER = logging.getLogger(__name__)
# We limit reads of a client_id page to the first 10kB.
MAX_FETCH_BYTES = 10240
async def verify_redirect_uri(
hass: HomeAssistant, client_id: str, redirect_uri: str
@@ -24,7 +29,10 @@ async def verify_redirect_uri(
except ValueError:
return False
redirect_parts = _parse_url(redirect_uri)
try:
redirect_parts = _parse_url(redirect_uri)
except ValueError:
return False
# Verify redirect url and client url have same scheme and domain.
is_valid = (
@@ -53,7 +61,15 @@ async def verify_redirect_uri(
# IndieAuth 4.2.2 allows for redirect_uri to be on different domain
# but needs to be specified in link tag when fetching `client_id`.
redirect_uris = await fetch_redirect_uris(hass, client_id)
return redirect_uri in redirect_uris
if redirect_uri in redirect_uris:
return True
_LOGGER.debug(
"redirect_uri %s is not among the advertised redirect uris %s for client_id %s",
redirect_uri,
redirect_uris,
client_id,
)
return False
class LinkTagParser(HTMLParser):
@@ -63,7 +79,7 @@ class LinkTagParser(HTMLParser):
"""Initialize a link tag parser."""
super().__init__()
self.rel = rel
self.found: list[str | None] = []
self.found: list[str] = []
@override
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
@@ -73,48 +89,115 @@ class LinkTagParser(HTMLParser):
attributes: dict[str, str | None] = dict(attrs)
if attributes.get("rel") == self.rel:
self.found.append(attributes.get("href"))
# Skip tags with a missing or empty href: urljoin resolves those to
# the client_id URL itself instead of naming a redirect target.
if attributes.get("rel") == self.rel and (href := attributes.get("href")):
self.found.append(href)
def _reject_json_constant(constant: str) -> None:
"""Reject NaN/Infinity/-Infinity, which RFC 8259 does not allow."""
raise ValueError(f"Invalid JSON constant: {constant}")
def _is_valid_metadata_client_id(url: str) -> bool:
"""Validate a client_id URL for the metadata-document fallback.
The client identifier URL must be https with a path component and no
fragment (a bare trailing # counts as a fragment component). The remaining
client identifier rules are enforced upstream by _parse_client_id.
"""
try:
parts = urlparse(url)
# urlparse defers port validation until the attribute is accessed.
_ = parts.port
except ValueError:
return False
return parts.scheme == "https" and bool(parts.path) and "#" not in url
def _is_valid_metadata_redirect_uri(redirect_uri: str) -> bool:
"""Validate a client ID metadata document redirect_uris entry.
Entries must be absolute, fragment-free URIs: a non-empty scheme (so
private-use schemes like app:/callback stay valid) and no fragment per
RFC 6749 3.1.2 (a bare trailing # counts as a fragment component).
"""
try:
parts = urlparse(redirect_uri)
# urlparse defers port validation until the attribute is accessed.
_ = parts.port
except ValueError:
return False
return bool(parts.scheme) and "#" not in redirect_uri
async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
"""Find link tag with redirect_uri values.
"""Find the redirect_uri values that a client_id advertises.
We support two formats, checked in this order:
IndieAuth 4.2.2
The client SHOULD publish one or more <link> tags or Link HTTP headers with
a rel attribute of redirect_uri at the client_id URL.
We limit to the first 10kB of the page.
OAuth Client ID Metadata Document
(draft-ietf-oauth-client-id-metadata-document)
The client_id URL returns a JSON document with a redirect_uris array. As we
advertise client_id_metadata_document_supported in the authorization server
metadata, we fall back to this format when no link tags are found.
We read roughly the first 10kB of the page and a fetch error yields no
redirect uris.
We do not implement extracting redirect uris from headers.
"""
parser = LinkTagParser("redirect_uri")
chunks = 0
body: bytes = b""
status: int | None = None
redirected = False
try:
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp,
):
status = resp.status
redirected = bool(resp.history)
async for data in resp.content.iter_chunked(1024):
parser.feed(data.decode())
chunks += 1
body += data
if chunks == 10:
if len(body) >= MAX_FETCH_BYTES:
break
except TimeoutError:
_LOGGER.error("Timeout while looking up redirect_uri %s", url)
return []
except aiohttp.client_exceptions.ClientSSLError:
_LOGGER.error("SSL error while looking up redirect_uri %s", url)
return []
except aiohttp.client_exceptions.ClientOSError as ex:
_LOGGER.error("OS error while looking up redirect_uri %s: %s", url, ex.strerror)
return []
except aiohttp.client_exceptions.ClientConnectionError:
_LOGGER.error(
"Low level connection error while looking up redirect_uri %s", url
)
return []
except aiohttp.client_exceptions.ClientError:
_LOGGER.error("Unknown error while looking up redirect_uri %s", url)
return []
if redirect_uris := _parse_link_tag_redirect_uris(url, body):
return redirect_uris
return _parse_metadata_document_redirect_uris(url, body, status, redirected)
def _parse_link_tag_redirect_uris(url: str, body: bytes) -> list[str]:
"""Find <link rel="redirect_uri"> values in the client_id page body."""
parser = LinkTagParser("redirect_uri")
parser.feed(body.decode(errors="replace"))
# Authorization endpoints verifying that a redirect_uri is allowed for use
# by a client MUST look for an exact match of the given redirect_uri in the
@@ -123,6 +206,77 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
return [urljoin(url, found) for found in parser.found]
def _parse_metadata_document_redirect_uris(
url: str, body: bytes, status: int | None, redirected: bool
) -> list[str]:
"""Parse the client_id page body as an OAuth Client ID Metadata Document.
Per draft-ietf-oauth-client-id-metadata-document the document only counts
when the client_id URL is https with a path and no fragment, the response
was a direct 200 (not redirected), the document's client_id round-trips,
and every redirect_uris entry is an absolute, fragment-free URI matched
exactly. The url and its document are client-controlled and fetched
unauthenticated, so rejections log at DEBUG (higher levels would be a
log-flood vector).
"""
# A body at the read cap may be truncated; a truncated prefix must not be
# trusted even if it happens to be parseable.
if (
len(body) >= MAX_FETCH_BYTES
or status != HTTPStatus.OK
or redirected
or not _is_valid_metadata_client_id(url)
):
_LOGGER.debug(
"Not treating %s as a client ID metadata document: body length %s,"
" status %s, redirected %s (client_id must be a fragment-free https"
" URL with a path)",
url,
len(body),
status,
redirected,
)
return []
try:
# Strict decode (RFC 8259 requires UTF-8): the link tag parser's
# lenient replacement decode would mask invalid bytes as U+FFFD.
document = json.loads(body.decode(), parse_constant=_reject_json_constant)
except UnicodeDecodeError:
_LOGGER.debug("Client ID metadata document at %s is not valid UTF-8", url)
return []
except ValueError:
_LOGGER.debug("Client ID metadata document at %s is not valid JSON", url)
return []
if not isinstance(document, dict):
_LOGGER.debug("Client ID metadata document at %s is not a JSON object", url)
return []
if document.get("client_id") != url:
_LOGGER.debug(
"Client ID metadata document at %s client_id does not match the"
" document URL",
url,
)
return []
# redirect_uris entries are returned unmodified for RFC 6749 exact matching
# rather than resolving relative references.
redirect_uris = document.get("redirect_uris")
if not isinstance(redirect_uris, list) or not all(
isinstance(redirect_uri, str) and _is_valid_metadata_redirect_uri(redirect_uri)
for redirect_uri in redirect_uris
):
_LOGGER.debug(
"Client ID metadata document at %s has missing or invalid redirect_uris",
url,
)
return []
return redirect_uris
def verify_client_id(client_id: str) -> bool:
"""Verify that the client id is valid."""
try:
+5 -6
View File
@@ -137,12 +137,11 @@ class WellKnownOAuthInfoView(HomeAssistantView):
"authorization_endpoint": f"{url_prefix}/auth/authorize",
"token_endpoint": f"{url_prefix}/auth/token",
"revocation_endpoint": f"{url_prefix}/auth/revoke",
# Home Assistant already accepts URL-based client_ids via
# IndieAuth without prior registration, which is compatible with
# draft-ietf-oauth-client-id-metadata-document. This flag
# advertises that support to encourage clients to use it. The
# metadata document is not actually fetched as IndieAuth doesn't
# require it.
# Home Assistant accepts URL-based client_ids via IndieAuth without
# prior registration, and discovers allowed redirect URIs from link
# tags or a Client ID Metadata Document served at the client_id URL.
# This flag advertises that support
# (draft-ietf-oauth-client-id-metadata-document).
"client_id_metadata_document_supported": True,
"response_types_supported": ["code"],
"service_documentation": (
@@ -55,9 +55,7 @@ async def async_setup_entry(
# As it has to be removed from the device on the app.
device_registry = dr.async_get(hass)
devices = device_registry.devices.get_devices_for_config_entry_id(
config_entry.entry_id
)
devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)
for device in devices:
if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in {
remote.serial_number for remote in remotes
@@ -185,8 +185,8 @@ class BeoWebsocket(BeoBase):
# Get remote devices connected to the device from Home Assistant
device_serial_numbers = [
device.serial_number
for device in device_registry.devices.get_devices_for_config_entry_id(
self.entry.entry_id
for device in dr.async_entries_for_config_entry(
device_registry, self.entry.entry_id
)
if device.serial_number is not None
and device.model == BeoModel.BEOREMOTE_ONE
@@ -59,6 +59,12 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
@property
@override
def available(self) -> bool:
"""Return if entity is available."""
return super().available and not self._feature.is_error
@property
@override
def hvac_modes(self) -> list[HVACMode]:
@@ -238,6 +238,12 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
self._attr_translation_key = f"{description.translation_key}_n"
self._attr_translation_placeholders = {"index": str(index)}
@property
@override
def available(self) -> bool:
"""Return if entity is available."""
return super().available and not self._feature.is_error
@property
@override
def native_value(self) -> StateType:
@@ -2,7 +2,7 @@
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
@@ -27,37 +27,27 @@ async def start_charge_session(service_call: ServiceCall) -> None:
charging_card_id = service_call.data[CHARGING_CARD_ID]
device_id = service_call.data[CONF_DEVICE_ID]
# Get the device based on the given device ID.
device = dr.async_get(service_call.hass).devices.get(device_id)
device, config_entry = dr.async_get_device_and_config_entry_for_domain(
service_call.hass, device_id, domain=DOMAIN
)
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="invalid_device_id"
)
blue_current_config_entry: ConfigEntry | None = None
for config_entry_id in device.config_entries:
config_entry = service_call.hass.config_entries.async_get_entry(config_entry_id)
if not config_entry or config_entry.domain != DOMAIN:
# Not the blue_current config entry.
continue
if config_entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
)
blue_current_config_entry = config_entry
break
if not blue_current_config_entry:
if not config_entry:
# The device is not connected to a valid blue_current config entry.
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="no_config_entry"
)
connector = blue_current_config_entry.runtime_data
if config_entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
)
connector = config_entry.runtime_data
# Get the evse_id from the identifier of the device.
evse_id = next(
@@ -407,7 +407,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if mode is BluetoothScanningMode.AUTO and not details.get(ADAPTER_PASSIVE_SCAN):
mode = BluetoothScanningMode.ACTIVE
scanner = HaScanner(mode, adapter, address)
scanner.async_setup()
entry.async_on_unload(scanner.async_setup())
if entry.title == address:
hass.config_entries.async_update_entry(
entry, title=adapter_title(adapter, details)
@@ -19,8 +19,8 @@
"bleak-retry-connector==4.6.3",
"bluetooth-adapters==2.4.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"bluetooth-data-tools==1.29.21",
"dbus-fast==5.0.22",
"habluetooth==6.26.5"
"habluetooth==6.26.7"
]
}
+1 -1
View File
@@ -128,7 +128,7 @@ def _async_remove_old_device_identifiers(
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: BondConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant, config_entry: BondConfigEntry, device_entry: dr.AnyDeviceEntry
) -> bool:
"""Remove bond config entry from a device."""
data = config_entry.runtime_data
+34 -3
View File
@@ -6,7 +6,12 @@ from typing import Any, override
from aiohttp.client_exceptions import ClientResponseError
from bond_async import Action, DeviceType
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import Entity
@@ -120,7 +125,16 @@ class BondLight(BondBaseLight, BondEntity, LightEntity):
) -> None:
"""Create HA entity representing Bond light."""
super().__init__(data, device, sub_device)
if device.supports_set_brightness():
if device.supports_set_color_temp():
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
self._attr_min_color_temp_kelvin = (
device.props.get("min_color_temp") or self._attr_min_color_temp_kelvin
)
self._attr_max_color_temp_kelvin = (
device.props.get("max_color_temp") or self._attr_max_color_temp_kelvin
)
elif device.supports_set_brightness():
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
@@ -130,16 +144,33 @@ class BondLight(BondBaseLight, BondEntity, LightEntity):
self._attr_is_on = state.get("light") == 1
brightness = state.get("brightness")
self._attr_brightness = round(brightness * 255 / 100) if brightness else None
color_temp_kelvin = state.get("color_temp")
# API resolution is 100K
self._attr_color_temp_kelvin = (
round(color_temp_kelvin, -2) if color_temp_kelvin else None
)
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
basic_on = True
if brightness := kwargs.get(ATTR_BRIGHTNESS):
await self._bond.action(
self._device_id,
Action.set_brightness(round((brightness * 100) / 255)),
)
else:
basic_on = False
if color_temp := kwargs.get(ATTR_COLOR_TEMP_KELVIN):
await self._bond.action(
self._device_id,
# API resolution is 100K
Action.set_color_temperature(round(color_temp, -2)),
)
basic_on = False
if basic_on:
await self._bond.action(self._device_id, Action.turn_light_on())
@override
+4
View File
@@ -130,6 +130,10 @@ class BondDevice:
"""Return True if this device supports setting a light brightness."""
return self._has_any_action({Action.SET_BRIGHTNESS})
def supports_set_color_temp(self) -> bool:
"""Return True if this device supports setting a light color temperature."""
return self._has_any_action({Action.SET_COLOR_TEMP})
class BondHub:
"""Hub device representing Bond Bridge."""
@@ -14,6 +14,9 @@ CONF_TIMEFRAME = "timeframe"
SUPPORTED_COUNTRY_CODES = ["NL", "BE"]
DEFAULT_COUNTRY = "NL"
SERVICE_TIME_ZONE = "Europe/Amsterdam"
"""Time zone of the buienradar.nl service, which updates around local midnight."""
SCHEDULE_OK = 10
"""Schedule next call after (minutes)."""
SCHEDULE_NOK = 2
+8 -3
View File
@@ -1,6 +1,6 @@
"""Shared utilities for different supported platforms."""
from datetime import datetime, timedelta
from datetime import timedelta
from http import HTTPStatus
import logging
from typing import Any
@@ -34,7 +34,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util import dt as dt_util
from .const import DEFAULT_TIMEOUT, SCHEDULE_NOK, SCHEDULE_OK
from .const import DEFAULT_TIMEOUT, SCHEDULE_NOK, SCHEDULE_OK, SERVICE_TIME_ZONE
__all__ = ["BrData"]
_LOGGER = logging.getLogger(__name__)
@@ -158,7 +158,12 @@ class BrData:
_LOGGER.debug("Buienradar parsed data: %s", result)
if result.get(SUCCESS) is not True:
if int(datetime.now().strftime("%H")) > 0: # pylint: disable=home-assistant-enforce-naive-now
# buienradar.nl updates its forecast for the next day between 00:00
# and 01:00 CE(S)T and often serves nothing during that hour, so the
# warning is only meaningful outside it. The hour that decides this
# is the one at the service, not in the user's configured time zone.
service_tz = await dt_util.async_get_time_zone(SERVICE_TIME_ZONE)
if service_tz is None or dt_util.utcnow().astimezone(service_tz).hour > 0:
_LOGGER.warning(
"Unable to parse data from Buienradar. (Msg: %s)",
result.get(MESSAGE),
+1 -1
View File
@@ -102,7 +102,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: CastConfigEntry) -> Non
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.AnyDeviceEntry
) -> bool:
"""Remove cast config entry from a device.
@@ -79,6 +79,10 @@ _CLOUD_ERRORS: dict[
HTTPStatus.BAD_GATEWAY,
"Unable to reach the Home Assistant Cloud.",
),
auth.AuthTimeoutError: (
HTTPStatus.GATEWAY_TIMEOUT,
"Authentication timed out.",
),
aiohttp.ClientError: (
HTTPStatus.INTERNAL_SERVER_ERROR,
"Error making internal request",
+1 -1
View File
@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==2.2.0", "openai==2.45.0"],
"requirements": ["hass-nabucasa==2.3.0", "openai==2.45.0"],
"single_config_entry": true
}
@@ -65,7 +65,7 @@ def websocket_list_composite_splits(
None,
),
}
for composite_id, devices in registry.devices.get_composite_splits().items()
for composite_id, devices in registry._devices.get_composite_splits().items() # noqa: SLF001
},
)
@@ -92,8 +92,7 @@ def websocket_list_devices(
inner = b",".join(
[
entry.json_repr
for container in (registry.devices, registry.child_devices)
for entry in container.values()
for entry in (*registry.devices, *registry.child_devices)
if entry.json_repr is not None
]
)
@@ -180,7 +179,8 @@ def websocket_update_device(
msg["labels"] = set(msg["labels"])
entry: dr.AnyDeviceEntry | None
if msg["device_id"] in registry.child_devices:
device = registry.async_get(msg["device_id"], include_composite_devices=False)
if isinstance(device, dr.ChildDeviceEntry):
entry = registry.async_update_child_device(**msg)
else:
entry = registry.async_update_device(**msg)
@@ -207,10 +207,16 @@ async def _async_remove_device(
device_id = msg["device_id"]
# A composite device id has no single underlying device to remove; reject it.
if registry.async_is_composite_device_id(device_id):
if (
registry.async_get(
device_id, include_main_devices=False, include_child_devices=False
)
is not None
):
raise HomeAssistantError("Cannot remove a composite device")
if (device_entry := registry.async_get(device_id)) is None:
if (
device_entry := registry.async_get(device_id, include_composite_devices=False)
) is None:
raise HomeAssistantError("Unknown device")
if (
@@ -82,7 +82,8 @@ _LOGGER = logging.getLogger(__name__)
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "device_id", "name", "original_name"]
_DEVICE_REGISTRY_UPDATE_FIELDS = ["name", "name_by_user"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
@@ -288,6 +289,15 @@ class DefaultAgent(ConversationEntity):
field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
)
@callback
def _filter_device_registry_changes(
self, event_data: dr.EventDeviceRegistryUpdatedData
) -> bool:
"""Filter device registry changed events."""
return event_data["action"] == "update" and any(
field in event_data["changes"] for field in _DEVICE_REGISTRY_UPDATE_FIELDS
)
@callback
def _filter_state_changes(self, event_data: EventStateChangedData) -> bool:
"""Filter state changed events."""
@@ -312,6 +322,11 @@ class DefaultAgent(ConversationEntity):
self._async_clear_slot_list,
event_filter=self._filter_entity_registry_changes,
),
self.hass.bus.async_listen(
dr.EVENT_DEVICE_REGISTRY_UPDATED,
self._async_clear_slot_list,
event_filter=self._filter_device_registry_changes,
),
self.hass.bus.async_listen(
EVENT_STATE_CHANGED,
self._async_clear_slot_list,
@@ -57,7 +57,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry)
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: CoolmasterConfigEntry,
device_entry: dr.DeviceEntry,
device_entry: dr.AnyDeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
return not device_entry.identifiers.intersection(
+6 -2
View File
@@ -137,7 +137,9 @@ def async_describe_events(
@callback
def async_describe_deconz_alarm_event(event: Event) -> dict[str, str]:
"""Describe deCONZ logbook alarm event."""
if device := device_registry.devices.get(event.data[ATTR_DEVICE_ID]):
if device := device_registry.async_get(
event.data[ATTR_DEVICE_ID], include_child_devices=False
):
deconz_alarm_event = _get_deconz_event_from_device(hass, device)
name = deconz_alarm_event.device.name
else:
@@ -153,7 +155,9 @@ def async_describe_events(
@callback
def async_describe_deconz_event(event: Event) -> dict[str, str]:
"""Describe deCONZ logbook event."""
if device := device_registry.devices.get(event.data[ATTR_DEVICE_ID]):
if device := device_registry.async_get(
event.data[ATTR_DEVICE_ID], include_child_devices=False
):
deconz_event = _get_deconz_event_from_device(hass, device)
name = deconz_event.device.name
else:
+2 -2
View File
@@ -181,8 +181,8 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None:
entities_to_be_removed = []
devices_to_be_removed = [
entry.id
for entry in device_registry.devices.get_devices_for_config_entry_id(
hub.config_entry.entry_id
for entry in dr.async_entries_for_config_entry(
device_registry, hub.config_entry.entry_id
)
]
@@ -240,7 +240,7 @@ async def async_get_device_automations(
entity_registry = er.async_get(hass)
domain_devices: dict[str, set[str]] = {}
device_entities_domains: dict[str, set[str]] = {}
match_device_ids = set(device_ids or device_registry.devices)
match_device_ids = set(device_ids or device_registry._devices) # noqa: SLF001
combined_results: dict[str, list[dict[str, Any]]] = {}
for device_id in match_device_ids:
@@ -53,7 +53,10 @@ def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str:
knows the current device id, not the removed composite id.
"""
device_registry = dr.async_get(hass)
if device_id in device_registry.devices:
if (
device_registry.async_get(device_id, include_composite_devices=False)
is not None
):
return device_id
if not (
split_devices := device_registry.async_get_devices_for_composite_device_id(
@@ -718,7 +718,7 @@ class ScannerEntity(
config_entry_id=self.platform.config_entry.entry_id,
config_subentry_id=self.registry_entry.config_subentry_id,
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)},
default_name=self.hostname or self.mac_address,
name=self.hostname or self.mac_address,
)
# Link the entity's registry entry to the device
@@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from .const import DOMAIN, PLATFORMS
@@ -91,7 +91,7 @@ async def async_unload_entry(
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: DevoloHomeControlConfigEntry,
device_entry: DeviceEntry,
device_entry: AnyDeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
return True
@@ -19,7 +19,6 @@ from homeassistant.helpers import (
integration_platform,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.json import (
ExtendedJSONEncoder,
find_paths_unserializable_data,
@@ -62,7 +61,7 @@ class DiagnosticsPlatformData:
)
device_diagnostics: (
Callable[
[HomeAssistant, ConfigEntry, DeviceEntry],
[HomeAssistant, ConfigEntry, dr.AnyDeviceEntry],
Coroutine[Any, Any, Mapping[str, Any]],
]
| None
@@ -100,9 +99,12 @@ class DiagnosticsProtocol(Protocol):
"""Return diagnostics for a config entry."""
async def async_get_device_diagnostics(
self, hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry
self, hass: HomeAssistant, config_entry: ConfigEntry, device: dr.AnyDeviceEntry
) -> Mapping[str, Any]:
"""Return diagnostics for a device."""
"""Return diagnostics for a device.
Only integrations that register child devices can receive a child device.
"""
@callback
@@ -314,10 +316,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
if info.device_diagnostics is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
# A device's diagnostics may be requested for a child device, but the
# callback is currently typed for a main device. Ignoring the mismatch until
# DiagnosticsPlatformData.device_diagnostics is widened to accept AnyDeviceEntry.
data = await info.device_diagnostics(hass, config_entry, device) # type: ignore[arg-type]
data = await info.device_diagnostics(hass, config_entry, device)
return await _async_get_json_file_response(
hass, data, data_issues, filename, config_entry.domain, d_id, sub_id
)
@@ -438,9 +438,9 @@ class DlnaDmrEntity(MediaPlayerEntity):
device_info = dr.DeviceInfo(
connections=connections,
default_manufacturer=self._device.manufacturer,
default_model=self._device.model_name,
default_name=self._device.name,
manufacturer=self._device.manufacturer,
model=self._device.model_name,
name=self._device.name,
)
self._attr_device_info = device_info
+2 -2
View File
@@ -494,7 +494,7 @@ class Thermostat(ClimateEntity):
"id": device.id,
"name_by_user": device.name_by_user or device.name,
}
for device in device_registry.devices.values()
for device in device_registry.devices
for sensor_info in sensors_info
if device.name == sensor_info["name"]
and any(identifier[0] == DOMAIN for identifier in device.identifiers)
@@ -830,7 +830,7 @@ class Thermostat(ClimateEntity):
return sorted(
[
device.name_by_user or device.name
for device in device_registry.devices.values()
for device in device_registry.devices
for sensor_name in sensor_names
if device.name == sensor_name
and any(identifier[0] == DOMAIN for identifier in device.identifiers)
@@ -176,17 +176,32 @@
"stats_area": {
"default": "mdi:floor-plan"
},
"stats_area_mower": {
"default": "mdi:floor-plan"
},
"stats_time": {
"default": "mdi:timer-outline"
},
"stats_time_mower": {
"default": "mdi:timer-outline"
},
"total_stats_area": {
"default": "mdi:floor-plan"
},
"total_stats_area_mower": {
"default": "mdi:floor-plan"
},
"total_stats_cleanings": {
"default": "mdi:counter"
},
"total_stats_cleanings_mower": {
"default": "mdi:counter"
},
"total_stats_time": {
"default": "mdi:timer-outline"
},
"total_stats_time_mower": {
"default": "mdi:timer-outline"
}
},
"switch": {
+61 -25
View File
@@ -1,8 +1,8 @@
"""Ecovacs sensor module."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, override
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field, fields, replace
from typing import Any, Self, override
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType
from deebot_client.device import Device
@@ -33,10 +33,10 @@ from homeassistant.const import (
UnitOfArea,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType
from . import EcovacsConfigEntry
from .const import LEGACY_SUPPORTED_LIFESPANS, SUPPORTED_LIFESPANS
@@ -49,6 +49,14 @@ from .entity import (
from .util import get_name_key, get_options, get_supported_entities
@dataclass(kw_only=True, frozen=True)
class EcovacsSensorDeviceTypeOverride:
"""Description values, which differ for a specific device type."""
native_unit_of_measurement: str | UndefinedType | None = UNDEFINED
translation_key: str | UndefinedType | None = UNDEFINED
@dataclass(kw_only=True, frozen=True)
class EcovacsSensorEntityDescription[EventT: Event](
EcovacsCapabilityEntityDescription,
@@ -57,15 +65,23 @@ class EcovacsSensorEntityDescription[EventT: Event](
"""Ecovacs sensor entity description."""
value_fn: Callable[[EventT], StateType]
native_unit_of_measurement_fn: Callable[[DeviceType], str | None] | None = None
device_type_overrides: Mapping[DeviceType, EcovacsSensorDeviceTypeOverride] = field(
default_factory=dict
)
def get_for(self, device: DeviceType) -> Self:
"""Get entity description for specific device type."""
if (overrides := self.device_type_overrides.get(device)) is None:
return self
@callback
def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None:
"""Get the area native unit of measurement based on device type."""
if device_type is DeviceType.MOWER:
return UnitOfArea.SQUARE_CENTIMETERS
return UnitOfArea.SQUARE_METERS
return replace(
self,
**{
f.name: value
for f in fields(overrides)
if (value := getattr(overrides, f.name)) is not UNDEFINED
},
)
ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
@@ -76,8 +92,14 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
value_fn=lambda e: e.area,
translation_key="stats_area",
device_class=SensorDeviceClass.AREA,
native_unit_of_measurement_fn=get_area_native_unit_of_measurement,
native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
suggested_unit_of_measurement=UnitOfArea.SQUARE_METERS,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
native_unit_of_measurement=UnitOfArea.SQUARE_CENTIMETERS,
translation_key="stats_area_mower",
)
},
),
EcovacsSensorEntityDescription[StatsEvent](
key="stats_time",
@@ -87,6 +109,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
translation_key="stats_time_mower",
)
},
),
# TotalStats
EcovacsSensorEntityDescription[TotalStatsEvent](
@@ -97,6 +124,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.AREA,
native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
state_class=SensorStateClass.TOTAL_INCREASING,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
translation_key="total_stats_area_mower",
)
},
),
EcovacsSensorEntityDescription[TotalStatsEvent](
capability_fn=lambda caps: caps.stats.total,
@@ -107,6 +139,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
state_class=SensorStateClass.TOTAL_INCREASING,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
translation_key="total_stats_time_mower",
)
},
),
EcovacsSensorEntityDescription[TotalStatsEvent](
capability_fn=lambda caps: caps.stats.total,
@@ -114,6 +151,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
key="total_stats_cleanings",
translation_key="total_stats_cleanings",
state_class=SensorStateClass.TOTAL_INCREASING,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
translation_key="total_stats_cleanings_mower",
)
},
),
EcovacsSensorEntityDescription[BatteryEvent](
capability_fn=lambda caps: caps.battery,
@@ -274,18 +316,12 @@ class EcovacsSensor(
**kwargs: Any,
) -> None:
"""Initialize entity."""
super().__init__(device, capability, entity_description, **kwargs)
if (
entity_description.native_unit_of_measurement_fn
and (
native_unit_of_measurement
:= entity_description.native_unit_of_measurement_fn(
device.capabilities.device_type
)
)
is not None
):
self._attr_native_unit_of_measurement = native_unit_of_measurement
super().__init__(
device,
capability,
entity_description.get_for(device.capabilities.device_type),
**kwargs,
)
@override
async def async_added_to_hass(self) -> None:
@@ -268,17 +268,32 @@
"stats_area": {
"name": "Area cleaned"
},
"stats_area_mower": {
"name": "Area mowed"
},
"stats_time": {
"name": "Cleaning duration"
},
"stats_time_mower": {
"name": "Mowing duration"
},
"total_stats_area": {
"name": "Total area cleaned"
},
"total_stats_area_mower": {
"name": "Total area mowed"
},
"total_stats_cleanings": {
"name": "Total cleanings"
},
"total_stats_cleanings_mower": {
"name": "Total mowings"
},
"total_stats_time": {
"name": "Total cleaning duration"
},
"total_stats_time_mower": {
"name": "Total mowing duration"
}
},
"switch": {
@@ -7,7 +7,7 @@ from eheimdigital.device import EheimDigitalDevice
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
@@ -61,7 +61,7 @@ async def async_unload_entry(
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: EheimDigitalConfigEntry,
device_entry: DeviceEntry,
device_entry: AnyDeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
return not any(
@@ -46,8 +46,8 @@ class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription):
def _get_hop_percentage(account_balance: AccountSummary) -> float:
"""Return the hop percentage from account summary."""
if power := account_balance.services.get("power"):
if connection := power.connections[0]:
return float(connection.hop_percentage)
if connections := power.connections:
return float(connections[0].hop_percentage)
return 0.0
@@ -26,15 +26,24 @@ from homeassistant.components import (
from homeassistant.components.climate import (
SERVICE_SET_TEMPERATURE,
ClimateEntityFeature,
ClimateEntityStateAttribute,
)
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_POSITION,
CoverEntityFeature,
CoverEntityStateAttribute,
)
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
FanEntityFeature,
FanEntityStateAttribute,
)
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntityFeature
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.humidifier import ATTR_HUMIDITY, SERVICE_SET_HUMIDITY
from homeassistant.components.humidifier import (
ATTR_HUMIDITY,
SERVICE_SET_HUMIDITY,
HumidifierEntityStateAttribute,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
@@ -42,11 +51,14 @@ from homeassistant.components.light import (
ATTR_TRANSITION,
ATTR_XY_COLOR,
ColorMode,
LightEntityCapabilityAttribute,
LightEntityFeature,
LightEntityStateAttribute,
)
from homeassistant.components.media_player import (
ATTR_MEDIA_VOLUME_LEVEL,
MediaPlayerEntityFeature,
MediaPlayerEntityStateAttribute,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -388,7 +400,7 @@ class HueOneLightChangeView(HomeAssistantView):
if entity.domain == light.DOMAIN:
color_modes = (
entity.attributes.get(
light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES
LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES
)
or []
)
@@ -704,9 +716,9 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]:
attributes = entity.attributes
if is_on:
data[STATE_BRIGHTNESS] = hass_to_hue_brightness(
attributes.get(ATTR_BRIGHTNESS) or 0
attributes.get(LightEntityStateAttribute.BRIGHTNESS) or 0
)
if (hue_sat := attributes.get(ATTR_HS_COLOR)) is not None:
if (hue_sat := attributes.get(LightEntityStateAttribute.HS_COLOR)) is not None:
hue = hue_sat[0]
sat = hue_sat[1]
# Convert hass hs values back to hue hs values
@@ -715,7 +727,7 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]:
else:
data[STATE_HUE] = HUE_API_STATE_HUE_MIN
data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN
kelvin = attributes.get(ATTR_COLOR_TEMP_KELVIN)
kelvin = attributes.get(LightEntityStateAttribute.COLOR_TEMP_KELVIN)
data[STATE_COLOR_TEMP] = (
color_util.color_temperature_kelvin_to_mired(kelvin)
if kelvin is not None
@@ -729,23 +741,26 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]:
data[STATE_COLOR_TEMP] = 0
if entity.domain == climate.DOMAIN:
temperature = attributes.get(ATTR_TEMPERATURE, 0)
temperature = attributes.get(ClimateEntityStateAttribute.TARGET_TEMPERATURE, 0)
# Convert 0-100 to 0-254
data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100)
elif entity.domain == humidifier.DOMAIN:
humidity = attributes.get(ATTR_HUMIDITY, 0)
humidity = attributes.get(HumidifierEntityStateAttribute.HUMIDITY, 0)
# Convert 0-100 to 0-254
data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100)
elif entity.domain == media_player.DOMAIN:
level = attributes.get(ATTR_MEDIA_VOLUME_LEVEL, 1.0 if is_on else 0.0)
level = attributes.get(
MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL,
1.0 if is_on else 0.0,
)
# Convert 0.0-1.0 to 0-254
data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX)
elif entity.domain == fan.DOMAIN:
percentage = attributes.get(ATTR_PERCENTAGE) or 0
percentage = attributes.get(FanEntityStateAttribute.PERCENTAGE) or 0
# Convert 0-100 to 0-254
data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100)
elif entity.domain == cover.DOMAIN:
level = attributes.get(ATTR_CURRENT_POSITION, 0)
level = attributes.get(CoverEntityStateAttribute.CURRENT_POSITION, 0)
data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX)
_clamp_values(data)
return data
@@ -777,8 +792,7 @@ def _entity_unique_id(entity_id: str) -> str:
def state_to_json(config: Config, state: State) -> dict[str, Any]:
"""Convert an entity to its Hue bridge JSON representation."""
color_modes = (
state.attributes.get(light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES)
or []
state.attributes.get(LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES) or []
)
unique_id = _entity_unique_id(state.entity_id)
state_dict = get_entity_state_dict(config, state)
@@ -106,7 +106,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) ->
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: EnphaseConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
device_entry: dr.AnyDeviceEntry,
) -> bool:
"""Remove an enphase_envoy config entry from a device."""
dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN}
+196 -74
View File
@@ -17,7 +17,7 @@ from aioesphomeapi import (
import voluptuous as vol
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
@@ -32,7 +32,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
# Import config flow so that it's added to the registry
from .entry_data import DeviceEntityKey, ESPHomeConfigEntry, RuntimeEntryData
from .entry_data import (
DeviceEntityKey,
ESPHomeConfigEntry,
RuntimeEntryData,
async_migrate_unique_id,
)
from .enum_mapper import EsphomeEnumMapper
_LOGGER = logging.getLogger(__name__)
@@ -42,6 +47,34 @@ _EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]")
_StateT = TypeVar("_StateT", bound=EntityState)
def _build_identity_indexes(
current_infos: dict[DeviceEntityKey, EntityInfo],
mac: str,
new_unique_ids: set[str],
) -> tuple[dict[str, DeviceEntityKey], dict[str, list[DeviceEntityKey]]]:
"""Index old infos by unique_id and by name for identity matching.
ESPHome validates that names are unique per device_id, so
unique_ids are unique. The key derives from the name (hash of the
name, or of the object_id which derives from the name), so a key
can never disambiguate entities the name cannot. Entities whose
unique_id is still present are matched by unique_id and are not
move candidates.
"""
old_info_by_unique_id: dict[str, DeviceEntityKey] = {}
movable_by_name: dict[str, list[DeviceEntityKey]] = {}
for dict_key, existing_info in current_infos.items():
old_unique_id = build_device_unique_id(mac, existing_info)
old_info_by_unique_id[old_unique_id] = dict_key
if old_unique_id not in new_unique_ids:
# Unnamed entities use the device derived object_id as
# their identity so they cannot pair across devices
movable_by_name.setdefault(
existing_info.name or existing_info.object_id, []
).append(dict_key)
return old_info_by_unique_id, movable_by_name
@callback
def async_static_info_updated(
hass: HomeAssistant,
@@ -58,43 +91,70 @@ def async_static_info_updated(
device_info = entry_data.device_info
if TYPE_CHECKING:
assert device_info is not None
new_infos: dict[DeviceEntityKey, EntityInfo] = {}
new_infos: dict[DeviceEntityKey, EntityInfo] = {
(info.device_id, info.key): info for info in infos
}
add_entities: list[_EntityT] = []
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
# Track info by (info.device_id, info.key) to properly handle entities
# moving between devices and support sub-devices with overlapping keys
for info in infos:
info_key = (info.device_id, info.key)
new_infos[info_key] = info
# The key is only session stable, so match by identity first
mac = device_info.mac_address
unique_ids = [build_device_unique_id(mac, info) for info in infos]
new_unique_ids = set(unique_ids)
old_info_by_unique_id, movable_by_name = _build_identity_indexes(
current_infos, mac, new_unique_ids
)
rekeys: list[tuple[EntityInfo, EntityInfo]] = []
deferred: list[tuple[EntityInfo, str]] = []
# Slots of brand new entities; movers are deliberately not
# tracked, though a mover's cached state under its old slot is
# still dropped whenever the sweep below runs
new_entity_slots: set[DeviceEntityKey] = set()
states = entry_data.state[state_type]
# Try to find existing entity - first with current device_id
old_info = current_infos.pop(info_key, None)
# If not found, search for entity with same key but different device_id
# This handles the case where entity moved between devices
if not old_info:
for existing_device_id, existing_key in list(current_infos):
if existing_key == info.key:
# Found entity with same key but different device_id
old_info = current_infos.pop((existing_device_id, existing_key))
break
# Create new entity if it doesn't exist
if not old_info:
entity = entity_type(entry_data, info, state_type)
add_entities.append(entity)
# First pass: unique_id matches and moves between devices. All
# moves resolve before any rename so a rename candidate cannot be
# mistaken for a mover.
for info, unique_id in zip(infos, unique_ids, strict=True):
# Identity match by unique_id; survives key re-derivation
if (old_dict_key := old_info_by_unique_id.pop(unique_id, None)) is not None:
matched_info = current_infos.pop(old_dict_key)
if matched_info.key != info.key:
# Same entity, new key: re-point subscriptions after
# the loop; the registry entry is untouched
rekeys.append((matched_info, info))
# Equal unique_ids imply equal device_ids
continue
# Entity exists - check if device_id has changed
if old_info.device_id == info.device_id:
# Name match: the entity moved between devices. Prefer a
# candidate whose (device_id, key) slot has no incoming info,
# since that slot's info is an in place rename of the candidate
if not (candidates := movable_by_name.get(info.name or info.object_id)):
deferred.append((info, unique_id))
continue
idx = next((i for i, key in enumerate(candidates) if key not in new_infos), -1)
if idx == -1:
idx = 0
_LOGGER.debug(
"Ambiguous move for %s: every candidate slot is occupied, "
"taking the first candidate",
info.name or info.object_id,
)
old_info = current_infos.pop(candidates.pop(idx))
# A cached state at the mover's destination key is only the
# mover's own if it was written from the mover's old slot;
# anything else is foreign and must not be adopted on re-add
if (cached_state := states.get(info.key)) is not None and (
cached_state.device_id != old_info.device_id or info.key != old_info.key
):
del states[info.key]
# Entity has switched devices, need to migrate unique_id
# and handle state subscriptions
old_unique_id = build_device_unique_id(device_info.mac_address, old_info)
old_unique_id = build_device_unique_id(mac, old_info)
entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id)
# If entity not found in registry, re-add it
@@ -111,33 +171,41 @@ def async_static_info_updated(
add_entities.append(entity)
continue
updates: dict[str, Any] = {}
new_unique_id = build_device_unique_id(device_info.mac_address, info)
# Update unique_id if it changed
if old_unique_id != new_unique_id:
updates["new_unique_id"] = new_unique_id
# Update device assignment in registry
if info.device_id:
# Entity now belongs to a sub device
new_device = dev_reg.async_get_device_by_identifier(
(DOMAIN, f"{device_info.mac_address}_{info.device_id}"),
entry_data.entry_id,
# Leave the entry untouched when the new unique_id is claimed;
# it cannot follow the move, so a partial update helps nothing
if old_unique_id != unique_id and ent_reg.async_get_entity_id(
platform.domain, DOMAIN, unique_id
):
_LOGGER.warning(
"Cannot migrate unique_id %s -> %s: already claimed",
old_unique_id,
unique_id,
)
else:
# Entity now belongs to the main device
new_device = dev_reg.async_get_device_by_connection(
(dr.CONNECTION_NETWORK_MAC, device_info.mac_address),
entry_data.entry_id,
)
updates: dict[str, Any] = {}
if old_unique_id != unique_id:
updates["new_unique_id"] = unique_id
if new_device:
updates["device_id"] = new_device.id
# Update device assignment in registry
if info.device_id:
# Entity now belongs to a sub device
new_device = dev_reg.async_get_device_by_identifier(
(DOMAIN, f"{mac}_{info.device_id}"),
entry_data.entry_id,
)
else:
# Entity now belongs to the main device
new_device = dev_reg.async_get_device_by_connection(
(dr.CONNECTION_NETWORK_MAC, mac),
entry_data.entry_id,
)
# Apply all registry updates at once
if updates:
ent_reg.async_update_entity(entity_id, **updates)
if new_device:
updates["device_id"] = new_device.id
# Apply all registry updates at once
if updates:
ent_reg.async_update_entity(entity_id, **updates)
# IMPORTANT: The entity's device assignment in Home
# Assistant is only read when the entity is first added.
@@ -157,17 +225,53 @@ def async_static_info_updated(
)
# Signal the existing entity to remove itself
# The entity is registered with the old device_id, so we signal with that
entry_data.async_signal_entity_removal(info_type, old_info.device_id, info.key)
# The entity is registered with the old device_id and old key,
# so we signal with those
entry_data.async_signal_entity_removal(
info_type, old_info.device_id, old_info.key
)
# Create new entity with the new device_id
add_entities.append(entity_type(entry_data, info, state_type))
# Second pass: anything left at an incoming (device_id, key) slot
# is a rename with a stable key; the registry entry follows the
# new unique_id. Everything else is a new entity.
for info, unique_id in deferred:
if (
renamed_info := current_infos.pop((info.device_id, info.key), None)
) is None:
new_entity_slots.add((info.device_id, info.key))
add_entities.append(entity_type(entry_data, info, state_type))
continue
async_migrate_unique_id(
ent_reg,
platform.domain,
build_device_unique_id(mac, renamed_info),
unique_id,
)
if rekeys:
entry_data.async_update_entity_keys(info_type, rekeys)
# Anything still in current_infos is now gone
if current_infos:
entry_data.async_remove_entities(
hass, current_infos.values(), device_info.mac_address
)
entry_data.async_remove_entities(hass, current_infos.values(), mac)
# A cached state is only valid while its (device_id, key) slot is
# occupied by the same entity; anything else is stale and must not
# be adopted by another entity through a reused key
if rekeys or current_infos or new_entity_slots:
for cached_key, cached_state in list(states.items()):
slot = (cached_state.device_id, cached_key)
if slot not in new_infos or slot in new_entity_slots:
del states[cached_key]
entry_data.stale_state -= {
stale_key
for stale_key in entry_data.stale_state
if stale_key[0] is state_type
and (stale_key[1], stale_key[2]) not in new_infos
}
# Then update the actual info
entry_data.info[info_type] = new_infos
@@ -357,6 +461,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): # noqa: UP046
self._on_entry_data_changed()
self._key = entity_info.key
self._state_type = state_type
self._key_unsubs: list[CALLBACK_TYPE] = []
self._on_static_info_update(entity_info)
# Determine the device connection based on whether this
@@ -383,30 +488,41 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): # noqa: UP046
self._on_device_update,
)
)
self.async_on_remove(
self._subscribe_key_updates()
self.async_on_remove(self._unsubscribe_key_updates)
self._update_state_from_entry_data()
@callback
def _subscribe_key_updates(self) -> None:
"""Subscribe to updates that are keyed by the session stable key."""
entry_data = self._entry_data
static_info = self._static_info
self._key_unsubs = [
entry_data.async_subscribe_state_update(
self._static_info.device_id,
static_info.device_id,
self._state_type,
self._key,
self._on_state_update,
)
)
self.async_on_remove(
),
entry_data.async_register_key_static_info_updated_callback(
self._static_info, self._on_static_info_update
)
)
# Register to be notified when this entity should remove itself
# This happens when the entity moves to a different device
self.async_on_remove(
static_info, self._on_static_info_update
),
# Register to be notified when this entity should remove itself
# This happens when the entity moves to a different device
entry_data.async_register_entity_removal_callback(
type(self._static_info),
self._static_info.device_id,
type(static_info),
static_info.device_id,
self._key,
self._on_removal_signal,
)
)
self._update_state_from_entry_data()
),
]
@callback
def _unsubscribe_key_updates(self) -> None:
"""Unsubscribe from updates that are keyed by the session stable key."""
for unsub in self._key_unsubs:
unsub()
self._key_unsubs = []
@callback
def _on_removal_signal(self) -> None:
@@ -430,10 +546,16 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): # noqa: UP046
if TYPE_CHECKING:
static_info = cast(_InfoT, static_info)
assert device_info
unique_id = build_device_unique_id(device_info.mac_address, static_info)
self._static_info = static_info
self._attr_unique_id = build_device_unique_id(
device_info.mac_address, static_info
)
if static_info.key != self._key:
# The key is only stable for a session; a firmware update may
# re-derive it. Move the key based subscriptions to the new key.
self._key = static_info.key
if self._key_unsubs:
self._unsubscribe_key_updates()
self._subscribe_key_updates()
self._attr_unique_id = unique_id
self._attr_entity_registry_enabled_default = not static_info.disabled_by_default
# https://github.com/home-assistant/core/issues/132532
# If the name is "", we need to set it to None since otherwise
+76 -19
View File
@@ -6,8 +6,8 @@ from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from functools import partial
import logging
from operator import delitem
from typing import TYPE_CHECKING, Any, Final, TypedDict, cast
from weakref import WeakKeyDictionary
from aioesphomeapi import (
COMPONENT_TYPE_TO_INFO,
@@ -56,6 +56,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import discovery_flow, entity_registry as er
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.storage import Store
@@ -103,6 +104,28 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
}
@callback
def async_migrate_unique_id(
ent_reg: er.EntityRegistry,
platform_domain: str,
old_unique_id: str,
new_unique_id: str,
) -> None:
"""Migrate a registry entry to a new unique_id unless it is claimed."""
if old_unique_id == new_unique_id or not (
entity_id := ent_reg.async_get_entity_id(platform_domain, DOMAIN, old_unique_id)
):
return
if ent_reg.async_get_entity_id(platform_domain, DOMAIN, new_unique_id):
_LOGGER.debug(
"Cannot migrate unique_id %s -> %s: already claimed",
old_unique_id,
new_unique_id,
)
return
ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
class StoreData(TypedDict, total=False):
"""ESPHome storage data."""
@@ -163,9 +186,11 @@ class RuntimeEntryData:
EntityInfoKey, list[Callable[[EntityInfo], None]]
] = field(default_factory=dict)
original_options: dict[str, Any] = field(default_factory=dict)
media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field(
default_factory=lambda: defaultdict(list)
)
# Keyed by the entity object so cleanup can never touch another
# entity's entry and never-added entities self clean via GC
media_player_formats: WeakKeyDictionary[
Entity, list[MediaPlayerSupportedFormat]
] = field(default_factory=WeakKeyDictionary)
assist_satellite_config_update_callbacks: list[
Callable[[AssistSatelliteConfiguration], None]
] = field(default_factory=list)
@@ -255,6 +280,36 @@ class RuntimeEntryData:
):
callback_(static_info)
@callback
def async_update_entity_keys(
self,
info_type: type[EntityInfo],
rekeys: Iterable[tuple[EntityInfo, EntityInfo]],
) -> None:
"""Notify entities registered under their old key that the key changed."""
callbacks = self.entity_info_key_updated_callbacks
# Snapshot all old keys' callbacks first: entities re-subscribe
# during dispatch and a new key may be another entity's old key
snapshots = [
(
tuple(callbacks.get((info_type, old_info.device_id, old_info.key), ())),
old_info,
new_info,
)
for old_info, new_info in rekeys
]
for entity_callbacks, old_info, new_info in snapshots:
if not entity_callbacks:
_LOGGER.debug(
"%s: no subscriber for key change %s -> %s",
new_info.name or new_info.object_id,
old_info.key,
new_info.key,
)
continue
for callback_ in entity_callbacks:
callback_(new_info)
async def _ensure_platforms_loaded(
self,
hass: HomeAssistant,
@@ -295,7 +350,6 @@ class RuntimeEntryData:
list
)
ent_reg = er.async_get(hass)
registry_get_entity = ent_reg.async_get_entity_id
for info in infos:
info_type = type(info)
if platform := info_types_to_platform.get(info_type):
@@ -307,18 +361,12 @@ class RuntimeEntryData:
# legacy ids collided (the bug this fixes) only one registry
# entry exists for it, so the first iterated info claims it and
# the rest get fresh version 3 ids.
old_unique_id = build_device_unique_id(mac, info, version=1)
new_unique_id = build_device_unique_id(mac, info, version=3)
if (
old_unique_id != new_unique_id
and (
old_entry := registry_get_entity(
platform, DOMAIN, old_unique_id
)
)
and not registry_get_entity(platform, DOMAIN, new_unique_id)
):
ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id)
async_migrate_unique_id(
ent_reg,
platform,
build_device_unique_id(mac, info, version=1),
build_device_unique_id(mac, info, version=3),
)
else:
_LOGGER.warning(
"Entity type %s is not supported in this version of Home Assistant",
@@ -363,7 +411,14 @@ class RuntimeEntryData:
"""Subscribe to state updates."""
subscription_key = (state_type, device_id, state_key)
self.state_subscriptions[subscription_key] = entity_callback
return partial(delitem, self.state_subscriptions, subscription_key)
@callback
def _unsubscribe() -> None:
# A re-keyed entity may have taken over this slot
if self.state_subscriptions.get(subscription_key) is entity_callback:
del self.state_subscriptions[subscription_key]
return _unsubscribe
@callback
def async_update_state(self, state: EntityState) -> None:
@@ -467,7 +522,9 @@ class RuntimeEntryData:
"""
self.available = False
if self.bluetooth_device:
self.bluetooth_device.available = False
# Fails pending BLE slot waiters and clears the dead
# session's allocations in addition to closing the gate.
self.bluetooth_device.async_set_unavailable()
# Make a copy since calling the disconnect callbacks
# may also try to discard/remove themselves.
for disconnect_cb in self.disconnect_callbacks.copy():
@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.6.1",
"aioesphomeapi==45.12.0",
"esphome-dashboard-api==1.4.0",
"bleak-esphome==3.9.7"
"bleak-esphome==4.0.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
@@ -2,7 +2,7 @@
from functools import partial
import logging
from typing import Any, cast, override
from typing import Any, override
from urllib.parse import urlparse
from aioesphomeapi import (
@@ -103,9 +103,9 @@ class EsphomeMediaPlayer(
for espflag in esp_flags:
flags |= _FEATURES[espflag]
self._attr_supported_features = flags
self._entry_data.media_player_formats[self.unique_id] = cast(
MediaPlayerInfo, static_info
).supported_formats
self._entry_data.media_player_formats[self] = (
self._static_info.supported_formats
)
@property
@esphome_state_property
@@ -143,9 +143,7 @@ class EsphomeMediaPlayer(
media_id = async_process_play_media_url(self.hass, media_id)
announcement = kwargs.get(ATTR_MEDIA_ANNOUNCE)
bypass_proxy = kwargs.get(ATTR_MEDIA_EXTRA, {}).get(ATTR_BYPASS_PROXY)
supported_formats: list[MediaPlayerSupportedFormat] | None = (
self._entry_data.media_player_formats.get(self.unique_id)
)
supported_formats = self._entry_data.media_player_formats.get(self)
if (
not bypass_proxy
@@ -171,7 +169,7 @@ class EsphomeMediaPlayer(
async def async_will_remove_from_hass(self) -> None:
"""Handle entity being removed."""
await super().async_will_remove_from_hass()
self._entry_data.media_player_formats.pop(self.unique_id, None)
self._entry_data.media_player_formats.pop(self, None)
def _get_proxy_url(
self,
+2 -2
View File
@@ -22,7 +22,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo
from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceInfo
from homeassistant.util import slugify
from .const import CONF_IMPORT_PLUGINS, DOMAIN
@@ -353,7 +353,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: FibaroConfigEntry, device_entry: DeviceEntry
hass: HomeAssistant, config_entry: FibaroConfigEntry, device_entry: AnyDeviceEntry
) -> bool:
"""Remove a device entry from fibaro integration.
@@ -154,7 +154,7 @@ async def async_unload_entry(
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: FjaraskupanConfigEntry,
device_entry: dr.DeviceEntry,
device_entry: dr.AnyDeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
for service_info in async_discovered_service_info(hass, False):
@@ -59,12 +59,6 @@
"state": {
"off": "mdi:radiator-off"
}
},
"fireplace_mode": {
"default": "mdi:fireplace",
"state": {
"off": "mdi:fireplace-off"
}
}
}
}
@@ -126,9 +126,6 @@
},
"electric_heater": {
"name": "Electric heater"
},
"fireplace_mode": {
"name": "Fireplace mode"
}
}
},
@@ -151,11 +148,5 @@
"switch_turn": {
"message": "Failed to turn the switch {state}."
}
},
"issues": {
"deprecated_fireplace_switch": {
"description": "The fireplace mode switch entity `{entity_id}` is deprecated and will be removed in Home Assistant 2026.9.\n\nFireplace mode has been moved to a climate preset on the climate entity to better match the device interface.\n\nPlease update your automations to use the `climate.set_preset_mode` action with preset mode `fireplace` instead of using the switch entity.\n\nAfter updating your automations, you can safely disable this switch entity.",
"title": "Fireplace mode switch is deprecated"
}
}
}
@@ -13,12 +13,9 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
from .coordinator import FlexitConfigEntry, FlexitCoordinator
@@ -49,13 +46,6 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = (
turn_on_fn=lambda data: data.activate_cooker_hood(),
turn_off_fn=lambda data: data.deactivate_cooker_hood(),
),
FlexitSwitchEntityDescription(
key="fireplace_mode",
translation_key="fireplace_mode",
is_on_fn=lambda data: data.fireplace_ventilation_status,
turn_on_fn=lambda data: data.trigger_fireplace_mode(),
turn_off_fn=lambda data: data.trigger_fireplace_mode(),
),
)
@@ -67,43 +57,9 @@ async def async_setup_entry(
"""Set up Flexit (bacnet) switch from a config entry."""
coordinator = config_entry.runtime_data
entities: list[FlexitSwitch] = []
for description in SWITCHES:
if description.key == "fireplace_mode":
# Check if deprecated fireplace switch is enabled and create repair issue
entity_reg = er.async_get(hass)
fireplace_switch_unique_id = (
f"{coordinator.device.serial_number}-fireplace_mode"
)
# Look up the fireplace switch entity by unique_id
fireplace_switch_entity_id = entity_reg.async_get_entity_id(
Platform.SWITCH, DOMAIN, fireplace_switch_unique_id
)
if not fireplace_switch_entity_id:
continue
entity_registry_entry = entity_reg.async_get(fireplace_switch_entity_id)
if entity_registry_entry:
if entity_registry_entry.disabled:
entity_reg.async_remove(fireplace_switch_entity_id)
else:
async_create_issue(
hass,
DOMAIN,
f"deprecated_switch_{fireplace_switch_unique_id}",
breaks_in_ha_version="2026.9.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_fireplace_switch",
translation_placeholders={
"entity_id": fireplace_switch_entity_id,
},
)
entities.append(FlexitSwitch(coordinator, description))
else:
entities.append(FlexitSwitch(coordinator, description))
async_add_entities(entities)
async_add_entities(
FlexitSwitch(coordinator, description) for description in SWITCHES
)
PARALLEL_UPDATES = 1
@@ -0,0 +1,51 @@
"""The Flow-it integration."""
import logging
from flow_it_api.client import FlowItVMCMachine
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from .coordinator import FlowItConfigEntry, FlowItCoordinator, FlowItData
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.FAN,
]
async def async_setup_entry(hass: HomeAssistant, entry: FlowItConfigEntry) -> bool:
"""Set up Flow-it from a config entry."""
vmc = FlowItVMCMachine(
entry.data[CONF_HOST],
entry.data[CONF_PASSWORD],
entry.data[CONF_USERNAME],
session=get_async_client(hass),
)
coordinator = FlowItCoordinator(hass, entry, vmc)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = FlowItData(
vmc=vmc,
coordinator=coordinator,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FlowItConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
vmc = entry.runtime_data.vmc
await vmc.close()
return unload_ok
@@ -0,0 +1,181 @@
"""Config flow for Flow-it integration."""
import logging
from typing import TYPE_CHECKING, Any, override
from flow_it_api.client import FlowItVMCMachine
from flow_it_api.exceptions import FlowItAuthError, FlowItConnectionError
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DEFAULT_USERNAME, DOMAIN
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
vmc = FlowItVMCMachine(
data[CONF_HOST],
data[CONF_PASSWORD],
data[CONF_USERNAME],
session=get_async_client(hass),
)
info = await vmc.get_info()
await vmc.refresh_state()
if TYPE_CHECKING:
assert vmc.state is not None
return {
"title": info.hostname,
"mac_address": vmc.state.name,
}
class FlowItConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Flow-it."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: dict[str, Any] = {}
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
if not URL(host).scheme:
host = str(URL.build(scheme="http", host=host))
user_input[CONF_HOST] = host
try:
info = await validate_input(self.hass, user_input)
except FlowItAuthError:
errors["base"] = "invalid_auth"
except FlowItConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(info["mac_address"])
self._abort_if_unique_id_configured(updates=user_input)
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): TextSelector(
TextSelectorConfig(
type=TextSelectorType.TEXT, autocomplete="username"
)
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
),
errors=errors,
)
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by zeroconf."""
errors: dict[str, str] = {}
if user_input is not None:
host = self._discovery_info[CONF_HOST]
if not URL(host).scheme:
host = str(URL.build(scheme="http", host=host))
data = {
CONF_HOST: host,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
try:
info = await validate_input(self.hass, data)
except FlowItAuthError:
errors["base"] = "invalid_auth"
except FlowItConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(info["mac_address"])
self._abort_if_unique_id_configured(updates=data)
return self.async_create_entry(
title=info["title"],
data=data,
)
data_schema = vol.Schema(
{
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): TextSelector(
TextSelectorConfig(
type=TextSelectorType.TEXT, autocomplete="username"
)
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD, autocomplete="current-password"
)
),
}
)
return self.async_show_form(
step_id="zeroconf_confirm",
data_schema=data_schema,
errors=errors,
description_placeholders={
"name": self._discovery_info.get(
"friendly_name",
self._discovery_info[CONF_HOST].removesuffix(".local"),
)
},
)
@override
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
host = discovery_info.host
hostname = discovery_info.hostname.rstrip(".")
friendly_name = discovery_info.name.removesuffix("._tbk_vmc._tcp.local.")
self._discovery_info = {
CONF_HOST: hostname,
"friendly_name": friendly_name,
}
self._async_abort_entries_match({CONF_HOST: host})
self._async_abort_entries_match({CONF_HOST: hostname})
self._async_abort_entries_match({CONF_HOST: f"http://{host}"})
self._async_abort_entries_match({CONF_HOST: f"http://{hostname}"})
self.context.update({"title_placeholders": {"name": friendly_name}})
return await self.async_step_zeroconf_confirm()
@@ -0,0 +1,6 @@
"""Constants for the Flow-it integration."""
DOMAIN = "flow_it"
DEFAULT_USERNAME = "api"
@@ -0,0 +1,93 @@
"""Data update coordinator for the Flow-it integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, override
from flow_it_api.client import FlowItVMCMachine
from flow_it_api.exceptions import (
FlowItAuthError,
FlowItConnectionError,
FlowItResponseError,
)
from flow_it_api.models import MachineData, MachineStatusResponse
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FlowItConfigEntry = ConfigEntry[FlowItData]
@dataclass(kw_only=True, frozen=True)
class FlowItCoordinatorData:
"""Data fetched from the Flow-it VMC."""
state: MachineStatusResponse
@dataclass(kw_only=True, frozen=True)
class FlowItData:
"""Data for the Flow-it integration."""
vmc: FlowItVMCMachine
coordinator: FlowItCoordinator
class FlowItCoordinator(DataUpdateCoordinator[FlowItCoordinatorData]):
"""Class to manage fetching Flow-it data."""
config_entry: FlowItConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: FlowItConfigEntry,
vmc: FlowItVMCMachine,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=60),
config_entry=config_entry,
)
self.vmc = vmc
async def _on_ws_data(data: MachineData) -> None:
"""Handle data from WebSocket."""
_LOGGER.debug("Received WebSocket update")
if self.data:
self.data.state.data = data
self.async_set_updated_data(self.data)
self.vmc.register_websocket_callback(_on_ws_data)
self.vmc.websocket.start()
@override
async def _async_update_data(self) -> FlowItCoordinatorData:
"""Fetch data from API endpoint."""
try:
await self.vmc.refresh_state()
except FlowItAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
except (FlowItConnectionError, FlowItResponseError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
) from err
if TYPE_CHECKING:
assert self.vmc.state is not None
return FlowItCoordinatorData(state=self.vmc.state)
@@ -0,0 +1,35 @@
"""Base entity for Flow-it."""
from flow_it_api.client import FlowItVMCMachine
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import FlowItCoordinator
class FlowItVmcEntity(CoordinatorEntity[FlowItCoordinator]):
"""Base entity for Flow-it."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FlowItCoordinator,
vmc: FlowItVMCMachine,
entity_description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self.vmc = vmc
self._attr_unique_id = f"{coordinator.data.state.name}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.data.state.name)},
name=coordinator.data.state.name,
manufacturer="FLOW-IT",
model="VMC",
sw_version=coordinator.data.state.data.alert.version,
)
+167
View File
@@ -0,0 +1,167 @@
"""Fan platform for Flow-it."""
from typing import Any, override
from flow_it_api.client import FlowItVMCMachine
from flow_it_api.const import Speed
from flow_it_api.exceptions import FlowItAuthError, FlowItCommandError, FlowItError
from homeassistant.components.fan import (
FanEntity,
FanEntityDescription,
FanEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
)
from .const import DOMAIN
from .coordinator import FlowItConfigEntry, FlowItCoordinator
from .entity import FlowItVmcEntity
ORDERED_NAMED_FAN_SPEEDS = [
Speed.LEVEL_1,
Speed.LEVEL_2,
Speed.LEVEL_3,
Speed.LEVEL_4,
Speed.LEVEL_5,
]
PRESET_MODES = [Speed.AUTO, Speed.BOOST]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FlowItConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flow-it fan."""
data = config_entry.runtime_data
async_add_entities([FlowItVmcFan(data.coordinator, data.vmc)])
class FlowItVmcFan(FlowItVmcEntity, FanEntity):
"""Flow-it fan entity."""
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
_attr_preset_modes = list(PRESET_MODES)
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
def __init__(self, coordinator: FlowItCoordinator, vmc: FlowItVMCMachine) -> None:
"""Initialize the fan."""
super().__init__(
coordinator,
vmc,
FanEntityDescription(
key="fan",
name=None,
),
)
@override
@property
def is_on(self) -> bool | None:
"""Return true if fan is on."""
return self.coordinator.data.state.data.mode.speed != Speed.OFF
@override
@property
def percentage(self) -> int | None:
"""Return the current speed percentage."""
speed = self.coordinator.data.state.data.mode.speed
if speed in ORDERED_NAMED_FAN_SPEEDS:
return ordered_list_item_to_percentage(ORDERED_NAMED_FAN_SPEEDS, speed)
return None
@override
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
speed = self.coordinator.data.state.data.mode.speed
if speed in PRESET_MODES:
return speed
return None
async def _async_send_command(
self, speed: Speed, flow_in: bool, flow_out: bool
) -> None:
"""Send a command to the VMC and handle exceptions."""
try:
await self.vmc.send_command(speed, flow_in=flow_in, flow_out=flow_out)
except FlowItAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
except (FlowItCommandError, FlowItError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
) from err
await self.coordinator.async_refresh()
@override
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage == 0:
await self.async_turn_off()
return
speed = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
mode = self.coordinator.data.state.data.mode
await self._async_send_command(
speed,
flow_in=mode.flowIn, # codespell:ignore flowin
flow_out=mode.flowOut,
)
@override
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
mode = self.coordinator.data.state.data.mode
await self._async_send_command(
Speed(preset_mode),
flow_in=mode.flowIn, # codespell:ignore flowin
flow_out=mode.flowOut,
)
@override
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
mode = self.coordinator.data.state.data.mode
if percentage is not None:
await self.async_set_percentage(percentage)
elif preset_mode is not None:
await self.async_set_preset_mode(preset_mode)
else:
await self._async_send_command(
Speed.LEVEL_1,
flow_in=mode.flowIn, # codespell:ignore flowin
flow_out=mode.flowOut,
)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
mode = self.coordinator.data.state.data.mode
await self._async_send_command(
Speed.OFF,
flow_in=mode.flowIn, # codespell:ignore flowin
flow_out=mode.flowOut,
)
@@ -0,0 +1,13 @@
{
"domain": "flow_it",
"name": "Flow-it",
"after_dependencies": ["zeroconf"],
"codeowners": ["@albertogeniola"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/flow_it",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["flow-it-api==0.0.1.1"],
"zeroconf": ["_tbk_vmc._tcp.local."]
}
@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not expose any action.
appropriate-polling: done
brands:
status: done
comment: logos and icons have been submitted for a PR on the dedicated repo.
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not expose custom actions.
docs-conditions:
status: exempt
comment: This integration does not expose custom conditions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-triggers:
status: exempt
comment: This integration does not expose custom triggers.
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not expose any action.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -0,0 +1,50 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of the Flow-it device.",
"password": "The password is available on the physical device LCD display, by interacting with the menu, clicking on the 'settings' icon and then on the 'wifi icon'.",
"username": "The default username is 'api'."
},
"title": "Configure Flow-it"
},
"zeroconf_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::flow_it::config::step::user::data_description::password%]",
"username": "[%key:component::flow_it::config::step::user::data_description::username%]"
},
"description": "Do you want to set up {name}?",
"title": "Discovered Flow-it"
}
}
},
"exceptions": {
"auth_failed": {
"message": "Authentication failed when communicating with Flow-it VMC"
},
"command_failed": {
"message": "Failed to send command to Flow-it VMC"
},
"update_failed": {
"message": "Error communicating with API"
}
}
}
@@ -83,6 +83,7 @@ SUPPORTED_FEATURES = (
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.GROUPING
)
SUPPORTED_FEATURES_ZONE = (
MediaPlayerEntityFeature.VOLUME_SET
@@ -27,6 +27,8 @@ from homeassistant.components.spotify import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@@ -51,6 +53,7 @@ from .const import (
DEFAULT_TTS_PAUSE_TIME,
DEFAULT_TTS_VOLUME,
DEFAULT_UNMUTE_VOLUME,
DOMAIN,
FD_NAME,
KNOWN_PIPES,
PIPE_FUNCTION_MAP,
@@ -458,6 +461,74 @@ class ForkedDaapdMaster(MediaPlayerEntity):
else:
await self.async_turn_off()
@override
async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` (outputs) to the current playback."""
entity_registry = er.async_get(self.hass)
known_output_ids = {output["id"] for output in self._outputs}
output_ids: list[str] = []
for entity_id in group_members:
if entity_id == self.entity_id:
continue
if not (entity_entry := entity_registry.async_get(entity_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={"entity_id": entity_id},
)
if (
entity_entry.platform != DOMAIN
or entity_entry.config_entry_id != self._entry_id
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_forked_daapd_output",
translation_placeholders={"entity_id": entity_id},
)
# Zone unique ids are f"{config_entry.entry_id}-{output_id}"
output_id = entity_entry.unique_id.split("-", 1)[1]
# Registry entries persist after an output disappears from the server
if output_id not in known_output_ids:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="output_not_found",
translation_placeholders={"entity_id": entity_id},
)
output_ids.append(output_id)
await asyncio.gather(
*(
self.api.change_output(output_id, selected=True)
for output_id in output_ids
)
)
@override
async def async_unjoin_player(self) -> None:
"""Remove all outputs from the current playback."""
if any(output["selected"] for output in self._outputs):
await self.api.set_enabled_outputs([])
@property
@override
def group_members(self) -> list[str]:
"""List of players which are currently grouped together."""
entity_registry = er.async_get(self.hass)
output_id_to_entity_id = {
entry.unique_id.split("-", 1)[1]: entry.entity_id
for entry in er.async_entries_for_config_entry(
entity_registry, self._entry_id
)
# Skip the master entity, whose unique id is the config entry id
if isinstance(entry.unique_id, str) and "-" in entry.unique_id
}
return [self.entity_id] + [
entity_id
for output in self._outputs
if output["selected"]
and (entity_id := output_id_to_entity_id.get(output["id"])) is not None
]
@property
@override
def name(self) -> str:
@@ -25,6 +25,17 @@
}
}
},
"exceptions": {
"entity_not_found": {
"message": "Entity {entity_id} not found."
},
"not_forked_daapd_output": {
"message": "Entity {entity_id} is not an output of this OwnTone server."
},
"output_not_found": {
"message": "The output for entity {entity_id} no longer exists on the OwnTone server."
}
},
"options": {
"step": {
"init": {
@@ -546,9 +546,9 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
connections={(CONNECTION_NETWORK_MAC, dev_mac)},
default_manufacturer="FRITZ!",
default_model="FRITZ!Box Tracked device",
default_name=device.hostname,
manufacturer="FRITZ!",
model="FRITZ!Box Tracked device",
name=device.hostname,
via_device_id=dr.async_get_device_id_by_identifier(
self.hass,
(DOMAIN, self.unique_id),
@@ -5,7 +5,7 @@ from requests.exceptions import ConnectionError as RequestConnectionError, HTTPE
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, UnitOfTemperature
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from .const import DOMAIN, LOGGER, PLATFORMS
@@ -66,7 +66,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) ->
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: FritzboxConfigEntry, device: DeviceEntry
hass: HomeAssistant, entry: FritzboxConfigEntry, device: AnyDeviceEntry
) -> bool:
"""Remove Fritzbox config entry from a device."""
coordinator = entry.runtime_data
+3 -1
View File
@@ -66,7 +66,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: FroniusConfigEntry) ->
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: FroniusConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant,
config_entry: FroniusConfigEntry,
device_entry: dr.AnyDeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
return True
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260729.6"]
"requirements": ["home-assistant-frontend==20260729.7"]
}
@@ -42,9 +42,7 @@ async def async_setup_entry(
dev_reg = dr.async_get(hass)
dev_ids = {
identifier[1]
for device in dev_reg.devices.get_devices_for_config_entry_id(
config_entry.entry_id
)
for device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)
for identifier in device.identifiers
}
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import override
from propcache.api import cached_property
from pygeosphere_warnings import (
GeoSphereApiError,
GeoSphereConnectionError,
@@ -21,7 +22,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER
from .const import DOMAIN, LOGGER, WARNINGS_URL
# Warnings are event driven and updated by GeoSphere Austria as needed.
# The cheap HEAD precheck keeps the cost of a poll low, so a relatively
@@ -56,6 +57,17 @@ class GeoSphereUpdateCoordinator(DataUpdateCoordinator[GeoSphereData]):
self.client = GeoSphereWarningsClient(async_get_clientsession(hass))
self._last_modified: datetime | None = None
@cached_property
def warnings_portal_url(self) -> str:
"""Returns the URL to the configured municipality's details page on the warnings portal."""
longitude = self.config_entry.data[CONF_LONGITUDE]
latitude = self.config_entry.data[CONF_LATITUDE]
return (
WARNINGS_URL
# codespell:ignore-next-line alle
+ f"wsapp/de/alle/gesamterzeitraum/0/{longitude:.5f},{latitude:.5f}"
)
@override
async def _async_update_data(self) -> GeoSphereData:
"""Fetch warnings, skipping the full fetch when nothing changed."""
@@ -4,7 +4,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, WARNINGS_URL
from .const import ATTRIBUTION, DOMAIN, MANUFACTURER
from .coordinator import GeoSphereUpdateCoordinator
@@ -29,5 +29,5 @@ class GeoSphereEntity(CoordinatorEntity[GeoSphereUpdateCoordinator]):
name=municipality.name,
manufacturer=MANUFACTURER,
entry_type=DeviceEntryType.SERVICE,
configuration_url=WARNINGS_URL,
configuration_url=coordinator.warnings_portal_url,
)
@@ -8,5 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==9.1.0", "oauth2client==4.1.3", "ical==14.1.0"]
"requirements": ["gcal-sync==9.1.0", "oauth2client==4.1.3", "ical==14.1.1"]
}
@@ -73,7 +73,6 @@ from homeassistant.components.water_heater import (
)
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_BATTERY_LEVEL,
ATTR_CODE,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
@@ -848,65 +847,6 @@ class LocatorTrait(_Trait):
)
@register_trait
class EnergyStorageTrait(_Trait):
"""Trait to offer EnergyStorage functionality.
https://developers.google.com/actions/smarthome/traits/energystorage
"""
name = TRAIT_ENERGY_STORAGE
commands = [COMMAND_CHARGE]
@staticmethod
@override
def supported(domain, features, device_class, _):
"""Test if state is supported."""
return domain == VACUUM_DOMAIN and features & VacuumEntityFeature.BATTERY
@override
def sync_attributes(self) -> dict[str, Any]:
"""Return EnergyStorage attributes for a sync request."""
return {
"isRechargeable": True,
"queryOnlyEnergyStorage": True,
}
@override
def query_attributes(self) -> dict[str, Any]:
"""Return EnergyStorage query attributes."""
battery_level = self.state.attributes.get(ATTR_BATTERY_LEVEL)
if battery_level is None:
return {}
if battery_level == 100:
descriptive_capacity_remaining = "FULL"
elif 75 <= battery_level < 100:
descriptive_capacity_remaining = "HIGH"
elif 50 <= battery_level < 75:
descriptive_capacity_remaining = "MEDIUM"
elif 25 <= battery_level < 50:
descriptive_capacity_remaining = "LOW"
elif 0 <= battery_level < 25:
descriptive_capacity_remaining = "CRITICALLY_LOW"
return {
"descriptiveCapacityRemaining": descriptive_capacity_remaining,
"capacityRemaining": [{"rawValue": battery_level, "unit": "PERCENTAGE"}],
"capacityUntilFull": [
{"rawValue": 100 - battery_level, "unit": "PERCENTAGE"}
],
"isCharging": self.state.state == vacuum.VacuumActivity.DOCKED,
"isPluggedIn": self.state.state == vacuum.VacuumActivity.DOCKED,
}
@override
async def execute(self, command, data, params, challenge):
"""Execute a dock command."""
raise SmartHomeError(
ERR_FUNCTION_NOT_SUPPORTED,
"Controlling charging of a vacuum is not yet supported",
)
@register_trait
class StartStopTrait(_Trait):
"""Trait to offer StartStop functionality.
@@ -1908,7 +1848,7 @@ class FanSpeedTrait(_Trait):
name = TRAIT_FAN_SPEED
commands = [COMMAND_SET_FAN_SPEED, COMMAND_REVERSE]
def __init__(self, hass, state, config):
def __init__(self, hass: HomeAssistant, state, config) -> None:
"""Initialize a trait for a state."""
super().__init__(hass, state, config)
if state.domain == FAN_DOMAIN:
@@ -42,6 +42,7 @@ POLLING_INTERVAL = timedelta(minutes=15)
BODY_POLLING_INTERVAL = timedelta(hours=1)
DEVICE_POLLING_INTERVAL = timedelta(hours=1)
DEFAULT_PAGE_SIZE = 1
SLEEP_PAGE_SIZE = 10
@dataclass
@@ -254,6 +255,7 @@ class GoogleHealthDeviceCoordinator(
class GoogleHealthSleepData:
"""Class to hold sleep data."""
# The most recent sleep session with summary data
sleep: Sleep | None = None
@@ -281,9 +283,15 @@ class GoogleHealthSleepCoordinator(
@override
async def _async_fetch_data(self) -> GoogleHealthSleepData:
"""Fetch latest sleep session."""
sleep_result = await self.api.sleep.list(page_size=DEFAULT_PAGE_SIZE)
sleep = sleep_result.data_points[0].data if sleep_result.data_points else None
return GoogleHealthSleepData(sleep=sleep)
sleep_result = await self.api.sleep.list(page_size=SLEEP_PAGE_SIZE)
# Find the first session with a sleep summary
for data_point in sleep_result.data_points or ():
if data_point.data and data_point.data.summary:
return GoogleHealthSleepData(sleep=data_point.data)
# No sessions or current session still in progress
return GoogleHealthSleepData()
@dataclass
@@ -8,5 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["google-health-api==0.8.0"]
"requirements": ["google-health-api==0.9.0"]
}
@@ -48,7 +48,7 @@ async def async_setup_entry(
dev_reg = dr.async_get(hass)
dev_ids = {
identifier[1]
for device in dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id)
for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
for identifier in device.identifiers
}
if not dev_ids:
@@ -265,6 +265,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
mode=service.data.get(ATTR_ALL),
object_id=object_id,
order=None,
context=service.context,
)
return
@@ -272,6 +273,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_LOGGER.warning("%s:Group '%s' doesn't exist!", service.service, object_id)
return
group.async_set_context(service.context)
# update group
if service.service == SERVICE_SET:
need_update = False

Some files were not shown because too many files have changed in this diff Show More