Compare commits

..

3 Commits

Author SHA1 Message Date
Mike Degatano
40cb7e8fdf Add jobs info mock to switch tests 2025-10-07 15:54:32 -04:00
Mike Degatano
d8051c5caa Add tests 2025-10-07 15:54:32 -04:00
Mike Degatano
94b4a7978c Add progress reporting for addon/core updates 2025-10-07 15:54:32 -04:00
547 changed files with 4854 additions and 20892 deletions

View File

@@ -37,7 +37,7 @@ on:
type: boolean type: boolean
env: env:
CACHE_VERSION: 9 CACHE_VERSION: 8
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.11" HA_SHORT_VERSION: "2025.11"
@@ -525,7 +525,7 @@ jobs:
. venv/bin/activate . venv/bin/activate
python --version python --version
pip install "$(grep '^uv' < requirements.txt)" pip install "$(grep '^uv' < requirements.txt)"
uv pip install -U "pip>=25.2" uv pip install -U "pip>=21.3.1" setuptools wheel
uv pip install -r requirements.txt uv pip install -r requirements.txt
python -m script.gen_requirements_all ci python -m script.gen_requirements_all ci
uv pip install -r requirements_all_pytest.txt -r requirements_test.txt uv pip install -r requirements_all_pytest.txt -r requirements_test.txt
@@ -625,7 +625,7 @@ jobs:
steps: steps:
- *checkout - *checkout
- name: Dependency review - name: Dependency review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with: with:
license-check: false # We use our own license audit checks license-check: false # We use our own license audit checks
@@ -741,7 +741,7 @@ jobs:
- name: Generate partial mypy restore key - name: Generate partial mypy restore key
id: generate-mypy-key id: generate-mypy-key
run: | run: |
mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3) mypy_version=$(cat requirements_test.txt | grep mypy | cut -d '=' -f 3)
echo "version=$mypy_version" >> $GITHUB_OUTPUT echo "version=$mypy_version" >> $GITHUB_OUTPUT
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-${{ echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-${{
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
with: with:
category: "/language:python" category: "/language:python"

1
.gitignore vendored
View File

@@ -79,6 +79,7 @@ junit.xml
.project .project
.pydevproject .pydevproject
.python-version
.tool-versions .tool-versions
# emacs auto backups # emacs auto backups

View File

@@ -1 +0,0 @@
3.13

View File

@@ -221,7 +221,6 @@ homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.* homeassistant.components.geo_location.*
homeassistant.components.geocaching.* homeassistant.components.geocaching.*
homeassistant.components.gios.* homeassistant.components.gios.*
homeassistant.components.github.*
homeassistant.components.glances.* homeassistant.components.glances.*
homeassistant.components.go2rtc.* homeassistant.components.go2rtc.*
homeassistant.components.goalzero.* homeassistant.components.goalzero.*

18
CODEOWNERS generated
View File

@@ -46,8 +46,6 @@ build.json @home-assistant/supervisor
/tests/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray /homeassistant/components/acmeda/ @atmurray
/tests/components/acmeda/ @atmurray /tests/components/acmeda/ @atmurray
/homeassistant/components/actron_air/ @kclif9 @JagadishDhanamjayam
/tests/components/actron_air/ @kclif9 @JagadishDhanamjayam
/homeassistant/components/adax/ @danielhiversen @lazytarget /homeassistant/components/adax/ @danielhiversen @lazytarget
/tests/components/adax/ @danielhiversen @lazytarget /tests/components/adax/ @danielhiversen @lazytarget
/homeassistant/components/adguard/ @frenck /homeassistant/components/adguard/ @frenck
@@ -764,8 +762,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intesishome/ @jnimmo /homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs /homeassistant/components/iometer/ @MaestroOnICe
/tests/components/iometer/ @jukrebs /tests/components/iometer/ @MaestroOnICe
/homeassistant/components/ios/ @robbiet480 /homeassistant/components/ios/ @robbiet480
/tests/components/ios/ @robbiet480 /tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard /homeassistant/components/iotawatt/ @gtdiehl @jyavenard
@@ -1067,8 +1065,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nilu/ @hfurubotten
/homeassistant/components/nina/ @DeerMaximum /homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum
/homeassistant/components/nintendo_parental_controls/ @pantherale0 /homeassistant/components/nintendo_parental/ @pantherale0
/tests/components/nintendo_parental_controls/ @pantherale0 /tests/components/nintendo_parental/ @pantherale0
/homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/nissan_leaf/ @filcole
/homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
@@ -1415,8 +1413,8 @@ build.json @home-assistant/supervisor
/tests/components/sfr_box/ @epenet /tests/components/sfr_box/ @epenet
/homeassistant/components/sftp_storage/ @maretodoric /homeassistant/components/sftp_storage/ @maretodoric
/tests/components/sftp_storage/ @maretodoric /tests/components/sftp_storage/ @maretodoric
/homeassistant/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre /homeassistant/components/sharkiq/ @JeffResc @funkybunch
/tests/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre /tests/components/sharkiq/ @JeffResc @funkybunch
/homeassistant/components/shell_command/ @home-assistant/core /homeassistant/components/shell_command/ @home-assistant/core
/tests/components/shell_command/ @home-assistant/core /tests/components/shell_command/ @home-assistant/core
/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco /homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco
@@ -1481,8 +1479,8 @@ build.json @home-assistant/supervisor
/tests/components/snoo/ @Lash-L /tests/components/snoo/ @Lash-L
/homeassistant/components/snooz/ @AustinBrunkhorst /homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck @bdraco @tronikos /homeassistant/components/solaredge/ @frenck @bdraco
/tests/components/solaredge/ @frenck @bdraco @tronikos /tests/components/solaredge/ @frenck @bdraco
/homeassistant/components/solaredge_local/ @drobtravels @scheric /homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79 @dontinelli /homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli

View File

@@ -34,11 +34,9 @@ WORKDIR /usr/src
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN uv python install 3.13.2
USER vscode USER vscode
COPY .python-version ./
RUN uv python install
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
RUN uv venv $VIRTUAL_ENV RUN uv venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH" ENV PATH="$VIRTUAL_ENV/bin:$PATH"

View File

@@ -71,4 +71,4 @@ POLLEN_CATEGORY_MAP = {
} }
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10) UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(minutes=30) UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)

View File

@@ -1,57 +0,0 @@
"""The Actron Air integration."""
from actron_neo_api import (
ActronAirNeoACSystem,
ActronNeoAPI,
ActronNeoAPIError,
ActronNeoAuthError,
)
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from .const import _LOGGER
from .coordinator import (
ActronAirConfigEntry,
ActronAirRuntimeData,
ActronAirSystemCoordinator,
)
PLATFORM = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Set up Actron Air integration from a config entry."""
api = ActronNeoAPI(refresh_token=entry.data[CONF_API_TOKEN])
systems: list[ActronAirNeoACSystem] = []
try:
systems = await api.get_ac_systems()
await api.update_status()
except ActronNeoAuthError:
_LOGGER.error("Authentication error while setting up Actron Air integration")
raise
except ActronNeoAPIError as err:
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
raise
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
_LOGGER.debug("Setting up coordinator for system: %s", system["serial"])
await coordinator.async_config_entry_first_refresh()
system_coordinators[system["serial"]] = coordinator
entry.runtime_data = ActronAirRuntimeData(
api=api,
system_coordinators=system_coordinators,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORM)

View File

@@ -1,259 +0,0 @@
"""Climate platform for Actron Air integration."""
from typing import Any
from actron_neo_api import ActronAirNeoStatus, ActronAirNeoZone
from homeassistant.components.climate import (
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
PARALLEL_UPDATES = 0
FAN_MODE_MAPPING_ACTRONAIR_TO_HA = {
"AUTO": FAN_AUTO,
"LOW": FAN_LOW,
"MED": FAN_MEDIUM,
"HIGH": FAN_HIGH,
}
FAN_MODE_MAPPING_HA_TO_ACTRONAIR = {
v: k for k, v in FAN_MODE_MAPPING_ACTRONAIR_TO_HA.items()
}
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA = {
"COOL": HVACMode.COOL,
"HEAT": HVACMode.HEAT,
"FAN": HVACMode.FAN_ONLY,
"AUTO": HVACMode.AUTO,
"OFF": HVACMode.OFF,
}
HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = {
v: k for k, v in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.items()
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air climate entities."""
system_coordinators = entry.runtime_data.system_coordinators
entities: list[ClimateEntity] = []
for coordinator in system_coordinators.values():
status = coordinator.data
name = status.ac_system.system_name
entities.append(ActronSystemClimate(coordinator, name))
entities.extend(
ActronZoneClimate(coordinator, zone)
for zone in status.remote_zone_info
if zone.exists
)
async_add_entities(entities)
class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity):
"""Base class for Actron Air climate entities."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_name = None
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
class ActronSystemClimate(BaseClimateEntity):
"""Representation of the Actron Air system."""
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator, name)
serial_number = coordinator.serial_number
self._attr_unique_id = serial_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=self._status.ac_system.system_name,
manufacturer="Actron Air",
model_id=self._status.ac_system.master_wc_model,
sw_version=self._status.ac_system.master_wc_firmware_version,
serial_number=serial_number,
)
@property
def min_temp(self) -> float:
"""Return the minimum temperature that can be set."""
return self._status.min_temp
@property
def max_temp(self) -> float:
"""Return the maximum temperature that can be set."""
return self._status.max_temp
@property
def _status(self) -> ActronAirNeoStatus:
"""Get the current status from the coordinator."""
return self.coordinator.data
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
if not self._status.user_aircon_settings.is_on:
return HVACMode.OFF
mode = self._status.user_aircon_settings.mode
return HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.get(mode)
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_mode = self._status.user_aircon_settings.fan_mode
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
@property
def current_humidity(self) -> float:
"""Return the current humidity."""
return self._status.master_info.live_humidity_pc
@property
def current_temperature(self) -> float:
"""Return the current temperature."""
return self._status.master_info.live_temp_c
@property
def target_temperature(self) -> float:
"""Return the target temperature."""
return self._status.user_aircon_settings.temperature_setpoint_cool_c
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower())
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
await self._status.user_aircon_settings.set_temperature(temperature=temp)
class ActronZoneClimate(BaseClimateEntity):
"""Representation of a zone within the Actron Air system."""
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
zone: ActronAirNeoZone,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator, zone.title)
serial_number = coordinator.serial_number
self._zone_id: int = zone.zone_id
self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}"
self._attr_device_info: DeviceInfo = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, serial_number),
)
@property
def min_temp(self) -> float:
"""Return the minimum temperature that can be set."""
return self._zone.min_temp
@property
def max_temp(self) -> float:
"""Return the maximum temperature that can be set."""
return self._zone.max_temp
@property
def _zone(self) -> ActronAirNeoZone:
"""Get the current zone data from the coordinator."""
status = self.coordinator.data
return status.zones[self._zone_id]
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
if self._zone.is_active:
mode = self._zone.hvac_mode
return HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.get(mode)
return HVACMode.OFF
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""
return self._zone.humidity
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._zone.live_temp_c
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
return self._zone.temperature_setpoint_cool_c
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
is_enabled = hvac_mode != HVACMode.OFF
await self._zone.enable(is_enabled)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs["temperature"])

View File

@@ -1,132 +0,0 @@
"""Setup config flow for Actron Air integration."""
import asyncio
from typing import Any
from actron_neo_api import ActronNeoAPI, ActronNeoAuthError
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
from .const import _LOGGER, DOMAIN
class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Actron Air."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._api: ActronNeoAPI | None = None
self._device_code: str | None = None
self._user_code: str = ""
self._verification_uri: str = ""
self._expires_minutes: str = "30"
self.login_task: asyncio.Task | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if self._api is None:
_LOGGER.debug("Initiating device authorization")
self._api = ActronNeoAPI()
try:
device_code_response = await self._api.request_device_code()
except ActronNeoAuthError as err:
_LOGGER.error("OAuth2 flow failed: %s", err)
return self.async_abort(reason="oauth2_error")
self._device_code = device_code_response["device_code"]
self._user_code = device_code_response["user_code"]
self._verification_uri = device_code_response["verification_uri_complete"]
self._expires_minutes = str(device_code_response["expires_in"] // 60)
async def _wait_for_authorization() -> None:
"""Wait for the user to authorize the device."""
assert self._api is not None
assert self._device_code is not None
_LOGGER.debug("Waiting for device authorization")
try:
await self._api.poll_for_token(self._device_code)
_LOGGER.debug("Authorization successful")
except ActronNeoAuthError as ex:
_LOGGER.exception("Error while waiting for device authorization")
raise CannotConnect from ex
_LOGGER.debug("Checking login task")
if self.login_task is None:
_LOGGER.debug("Creating task for device authorization")
self.login_task = self.hass.async_create_task(_wait_for_authorization())
if self.login_task.done():
_LOGGER.debug("Login task is done, checking results")
if exception := self.login_task.exception():
if isinstance(exception, CannotConnect):
return self.async_show_progress_done(
next_step_id="connection_error"
)
return self.async_show_progress_done(next_step_id="timeout")
return self.async_show_progress_done(next_step_id="finish_login")
return self.async_show_progress(
step_id="user",
progress_action="wait_for_authorization",
description_placeholders={
"user_code": self._user_code,
"verification_uri": self._verification_uri,
"expires_minutes": self._expires_minutes,
},
progress_task=self.login_task,
)
async def async_step_finish_login(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the finalization of login."""
_LOGGER.debug("Finalizing authorization")
assert self._api is not None
try:
user_data = await self._api.get_user_info()
except ActronNeoAuthError as err:
_LOGGER.error("Error getting user info: %s", err)
return self.async_abort(reason="oauth2_error")
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
)
async def async_step_timeout(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle issues that need transition await from progress step."""
if user_input is None:
return self.async_show_form(
step_id="timeout",
)
del self.login_task
return await self.async_step_user()
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle connection error from progress step."""
if user_input is None:
return self.async_show_form(step_id="connection_error")
# Reset state and try again
self._api = None
self._device_code = None
self.login_task = None
return await self.async_step_user()
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -1,6 +0,0 @@
"""Constants used by Actron Air integration."""
import logging
_LOGGER = logging.getLogger(__package__)
DOMAIN = "actron_air"

View File

@@ -1,69 +0,0 @@
"""Coordinator for Actron Air integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import ActronAirNeoACSystem, ActronAirNeoStatus, ActronNeoAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import _LOGGER
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
ERROR_UNKNOWN = "unknown_error"
@dataclass
class ActronAirRuntimeData:
"""Runtime data for the Actron Air integration."""
api: ActronNeoAPI
system_coordinators: dict[str, ActronAirSystemCoordinator]
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
AUTH_ERROR_THRESHOLD = 3
SCAN_INTERVAL = timedelta(seconds=30)
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirNeoACSystem]):
"""System coordinator for Actron Air integration."""
def __init__(
self,
hass: HomeAssistant,
entry: ActronAirConfigEntry,
api: ActronNeoAPI,
system: ActronAirNeoACSystem,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name="Actron Air Status",
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
self.system = system
self.serial_number = system["serial"]
self.api = api
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
async def _async_update_data(self) -> ActronAirNeoStatus:
"""Fetch updates and merge incremental changes into the full state."""
await self.api.update_status()
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status
def is_device_stale(self) -> bool:
"""Check if a device is stale (not seen for a while)."""
return (dt_util.utcnow() - self.last_seen) > STALE_DEVICE_TIMEOUT

View File

@@ -1,16 +0,0 @@
{
"domain": "actron_air",
"name": "Actron Air",
"codeowners": ["@kclif9", "@JagadishDhanamjayam"],
"config_flow": true,
"dhcp": [
{
"hostname": "neo-*",
"macaddress": "FC0FE7*"
}
],
"documentation": "https://www.home-assistant.io/integrations/actron_air",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["actron-neo-api==0.1.84"]
}

View File

@@ -1,78 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not have custom service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not have custom service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not subscribe to external events.
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: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options flow
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update.
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category:
status: exempt
comment: This integration does not use entity categories.
entity-device-class:
status: exempt
comment: This integration does not use entity device classes.
entity-disabled-by-default:
status: exempt
comment: Not required for this integration at this stage.
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration does not have any known issues that require repair.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo

View File

@@ -1,29 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Actron Air OAuth2 Authorization"
},
"timeout": {
"title": "Authorization timeout",
"description": "The authorization process timed out. Please try again.",
"data": {}
},
"connection_error": {
"title": "Connection error",
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
"data": {}
}
},
"progress": {
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
},
"error": {
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
},
"abort": {
"oauth2_error": "Failed to start OAuth2 flow",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
}
}

View File

@@ -71,14 +71,7 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
} }
) )
return self.async_show_form( return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
step_id="user",
data_schema=schema,
errors=errors,
description_placeholders={
"api_key_url": "https://opendata.aemet.es/centrodedescargas/altaUsuario"
},
)
@staticmethod @staticmethod
@callback @callback

View File

@@ -14,7 +14,7 @@
"longitude": "[%key:common::config_flow::data::longitude%]", "longitude": "[%key:common::config_flow::data::longitude%]",
"name": "Name of the integration" "name": "Name of the integration"
}, },
"description": "To generate API key go to {api_key_url}" "description": "To generate API key go to https://opendata.aemet.es/centrodedescargas/altaUsuario"
} }
} }
}, },

View File

@@ -18,10 +18,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS
DESCRIPTION_PLACEHOLDERS = {
"developer_registration_url": "https://developer.airly.eu/register",
}
class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Airly.""" """Config flow for Airly."""
@@ -89,7 +85,6 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
} }
), ),
errors=errors, errors=errors,
description_placeholders=DESCRIPTION_PLACEHOLDERS,
) )

View File

