mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Merge branch 'dev' into kitchen-sink-via-device
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user