@@ -2,7 +2,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"description": "To generate API key go to {developer_registration_url}", "description": "To generate API key go to https://developer.airly.eu/register",
"data": { "data": {
"name": "[%key:common::config_flow::data::name%]", "name": "[%key:common::config_flow::data::name%]",
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",

View File

@@ -36,11 +36,6 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
identifiers={(DOMAIN, str(airos_data.host.device_id))}, identifiers={(DOMAIN, str(airos_data.host.device_id))},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=airos_data.host.devmodel, model=airos_data.host.devmodel,
model_id=(
sku
if (sku := airos_data.derived.sku) not in ["UNKNOWN", "AMBIGUOUS"]
else None
),
name=airos_data.host.hostname, name=airos_data.host.hostname,
sw_version=airos_data.host.fwversion, sw_version=airos_data.host.fwversion,
) )

View File

@@ -4,8 +4,7 @@
"codeowners": ["@CoMPaTech"], "codeowners": ["@CoMPaTech"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airos", "documentation": "https://www.home-assistant.io/integrations/airos",
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["airos==0.5.6"] "requirements": ["airos==0.5.5"]
} }

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairq"], "loggers": ["aioairq"],
"requirements": ["aioairq==0.4.7"] "requirements": ["aioairq==0.4.6"]
} }

View File

@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any, Final, final from typing import TYPE_CHECKING, Any, Final, final
from propcache.api import cached_property from propcache.api import cached_property
import voluptuous as vol import voluptuous as vol
@@ -27,6 +28,8 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
@@ -146,11 +149,68 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
) )
_alarm_control_panel_option_default_code: str | None = None _alarm_control_panel_option_default_code: str | None = None
__alarm_legacy_state: bool = False
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if any(method in cls.__dict__ for method in ("_attr_state", "state")):
# Integrations should use the 'alarm_state' property instead of
# setting the state directly.
cls.__alarm_legacy_state = True
def __setattr__(self, name: str, value: Any, /) -> None:
"""Set attribute.
Deprecation warning if setting '_attr_state' directly
unless already reported.
"""
if name == "_attr_state":
self._report_deprecated_alarm_state_handling()
return super().__setattr__(name, value)
@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
if self.__alarm_legacy_state:
self._report_deprecated_alarm_state_handling()
@callback
def _report_deprecated_alarm_state_handling(self) -> None:
"""Report on deprecated handling of alarm state.
Integrations should implement alarm_state instead of using state directly.
"""
report_usage(
"is setting state directly."
f" Entity {self.entity_id} ({type(self)}) should implement the 'alarm_state'"
" property and return its state using the AlarmControlPanelState enum",
core_integration_behavior=ReportBehavior.ERROR,
custom_integration_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2025.11",
integration_domain=self.platform.platform_name if self.platform else None,
exclude_integrations={DOMAIN},
)
@final @final
@property @property
def state(self) -> str | None: def state(self) -> str | None:
"""Return the current state.""" """Return the current state."""
return self.alarm_state if (alarm_state := self.alarm_state) is not None:
return alarm_state
if self._attr_state is not None:
# Backwards compatibility for integrations that set state directly
# Should be removed in 2025.11
if TYPE_CHECKING:
assert isinstance(self._attr_state, str)
return self._attr_state
return None
@cached_property @cached_property
def alarm_state(self) -> AlarmControlPanelState | None: def alarm_state(self) -> AlarmControlPanelState | None:

View File

@@ -1472,10 +1472,10 @@ class AlexaModeController(AlexaCapability):
# Return state instead of position when using ModeController. # Return state instead of position when using ModeController.
mode = self.entity.state mode = self.entity.state
if mode in ( if mode in (
cover.CoverState.OPEN, cover.STATE_OPEN,
cover.CoverState.OPENING, cover.STATE_OPENING,
cover.CoverState.CLOSED, cover.STATE_CLOSED,
cover.CoverState.CLOSING, cover.STATE_CLOSING,
STATE_UNKNOWN, STATE_UNKNOWN,
): ):
return f"{cover.ATTR_POSITION}.{mode}" return f"{cover.ATTR_POSITION}.{mode}"
@@ -1594,11 +1594,11 @@ class AlexaModeController(AlexaCapability):
["Position", AlexaGlobalCatalog.SETTING_OPENING], False ["Position", AlexaGlobalCatalog.SETTING_OPENING], False
) )
self._resource.add_mode( self._resource.add_mode(
f"{cover.ATTR_POSITION}.{cover.CoverState.OPEN}", f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}",
[AlexaGlobalCatalog.VALUE_OPEN], [AlexaGlobalCatalog.VALUE_OPEN],
) )
self._resource.add_mode( self._resource.add_mode(
f"{cover.ATTR_POSITION}.{cover.CoverState.CLOSED}", f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}",
[AlexaGlobalCatalog.VALUE_CLOSE], [AlexaGlobalCatalog.VALUE_CLOSE],
) )
self._resource.add_mode( self._resource.add_mode(
@@ -1651,22 +1651,22 @@ class AlexaModeController(AlexaCapability):
raise_labels.append(AlexaSemantics.ACTION_OPEN) raise_labels.append(AlexaSemantics.ACTION_OPEN)
self._semantics.add_states_to_value( self._semantics.add_states_to_value(
[AlexaSemantics.STATES_CLOSED], [AlexaSemantics.STATES_CLOSED],
f"{cover.ATTR_POSITION}.{cover.CoverState.CLOSED}", f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}",
) )
self._semantics.add_states_to_value( self._semantics.add_states_to_value(
[AlexaSemantics.STATES_OPEN], [AlexaSemantics.STATES_OPEN],
f"{cover.ATTR_POSITION}.{cover.CoverState.OPEN}", f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}",
) )
self._semantics.add_action_to_directive( self._semantics.add_action_to_directive(
lower_labels, lower_labels,
"SetMode", "SetMode",
{"mode": f"{cover.ATTR_POSITION}.{cover.CoverState.CLOSED}"}, {"mode": f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}"},
) )
self._semantics.add_action_to_directive( self._semantics.add_action_to_directive(
raise_labels, raise_labels,
"SetMode", "SetMode",
{"mode": f"{cover.ATTR_POSITION}.{cover.CoverState.OPEN}"}, {"mode": f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}"},
) )
return self._semantics.serialize_semantics() return self._semantics.serialize_semantics()

View File

@@ -1261,9 +1261,9 @@ async def async_api_set_mode(
elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
position = mode.split(".")[1] position = mode.split(".")[1]
if position == cover.CoverState.CLOSED: if position == cover.STATE_CLOSED:
service = cover.SERVICE_CLOSE_COVER service = cover.SERVICE_CLOSE_COVER
elif position == cover.CoverState.OPEN: elif position == cover.STATE_OPEN:
service = cover.SERVICE_OPEN_COVER service = cover.SERVICE_OPEN_COVER
elif position == "custom": elif position == "custom":
service = cover.SERVICE_STOP_COVER service = cover.SERVICE_STOP_COVER

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aioamazondevices==6.4.3"] "requirements": ["aioamazondevices==6.2.9"]
} }

View File

@@ -4,15 +4,12 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from functools import partial from functools import partial
import json
import logging import logging
from typing import Any, cast from typing import Any, cast
import anthropic import anthropic
import voluptuous as vol import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.config_entries import ( from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigEntryState, ConfigEntryState,
@@ -21,13 +18,7 @@ from homeassistant.config_entries import (
ConfigSubentryFlow, ConfigSubentryFlow,
SubentryFlowResult, SubentryFlowResult,
) )
from homeassistant.const import ( from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_API_KEY,
CONF_LLM_HASS_API,
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm from homeassistant.helpers import llm
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
@@ -46,23 +37,12 @@ from .const import (
CONF_RECOMMENDED, CONF_RECOMMENDED,
CONF_TEMPERATURE, CONF_TEMPERATURE,
CONF_THINKING_BUDGET, CONF_THINKING_BUDGET,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_MAX_USES,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_CONVERSATION_NAME, DEFAULT_CONVERSATION_NAME,
DOMAIN, DOMAIN,
RECOMMENDED_CHAT_MODEL, RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS, RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE, RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET,
RECOMMENDED_WEB_SEARCH,
RECOMMENDED_WEB_SEARCH_MAX_USES,
RECOMMENDED_WEB_SEARCH_USER_LOCATION,
WEB_SEARCH_UNSUPPORTED_MODELS,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -188,14 +168,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
if user_input.get(CONF_WEB_SEARCH, RECOMMENDED_WEB_SEARCH):
model = user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
if model.startswith(tuple(WEB_SEARCH_UNSUPPORTED_MODELS)):
errors[CONF_WEB_SEARCH] = "web_search_unsupported_model"
elif user_input.get(
CONF_WEB_SEARCH_USER_LOCATION, RECOMMENDED_WEB_SEARCH_USER_LOCATION
):
user_input.update(await self._get_location_data())
if not errors: if not errors:
if self._is_new: if self._is_new:
@@ -243,68 +215,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
errors=errors or None, errors=errors or None,
) )
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""
location_data: dict[str, str] = {}
zone_home = self.hass.states.get(ENTITY_ID_HOME)
if zone_home is not None:
client = await self.hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=self._get_entry().data[CONF_API_KEY],
)
)
location_schema = vol.Schema(
{
vol.Optional(
CONF_WEB_SEARCH_CITY,
description="Free text input for the city, e.g. `San Francisco`",
): str,
vol.Optional(
CONF_WEB_SEARCH_REGION,
description="Free text input for the region, e.g. `California`",
): str,
}
)
response = await client.messages.create(
model=RECOMMENDED_CHAT_MODEL,
messages=[
{
"role": "user",
"content": "Where are the following coordinates located: "
f"({zone_home.attributes[ATTR_LATITUDE]},"
f" {zone_home.attributes[ATTR_LONGITUDE]})? Please respond "
"only with a JSON object using the following schema:\n"
f"{convert(location_schema)}",
},
{
"role": "assistant",
"content": "{", # hints the model to skip any preamble
},
],
max_tokens=RECOMMENDED_MAX_TOKENS,
)
_LOGGER.debug("Model response: %s", response.content)
location_data = location_schema(
json.loads(
"{"
+ "".join(
block.text
for block in response.content
if isinstance(block, anthropic.types.TextBlock)
)
)
or {}
)
if self.hass.config.country:
location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country
location_data[CONF_WEB_SEARCH_TIMEZONE] = self.hass.config.time_zone
_LOGGER.debug("Location data: %s", location_data)
return location_data
async_step_user = async_step_set_options async_step_user = async_step_set_options
async_step_reconfigure = async_step_set_options async_step_reconfigure = async_step_set_options
@@ -363,18 +273,6 @@ def anthropic_config_option_schema(
CONF_THINKING_BUDGET, CONF_THINKING_BUDGET,
default=RECOMMENDED_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET,
): int, ): int,
vol.Optional(
CONF_WEB_SEARCH,
default=RECOMMENDED_WEB_SEARCH,
): bool,
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=RECOMMENDED_WEB_SEARCH_MAX_USES,
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=RECOMMENDED_WEB_SEARCH_USER_LOCATION,
): bool,
} }
) )
return schema return schema

View File

@@ -18,26 +18,9 @@ RECOMMENDED_TEMPERATURE = 1.0
CONF_THINKING_BUDGET = "thinking_budget" CONF_THINKING_BUDGET = "thinking_budget"
RECOMMENDED_THINKING_BUDGET = 0 RECOMMENDED_THINKING_BUDGET = 0
MIN_THINKING_BUDGET = 1024 MIN_THINKING_BUDGET = 1024
CONF_WEB_SEARCH = "web_search"
RECOMMENDED_WEB_SEARCH = False
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
RECOMMENDED_WEB_SEARCH_USER_LOCATION = False
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
RECOMMENDED_WEB_SEARCH_MAX_USES = 5
CONF_WEB_SEARCH_CITY = "city"
CONF_WEB_SEARCH_REGION = "region"
CONF_WEB_SEARCH_COUNTRY = "country"
CONF_WEB_SEARCH_TIMEZONE = "timezone"
NON_THINKING_MODELS = [ NON_THINKING_MODELS = [
"claude-3-5", # Both sonnet and haiku "claude-3-5", # Both sonnet and haiku
"claude-3-opus", "claude-3-opus",
"claude-3-haiku", "claude-3-haiku",
] ]
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
"claude-3-opus",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
]

View File

@@ -1,17 +1,12 @@
"""Base entity for Anthropic.""" """Base entity for Anthropic."""
from collections.abc import AsyncGenerator, Callable, Iterable from collections.abc import AsyncGenerator, Callable, Iterable
from dataclasses import dataclass, field
import json import json
from typing import Any from typing import Any
import anthropic import anthropic
from anthropic import AsyncStream from anthropic import AsyncStream
from anthropic.types import ( from anthropic.types import (
CitationsDelta,
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
ContentBlockParam,
InputJSONDelta, InputJSONDelta,
MessageDeltaUsage, MessageDeltaUsage,
MessageParam, MessageParam,
@@ -21,16 +16,11 @@ from anthropic.types import (
RawContentBlockStopEvent, RawContentBlockStopEvent,
RawMessageDeltaEvent, RawMessageDeltaEvent,
RawMessageStartEvent, RawMessageStartEvent,
RawMessageStopEvent,
RedactedThinkingBlock, RedactedThinkingBlock,
RedactedThinkingBlockParam, RedactedThinkingBlockParam,
ServerToolUseBlock,
ServerToolUseBlockParam,
SignatureDelta, SignatureDelta,
TextBlock, TextBlock,
TextBlockParam, TextBlockParam,
TextCitation,
TextCitationParam,
TextDelta, TextDelta,
ThinkingBlock, ThinkingBlock,
ThinkingBlockParam, ThinkingBlockParam,
@@ -39,15 +29,9 @@ from anthropic.types import (
ThinkingDelta, ThinkingDelta,
ToolParam, ToolParam,
ToolResultBlockParam, ToolResultBlockParam,
ToolUnionParam,
ToolUseBlock, ToolUseBlock,
ToolUseBlockParam, ToolUseBlockParam,
Usage, Usage,
WebSearchTool20250305Param,
WebSearchToolRequestErrorParam,
WebSearchToolResultBlock,
WebSearchToolResultBlockParam,
WebSearchToolResultError,
) )
from anthropic.types.message_create_params import MessageCreateParamsStreaming from anthropic.types.message_create_params import MessageCreateParamsStreaming
from voluptuous_openapi import convert from voluptuous_openapi import convert
@@ -64,13 +48,6 @@ from .const import (
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
CONF_TEMPERATURE, CONF_TEMPERATURE,
CONF_THINKING_BUDGET, CONF_THINKING_BUDGET,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_MAX_USES,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DOMAIN, DOMAIN,
LOGGER, LOGGER,
MIN_THINKING_BUDGET, MIN_THINKING_BUDGET,
@@ -96,69 +73,6 @@ def _format_tool(
) )
@dataclass(slots=True)
class CitationDetails:
"""Citation details for a content part."""
index: int = 0
"""Start position of the text."""
length: int = 0
"""Length of the relevant data."""
citations: list[TextCitationParam] = field(default_factory=list)
"""Citations for the content part."""
@dataclass(slots=True)
class ContentDetails:
"""Native data for AssistantContent."""
citation_details: list[CitationDetails] = field(default_factory=list)
def has_content(self) -> bool:
"""Check if there is any content."""
return any(detail.length > 0 for detail in self.citation_details)
def has_citations(self) -> bool:
"""Check if there are any citations."""
return any(detail.citations for detail in self.citation_details)
def add_citation_detail(self) -> None:
"""Add a new citation detail."""
if not self.citation_details or self.citation_details[-1].length > 0:
self.citation_details.append(
CitationDetails(
index=self.citation_details[-1].index
+ self.citation_details[-1].length
if self.citation_details
else 0
)
)
def add_citation(self, citation: TextCitation) -> None:
"""Add a citation to the current detail."""
if not self.citation_details:
self.citation_details.append(CitationDetails())
citation_param: TextCitationParam | None = None
if isinstance(citation, CitationsWebSearchResultLocation):
citation_param = CitationWebSearchResultLocationParam(
type="web_search_result_location",
title=citation.title,
url=citation.url,
cited_text=citation.cited_text,
encrypted_index=citation.encrypted_index,
)
if citation_param:
self.citation_details[-1].citations.append(citation_param)
def delete_empty(self) -> None:
"""Delete empty citation details."""
self.citation_details = [
detail for detail in self.citation_details if detail.citations
]
def _convert_content( def _convert_content(
chat_content: Iterable[conversation.Content], chat_content: Iterable[conversation.Content],
) -> list[MessageParam]: ) -> list[MessageParam]:
@@ -167,31 +81,15 @@ def _convert_content(
for content in chat_content: for content in chat_content:
if isinstance(content, conversation.ToolResultContent): if isinstance(content, conversation.ToolResultContent):
if content.tool_name == "web_search":
tool_result_block: ContentBlockParam = WebSearchToolResultBlockParam(
type="web_search_tool_result",
tool_use_id=content.tool_call_id,
content=content.tool_result["content"]
if "content" in content.tool_result
else WebSearchToolRequestErrorParam(
type="web_search_tool_result_error",
error_code=content.tool_result.get("error_code", "unavailable"), # type: ignore[typeddict-item]
),
)
external_tool = True
else:
tool_result_block = ToolResultBlockParam( tool_result_block = ToolResultBlockParam(
type="tool_result", type="tool_result",
tool_use_id=content.tool_call_id, tool_use_id=content.tool_call_id,
content=json.dumps(content.tool_result), content=json.dumps(content.tool_result),
) )
external_tool = False if not messages or messages[-1]["role"] != "user":
if not messages or messages[-1]["role"] != (
"assistant" if external_tool else "user"
):
messages.append( messages.append(
MessageParam( MessageParam(
role="assistant" if external_tool else "user", role="user",
content=[tool_result_block], content=[tool_result_block],
) )
) )
@@ -253,56 +151,13 @@ def _convert_content(
redacted_thinking_block redacted_thinking_block
) )
if content.content: if content.content:
current_index = 0
for detail in (
content.native.citation_details
if isinstance(content.native, ContentDetails)
else [CitationDetails(length=len(content.content))]
):
if detail.index > current_index:
# Add text block for any text without citations
messages[-1]["content"].append( # type: ignore[union-attr] messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam( TextBlockParam(type="text", text=content.content)
type="text",
text=content.content[current_index : detail.index],
)
)
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",
text=content.content[
detail.index : detail.index + detail.length
],
citations=detail.citations,
)
if detail.citations
else TextBlockParam(
type="text",
text=content.content[
detail.index : detail.index + detail.length
],
)
)
current_index = detail.index + detail.length
if current_index < len(content.content):
# Add text block for any remaining text without citations
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",
text=content.content[current_index:],
)
) )
if content.tool_calls: if content.tool_calls:
messages[-1]["content"].extend( # type: ignore[union-attr] messages[-1]["content"].extend( # type: ignore[union-attr]
[ [
ServerToolUseBlockParam( ToolUseBlockParam(
type="server_tool_use",
id=tool_call.id,
name="web_search",
input=tool_call.tool_args,
)
if tool_call.external and tool_call.tool_name == "web_search"
else ToolUseBlockParam(
type="tool_use", type="tool_use",
id=tool_call.id, id=tool_call.id,
name=tool_call.tool_name, name=tool_call.tool_name,
@@ -318,12 +173,10 @@ def _convert_content(
return messages return messages
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place async def _transform_stream(
chat_log: conversation.ChatLog, chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent], stream: AsyncStream[MessageStreamEvent],
) -> AsyncGenerator[ ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
"""Transform the response stream into HA format. """Transform the response stream into HA format.
A typical stream of responses might look something like the following: A typical stream of responses might look something like the following:
@@ -356,13 +209,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
if stream is None: if stream is None:
raise TypeError("Expected a stream of messages") raise TypeError("Expected a stream of messages")
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None current_tool_block: ToolUseBlockParam | None = None
current_tool_args: str current_tool_args: str
content_details = ContentDetails()
content_details.add_citation_detail()
input_usage: Usage | None = None input_usage: Usage | None = None
has_content = False
has_native = False has_native = False
first_block: bool
async for response in stream: async for response in stream:
LOGGER.debug("Received response: %s", response) LOGGER.debug("Received response: %s", response)
@@ -371,7 +222,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
if response.message.role != "assistant": if response.message.role != "assistant":
raise ValueError("Unexpected message role") raise ValueError("Unexpected message role")
input_usage = response.message.usage input_usage = response.message.usage
first_block = True
elif isinstance(response, RawContentBlockStartEvent): elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock): if isinstance(response.content_block, ToolUseBlock):
current_tool_block = ToolUseBlockParam( current_tool_block = ToolUseBlockParam(
@@ -382,37 +232,17 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
) )
current_tool_args = "" current_tool_args = ""
elif isinstance(response.content_block, TextBlock): elif isinstance(response.content_block, TextBlock):
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead. if has_content:
first_block
or (
not content_details.has_citations()
and response.content_block.citations is None
and content_details.has_content()
)
):
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
yield {"role": "assistant"} yield {"role": "assistant"}
has_native = False has_native = False
first_block = False has_content = True
content_details.add_citation_detail()
if response.content_block.text: if response.content_block.text:
content_details.citation_details[-1].length += len(
response.content_block.text
)
yield {"content": response.content_block.text} yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock): elif isinstance(response.content_block, ThinkingBlock):
if first_block or has_native: if has_native:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"} yield {"role": "assistant"}
has_native = False has_native = False
first_block = False has_content = False
elif isinstance(response.content_block, RedactedThinkingBlock): elif isinstance(response.content_block, RedactedThinkingBlock):
LOGGER.debug( LOGGER.debug(
"Some of Claudes internal reasoning has been automatically " "Some of Claudes internal reasoning has been automatically "
@@ -420,60 +250,15 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
"responses" "responses"
) )
if has_native: if has_native:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"} yield {"role": "assistant"}
has_native = False has_native = False
first_block = False has_content = False
yield {"native": response.content_block} yield {"native": response.content_block}
has_native = True has_native = True
elif isinstance(response.content_block, ServerToolUseBlock):
current_tool_block = ServerToolUseBlockParam(
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
)
current_tool_args = ""
elif isinstance(response.content_block, WebSearchToolResultBlock):
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {
"role": "tool_result",
"tool_call_id": response.content_block.tool_use_id,
"tool_name": "web_search",
"tool_result": {
"type": "web_search_tool_result_error",
"error_code": response.content_block.content.error_code,
}
if isinstance(
response.content_block.content, WebSearchToolResultError
)
else {
"content": [
{
"type": "web_search_result",
"encrypted_content": block.encrypted_content,
"page_age": block.page_age,
"title": block.title,
"url": block.url,
}
for block in response.content_block.content
]
},
}
first_block = True
elif isinstance(response, RawContentBlockDeltaEvent): elif isinstance(response, RawContentBlockDeltaEvent):
if isinstance(response.delta, InputJSONDelta): if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta): elif isinstance(response.delta, TextDelta):
content_details.citation_details[-1].length += len(response.delta.text)
yield {"content": response.delta.text} yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta): elif isinstance(response.delta, ThinkingDelta):
yield {"thinking_content": response.delta.thinking} yield {"thinking_content": response.delta.thinking}
@@ -486,8 +271,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
) )
} }
has_native = True has_native = True
elif isinstance(response.delta, CitationsDelta):
content_details.add_citation(response.delta.citation)
elif isinstance(response, RawContentBlockStopEvent): elif isinstance(response, RawContentBlockStopEvent):
if current_tool_block is not None: if current_tool_block is not None:
tool_args = json.loads(current_tool_args) if current_tool_args else {} tool_args = json.loads(current_tool_args) if current_tool_args else {}
@@ -498,7 +281,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
id=current_tool_block["id"], id=current_tool_block["id"],
tool_name=current_tool_block["name"], tool_name=current_tool_block["name"],
tool_args=tool_args, tool_args=tool_args,
external=current_tool_block["type"] == "server_tool_use",
) )
] ]
} }
@@ -508,12 +290,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
chat_log.async_trace(_create_token_stats(input_usage, usage)) chat_log.async_trace(_create_token_stats(input_usage, usage))
if response.delta.stop_reason == "refusal": if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected") raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
def _create_token_stats( def _create_token_stats(
@@ -561,11 +337,21 @@ class AnthropicBaseLLMEntity(Entity):
"""Generate an answer for the chat log.""" """Generate an answer for the chat log."""
options = self.subentry.data options = self.subentry.data
tools: list[ToolParam] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
system = chat_log.content[0] system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent): if not isinstance(system, conversation.SystemContent):
raise TypeError("First message must be a system message") raise TypeError("First message must be a system message")
messages = _convert_content(chat_log.content[1:]) messages = _convert_content(chat_log.content[1:])
client = self.entry.runtime_data
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
model_args = MessageCreateParamsStreaming( model_args = MessageCreateParamsStreaming(
@@ -575,8 +361,8 @@ class AnthropicBaseLLMEntity(Entity):
system=system.content, system=system.content,
stream=True, stream=True,
) )
if tools:
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) model_args["tools"] = tools
if ( if (
not model.startswith(tuple(NON_THINKING_MODELS)) not model.startswith(tuple(NON_THINKING_MODELS))
and thinking_budget >= MIN_THINKING_BUDGET and thinking_budget >= MIN_THINKING_BUDGET
@@ -590,34 +376,6 @@ class AnthropicBaseLLMEntity(Entity):
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
) )
tools: list[ToolUnionParam] = []
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
if options.get(CONF_WEB_SEARCH):
web_search = WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = {
"type": "approximate",
"city": options.get(CONF_WEB_SEARCH_CITY, ""),
"region": options.get(CONF_WEB_SEARCH_REGION, ""),
"country": options.get(CONF_WEB_SEARCH_COUNTRY, ""),
"timezone": options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
}
tools.append(web_search)
if tools:
model_args["tools"] = tools
client = self.entry.runtime_data
# To prevent infinite loops, we limit the number of iterations # To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS): for _iteration in range(MAX_TOOL_ITERATIONS):
try: try:

View File

@@ -35,17 +35,11 @@
"temperature": "Temperature", "temperature": "Temperature",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"recommended": "Recommended model settings", "recommended": "Recommended model settings",
"thinking_budget": "Thinking budget", "thinking_budget_tokens": "Thinking budget"
"web_search": "Enable web search",
"web_search_max_uses": "Maximum web searches",
"user_location": "Include home location"
}, },
"data_description": { "data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template.", "prompt": "Instruct how the LLM should respond. This can be a template.",
"thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.", "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking."
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
"web_search_max_uses": "Limit the number of searches performed per response",
"user_location": "Localize search results based on home location"
} }
} }
}, },
@@ -54,8 +48,7 @@
"entry_not_loaded": "Cannot add things while the configuration is disabled." "entry_not_loaded": "Cannot add things while the configuration is disabled."
}, },
"error": { "error": {
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget.", "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
"web_search_unsupported_model": "Web search is not supported by the selected model. Please choose a compatible model or disable web search."
} }
} }
} }

View File

@@ -7,8 +7,6 @@ from typing import Any
from pyaprilaire.const import Attribute from pyaprilaire.const import Attribute
from homeassistant.components.climate import ( from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
FAN_AUTO, FAN_AUTO,
FAN_ON, FAN_ON,
PRESET_AWAY, PRESET_AWAY,
@@ -18,12 +16,7 @@ from homeassistant.components.climate import (
HVACAction, HVACAction,
HVACMode, HVACMode,
) )
from homeassistant.const import ( from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature
ATTR_TEMPERATURE,
PRECISION_HALVES,
PRECISION_WHOLE,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -239,15 +232,15 @@ class AprilaireClimate(BaseAprilaireEntity, ClimateEntity):
cool_setpoint = 0 cool_setpoint = 0
heat_setpoint = 0 heat_setpoint = 0
if temperature := kwargs.get(ATTR_TEMPERATURE): if temperature := kwargs.get("temperature"):
if self.coordinator.data.get(Attribute.MODE) == 3: if self.coordinator.data.get(Attribute.MODE) == 3:
cool_setpoint = temperature cool_setpoint = temperature
else: else:
heat_setpoint = temperature heat_setpoint = temperature
else: else:
if target_temp_low := kwargs.get(ATTR_TARGET_TEMP_LOW): if target_temp_low := kwargs.get("target_temp_low"):
heat_setpoint = target_temp_low heat_setpoint = target_temp_low
if target_temp_high := kwargs.get(ATTR_TARGET_TEMP_HIGH): if target_temp_high := kwargs.get("target_temp_high"):
cool_setpoint = target_temp_high cool_setpoint = target_temp_high
if cool_setpoint == 0 and heat_setpoint == 0: if cool_setpoint == 0 and heat_setpoint == 0:

View File

@@ -7,13 +7,13 @@ from collections import namedtuple
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine
import functools import functools
import logging import logging
from typing import Any from typing import Any, cast
from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy
from aiohttp import ClientSession from aiohttp import ClientSession
from asusrouter import AsusRouter, AsusRouterError from asusrouter import AsusRouter, AsusRouterError
from asusrouter.config import ARConfigKey from asusrouter.config import ARConfigKey
from asusrouter.modules.client import AsusClient, ConnectionState from asusrouter.modules.client import AsusClient
from asusrouter.modules.data import AsusData from asusrouter.modules.data import AsusData
from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors
from asusrouter.tools.connection import get_cookie_jar from asusrouter.tools.connection import get_cookie_jar
@@ -219,7 +219,7 @@ class AsusWrtLegacyBridge(AsusWrtBridge):
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Get connected status.""" """Get connected status."""
return self._api.is_connected return cast(bool, self._api.is_connected)
async def async_connect(self) -> None: async def async_connect(self) -> None:
"""Connect to the device.""" """Connect to the device."""
@@ -235,7 +235,8 @@ class AsusWrtLegacyBridge(AsusWrtBridge):
async def async_disconnect(self) -> None: async def async_disconnect(self) -> None:
"""Disconnect to the device.""" """Disconnect to the device."""
await self._api.async_disconnect() if self._api is not None and self._protocol == PROTOCOL_TELNET:
self._api.connection.disconnect()
async def async_get_connected_devices(self) -> dict[str, WrtDevice]: async def async_get_connected_devices(self) -> dict[str, WrtDevice]:
"""Get list of connected devices.""" """Get list of connected devices."""
@@ -436,7 +437,6 @@ class AsusWrtHttpBridge(AsusWrtBridge):
if dev.connection is not None if dev.connection is not None
and dev.description is not None and dev.description is not None
and dev.connection.ip_address is not None and dev.connection.ip_address is not None
and dev.state is ConnectionState.CONNECTED
} }
async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"], "loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
"requirements": ["aioasuswrt==1.5.1", "asusrouter==1.21.0"] "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.21.0"]
} }

View File

@@ -36,14 +36,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo
raise ConfigEntryAuthFailed("Migration to OAuth required") raise ConfigEntryAuthFailed("Migration to OAuth required")
session = async_create_august_clientsession(hass) session = async_create_august_clientsession(hass)
try:
implementation = ( implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation( await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry hass, entry
) )
) )
except ValueError as err:
raise ConfigEntryNotReady("OAuth implementation not available") from err
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session) august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
try: try:

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/autarco", "documentation": "https://www.home-assistant.io/integrations/autarco",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["autarco==3.2.0"] "requirements": ["autarco==3.1.0"]
} }

View File

@@ -57,7 +57,6 @@ from .api import (
_get_manager, _get_manager,
async_address_present, async_address_present,
async_ble_device_from_address, async_ble_device_from_address,
async_clear_address_from_match_history,
async_current_scanners, async_current_scanners,
async_discovered_service_info, async_discovered_service_info,
async_get_advertisement_callback, async_get_advertisement_callback,
@@ -116,7 +115,6 @@ __all__ = [
"HomeAssistantRemoteScanner", "HomeAssistantRemoteScanner",
"async_address_present", "async_address_present",
"async_ble_device_from_address", "async_ble_device_from_address",
"async_clear_address_from_match_history",
"async_current_scanners", "async_current_scanners",
"async_discovered_service_info", "async_discovered_service_info",
"async_get_advertisement_callback", "async_get_advertisement_callback",

View File

@@ -193,20 +193,6 @@ def async_rediscover_address(hass: HomeAssistant, address: str) -> None:
_get_manager(hass).async_rediscover_address(address) _get_manager(hass).async_rediscover_address(address)
@hass_callback
def async_clear_address_from_match_history(hass: HomeAssistant, address: str) -> None:
"""Clear an address from the integration matcher history.
This allows future advertisements from this address to trigger discovery
even if the advertisement content has changed but the service data UUIDs
remain the same.
Unlike async_rediscover_address, this does not immediately re-trigger
discovery with the current advertisement in history.
"""
_get_manager(hass).async_clear_address_from_match_history(address)
@hass_callback @hass_callback
def async_register_scanner( def async_register_scanner(
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -120,19 +120,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
if service_info := self._all_history.get(address): if service_info := self._all_history.get(address):
self._async_trigger_matching_discovery(service_info) self._async_trigger_matching_discovery(service_info)
@hass_callback
def async_clear_address_from_match_history(self, address: str) -> None:
"""Clear an address from the integration matcher history.
This allows future advertisements from this address to trigger discovery
even if the advertisement content has changed but the service data UUIDs
remain the same.
Unlike async_rediscover_address, this does not immediately re-trigger
discovery with the current advertisement in history.
"""
self._integration_matcher.async_clear_address(address)
def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
matched_domains = self._integration_matcher.match_domains(service_info) matched_domains = self._integration_matcher.match_domains(service_info)
if self._debug: if self._debug:

View File

@@ -68,17 +68,12 @@ class IntegrationMatchHistory:
manufacturer_data: bool manufacturer_data: bool
service_data: set[str] service_data: set[str]
service_uuids: set[str] service_uuids: set[str]
name: str
def seen_all_fields( def seen_all_fields(
previous_match: IntegrationMatchHistory, previous_match: IntegrationMatchHistory, advertisement_data: AdvertisementData
advertisement_data: AdvertisementData,
name: str,
) -> bool: ) -> bool:
"""Return if we have seen all fields.""" """Return if we have seen all fields."""
if previous_match.name != name:
return False
if not previous_match.manufacturer_data and advertisement_data.manufacturer_data: if not previous_match.manufacturer_data and advertisement_data.manufacturer_data:
return False return False
if advertisement_data.service_data and ( if advertisement_data.service_data and (
@@ -127,11 +122,10 @@ class IntegrationMatcher:
device = service_info.device device = service_info.device
advertisement_data = service_info.advertisement advertisement_data = service_info.advertisement
connectable = service_info.connectable connectable = service_info.connectable
name = service_info.name
matched = self._matched_connectable if connectable else self._matched matched = self._matched_connectable if connectable else self._matched
matched_domains: set[str] = set() matched_domains: set[str] = set()
if (previous_match := matched.get(device.address)) and seen_all_fields( if (previous_match := matched.get(device.address)) and seen_all_fields(
previous_match, advertisement_data, name previous_match, advertisement_data
): ):
# We have seen all fields so we can skip the rest of the matchers # We have seen all fields so we can skip the rest of the matchers
return matched_domains return matched_domains
@@ -146,13 +140,11 @@ class IntegrationMatcher:
) )
previous_match.service_data |= set(advertisement_data.service_data) previous_match.service_data |= set(advertisement_data.service_data)
previous_match.service_uuids |= set(advertisement_data.service_uuids) previous_match.service_uuids |= set(advertisement_data.service_uuids)
previous_match.name = name
else: else:
matched[device.address] = IntegrationMatchHistory( matched[device.address] = IntegrationMatchHistory(
manufacturer_data=bool(advertisement_data.manufacturer_data), manufacturer_data=bool(advertisement_data.manufacturer_data),
service_data=set(advertisement_data.service_data), service_data=set(advertisement_data.service_data),
service_uuids=set(advertisement_data.service_uuids), service_uuids=set(advertisement_data.service_uuids),
name=name,
) )
return matched_domains return matched_domains

View File

@@ -8,7 +8,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"requirements": ["brother==5.1.1"], "requirements": ["brother==5.1.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_printer._tcp.local.", "type": "_printer._tcp.local.",

View File

@@ -7,14 +7,12 @@ from typing import Any
from evolutionhttp import BryantEvolutionLocalClient from evolutionhttp import BryantEvolutionLocalClient
from homeassistant.components.climate import ( from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity, ClimateEntity,
ClimateEntityFeature, ClimateEntityFeature,
HVACAction, HVACAction,
HVACMode, HVACMode,
) )
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@@ -210,24 +208,24 @@ class BryantEvolutionClimate(ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature.""" """Set new target temperature."""
if value := kwargs.get(ATTR_TARGET_TEMP_HIGH): if kwargs.get("target_temp_high"):
temp = int(value) temp = int(kwargs["target_temp_high"])
if not await self._client.set_cooling_setpoint(temp): if not await self._client.set_cooling_setpoint(temp):
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_clsp" translation_domain=DOMAIN, translation_key="failed_to_set_clsp"
) )
self._attr_target_temperature_high = temp self._attr_target_temperature_high = temp
if value := kwargs.get(ATTR_TARGET_TEMP_LOW): if kwargs.get("target_temp_low"):
temp = int(value) temp = int(kwargs["target_temp_low"])
if not await self._client.set_heating_setpoint(temp): if not await self._client.set_heating_setpoint(temp):
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_htsp" translation_domain=DOMAIN, translation_key="failed_to_set_htsp"
) )
self._attr_target_temperature_low = temp self._attr_target_temperature_low = temp
if value := kwargs.get(ATTR_TEMPERATURE): if kwargs.get("temperature"):
temp = int(value) temp = int(kwargs["temperature"])
fn = ( fn = (
self._client.set_heating_setpoint self._client.set_heating_setpoint
if self.hvac_mode == HVACMode.HEAT if self.hvac_mode == HVACMode.HEAT

View File

@@ -169,7 +169,7 @@ class CalendarEventListener:
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
job: HassJob[..., Coroutine[Any, Any, None] | Any], job: HassJob[..., Coroutine[Any, Any, None]],
trigger_data: dict[str, Any], trigger_data: dict[str, Any],
fetcher: QueuedEventFetcher, fetcher: QueuedEventFetcher,
) -> None: ) -> None:

View File

@@ -4,6 +4,5 @@
"codeowners": [], "codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/citybikes", "documentation": "https://www.home-assistant.io/integrations/citybikes",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "legacy", "quality_scale": "legacy"
"requirements": ["python-citybikes==0.3.3"]
} }

View File

@@ -5,11 +5,8 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
import sys
import aiohttp import aiohttp
from citybikes import __version__ as CITYBIKES_CLIENT_VERSION
from citybikes.asyncio import Client as CitybikesClient
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@@ -18,18 +15,21 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
) )
from homeassistant.const import ( from homeassistant.const import (
APPLICATION_NAME, ATTR_ID,
ATTR_LATITUDE,
ATTR_LOCATION,
ATTR_LONGITUDE,
ATTR_NAME,
CONF_LATITUDE, CONF_LATITUDE,
CONF_LONGITUDE, CONF_LONGITUDE,
CONF_NAME, CONF_NAME,
CONF_RADIUS, CONF_RADIUS,
EVENT_HOMEASSISTANT_CLOSE,
UnitOfLength, UnitOfLength,
__version__,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
@@ -40,33 +40,31 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
HA_USER_AGENT = (
f"{APPLICATION_NAME}/{__version__} "
f"python-citybikes/{CITYBIKES_CLIENT_VERSION} "
f"Python/{sys.version_info[0]}.{sys.version_info[1]}"
)
ATTR_UID = "uid"
ATTR_LATITUDE = "latitude"
ATTR_LONGITUDE = "longitude"
ATTR_EMPTY_SLOTS = "empty_slots" ATTR_EMPTY_SLOTS = "empty_slots"
ATTR_EXTRA = "extra"
ATTR_FREE_BIKES = "free_bikes"
ATTR_NETWORK = "network"
ATTR_NETWORKS_LIST = "networks"
ATTR_STATIONS_LIST = "stations"
ATTR_TIMESTAMP = "timestamp" ATTR_TIMESTAMP = "timestamp"
ATTR_UID = "uid"
CONF_NETWORK = "network" CONF_NETWORK = "network"
CONF_STATIONS_LIST = "stations" CONF_STATIONS_LIST = "stations"
DEFAULT_ENDPOINT = "https://api.citybik.es/{uri}"
PLATFORM = "citybikes" PLATFORM = "citybikes"
MONITORED_NETWORKS = "monitored-networks" MONITORED_NETWORKS = "monitored-networks"
DATA_CLIENT = "client"
NETWORKS_URI = "v2/networks" NETWORKS_URI = "v2/networks"
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5) REQUEST_TIMEOUT = 5 # In seconds; argument to asyncio.timeout
SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API
STATIONS_URI = "v2/networks/{uid}?fields=network.stations"
CITYBIKES_ATTRIBUTION = ( CITYBIKES_ATTRIBUTION = (
"Information provided by the CityBikes Project (https://citybik.es/#about)" "Information provided by the CityBikes Project (https://citybik.es/#about)"
) )
@@ -89,6 +87,72 @@ PLATFORM_SCHEMA = vol.All(
), ),
) )
NETWORK_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ID): cv.string,
vol.Required(ATTR_NAME): cv.string,
vol.Required(ATTR_LOCATION): vol.Schema(
{
vol.Required(ATTR_LATITUDE): cv.latitude,
vol.Required(ATTR_LONGITUDE): cv.longitude,
},
extra=vol.REMOVE_EXTRA,
),
},
extra=vol.REMOVE_EXTRA,
)
NETWORKS_RESPONSE_SCHEMA = vol.Schema(
{vol.Required(ATTR_NETWORKS_LIST): [NETWORK_SCHEMA]}
)
STATION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_FREE_BIKES): cv.positive_int,
vol.Required(ATTR_EMPTY_SLOTS): vol.Any(cv.positive_int, None),
vol.Required(ATTR_LATITUDE): cv.latitude,
vol.Required(ATTR_LONGITUDE): cv.longitude,
vol.Required(ATTR_ID): cv.string,
vol.Required(ATTR_NAME): cv.string,
vol.Required(ATTR_TIMESTAMP): cv.string,
vol.Optional(ATTR_EXTRA): vol.Schema(
{vol.Optional(ATTR_UID): cv.string}, extra=vol.REMOVE_EXTRA
),
},
extra=vol.REMOVE_EXTRA,
)
STATIONS_RESPONSE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_NETWORK): vol.Schema(
{vol.Required(ATTR_STATIONS_LIST): [STATION_SCHEMA]}, extra=vol.REMOVE_EXTRA
)
}
)
class CityBikesRequestError(Exception):
"""Error to indicate a CityBikes API request has failed."""
async def async_citybikes_request(hass, uri, schema):
"""Perform a request to CityBikes API endpoint, and parse the response."""
try:
session = async_get_clientsession(hass)
async with asyncio.timeout(REQUEST_TIMEOUT):
req = await session.get(DEFAULT_ENDPOINT.format(uri=uri))
json_response = await req.json()
return schema(json_response)
except (TimeoutError, aiohttp.ClientError):
_LOGGER.error("Could not connect to CityBikes API endpoint")
except ValueError:
_LOGGER.error("Received non-JSON data from CityBikes API endpoint")
except vol.Invalid as err:
_LOGGER.error("Received unexpected JSON from CityBikes API endpoint: %s", err)
raise CityBikesRequestError
async def async_setup_platform( async def async_setup_platform(
hass: HomeAssistant, hass: HomeAssistant,
@@ -111,14 +175,6 @@ async def async_setup_platform(
radius, UnitOfLength.FEET, UnitOfLength.METERS radius, UnitOfLength.FEET, UnitOfLength.METERS
) )
client = CitybikesClient(user_agent=HA_USER_AGENT, timeout=REQUEST_TIMEOUT)
hass.data[PLATFORM][DATA_CLIENT] = client
async def _async_close_client(event):
await client.close()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_client)
# Create a single instance of CityBikesNetworks. # Create a single instance of CityBikesNetworks.
networks = hass.data.setdefault(CITYBIKES_NETWORKS, CityBikesNetworks(hass)) networks = hass.data.setdefault(CITYBIKES_NETWORKS, CityBikesNetworks(hass))
@@ -138,10 +194,10 @@ async def async_setup_platform(
devices = [] devices = []
for station in network.stations: for station in network.stations:
dist = location_util.distance( dist = location_util.distance(
latitude, longitude, station.latitude, station.longitude latitude, longitude, station[ATTR_LATITUDE], station[ATTR_LONGITUDE]
) )
station_id = station.id station_id = station[ATTR_ID]
station_uid = str(station.extra.get(ATTR_UID, "")) station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, ""))
if radius > dist or stations_list.intersection((station_id, station_uid)): if radius > dist or stations_list.intersection((station_id, station_uid)):
if name: if name:
@@ -160,7 +216,6 @@ class CityBikesNetworks:
def __init__(self, hass): def __init__(self, hass):
"""Initialize the networks instance.""" """Initialize the networks instance."""
self.hass = hass self.hass = hass
self.client = hass.data[PLATFORM][DATA_CLIENT]
self.networks = None self.networks = None
self.networks_loading = asyncio.Condition() self.networks_loading = asyncio.Condition()
@@ -169,21 +224,24 @@ class CityBikesNetworks:
try: try:
await self.networks_loading.acquire() await self.networks_loading.acquire()
if self.networks is None: if self.networks is None:
self.networks = await self.client.networks.fetch() networks = await async_citybikes_request(
except aiohttp.ClientError as err: self.hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA
)
self.networks = networks[ATTR_NETWORKS_LIST]
except CityBikesRequestError as err:
raise PlatformNotReady from err raise PlatformNotReady from err
else: else:
result = None result = None
minimum_dist = None minimum_dist = None
for network in self.networks: for network in self.networks:
network_latitude = network.location.latitude network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE]
network_longitude = network.location.longitude network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE]
dist = location_util.distance( dist = location_util.distance(
latitude, longitude, network_latitude, network_longitude latitude, longitude, network_latitude, network_longitude
) )
if minimum_dist is None or dist < minimum_dist: if minimum_dist is None or dist < minimum_dist:
minimum_dist = dist minimum_dist = dist
result = network.id result = network[ATTR_ID]
return result return result
finally: finally:
@@ -199,20 +257,22 @@ class CityBikesNetwork:
self.network_id = network_id self.network_id = network_id
self.stations = [] self.stations = []
self.ready = asyncio.Event() self.ready = asyncio.Event()
self.client = hass.data[PLATFORM][DATA_CLIENT]
async def async_refresh(self, now=None): async def async_refresh(self, now=None):
"""Refresh the state of the network.""" """Refresh the state of the network."""
try: try:
network = await self.client.network(uid=self.network_id).fetch() network = await async_citybikes_request(
except aiohttp.ClientError as err: self.hass,
if now is None: STATIONS_URI.format(uid=self.network_id),
raise PlatformNotReady from err STATIONS_RESPONSE_SCHEMA,
self.ready.clear() )
return self.stations = network[ATTR_NETWORK][ATTR_STATIONS_LIST]
self.stations = network.stations
self.ready.set() self.ready.set()
except CityBikesRequestError as err:
if now is not None:
self.ready.clear()
else:
raise PlatformNotReady from err
class CityBikesStation(SensorEntity): class CityBikesStation(SensorEntity):
@@ -230,13 +290,16 @@ class CityBikesStation(SensorEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update station state.""" """Update station state."""
station = next(s for s in self._network.stations if s.id == self._station_id) for station in self._network.stations:
self._attr_name = station.name if station[ATTR_ID] == self._station_id:
self._attr_native_value = station.free_bikes station_data = station
break
self._attr_name = station_data.get(ATTR_NAME)
self._attr_native_value = station_data.get(ATTR_FREE_BIKES)
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
ATTR_UID: station.extra.get(ATTR_UID), ATTR_UID: station_data.get(ATTR_EXTRA, {}).get(ATTR_UID),
ATTR_LATITUDE: station.latitude, ATTR_LATITUDE: station_data.get(ATTR_LATITUDE),
ATTR_LONGITUDE: station.longitude, ATTR_LONGITUDE: station_data.get(ATTR_LONGITUDE),
ATTR_EMPTY_SLOTS: station.empty_slots, ATTR_EMPTY_SLOTS: station_data.get(ATTR_EMPTY_SLOTS),
ATTR_TIMESTAMP: station.timestamp, ATTR_TIMESTAMP: station_data.get(ATTR_TIMESTAMP),
} }

View File

@@ -13,6 +13,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"], "loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.3.0"], "requirements": ["hass-nabucasa==1.2.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -7,7 +7,14 @@ from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState from homeassistant.components.cover import (
STATE_CLOSED,
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
CoverDeviceClass,
CoverEntity,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
@@ -121,9 +128,9 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
await super().async_added_to_hass() await super().async_added_to_hass()
if (state := await self.async_get_last_state()) is not None: if (state := await self.async_get_last_state()) is not None:
if state.state == CoverState.CLOSED: if state.state == STATE_CLOSED:
self._last_action = STATE_COVER.index(CoverState.CLOSING) self._last_action = STATE_COVER.index(STATE_CLOSING)
if state.state == CoverState.OPEN: if state.state == STATE_OPEN:
self._last_action = STATE_COVER.index(CoverState.OPENING) self._last_action = STATE_COVER.index(STATE_OPENING)
self._attr_is_closed = state.state == CoverState.CLOSED self._attr_is_closed = state.state == STATE_CLOSED

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiocomelit"], "loggers": ["aiocomelit"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aiocomelit==1.1.2"] "requirements": ["aiocomelit==1.1.1"]
} }

View File

@@ -138,7 +138,7 @@ def new_device_listener(
data_type: str, data_type: str,
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Subscribe to coordinator updates to check for new devices.""" """Subscribe to coordinator updates to check for new devices."""
known_devices: dict[str, list[int]] = {} known_devices: set[int] = set()
def _check_devices() -> None: def _check_devices() -> None:
"""Check for new devices and call callback with any new monitors.""" """Check for new devices and call callback with any new monitors."""
@@ -147,8 +147,8 @@ def new_device_listener(
new_devices: list[DeviceType] = [] new_devices: list[DeviceType] = []
for _id in coordinator.data[data_type]: for _id in coordinator.data[data_type]:
if _id not in (id_list := known_devices.get(data_type, [])): if _id not in known_devices:
known_devices.update({data_type: [*id_list, _id]}) known_devices.add(_id)
new_devices.append(coordinator.data[data_type][_id]) new_devices.append(coordinator.data[data_type][_id])
if new_devices: if new_devices:

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/control4", "documentation": "https://www.home-assistant.io/integrations/control4",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyControl4"], "loggers": ["pyControl4"],
"requirements": ["pyControl4==1.5.0"], "requirements": ["pyControl4==1.2.0"],
"ssdp": [ "ssdp": [
{ {
"st": "c4:director" "st": "c4:director"

View File

@@ -514,7 +514,7 @@ class ChatLog:
"""Set the LLM system prompt.""" """Set the LLM system prompt."""
llm_api: llm.APIInstance | None = None llm_api: llm.APIInstance | None = None
if not user_llm_hass_api: if user_llm_hass_api is None:
pass pass
elif isinstance(user_llm_hass_api, llm.API): elif isinstance(user_llm_hass_api, llm.API):
llm_api = await user_llm_hass_api.async_get_api_instance(llm_context) llm_api = await user_llm_hass_api.async_get_api_instance(llm_context)

View File

@@ -13,7 +13,7 @@ from propcache.api import cached_property
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import ( # noqa: F401
SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER,
SERVICE_CLOSE_COVER_TILT, SERVICE_CLOSE_COVER_TILT,
SERVICE_OPEN_COVER, SERVICE_OPEN_COVER,
@@ -24,9 +24,19 @@ from homeassistant.const import (
SERVICE_STOP_COVER_TILT, SERVICE_STOP_COVER_TILT,
SERVICE_TOGGLE, SERVICE_TOGGLE,
SERVICE_TOGGLE_COVER_TILT, SERVICE_TOGGLE_COVER_TILT,
STATE_CLOSED,
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@@ -53,6 +63,15 @@ class CoverState(StrEnum):
OPENING = "opening" OPENING = "opening"
# STATE_* below are deprecated as of 2024.11
# when imported from homeassistant.components.cover
# use the CoverState enum instead.
_DEPRECATED_STATE_CLOSED = DeprecatedConstantEnum(CoverState.CLOSED, "2025.11")
_DEPRECATED_STATE_CLOSING = DeprecatedConstantEnum(CoverState.CLOSING, "2025.11")
_DEPRECATED_STATE_OPEN = DeprecatedConstantEnum(CoverState.OPEN, "2025.11")
_DEPRECATED_STATE_OPENING = DeprecatedConstantEnum(CoverState.OPENING, "2025.11")
class CoverDeviceClass(StrEnum): class CoverDeviceClass(StrEnum):
"""Device class for cover.""" """Device class for cover."""
@@ -444,3 +463,11 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return ( return (
fns["close"] if self._cover_is_last_toggle_direction_open else fns["open"] fns["close"] if self._cover_is_last_toggle_direction_open else fns["open"]
) )
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = ft.partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View File

@@ -24,7 +24,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN from .const import DOMAIN
@@ -147,7 +146,6 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
name=f"{name_prefix} Consumption", name=f"{name_prefix} Consumption",
source=DOMAIN, source=DOMAIN,
statistic_id=consumption_statistic_id, statistic_id=consumption_statistic_id,
unit_class=EnergyConverter.UNIT_CLASS,
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR
if meter["serviceType"] == "ELECTRIC" if meter["serviceType"] == "ELECTRIC"
else UnitOfVolume.CENTUM_CUBIC_FEET, else UnitOfVolume.CENTUM_CUBIC_FEET,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"] "requirements": ["py-sucks==0.9.11", "deebot-client==15.0.0"]
} }

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import logging
from elevenlabs import AsyncElevenLabs, Model from elevenlabs import AsyncElevenLabs, Model
from elevenlabs.core import ApiError from elevenlabs.core import ApiError
@@ -19,14 +18,9 @@ from homeassistant.exceptions import (
) )
from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_MODEL, CONF_STT_MODEL from .const import CONF_MODEL
_LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.TTS]
PLATFORMS: list[Platform] = [
Platform.STT,
Platform.TTS,
]
async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None: async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None:
@@ -45,7 +39,6 @@ class ElevenLabsData:
client: AsyncElevenLabs client: AsyncElevenLabs
model: Model model: Model
stt_model: str
type ElevenLabsConfigEntry = ConfigEntry[ElevenLabsData] type ElevenLabsConfigEntry = ConfigEntry[ElevenLabsData]
@@ -69,9 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -
if model is None or (not model.languages): if model is None or (not model.languages):
raise ConfigEntryError("Model could not be resolved") raise ConfigEntryError("Model could not be resolved")
entry.runtime_data = ElevenLabsData( entry.runtime_data = ElevenLabsData(client=client, model=model)
client=client, model=model, stt_model=entry.options[CONF_STT_MODEL]
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
@@ -87,44 +78,3 @@ async def update_listener(
) -> None: ) -> None:
"""Handle options update.""" """Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id) await hass.config_entries.async_reload(config_entry.entry_id)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ElevenLabsConfigEntry
) -> bool:
"""Migrate old config entry to new format."""
_LOGGER.debug(
"Migrating configuration from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
new_options = {**config_entry.options}
if config_entry.minor_version < 2:
# Add defaults only if theyre not already present
if "stt_auto_language" not in new_options:
new_options["stt_auto_language"] = False
if "stt_model" not in new_options:
new_options["stt_model"] = "scribe_v1"
hass.config_entries.async_update_entry(
config_entry,
options=new_options,
minor_version=2,
version=1,
)
_LOGGER.debug(
"Migration to configuration version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True # already up to date

View File

@@ -25,20 +25,15 @@ from .const import (
CONF_MODEL, CONF_MODEL,
CONF_SIMILARITY, CONF_SIMILARITY,
CONF_STABILITY, CONF_STABILITY,
CONF_STT_AUTO_LANGUAGE,
CONF_STT_MODEL,
CONF_STYLE, CONF_STYLE,
CONF_USE_SPEAKER_BOOST, CONF_USE_SPEAKER_BOOST,
CONF_VOICE, CONF_VOICE,
DEFAULT_MODEL,
DEFAULT_SIMILARITY, DEFAULT_SIMILARITY,
DEFAULT_STABILITY, DEFAULT_STABILITY,
DEFAULT_STT_AUTO_LANGUAGE,
DEFAULT_STT_MODEL,
DEFAULT_STYLE, DEFAULT_STYLE,
DEFAULT_TTS_MODEL,
DEFAULT_USE_SPEAKER_BOOST, DEFAULT_USE_SPEAKER_BOOST,
DOMAIN, DOMAIN,
STT_MODELS,
) )
USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
@@ -73,7 +68,6 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for ElevenLabs text-to-speech.""" """Handle a config flow for ElevenLabs text-to-speech."""
VERSION = 1 VERSION = 1
MINOR_VERSION = 2
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -94,12 +88,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry( return self.async_create_entry(
title="ElevenLabs", title="ElevenLabs",
data=user_input, data=user_input,
options={ options={CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: list(voices)[0]},
CONF_MODEL: DEFAULT_TTS_MODEL,
CONF_VOICE: list(voices)[0],
CONF_STT_MODEL: DEFAULT_STT_MODEL,
CONF_STT_AUTO_LANGUAGE: False,
},
) )
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors
@@ -124,9 +113,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
self.models: dict[str, str] = {} self.models: dict[str, str] = {}
self.model: str | None = None self.model: str | None = None
self.voice: str | None = None self.voice: str | None = None
self.stt_models: dict[str, str] = STT_MODELS
self.stt_model: str | None = None
self.auto_language: bool | None = None
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -140,8 +126,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
if user_input is not None: if user_input is not None:
self.model = user_input[CONF_MODEL] self.model = user_input[CONF_MODEL]
self.voice = user_input[CONF_VOICE] self.voice = user_input[CONF_VOICE]
self.stt_model = user_input[CONF_STT_MODEL]
self.auto_language = user_input[CONF_STT_AUTO_LANGUAGE]
configure_voice = user_input.pop(CONF_CONFIGURE_VOICE) configure_voice = user_input.pop(CONF_CONFIGURE_VOICE)
if configure_voice: if configure_voice:
return await self.async_step_voice_settings() return await self.async_step_voice_settings()
@@ -181,22 +165,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
] ]
) )
), ),
vol.Required(
CONF_STT_MODEL,
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(label=model_name, value=model_id)
for model_id, model_name in self.stt_models.items()
]
)
),
vol.Required(
CONF_STT_AUTO_LANGUAGE,
default=self.config_entry.options.get(
CONF_STT_AUTO_LANGUAGE, DEFAULT_STT_AUTO_LANGUAGE
),
): bool,
vol.Required(CONF_CONFIGURE_VOICE, default=False): bool, vol.Required(CONF_CONFIGURE_VOICE, default=False): bool,
} }
), ),
@@ -211,8 +179,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
if user_input is not None: if user_input is not None:
user_input[CONF_MODEL] = self.model user_input[CONF_MODEL] = self.model
user_input[CONF_VOICE] = self.voice user_input[CONF_VOICE] = self.voice
user_input[CONF_STT_MODEL] = self.stt_model
user_input[CONF_STT_AUTO_LANGUAGE] = self.auto_language
return self.async_create_entry( return self.async_create_entry(
title="ElevenLabs", title="ElevenLabs",
data=user_input, data=user_input,

View File

@@ -7,123 +7,12 @@ CONF_MODEL = "model"
CONF_CONFIGURE_VOICE = "configure_voice" CONF_CONFIGURE_VOICE = "configure_voice"
CONF_STABILITY = "stability" CONF_STABILITY = "stability"
CONF_SIMILARITY = "similarity" CONF_SIMILARITY = "similarity"
CONF_STT_AUTO_LANGUAGE = "stt_auto_language"
CONF_STT_MODEL = "stt_model"
CONF_STYLE = "style" CONF_STYLE = "style"
CONF_USE_SPEAKER_BOOST = "use_speaker_boost" CONF_USE_SPEAKER_BOOST = "use_speaker_boost"
DOMAIN = "elevenlabs" DOMAIN = "elevenlabs"
DEFAULT_TTS_MODEL = "eleven_multilingual_v2" DEFAULT_MODEL = "eleven_multilingual_v2"
DEFAULT_STABILITY = 0.5 DEFAULT_STABILITY = 0.5
DEFAULT_SIMILARITY = 0.75 DEFAULT_SIMILARITY = 0.75
DEFAULT_STT_AUTO_LANGUAGE = False
DEFAULT_STT_MODEL = "scribe_v1"
DEFAULT_STYLE = 0 DEFAULT_STYLE = 0
DEFAULT_USE_SPEAKER_BOOST = True DEFAULT_USE_SPEAKER_BOOST = True
STT_LANGUAGES = [
"af-ZA", # Afrikaans
"am-ET", # Amharic
"ar-SA", # Arabic
"hy-AM", # Armenian
"as-IN", # Assamese
"ast-ES", # Asturian
"az-AZ", # Azerbaijani
"be-BY", # Belarusian
"bn-IN", # Bengali
"bs-BA", # Bosnian
"bg-BG", # Bulgarian
"my-MM", # Burmese
"yue-HK", # Cantonese
"ca-ES", # Catalan
"ceb-PH", # Cebuano
"ny-MW", # Chichewa
"hr-HR", # Croatian
"cs-CZ", # Czech
"da-DK", # Danish
"nl-NL", # Dutch
"en-US", # English
"et-EE", # Estonian
"fil-PH", # Filipino
"fi-FI", # Finnish
"fr-FR", # French
"ff-SN", # Fulah
"gl-ES", # Galician
"lg-UG", # Ganda
"ka-GE", # Georgian
"de-DE", # German
"el-GR", # Greek
"gu-IN", # Gujarati
"ha-NG", # Hausa
"he-IL", # Hebrew
"hi-IN", # Hindi
"hu-HU", # Hungarian
"is-IS", # Icelandic
"ig-NG", # Igbo
"id-ID", # Indonesian
"ga-IE", # Irish
"it-IT", # Italian
"ja-JP", # Japanese
"jv-ID", # Javanese
"kea-CV", # Kabuverdianu
"kn-IN", # Kannada
"kk-KZ", # Kazakh
"km-KH", # Khmer
"ko-KR", # Korean
"ku-TR", # Kurdish
"ky-KG", # Kyrgyz
"lo-LA", # Lao
"lv-LV", # Latvian
"ln-CD", # Lingala
"lt-LT", # Lithuanian
"luo-KE", # Luo
"lb-LU", # Luxembourgish
"mk-MK", # Macedonian
"ms-MY", # Malay
"ml-IN", # Malayalam
"mt-MT", # Maltese
"zh-CN", # Mandarin Chinese
"mi-NZ", # Māori
"mr-IN", # Marathi
"mn-MN", # Mongolian
"ne-NP", # Nepali
"nso-ZA", # Northern Sotho
"no-NO", # Norwegian
"oc-FR", # Occitan
"or-IN", # Odia
"ps-AF", # Pashto
"fa-IR", # Persian
"pl-PL", # Polish
"pt-PT", # Portuguese
"pa-IN", # Punjabi
"ro-RO", # Romanian
"ru-RU", # Russian
"sr-RS", # Serbian
"sn-ZW", # Shona
"sd-PK", # Sindhi
"sk-SK", # Slovak
"sl-SI", # Slovenian
"so-SO", # Somali
"es-ES", # Spanish
"sw-KE", # Swahili
"sv-SE", # Swedish
"ta-IN", # Tamil
"tg-TJ", # Tajik
"te-IN", # Telugu
"th-TH", # Thai
"tr-TR", # Turkish
"uk-UA", # Ukrainian
"umb-AO", # Umbundu
"ur-PK", # Urdu
"uz-UZ", # Uzbek
"vi-VN", # Vietnamese
"cy-GB", # Welsh
"wo-SN", # Wolof
"xh-ZA", # Xhosa
"zu-ZA", # Zulu
]
STT_MODELS = {
"scribe_v1": "Scribe v1",
"scribe_v1_experimental": "Scribe v1 Experimental",
}

View File

@@ -21,15 +21,11 @@
"data": { "data": {
"voice": "Voice", "voice": "Voice",
"model": "Model", "model": "Model",
"stt_model": "Speech-to-Text Model",
"stt_auto_language": "Auto-detect language",
"configure_voice": "Configure advanced voice settings" "configure_voice": "Configure advanced voice settings"
}, },
"data_description": { "data_description": {
"voice": "Voice to use for text-to-speech.", "voice": "Voice to use for the TTS.",
"model": "ElevenLabs model to use. Please note that not all models support all languages equally well.", "model": "ElevenLabs model to use. Please note that not all models support all languages equally well.",
"stt_model": "Speech-to-Text model to use.",
"stt_auto_language": "Automatically detect the spoken language for speech-to-text.",
"configure_voice": "Configure advanced voice settings. Find more information in the ElevenLabs documentation." "configure_voice": "Configure advanced voice settings. Find more information in the ElevenLabs documentation."
} }
}, },
@@ -48,17 +44,5 @@
} }
} }
} }
},
"entity": {
"tts": {
"elevenlabs_tts": {
"name": "Text-to-Speech"
}
},
"stt": {
"elevenlabs_stt": {
"name": "Speech-to-Text"
}
}
} }
} }

View File

@@ -1,207 +0,0 @@
"""Support for the ElevenLabs speech-to-text service."""
from __future__ import annotations
from collections.abc import AsyncIterable
from io import BytesIO
import logging
from elevenlabs import AsyncElevenLabs
from elevenlabs.core import ApiError
from elevenlabs.types import Model
from homeassistant.components import stt
from homeassistant.components.stt import (
AudioBitRates,
AudioChannels,
AudioCodecs,
AudioFormats,
AudioSampleRates,
SpeechMetadata,
SpeechResultState,
SpeechToTextEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElevenLabsConfigEntry
from .const import (
CONF_STT_AUTO_LANGUAGE,
DEFAULT_STT_AUTO_LANGUAGE,
DOMAIN,
STT_LANGUAGES,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 10
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElevenLabsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ElevenLabs stt platform via config entry."""
client = config_entry.runtime_data.client
auto_detect = config_entry.options.get(
CONF_STT_AUTO_LANGUAGE, DEFAULT_STT_AUTO_LANGUAGE
)
async_add_entities(
[
ElevenLabsSTTEntity(
client,
config_entry.runtime_data.model,
config_entry.runtime_data.stt_model,
config_entry.entry_id,
auto_detect_language=auto_detect,
)
]
)
class ElevenLabsSTTEntity(SpeechToTextEntity):
"""The ElevenLabs STT API entity."""
_attr_has_entity_name = True
_attr_translation_key = "elevenlabs_stt"
def __init__(
self,
client: AsyncElevenLabs,
model: Model,
stt_model: str,
entry_id: str,
auto_detect_language: bool = False,
) -> None:
"""Init ElevenLabs TTS service."""
self._client = client
self._auto_detect_language = auto_detect_language
self._stt_model = stt_model
# Entity attributes
self._attr_unique_id = entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry_id)},
manufacturer="ElevenLabs",
model=model.name,
name="ElevenLabs",
entry_type=DeviceEntryType.SERVICE,
)
@property
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
return STT_LANGUAGES
@property
def supported_formats(self) -> list[AudioFormats]:
"""Return a list of supported formats."""
return [AudioFormats.WAV, AudioFormats.OGG]
@property
def supported_codecs(self) -> list[AudioCodecs]:
"""Return a list of supported codecs."""
return [AudioCodecs.PCM, AudioCodecs.OPUS]
@property
def supported_bit_rates(self) -> list[AudioBitRates]:
"""Return a list of supported bit rates."""
return [AudioBitRates.BITRATE_16]
@property
def supported_sample_rates(self) -> list[AudioSampleRates]:
"""Return a list of supported sample rates."""
return [AudioSampleRates.SAMPLERATE_16000]
@property
def supported_channels(self) -> list[AudioChannels]:
"""Return a list of supported channels."""
return [
AudioChannels.CHANNEL_MONO,
AudioChannels.CHANNEL_STEREO,
]
async def async_process_audio_stream(
self, metadata: SpeechMetadata, stream: AsyncIterable[bytes]
) -> stt.SpeechResult:
"""Process an audio stream to STT service."""
_LOGGER.debug(
"Processing audio stream for STT: model=%s, language=%s, format=%s, codec=%s, sample_rate=%s, channels=%s, bit_rate=%s",
self._stt_model,
metadata.language,
metadata.format,
metadata.codec,
metadata.sample_rate,
metadata.channel,
metadata.bit_rate,
)
if self._auto_detect_language:
lang_code = None
else:
language = metadata.language
if language.lower() not in [lang.lower() for lang in STT_LANGUAGES]:
_LOGGER.warning("Unsupported language: %s", language)
return stt.SpeechResult(None, SpeechResultState.ERROR)
lang_code = language.split("-")[0]
raw_pcm_compatible = (
metadata.codec == AudioCodecs.PCM
and metadata.sample_rate == AudioSampleRates.SAMPLERATE_16000
and metadata.channel == AudioChannels.CHANNEL_MONO
and metadata.bit_rate == AudioBitRates.BITRATE_16
)
if raw_pcm_compatible:
file_format = "pcm_s16le_16"
elif metadata.codec == AudioCodecs.PCM:
_LOGGER.warning("PCM input does not meet expected raw format requirements")
return stt.SpeechResult(None, SpeechResultState.ERROR)
else:
file_format = "other"
audio = b""
async for chunk in stream:
audio += chunk
_LOGGER.debug("Finished reading audio stream, total size: %d bytes", len(audio))
if not audio:
_LOGGER.warning("No audio received in stream")
return stt.SpeechResult(None, SpeechResultState.ERROR)
lang_display = lang_code if lang_code else "auto-detected"
_LOGGER.debug(
"Transcribing audio (%s), format: %s, size: %d bytes",
lang_display,
file_format,
len(audio),
)
try:
response = await self._client.speech_to_text.convert(
file=BytesIO(audio),
file_format=file_format,
model_id=self._stt_model,
language_code=lang_code,
tag_audio_events=False,
num_speakers=1,
diarize=False,
)
except ApiError as exc:
_LOGGER.error("Error during processing of STT request: %s", exc)
return stt.SpeechResult(None, SpeechResultState.ERROR)
text = response.text or ""
detected_lang_code = response.language_code or "?"
detected_lang_prob = response.language_probability or "?"
_LOGGER.debug(
"Transcribed text is in language %s (probability %s): %s",
detected_lang_code,
detected_lang_prob,
text,
)
return stt.SpeechResult(text, SpeechResultState.SUCCESS)

View File

@@ -71,6 +71,7 @@ async def async_setup_entry(
voices, voices,
default_voice_id, default_voice_id,
config_entry.entry_id, config_entry.entry_id,
config_entry.title,
voice_settings, voice_settings,
) )
] ]
@@ -82,8 +83,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
_attr_supported_options = [ATTR_VOICE, ATTR_MODEL] _attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
_attr_entity_category = EntityCategory.CONFIG _attr_entity_category = EntityCategory.CONFIG
_attr_has_entity_name = True
_attr_translation_key = "elevenlabs_tts"
def __init__( def __init__(
self, self,
@@ -92,6 +91,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
voices: list[ElevenLabsVoice], voices: list[ElevenLabsVoice],
default_voice_id: str, default_voice_id: str,
entry_id: str, entry_id: str,
title: str,
voice_settings: VoiceSettings, voice_settings: VoiceSettings,
) -> None: ) -> None:
"""Init ElevenLabs TTS service.""" """Init ElevenLabs TTS service."""
@@ -112,11 +112,11 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
# Entity attributes # Entity attributes
self._attr_unique_id = entry_id self._attr_unique_id = entry_id
self._attr_name = title
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry_id)}, identifiers={(DOMAIN, entry_id)},
manufacturer="ElevenLabs", manufacturer="ElevenLabs",
model=model.name, model=model.name,
name="ElevenLabs",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
) )
self._attr_supported_languages = [ self._attr_supported_languages = [

View File

@@ -20,7 +20,6 @@ from homeassistant.components.recorder.statistics import (
from homeassistant.components.recorder.util import get_instance from homeassistant.components.recorder.util import get_instance
from homeassistant.const import UnitOfEnergy from homeassistant.const import UnitOfEnergy
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
@@ -154,7 +153,6 @@ class ElviaImporter:
name=f"{self.metering_point_id} Consumption", name=f"{self.metering_point_id} Consumption",
source=DOMAIN, source=DOMAIN,
statistic_id=statistic_id, statistic_id=statistic_id,
unit_class=EnergyConverter.UNIT_CLASS,
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
), ),
statistics=statistics, statistics=statistics,

View File

@@ -47,8 +47,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
radar_coordinator = ECDataUpdateCoordinator( radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
) )
# Skip initial refresh for radar since the camera entity is disabled by default. try:
# The coordinator will fetch data when the entity is enabled. await radar_coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada radar")
aqhi_data = ECAirQuality(coordinates=(lat, lon)) aqhi_data = ECAirQuality(coordinates=(lat, lon))
aqhi_coordinator = ECDataUpdateCoordinator( aqhi_coordinator = ECDataUpdateCoordinator(
@@ -60,9 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
errors = errors + 1 errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada AQHI") _LOGGER.warning("Unable to retrieve Environment Canada AQHI")
# Require at least one coordinator to succeed (weather or AQHI) if errors == 3:
# Radar is optional since the camera entity is disabled by default
if errors >= 2:
raise ConfigEntryNotReady raise ConfigEntryNotReady
config_entry.runtime_data = ECRuntimeData( config_entry.runtime_data = ECRuntimeData(

View File

@@ -59,14 +59,6 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera
self.content_type = "image/gif" self.content_type = "image/gif"
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
# Trigger coordinator refresh when entity is enabled
# since radar coordinator skips initial refresh during setup
if not self.coordinator.last_update_success:
await self.coordinator.async_request_refresh()
def camera_image( def camera_image(
self, width: int | None = None, height: int | None = None self, width: int | None = None, height: int | None = None
) -> bytes | None: ) -> bytes | None:

View File

@@ -6,18 +6,11 @@ import xml.etree.ElementTree as ET
import aiohttp import aiohttp
from env_canada import ECWeather, ec_exc from env_canada import ECWeather, ec_exc
from env_canada.ec_weather import get_ec_sites_list
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_STATION, CONF_TITLE, DOMAIN from .const import CONF_STATION, CONF_TITLE, DOMAIN
@@ -32,17 +25,15 @@ async def validate_input(data):
lang = data.get(CONF_LANGUAGE).lower() lang = data.get(CONF_LANGUAGE).lower()
if station: if station:
# When station is provided, use it and get the coordinates from ECWeather
weather_data = ECWeather(station_id=station, language=lang) weather_data = ECWeather(station_id=station, language=lang)
await weather_data.update()
# Always use the station's coordinates, not the user-provided ones
lat = weather_data.lat
lon = weather_data.lon
else: else:
# When no station is provided, use coordinates to find nearest station
weather_data = ECWeather(coordinates=(lat, lon), language=lang) weather_data = ECWeather(coordinates=(lat, lon), language=lang)
await weather_data.update() await weather_data.update()
if lat is None or lon is None:
lat = weather_data.lat
lon = weather_data.lon
return { return {
CONF_TITLE: weather_data.metadata.location, CONF_TITLE: weather_data.metadata.location,
CONF_STATION: weather_data.station_id, CONF_STATION: weather_data.station_id,
@@ -55,13 +46,6 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Environment Canada weather.""" """Handle a config flow for Environment Canada weather."""
VERSION = 1 VERSION = 1
_station_codes: list[dict[str, str]] | None = None
async def _get_station_codes(self) -> list[dict[str, str]]:
"""Get station codes, cached after first call."""
if self._station_codes is None:
self._station_codes = await get_ec_sites_list()
return self._station_codes
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -96,21 +80,9 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry(title=info[CONF_TITLE], data=user_input) return self.async_create_entry(title=info[CONF_TITLE], data=user_input)
station_codes = await self._get_station_codes()
data_schema = vol.Schema( data_schema = vol.Schema(
{ {
vol.Optional(CONF_STATION): SelectSelector( vol.Optional(CONF_STATION): str,
SelectSelectorConfig(
options=[
SelectOptionDict(
value=station["value"], label=station["label"]
)
for station in station_codes
],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional( vol.Optional(
CONF_LATITUDE, default=self.hass.config.latitude CONF_LATITUDE, default=self.hass.config.latitude
): cv.latitude, ): cv.latitude,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["env_canada"], "loggers": ["env_canada"],
"requirements": ["env-canada==0.12.1"] "requirements": ["env-canada==0.11.2"]
} }

View File

@@ -3,11 +3,11 @@
"step": { "step": {
"user": { "user": {
"title": "Environment Canada: weather location and language", "title": "Environment Canada: weather location and language",
"description": "Select a weather station from the dropdown, or specify coordinates to use the closest station. The default coordinates are from your Home Assistant installation. Weather information can be retrieved in English or French.", "description": "Either a station ID or latitude/longitude must be specified. The default latitude/longitude used are the values configured in your Home Assistant installation. The closest weather station to the coordinates will be used if specifying coordinates. If a station code is used it must follow the format: PP/code, where PP is the two-letter province and code is the station ID. The list of station IDs can be found here: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Weather information can be retrieved in either English or French.",
"data": { "data": {
"latitude": "[%key:common::config_flow::data::latitude%]", "latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]", "longitude": "[%key:common::config_flow::data::longitude%]",
"station": "Weather station", "station": "Weather station ID",
"language": "Weather information language" "language": "Weather information language"
} }
} }
@@ -16,7 +16,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]" "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}, },
"error": { "error": {
"bad_station_id": "Station code is invalid, missing, or not found in the station code database", "bad_station_id": "Station ID is invalid, missing, or not found in the station ID database",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"error_response": "Response from Environment Canada in error", "error_response": "Response from Environment Canada in error",
"too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds", "too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds",

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"aioesphomeapi==41.15.0", "aioesphomeapi==41.12.0",
"esphome-dashboard-api==1.3.0", "esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0" "bleak-esphome==3.4.0"
], ],

View File

@@ -29,12 +29,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature, ClimateEntityFeature,
HVACMode, HVACMode,
) )
from homeassistant.const import ( from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature
ATTR_MODE,
ATTR_TEMPERATURE,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -248,7 +243,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature.""" """Set a new target temperature."""
temperature = kwargs[ATTR_TEMPERATURE] temperature = kwargs["temperature"]
if (until := kwargs.get("until")) is None: if (until := kwargs.get("until")) is None:
if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE: if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE:

View File

@@ -35,16 +35,9 @@ class FoscamDeviceInfo:
is_turn_off_volume: bool is_turn_off_volume: bool
is_turn_off_light: bool is_turn_off_light: bool
supports_speak_volume_adjustment: bool supports_speak_volume_adjustment: bool
supports_pet_adjustment: bool
supports_car_adjustment: bool
supports_wdr_adjustment: bool
supports_hdr_adjustment: bool
is_open_wdr: bool | None = None is_open_wdr: bool | None = None
is_open_hdr: bool | None = None is_open_hdr: bool | None = None
is_pet_detection_on: bool | None = None
is_car_detection_on: bool | None = None
is_human_detection_on: bool | None = None
class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]): class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
@@ -114,15 +107,14 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
is_open_wdr = None is_open_wdr = None
is_open_hdr = None is_open_hdr = None
reserve3 = product_info.get("reserve4") reserve3 = product_info.get("reserve3")
reserve3_int = int(reserve3) if reserve3 is not None else 0 reserve3_int = int(reserve3) if reserve3 is not None else 0
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
supports_hdr_adjustment_val = bool(int(reserve3_int & 128)) if (reserve3_int & (1 << 8)) != 0:
if supports_wdr_adjustment_val:
ret_wdr, is_open_wdr_data = self.session.getWdrMode() ret_wdr, is_open_wdr_data = self.session.getWdrMode()
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0 mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
is_open_wdr = bool(int(mode)) is_open_wdr = bool(int(mode))
elif supports_hdr_adjustment_val: else:
ret_hdr, is_open_hdr_data = self.session.getHdrMode() ret_hdr, is_open_hdr_data = self.session.getHdrMode()
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
is_open_hdr = bool(int(mode)) is_open_hdr = bool(int(mode))
@@ -134,34 +126,6 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
if ret_sw == 0 if ret_sw == 0
else False else False
) )
pet_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities2")) & 512)
if ret_sw == 0
else False
)
car_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities2")) & 256)
if ret_sw == 0
else False
)
ret_md, mothion_config_val = self.session.get_motion_detect_config()
if pet_adjustment_val:
is_pet_detection_on_val = (
mothion_config_val["petEnable"] == "1" if ret_md == 0 else False
)
else:
is_pet_detection_on_val = False
if car_adjustment_val:
is_car_detection_on_val = (
mothion_config_val["carEnable"] == "1" if ret_md == 0 else False
)
else:
is_car_detection_on_val = False
is_human_detection_on_val = (
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False
)
return FoscamDeviceInfo( return FoscamDeviceInfo(
dev_info=dev_info, dev_info=dev_info,
@@ -177,15 +141,8 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
is_turn_off_volume=is_turn_off_volume_val, is_turn_off_volume=is_turn_off_volume_val,
is_turn_off_light=is_turn_off_light_val, is_turn_off_light=is_turn_off_light_val,
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val, supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
supports_pet_adjustment=pet_adjustment_val,
supports_car_adjustment=car_adjustment_val,
supports_hdr_adjustment=supports_hdr_adjustment_val,
supports_wdr_adjustment=supports_wdr_adjustment_val,
is_open_wdr=is_open_wdr, is_open_wdr=is_open_wdr,
is_open_hdr=is_open_hdr, is_open_hdr=is_open_hdr,
is_pet_detection_on=is_pet_detection_on_val,
is_car_detection_on=is_car_detection_on_val,
is_human_detection_on=is_human_detection_on_val,
) )
async def _async_update_data(self) -> FoscamDeviceInfo: async def _async_update_data(self) -> FoscamDeviceInfo:

View File

@@ -38,15 +38,6 @@
}, },
"wdr_switch": { "wdr_switch": {
"default": "mdi:alpha-w-box" "default": "mdi:alpha-w-box"
},
"pet_detection": {
"default": "mdi:paw"
},
"car_detection": {
"default": "mdi:car-hatchback"
},
"human_detection": {
"default": "mdi:human"
} }
}, },
"number": { "number": {

View File

@@ -22,7 +22,7 @@ class FoscamNumberEntityDescription(NumberEntityDescription):
native_value_fn: Callable[[FoscamCoordinator], int] native_value_fn: Callable[[FoscamCoordinator], int]
set_value_fn: Callable[[FoscamCamera, float], Any] set_value_fn: Callable[[FoscamCamera, float], Any]
exists_fn: Callable[[FoscamCoordinator], bool] = lambda _: True exists_fn: Callable[[FoscamCoordinator], bool]
NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [ NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [
@@ -34,6 +34,7 @@ NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [
native_step=1, native_step=1,
native_value_fn=lambda coordinator: coordinator.data.device_volume, native_value_fn=lambda coordinator: coordinator.data.device_volume,
set_value_fn=lambda session, value: session.setAudioVolume(value), set_value_fn=lambda session, value: session.setAudioVolume(value),
exists_fn=lambda _: True,
), ),
FoscamNumberEntityDescription( FoscamNumberEntityDescription(
key="speak_volume", key="speak_volume",

View File

@@ -61,15 +61,6 @@
}, },
"wdr_switch": { "wdr_switch": {
"name": "WDR" "name": "WDR"
},
"pet_detection": {
"name": "Pet detection"
},
"car_detection": {
"name": "Car detection"
},
"human_detection": {
"name": "Human detection"
} }
}, },
"number": { "number": {

View File

@@ -30,14 +30,6 @@ def handle_ir_turn_off(session: FoscamCamera) -> None:
session.close_infra_led() session.close_infra_led()
def set_motion_detection(session: FoscamCamera, field: str, enabled: bool) -> None:
"""Turns on pet detection."""
ret, config = session.get_motion_detect_config()
if not ret:
config[field] = int(enabled)
session.set_motion_detect_config(config)
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class FoscamSwitchEntityDescription(SwitchEntityDescription): class FoscamSwitchEntityDescription(SwitchEntityDescription):
"""A custom entity description that supports a turn_off function.""" """A custom entity description that supports a turn_off function."""
@@ -45,7 +37,6 @@ class FoscamSwitchEntityDescription(SwitchEntityDescription):
native_value_fn: Callable[..., bool] native_value_fn: Callable[..., bool]
turn_off_fn: Callable[[FoscamCamera], None] turn_off_fn: Callable[[FoscamCamera], None]
turn_on_fn: Callable[[FoscamCamera], None] turn_on_fn: Callable[[FoscamCamera], None]
exists_fn: Callable[[FoscamCoordinator], bool] = lambda _: True
SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
@@ -111,7 +102,6 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
native_value_fn=lambda data: data.is_open_hdr, native_value_fn=lambda data: data.is_open_hdr,
turn_off_fn=lambda session: session.setHdrMode(0), turn_off_fn=lambda session: session.setHdrMode(0),
turn_on_fn=lambda session: session.setHdrMode(1), turn_on_fn=lambda session: session.setHdrMode(1),
exists_fn=lambda coordinator: coordinator.data.supports_hdr_adjustment,
), ),
FoscamSwitchEntityDescription( FoscamSwitchEntityDescription(
key="is_open_wdr", key="is_open_wdr",
@@ -119,30 +109,6 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
native_value_fn=lambda data: data.is_open_wdr, native_value_fn=lambda data: data.is_open_wdr,
turn_off_fn=lambda session: session.setWdrMode(0), turn_off_fn=lambda session: session.setWdrMode(0),
turn_on_fn=lambda session: session.setWdrMode(1), turn_on_fn=lambda session: session.setWdrMode(1),
exists_fn=lambda coordinator: coordinator.data.supports_wdr_adjustment,
),
FoscamSwitchEntityDescription(
key="pet_detection",
translation_key="pet_detection",
native_value_fn=lambda data: data.is_pet_detection_on,
turn_off_fn=lambda session: set_motion_detection(session, "petEnable", False),
turn_on_fn=lambda session: set_motion_detection(session, "petEnable", True),
exists_fn=lambda coordinator: coordinator.data.supports_pet_adjustment,
),
FoscamSwitchEntityDescription(
key="car_detection",
translation_key="car_detection",
native_value_fn=lambda data: data.is_car_detection_on,
turn_off_fn=lambda session: set_motion_detection(session, "carEnable", False),
turn_on_fn=lambda session: set_motion_detection(session, "carEnable", True),
exists_fn=lambda coordinator: coordinator.data.supports_car_adjustment,
),
FoscamSwitchEntityDescription(
key="human_detection",
translation_key="human_detection",
native_value_fn=lambda data: data.is_human_detection_on,
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
), ),
] ]
@@ -156,11 +122,24 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data coordinator = config_entry.runtime_data
async_add_entities( entities = []
FoscamGenericSwitch(coordinator, description)
for description in SWITCH_DESCRIPTIONS product_info = coordinator.data.product_info
if description.exists_fn(coordinator) reserve3 = product_info.get("reserve3", "0")
)
for description in SWITCH_DESCRIPTIONS:
if description.key == "is_asleep":
if not coordinator.data.is_asleep["supported"]:
continue
elif description.key == "is_open_hdr":
if ((1 << 8) & int(reserve3)) != 0 or ((1 << 7) & int(reserve3)) == 0:
continue
elif description.key == "is_open_wdr":
if ((1 << 8) & int(reserve3)) == 0:
continue
entities.append(FoscamGenericSwitch(coordinator, description))
async_add_entities(entities)
class FoscamGenericSwitch(FoscamEntity, SwitchEntity): class FoscamGenericSwitch(FoscamEntity, SwitchEntity):

View File

@@ -13,14 +13,27 @@ from homeassistant.components.climate import (
ClimateEntityFeature, ClimateEntityFeature,
HVACMode, HVACMode,
) )
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_TEMPERATURE,
PRECISION_HALVES,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LOGGER from .const import (
ATTR_STATE_BATTERY_LOW,
ATTR_STATE_HOLIDAY_MODE,
ATTR_STATE_SUMMER_MODE,
ATTR_STATE_WINDOW_OPEN,
DOMAIN,
LOGGER,
)
from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator
from .entity import FritzBoxDeviceEntity from .entity import FritzBoxDeviceEntity
from .model import ClimateExtraAttributes
from .sensor import value_scheduled_preset from .sensor import value_scheduled_preset
HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF] HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF]
@@ -189,6 +202,26 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
self.check_active_or_lock_mode() self.check_active_or_lock_mode()
await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode]) await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode])
@property
def extra_state_attributes(self) -> ClimateExtraAttributes:
"""Return the device specific state attributes."""
# deprecated with #143394, can be removed in 2025.11
attrs: ClimateExtraAttributes = {
ATTR_STATE_BATTERY_LOW: self.data.battery_low,
}
# the following attributes are available since fritzos 7
if self.data.battery_level is not None:
attrs[ATTR_BATTERY_LEVEL] = self.data.battery_level
if self.data.holiday_active is not None:
attrs[ATTR_STATE_HOLIDAY_MODE] = self.data.holiday_active
if self.data.summer_active is not None:
attrs[ATTR_STATE_SUMMER_MODE] = self.data.summer_active
if self.data.window_open is not None:
attrs[ATTR_STATE_WINDOW_OPEN] = self.data.window_open
return attrs
def check_active_or_lock_mode(self) -> None: def check_active_or_lock_mode(self) -> None:
"""Check if in summer/vacation mode or lock enabled.""" """Check if in summer/vacation mode or lock enabled."""
if self.data.holiday_active or self.data.summer_active: if self.data.holiday_active or self.data.summer_active:

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251001.4"] "requirements": ["home-assistant-frontend==20251001.0"]
} }

View File

@@ -191,9 +191,7 @@ async def async_test_still(
try: try:
async_client = get_async_client(hass, verify_ssl=verify_ssl) async_client = get_async_client(hass, verify_ssl=verify_ssl)
async with asyncio.timeout(GET_IMAGE_TIMEOUT): async with asyncio.timeout(GET_IMAGE_TIMEOUT):
response = await async_client.get( response = await async_client.get(url, auth=auth, timeout=GET_IMAGE_TIMEOUT)
url, auth=auth, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True
)
response.raise_for_status() response.raise_for_status()
image = response.content image = response.content
except ( except (

View File

@@ -167,6 +167,6 @@ class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) )
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.unsubscribe) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.unsubscribe)
def unsubscribe(self, *args: Any) -> None: def unsubscribe(self, *args) -> None:
"""Unsubscribe to repository events.""" """Unsubscribe to repository events."""
self._client.repos.events.unsubscribe(subscription_id=self._subscription_id) self._client.repos.events.unsubscribe(subscription_id=self._subscription_id)

View File

@@ -182,10 +182,10 @@ FAN_SPEED_MAX_SPEED_COUNT = 5
COVER_VALVE_STATES = { COVER_VALVE_STATES = {
cover.DOMAIN: { cover.DOMAIN: {
"closed": cover.CoverState.CLOSED.value, "closed": cover.STATE_CLOSED,
"closing": cover.CoverState.CLOSING.value, "closing": cover.STATE_CLOSING,
"open": cover.CoverState.OPEN.value, "open": cover.STATE_OPEN,
"opening": cover.CoverState.OPENING.value, "opening": cover.STATE_OPENING,
}, },
valve.DOMAIN: { valve.DOMAIN: {
"closed": valve.STATE_CLOSED, "closed": valve.STATE_CLOSED,

View File

@@ -8,12 +8,7 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
@@ -45,12 +40,6 @@ class OAuth2FlowHandler(
"prompt": "consent", "prompt": "consent",
} }
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow."""
return await self.async_step_user(user_input)
async def async_step_reauth( async def async_step_reauth(
self, entry_data: Mapping[str, Any] self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -71,10 +60,6 @@ class OAuth2FlowHandler(
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data self._get_reauth_entry(), data=data
) )
if self.source == SOURCE_RECONFIGURE:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data=data
)
return self.async_create_entry( return self.async_create_entry(
title=DEFAULT_NAME, title=DEFAULT_NAME,

View File

@@ -30,8 +30,7 @@
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}, },
"create_entry": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@@ -36,7 +36,7 @@ DEFAULT_URL = SERVER_URLS[0]
DOMAIN = "growatt_server" DOMAIN = "growatt_server"
PLATFORMS = [Platform.SENSOR, Platform.SWITCH] PLATFORMS = [Platform.SENSOR]
LOGIN_INVALID_AUTH_CODE = "502" LOGIN_INVALID_AUTH_CODE = "502"

View File

@@ -210,15 +210,6 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
precision=1, precision=1,
), ),
GrowattSensorEntityDescription(
key="tlx_solar_generation_today",
translation_key="tlx_solar_generation_today",
api_key="epvToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_solar_generation_total", key="tlx_solar_generation_total",
translation_key="tlx_solar_generation_total", translation_key="tlx_solar_generation_total",
@@ -439,120 +430,4 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY, device_class=SensorDeviceClass.BATTERY,
), ),
GrowattSensorEntityDescription(
key="tlx_pac_to_local_load",
translation_key="tlx_pac_to_local_load",
api_key="pacToLocalLoad",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_pac_to_user_total",
translation_key="tlx_pac_to_user_total",
api_key="pacToUserTotal",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_pac_to_grid_total",
translation_key="tlx_pac_to_grid_total",
api_key="pacToGridTotal",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_system_production_today",
translation_key="tlx_system_production_today",
api_key="esystemToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_system_production_total",
translation_key="tlx_system_production_total",
api_key="esystemTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_self_consumption_today",
translation_key="tlx_self_consumption_today",
api_key="eselfToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_self_consumption_total",
translation_key="tlx_self_consumption_total",
api_key="eselfTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_import_from_grid_today",
translation_key="tlx_import_from_grid_today",
api_key="etoUserToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_import_from_grid_total",
translation_key="tlx_import_from_grid_total",
api_key="etoUserTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_batteries_charged_from_grid_today",
translation_key="tlx_batteries_charged_from_grid_today",
api_key="eacChargeToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_batteries_charged_from_grid_total",
translation_key="tlx_batteries_charged_from_grid_total",
api_key="eacChargeTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_p_system",
translation_key="tlx_p_system",
api_key="psystem",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
precision=1,
),
GrowattSensorEntityDescription(
key="tlx_p_self",
translation_key="tlx_p_self",
api_key="pself",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
precision=1,
),
) )

View File

@@ -362,9 +362,6 @@
"tlx_wattage_input_4": { "tlx_wattage_input_4": {
"name": "Input 4 wattage" "name": "Input 4 wattage"
}, },
"tlx_solar_generation_today": {
"name": "Solar energy today"
},
"tlx_solar_generation_total": { "tlx_solar_generation_total": {
"name": "Lifetime total solar energy" "name": "Lifetime total solar energy"
}, },
@@ -446,45 +443,6 @@
"tlx_statement_of_charge": { "tlx_statement_of_charge": {
"name": "State of charge (SoC)" "name": "State of charge (SoC)"
}, },
"tlx_pac_to_local_load": {
"name": "Local load power"
},
"tlx_pac_to_user_total": {
"name": "Import power"
},
"tlx_pac_to_grid_total": {
"name": "Export power"
},
"tlx_system_production_today": {
"name": "System production today"
},
"tlx_system_production_total": {
"name": "Lifetime system production"
},
"tlx_self_consumption_today": {
"name": "Self consumption today"
},
"tlx_self_consumption_total": {
"name": "Lifetime self consumption"
},
"tlx_import_from_grid_today": {
"name": "Import from grid today"
},
"tlx_import_from_grid_total": {
"name": "Lifetime import from grid"
},
"tlx_batteries_charged_from_grid_today": {
"name": "Batteries charged from grid today"
},
"tlx_batteries_charged_from_grid_total": {
"name": "Lifetime batteries charged from grid"
},
"tlx_p_system": {
"name": "System power"
},
"tlx_p_self": {
"name": "Self power"
},
"total_money_today": { "total_money_today": {
"name": "Total money today" "name": "Total money today"
}, },
@@ -503,11 +461,6 @@
"total_maximum_output": { "total_maximum_output": {
"name": "Maximum power" "name": "Maximum power"
} }
},
"switch": {
"ac_charge": {
"name": "Charge from grid"
}
} }
} }
} }

View File

@@ -1,138 +0,0 @@
"""Switch platform for Growatt."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Any
from growattServer import GrowattV1ApiError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = (
1 # Serialize updates as inverter does not handle concurrent requests
)
@dataclass(frozen=True, kw_only=True)
class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKeysMixin):
"""Describes Growatt switch entity."""
write_key: str | None = None # Parameter ID for writing (if different from api_key)
# Note that the Growatt V1 API uses different keys for reading and writing parameters.
# Reading values returns camelCase keys, while writing requires snake_case keys.
MIN_SWITCH_TYPES: tuple[GrowattSwitchEntityDescription, ...] = (
GrowattSwitchEntityDescription(
key="ac_charge",
translation_key="ac_charge",
api_key="acChargeEnable", # Key returned by V1 API
write_key="ac_charge", # Key used to write parameter
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GrowattConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Growatt switch entities."""
runtime_data = entry.runtime_data
# Add switch entities for each MIN device (only supported with V1 API)
async_add_entities(
GrowattSwitch(device_coordinator, description)
for device_coordinator in runtime_data.devices.values()
if (
device_coordinator.device_type == "min"
and device_coordinator.api_version == "v1"
)
for description in MIN_SWITCH_TYPES
)
class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
"""Representation of a Growatt switch."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
entity_description: GrowattSwitchEntityDescription
def __init__(
self,
coordinator: GrowattCoordinator,
description: GrowattSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device_id)},
manufacturer="Growatt",
name=coordinator.device_id,
)
@property
def is_on(self) -> bool | None:
"""Return true if the switch is on."""
value = self.coordinator.data.get(self.entity_description.api_key)
if value is None:
return None
# API returns integer 1 for enabled, 0 for disabled
return bool(value)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._async_set_state(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._async_set_state(False)
async def _async_set_state(self, state: bool) -> None:
"""Set the switch state."""
# Use write_key if specified, otherwise fall back to api_key
parameter_id = (
self.entity_description.write_key or self.entity_description.api_key
)
api_value = int(state)
try:
# Use V1 API to write parameter
await self.hass.async_add_executor_job(
self.coordinator.api.min_write_parameter,
self.coordinator.device_id,
parameter_id,
api_value,
)
except GrowattV1ApiError as e:
raise HomeAssistantError(f"Error while setting switch state: {e}") from e
# If no exception was raised, the write was successful
_LOGGER.debug(
"Set switch %s to %s",
parameter_id,
api_value,
)
# Update the value in coordinator data (keep as integer like API returns)
self.coordinator.data[self.entity_description.api_key] = api_value
self.async_write_ha_state()

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from enum import StrEnum from enum import StrEnum
import logging import logging
import math
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
@@ -282,7 +281,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
return sorted( return sorted(
tasks, tasks,
key=lambda task: ( key=lambda task: (
math.inf float("inf")
if (uid := UUID(task.uid)) if (uid := UUID(task.uid))
not in (tasks_order := self.coordinator.data.user.tasksOrder.todos) not in (tasks_order := self.coordinator.data.user.tasksOrder.todos)
else tasks_order.index(uid) else tasks_order.index(uid)
@@ -368,7 +367,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
return sorted( return sorted(
tasks, tasks,
key=lambda task: ( key=lambda task: (
math.inf float("inf")
if (uid := UUID(task.uid)) if (uid := UUID(task.uid))
not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys) not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys)
else tasks_order.index(uid) else tasks_order.index(uid)

View File

@@ -68,6 +68,7 @@ EVENT_HEALTH_CHANGED = "health_changed"
EVENT_SUPPORTED_CHANGED = "supported_changed" EVENT_SUPPORTED_CHANGED = "supported_changed"
EVENT_ISSUE_CHANGED = "issue_changed" EVENT_ISSUE_CHANGED = "issue_changed"
EVENT_ISSUE_REMOVED = "issue_removed" EVENT_ISSUE_REMOVED = "issue_removed"
EVENT_JOB = "job"
UPDATE_KEY_SUPERVISOR = "supervisor" UPDATE_KEY_SUPERVISOR = "supervisor"

View File

@@ -56,6 +56,7 @@ from .const import (
SupervisorEntityModel, SupervisorEntityModel,
) )
from .handler import HassioAPIError, get_supervisor_client from .handler import HassioAPIError, get_supervisor_client
from .jobs import SupervisorJobs
if TYPE_CHECKING: if TYPE_CHECKING:
from .issues import SupervisorIssues from .issues import SupervisorIssues
@@ -311,6 +312,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
lambda: defaultdict(set) lambda: defaultdict(set)
) )
self.supervisor_client = get_supervisor_client(hass) self.supervisor_client = get_supervisor_client(hass)
self.jobs = SupervisorJobs(hass)
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library.""" """Update data via library."""
@@ -485,6 +487,9 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
) )
) )
# Refresh jobs data
await self.jobs.refresh_data(first_update)
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]: async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
"""Update single addon stats.""" """Update single addon stats."""
try: try:

View File

@@ -0,0 +1,157 @@
"""Track Supervisor job data and allow subscription to updates."""
from collections.abc import Callable
from dataclasses import dataclass, replace
from functools import partial
from typing import Any
from uuid import UUID
from aiohasupervisor.models import Job
from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant,
callback,
is_callback_check_partial,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
ATTR_DATA,
ATTR_UPDATE_KEY,
ATTR_WS_EVENT,
EVENT_JOB,
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
UPDATE_KEY_SUPERVISOR,
)
from .handler import get_supervisor_client
@dataclass(slots=True, frozen=True)
class JobSubscription:
"""Subscribe for updates on jobs which match filters.
UUID is preferred match but only available in cases of a background API that
returns the UUID before taking the action. Others are used to match jobs only
if UUID is omitted. Either name or UUID is required to be able to match.
event_callback must be safe annotated as a homeassistant.core.callback
and safe to call in the event loop.
"""
event_callback: Callable[[Job], Any]
uuid: str | None = None
name: str | None = None
reference: str | None | type[Any] = Any
def __post_init__(self) -> None:
"""Validate at least one filter option is present."""
if not self.name and not self.uuid:
raise ValueError("Either name or uuid must be provided!")
if not is_callback_check_partial(self.event_callback):
raise ValueError("event_callback must be a homeassistant.core.callback!")
def matches(self, job: Job) -> bool:
"""Return true if job matches subscription filters."""
if self.uuid:
return job.uuid == self.uuid
return job.name == self.name and self.reference in (Any, job.reference)
class SupervisorJobs:
"""Manage access to Supervisor jobs."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize object."""
self._hass = hass
self._supervisor_client = get_supervisor_client(hass)
self._jobs: dict[UUID, Job] = {}
self._subscriptions: set[JobSubscription] = set()
@property
def current_jobs(self) -> list[Job]:
"""Return current jobs."""
return list(self._jobs.values())
def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
"""Subscribe to updates for job. Return callback is used to unsubscribe.
If any jobs match the subscription at the time this is called, creates
tasks to run their callback on it.
"""
self._subscriptions.add(subscription)
# As these are callbacks they are safe to run in the event loop
# We wrap these in an asyncio task so subscribing does not wait on the logic
if matches := [job for job in self._jobs.values() if subscription.matches(job)]:
async def event_callback_async(job: Job) -> Any:
return subscription.event_callback(job)
for match in matches:
self._hass.async_create_task(event_callback_async(match))
return partial(self._subscriptions.discard, subscription)
async def refresh_data(self, first_update: bool = False) -> None:
"""Refresh job data."""
job_data = await self._supervisor_client.jobs.info()
job_queue: list[Job] = job_data.jobs.copy()
new_jobs: dict[UUID, Job] = {}
changed_jobs: list[Job] = []
# Rebuild our job cache from new info and compare to find changes
while job_queue:
job = job_queue.pop(0)
job_queue.extend(job.child_jobs)
job = replace(job, child_jobs=[])
if job.uuid not in self._jobs or job != self._jobs[job.uuid]:
changed_jobs.append(job)
new_jobs[job.uuid] = replace(job, child_jobs=[])
# For any jobs that disappeared which weren't done, tell subscribers they
# changed to done. We don't know what else happened to them so leave the
# rest of their state as is rather then guessing
changed_jobs.extend(
[
replace(job, done=True)
for uuid, job in self._jobs.items()
if uuid not in new_jobs and job.done is False
]
)
# Replace our cache and inform subscribers of all changes
self._jobs = new_jobs
for job in changed_jobs:
self._process_job_change(job)
# If this is the first update register to receive Supervisor events
if first_update:
async_dispatcher_connect(
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
)
@callback
def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None:
"""Update job data cache from supervisor events."""
if ATTR_WS_EVENT not in event:
return
if (
event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
):
self._hass.async_create_task(self.refresh_data())
elif event[ATTR_WS_EVENT] == EVENT_JOB:
job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []})
self._jobs[job.uuid] = job
self._process_job_change(job)
def _process_job_change(self, job: Job) -> None:
"""Process a job change by triggering callbacks on subscribers."""
for sub in self._subscriptions:
if sub.matches(job):
sub.event_callback(job)

View File

@@ -6,6 +6,7 @@ import re
from typing import Any from typing import Any
from aiohasupervisor import SupervisorError from aiohasupervisor import SupervisorError
from aiohasupervisor.models import Job
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
from homeassistant.components.update import ( from homeassistant.components.update import (
@@ -15,7 +16,7 @@ from homeassistant.components.update import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON, ATTR_NAME from homeassistant.const import ATTR_ICON, ATTR_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -35,6 +36,7 @@ from .entity import (
HassioOSEntity, HassioOSEntity,
HassioSupervisorEntity, HassioSupervisorEntity,
) )
from .jobs import JobSubscription
from .update_helper import update_addon, update_core, update_os from .update_helper import update_addon, update_core, update_os
ENTITY_DESCRIPTION = UpdateEntityDescription( ENTITY_DESCRIPTION = UpdateEntityDescription(
@@ -89,6 +91,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
UpdateEntityFeature.INSTALL UpdateEntityFeature.INSTALL
| UpdateEntityFeature.BACKUP | UpdateEntityFeature.BACKUP
| UpdateEntityFeature.RELEASE_NOTES | UpdateEntityFeature.RELEASE_NOTES
| UpdateEntityFeature.PROGRESS
) )
@property @property
@@ -154,6 +157,30 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
) )
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
@callback
def _update_job_changed(self, job: Job) -> None:
"""Process update for this entity's update job."""
if job.done is False:
self._attr_in_progress = True
self._attr_update_percentage = job.progress
else:
self._attr_in_progress = False
self._attr_update_percentage = None
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Subscribe to progress updates."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.jobs.subscribe(
JobSubscription(
self._update_job_changed,
name="addon_manager_update",
reference=self._addon_slug,
)
)
)
class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
"""Update entity to handle updates for the Home Assistant Operating System.""" """Update entity to handle updates for the Home Assistant Operating System."""
@@ -250,6 +277,7 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
UpdateEntityFeature.INSTALL UpdateEntityFeature.INSTALL
| UpdateEntityFeature.SPECIFIC_VERSION | UpdateEntityFeature.SPECIFIC_VERSION
| UpdateEntityFeature.BACKUP | UpdateEntityFeature.BACKUP
| UpdateEntityFeature.PROGRESS
) )
_attr_title = "Home Assistant Core" _attr_title = "Home Assistant Core"
@@ -281,3 +309,25 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
) -> None: ) -> None:
"""Install an update.""" """Install an update."""
await update_core(self.hass, version, backup) await update_core(self.hass, version, backup)
@callback
def _update_job_changed(self, job: Job) -> None:
"""Process update for this entity's update job."""
if job.done is False:
self._attr_in_progress = True
self._attr_update_percentage = job.progress
else:
self._attr_in_progress = False
self._attr_update_percentage = None
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Subscribe to progress updates."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.jobs.subscribe(
JobSubscription(
self._update_job_changed, name="home_assistant_core_update"
)
)
)

View File

@@ -8,7 +8,7 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pyheos"], "loggers": ["pyheos"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pyheos==1.0.6"], "requirements": ["pyheos==1.0.5"],
"ssdp": [ "ssdp": [
{ {
"st": "urn:schemas-denon-com:device:ACT-Denon:1" "st": "urn:schemas-denon-com:device:ACT-Denon:1"

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday", "documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["holidays==0.82", "babel==2.15.0"] "requirements": ["holidays==0.81", "babel==2.15.0"]
} }

View File

@@ -99,20 +99,6 @@ CLEANING_MODE_OPTIONS = {
"ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent", "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent",
"ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard", "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard",
"ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power", "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.IntelligentMode",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.VacuumOnly",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.MopOnly",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.VacuumAndMop",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.MopAfterVacuum",
)
}
SUCTION_POWER_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CleaningRobot.EnumType.SuctionPower.Silent",
"ConsumerProducts.CleaningRobot.EnumType.SuctionPower.Standard",
"ConsumerProducts.CleaningRobot.EnumType.SuctionPower.Max",
) )
} }
@@ -323,10 +309,6 @@ PROGRAM_ENUM_OPTIONS = {
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
CLEANING_MODE_OPTIONS, CLEANING_MODE_OPTIONS,
), ),
(
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_SUCTION_POWER,
SUCTION_POWER_OPTIONS,
),
(OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS), (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS),
( (
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE,

View File

@@ -30,7 +30,6 @@ from .const import (
INTENSIVE_LEVEL_OPTIONS, INTENSIVE_LEVEL_OPTIONS,
PROGRAMS_TRANSLATION_KEYS_MAP, PROGRAMS_TRANSLATION_KEYS_MAP,
SPIN_SPEED_OPTIONS, SPIN_SPEED_OPTIONS,
SUCTION_POWER_OPTIONS,
TEMPERATURE_OPTIONS, TEMPERATURE_OPTIONS,
TRANSLATION_KEYS_PROGRAMS_MAP, TRANSLATION_KEYS_PROGRAMS_MAP,
VARIO_PERFECT_OPTIONS, VARIO_PERFECT_OPTIONS,
@@ -169,16 +168,6 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
for translation_key, value in CLEANING_MODE_OPTIONS.items() for translation_key, value in CLEANING_MODE_OPTIONS.items()
}, },
), ),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_SUCTION_POWER,
translation_key="suction_power",
options=list(SUCTION_POWER_OPTIONS),
translation_key_values=SUCTION_POWER_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in SUCTION_POWER_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription( HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT,
translation_key="bean_amount", translation_key="bean_amount",

View File

@@ -202,22 +202,6 @@ set_program_and_options:
- consumer_products_cleaning_robot_enum_type_cleaning_modes_silent - consumer_products_cleaning_robot_enum_type_cleaning_modes_silent
- consumer_products_cleaning_robot_enum_type_cleaning_modes_standard - consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
- consumer_products_cleaning_robot_enum_type_cleaning_modes_power - consumer_products_cleaning_robot_enum_type_cleaning_modes_power
- consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode
- consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only
- consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only
- consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop
- consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum
consumer_products_cleaning_robot_option_suction_power:
example: consumer_products_cleaning_robot_enum_type_suction_power_standard
required: false
selector:
select:
mode: dropdown
translation_key: suction_power
options:
- consumer_products_cleaning_robot_enum_type_suction_power_silent
- consumer_products_cleaning_robot_enum_type_suction_power_standard
- consumer_products_cleaning_robot_enum_type_suction_power_max
coffee_maker_options: coffee_maker_options:
collapsed: true collapsed: true
fields: fields:

View File

@@ -324,19 +324,7 @@
"options": { "options": {
"consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "Silent", "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "Silent",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "Standard", "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "Standard",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power", "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power"
"consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode": "Intelligent mode",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only": "Vacuum only",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only": "Mop only",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop": "Vacuum and mop",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum": "Mop after vacuum"
}
},
"suction_power": {
"options": {
"consumer_products_cleaning_robot_enum_type_suction_power_silent": "Silent",
"consumer_products_cleaning_robot_enum_type_suction_power_standard": "Standard",
"consumer_products_cleaning_robot_enum_type_suction_power_max": "Max"
} }
}, },
"bean_amount": { "bean_amount": {
@@ -531,10 +519,6 @@
"name": "Cleaning mode", "name": "Cleaning mode",
"description": "Defines the favored cleaning mode." "description": "Defines the favored cleaning mode."
}, },
"consumer_products_cleaning_robot_option_suction_power": {
"name": "Suction power",
"description": "Defines the suction power."
},
"consumer_products_coffee_maker_option_bean_amount": { "consumer_products_coffee_maker_option_bean_amount": {
"name": "Bean amount", "name": "Bean amount",
"description": "Describes the amount of coffee beans used in a coffee machine program." "description": "Describes the amount of coffee beans used in a coffee machine program."
@@ -1212,20 +1196,7 @@
"state": { "state": {
"consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]", "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]", "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]", "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]"
"consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode%]",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only%]",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only%]",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop%]",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum%]"
}
},
"suction_power": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_suction_power::name%]",
"state": {
"consumer_products_cleaning_robot_enum_type_suction_power_silent": "[%key:component::home_connect::selector::suction_power::options::consumer_products_cleaning_robot_enum_type_suction_power_silent%]",
"consumer_products_cleaning_robot_enum_type_suction_power_standard": "[%key:component::home_connect::selector::suction_power::options::consumer_products_cleaning_robot_enum_type_suction_power_standard%]",
"consumer_products_cleaning_robot_enum_type_suction_power_max": "[%key:component::home_connect::selector::suction_power::options::consumer_products_cleaning_robot_enum_type_suction_power_max%]"
} }
}, },
"bean_amount": { "bean_amount": {

View File

@@ -7,18 +7,11 @@ from typing import TYPE_CHECKING, Any, Protocol
from homeassistant.components import usb from homeassistant.components import usb
from homeassistant.components.homeassistant_hardware import firmware_config_flow from homeassistant.components.homeassistant_hardware import firmware_config_flow
from homeassistant.components.homeassistant_hardware.helpers import (
HardwareFirmwareDiscoveryInfo,
)
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget, ResetTarget,
) )
from homeassistant.components.usb import (
usb_service_info_from_device,
usb_unique_id_from_service_info,
)
from homeassistant.config_entries import ( from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigEntryBaseFlow, ConfigEntryBaseFlow,
@@ -130,16 +123,22 @@ class HomeAssistantConnectZBT2ConfigFlow(
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle usb discovery.""" """Handle usb discovery."""
unique_id = usb_unique_id_from_service_info(discovery_info) device = discovery_info.device
vid = discovery_info.vid
pid = discovery_info.pid
serial_number = discovery_info.serial_number
manufacturer = discovery_info.manufacturer
description = discovery_info.description
unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}"
discovery_info.device = await self.hass.async_add_executor_job( device = discovery_info.device = await self.hass.async_add_executor_job(
usb.get_serial_by_id, discovery_info.device usb.get_serial_by_id, discovery_info.device
) )
try: try:
await self.async_set_unique_id(unique_id) await self.async_set_unique_id(unique_id)
finally: finally:
self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device}) self._abort_if_unique_id_configured(updates={DEVICE: device})
self._usb_info = discovery_info self._usb_info = discovery_info
@@ -149,24 +148,6 @@ class HomeAssistantConnectZBT2ConfigFlow(
return await self.async_step_confirm() return await self.async_step_confirm()
async def async_step_import(
self, fw_discovery_info: HardwareFirmwareDiscoveryInfo
) -> ConfigFlowResult:
"""Handle import from ZHA/OTBR firmware notification."""
assert fw_discovery_info["usb_device"] is not None
usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"])
unique_id = usb_unique_id_from_service_info(usb_info)
if await self.async_set_unique_id(unique_id, raise_on_progress=False):
self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device})
self._usb_info = usb_info
self._device = usb_info.device
self._hardware_name = HARDWARE_NAME
self._probed_firmware_info = fw_discovery_info["firmware_info"]
return self._async_flow_finished()
def _async_flow_finished(self) -> ConfigFlowResult: def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry.""" """Create the config entry."""
assert self._usb_info is not None assert self._usb_info is not None

View File

@@ -13,12 +13,6 @@
"pid": "4001", "pid": "4001",
"description": "*zbt-2*", "description": "*zbt-2*",
"known_devices": ["ZBT-2"] "known_devices": ["ZBT-2"]
},
{
"vid": "303A",
"pid": "831A",
"description": "*zbt-2*",
"known_devices": ["ZBT-2"]
} }
] ]
} }

View File

@@ -19,12 +19,6 @@ DATA_COMPONENT: HassKey[HardwareInfoDispatcher] = HassKey(DOMAIN)
ZHA_DOMAIN = "zha" ZHA_DOMAIN = "zha"
OTBR_DOMAIN = "otbr" OTBR_DOMAIN = "otbr"
HARDWARE_INTEGRATION_DOMAINS = {
"homeassistant_sky_connect",
"homeassistant_connect_zbt2",
"homeassistant_yellow",
}
OTBR_ADDON_NAME = "OpenThread Border Router" OTBR_ADDON_NAME = "OpenThread Border Router"
OTBR_ADDON_MANAGER_DATA = "openthread_border_router" OTBR_ADDON_MANAGER_DATA = "openthread_border_router"
OTBR_ADDON_SLUG = "core_openthread_border_router" OTBR_ADDON_SLUG = "core_openthread_border_router"

View File

@@ -1,38 +1,19 @@
"""Home Assistant Hardware integration helpers.""" """Home Assistant Hardware integration helpers."""
from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import logging import logging
from typing import TYPE_CHECKING, Protocol, TypedDict from typing import Protocol
from homeassistant.components.usb import ( from homeassistant.config_entries import ConfigEntry
USBDevice,
async_get_usb_matchers_for_device,
usb_device_from_path,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from . import DATA_COMPONENT from . import DATA_COMPONENT
from .const import HARDWARE_INTEGRATION_DOMAINS from .util import FirmwareInfo
if TYPE_CHECKING:
from .util import FirmwareInfo
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class HardwareFirmwareDiscoveryInfo(TypedDict):
"""Data for triggering hardware integration discovery via firmware notification."""
usb_device: USBDevice | None
firmware_info: FirmwareInfo
class SyncHardwareFirmwareInfoModule(Protocol): class SyncHardwareFirmwareInfoModule(Protocol):
"""Protocol type for Home Assistant Hardware firmware info platform modules.""" """Protocol type for Home Assistant Hardware firmware info platform modules."""
@@ -60,23 +41,6 @@ type HardwareFirmwareInfoModule = (
) )
@hass_callback
def async_get_hardware_domain_for_usb_device(
hass: HomeAssistant, usb_device: USBDevice
) -> str | None:
"""Identify which hardware domain should handle a USB device."""
matched = async_get_usb_matchers_for_device(hass, usb_device)
hw_domains = {match["domain"] for match in matched} & HARDWARE_INTEGRATION_DOMAINS
if not hw_domains:
return None
# We can never have two hardware integrations overlap in discovery
assert len(hw_domains) == 1
return list(hw_domains)[0]
class HardwareInfoDispatcher: class HardwareInfoDispatcher:
"""Central dispatcher for hardware/firmware information.""" """Central dispatcher for hardware/firmware information."""
@@ -87,7 +51,6 @@ class HardwareInfoDispatcher:
self._notification_callbacks: defaultdict[ self._notification_callbacks: defaultdict[
str, set[Callable[[FirmwareInfo], None]] str, set[Callable[[FirmwareInfo], None]]
] = defaultdict(set) ] = defaultdict(set)
self._active_firmware_updates: dict[str, str] = {}
def register_firmware_info_provider( def register_firmware_info_provider(
self, domain: str, platform: HardwareFirmwareInfoModule self, domain: str, platform: HardwareFirmwareInfoModule
@@ -125,7 +88,7 @@ class HardwareInfoDispatcher:
"Received firmware info notification from %r: %s", domain, firmware_info "Received firmware info notification from %r: %s", domain, firmware_info
) )
for callback in list(self._notification_callbacks[firmware_info.device]): for callback in self._notification_callbacks.get(firmware_info.device, []):
try: try:
callback(firmware_info) callback(firmware_info)
except Exception: except Exception:
@@ -133,48 +96,6 @@ class HardwareInfoDispatcher:
"Error while notifying firmware info listener %s", callback "Error while notifying firmware info listener %s", callback
) )
await self._async_trigger_hardware_discovery(firmware_info)
async def _async_trigger_hardware_discovery(
self, firmware_info: FirmwareInfo
) -> None:
"""Trigger hardware integration config flows from firmware info.
Identifies which hardware integration should handle the device based on
USB matchers, then triggers an import flow for only that integration.
"""
usb_device = await self.hass.async_add_executor_job(
usb_device_from_path, firmware_info.device
)
if usb_device is None:
_LOGGER.debug("Cannot find USB for path %s", firmware_info.device)
return
hardware_domain = async_get_hardware_domain_for_usb_device(
self.hass, usb_device
)
if hardware_domain is None:
_LOGGER.debug("No hardware integration found for device %s", usb_device)
return
_LOGGER.debug(
"Triggering %s import flow for device %s",
hardware_domain,
firmware_info.device,
)
await self.hass.config_entries.flow.async_init(
hardware_domain,
context={"source": SOURCE_IMPORT},
data=HardwareFirmwareDiscoveryInfo(
usb_device=usb_device,
firmware_info=firmware_info,
),
)
async def iter_firmware_info(self) -> AsyncIterator[FirmwareInfo]: async def iter_firmware_info(self) -> AsyncIterator[FirmwareInfo]:
"""Iterate over all firmware information for all hardware.""" """Iterate over all firmware information for all hardware."""
for domain, fw_info_module in self._providers.items(): for domain, fw_info_module in self._providers.items():
@@ -197,36 +118,6 @@ class HardwareInfoDispatcher:
if fw_info is not None: if fw_info is not None:
yield fw_info yield fw_info
def register_firmware_update_in_progress(
self, device: str, source_domain: str
) -> None:
"""Register that a firmware update is in progress for a device."""
if device in self._active_firmware_updates:
current_domain = self._active_firmware_updates[device]
raise ValueError(
f"Firmware update already in progress for {device} by {current_domain}"
)
self._active_firmware_updates[device] = source_domain
def unregister_firmware_update_in_progress(
self, device: str, source_domain: str
) -> None:
"""Unregister a firmware update for a device."""
if device not in self._active_firmware_updates:
raise ValueError(f"No firmware update in progress for {device}")
if self._active_firmware_updates[device] != source_domain:
current_domain = self._active_firmware_updates[device]
raise ValueError(
f"Firmware update for {device} is owned by {current_domain}, not {source_domain}"
)
del self._active_firmware_updates[device]
def is_firmware_update_in_progress(self, device: str) -> bool:
"""Check if a firmware update is in progress for a device."""
return device in self._active_firmware_updates
@hass_callback @hass_callback
def async_register_firmware_info_provider( def async_register_firmware_info_provider(
@@ -250,42 +141,3 @@ def async_notify_firmware_info(
) -> Awaitable[None]: ) -> Awaitable[None]:
"""Notify the dispatcher of new firmware information.""" """Notify the dispatcher of new firmware information."""
return hass.data[DATA_COMPONENT].notify_firmware_info(domain, firmware_info) return hass.data[DATA_COMPONENT].notify_firmware_info(domain, firmware_info)
@hass_callback
def async_register_firmware_update_in_progress(
hass: HomeAssistant, device: str, source_domain: str
) -> None:
"""Register that a firmware update is in progress for a device."""
return hass.data[DATA_COMPONENT].register_firmware_update_in_progress(
device, source_domain
)
@hass_callback
def async_unregister_firmware_update_in_progress(
hass: HomeAssistant, device: str, source_domain: str
) -> None:
"""Unregister a firmware update for a device."""
return hass.data[DATA_COMPONENT].unregister_firmware_update_in_progress(
device, source_domain
)
@hass_callback
def async_is_firmware_update_in_progress(hass: HomeAssistant, device: str) -> bool:
"""Check if a firmware update is in progress for a device."""
return hass.data[DATA_COMPONENT].is_firmware_update_in_progress(device)
@asynccontextmanager
async def async_firmware_update_context(
hass: HomeAssistant, device: str, source_domain: str
) -> AsyncIterator[None]:
"""Register a device as having its firmware being actively updated."""
async_register_firmware_update_in_progress(hass, device, source_domain)
try:
yield
finally:
async_unregister_firmware_update_in_progress(hass, device, source_domain)

View File

@@ -3,7 +3,6 @@
"name": "Home Assistant Hardware", "name": "Home Assistant Hardware",
"after_dependencies": ["hassio"], "after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system", "integration_type": "system",
"requirements": [ "requirements": [

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