mirror of
https://github.com/home-assistant/core.git
synced 2026-02-23 18:51:08 +01:00
Compare commits
116 Commits
2026.2.0b3
...
2026.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c640fe0fa | ||
|
|
62145e5f9e | ||
|
|
c0fc414bb9 | ||
|
|
69411a05ff | ||
|
|
06c9ec861d | ||
|
|
946df1755f | ||
|
|
d0678e0641 | ||
|
|
ec56f183da | ||
|
|
033005e0de | ||
|
|
91f9f5a826 | ||
|
|
ac4fcab827 | ||
|
|
d0eea77178 | ||
|
|
fb38fa3844 | ||
|
|
440efb953e | ||
|
|
7ce47cca0d | ||
|
|
a5f607bb91 | ||
|
|
b03043aa6f | ||
|
|
0f3c7ca277 | ||
|
|
3abf7c22f3 | ||
|
|
292e1de126 | ||
|
|
2d776a8193 | ||
|
|
039bbbb48c | ||
|
|
ad5565df95 | ||
|
|
3e6bc29a6a | ||
|
|
ec8067a5a8 | ||
|
|
6f47716d0a | ||
|
|
efba5c6bcc | ||
|
|
d10e78079f | ||
|
|
6d4581580f | ||
|
|
0d9a41a540 | ||
|
|
cd69e6db73 | ||
|
|
1320367d0d | ||
|
|
dfa4698887 | ||
|
|
b426115de7 | ||
|
|
fb79fa37f8 | ||
|
|
6a5f7bf424 | ||
|
|
142ca6dec1 | ||
|
|
0f986c24d0 | ||
|
|
01f2b7b6f6 | ||
|
|
b9469027f5 | ||
|
|
fbb94af748 | ||
|
|
148bdf6e3a | ||
|
|
91999f8871 | ||
|
|
aecca4eb99 | ||
|
|
bf8aa49bae | ||
|
|
4423425683 | ||
|
|
44202da53d | ||
|
|
9f7dfb72c4 | ||
|
|
de07a69e4f | ||
|
|
bbf4c38115 | ||
|
|
e1bb5d52ef | ||
|
|
eb64b6bdee | ||
|
|
ecb288b735 | ||
|
|
a419c9c420 | ||
|
|
dd29133324 | ||
|
|
90f22ea516 | ||
|
|
9db1428265 | ||
|
|
a696b05b0d | ||
|
|
77ddb63b73 | ||
|
|
4180a6e176 | ||
|
|
6d74c912d2 | ||
|
|
8a01dfcc00 | ||
|
|
9722898dc6 | ||
|
|
7438c71fcb | ||
|
|
0b5e55b923 | ||
|
|
61ed959e8e | ||
|
|
3989532465 | ||
|
|
28027ddca4 | ||
|
|
fe0d7b3cca | ||
|
|
0dcc4e9527 | ||
|
|
b13b189703 | ||
|
|
150829f599 | ||
|
|
57dd9d9c23 | ||
|
|
e2056cb12c | ||
|
|
fa2c8992cf | ||
|
|
ddf5c7fe3a | ||
|
|
7034ed6d3f | ||
|
|
9015b53c1b | ||
|
|
1cfa6561f7 | ||
|
|
eead02dcca | ||
|
|
456e51a221 | ||
|
|
5d984ce186 | ||
|
|
61f45489ac | ||
|
|
f72c643b38 | ||
|
|
27bc26e886 | ||
|
|
0e9f03cbc1 | ||
|
|
9480c33fb0 | ||
|
|
3e6b8663e8 | ||
|
|
1c69a83793 | ||
|
|
3e8923f105 | ||
|
|
17cca3e69d | ||
|
|
12714c489f | ||
|
|
f788d61b4a | ||
|
|
5c726af00b | ||
|
|
d1d207fbb2 | ||
|
|
6c7f8df7f7 | ||
|
|
6f8c9b1504 | ||
|
|
4f9aedbc84 | ||
|
|
52fb0343e4 | ||
|
|
1050b4580a | ||
|
|
344c42172e | ||
|
|
93cc0fd7f1 | ||
|
|
05fe636b55 | ||
|
|
f22467d099 | ||
|
|
4bc3899b32 | ||
|
|
fc4d6bf5f1 | ||
|
|
8ed0672a8f | ||
|
|
282e347a1b | ||
|
|
1bfb02b440 | ||
|
|
71b03bd9ae | ||
|
|
cbd69822eb | ||
|
|
db900f4dd2 | ||
|
|
a707e695bc | ||
|
|
4feceac205 | ||
|
|
10c20faaca | ||
|
|
abcd512401 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 2
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.2"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==11.1.1"]
|
||||
"requirements": ["aioamazondevices==11.1.3"]
|
||||
}
|
||||
|
||||
@@ -59,13 +59,15 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Replace unique id for "DND" switch and remove from Speaker Group
|
||||
await async_update_unique_id(
|
||||
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
|
||||
)
|
||||
# DND keys
|
||||
old_key = "do_not_disturb"
|
||||
new_key = "dnd"
|
||||
|
||||
# Remove DND switch from virtual groups
|
||||
await async_remove_dnd_from_virtual_group(hass, coordinator)
|
||||
# Remove old DND switch from virtual groups
|
||||
await async_remove_dnd_from_virtual_group(hass, coordinator, old_key)
|
||||
|
||||
# Replace unique id for DND switch
|
||||
await async_update_unique_id(hass, coordinator, SWITCH_DOMAIN, old_key, new_key)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ def alexa_api_call[_T: AmazonEntity, **_P](
|
||||
async def async_update_unique_id(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
domain: str,
|
||||
platform: str,
|
||||
old_key: str,
|
||||
new_key: str,
|
||||
) -> None:
|
||||
@@ -63,7 +63,9 @@ async def async_update_unique_id(
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
unique_id = f"{serial_num}-{old_key}"
|
||||
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
DOMAIN, platform, unique_id
|
||||
):
|
||||
_LOGGER.debug("Updating unique_id for %s", entity_id)
|
||||
new_unique_id = unique_id.replace(old_key, new_key)
|
||||
|
||||
@@ -74,12 +76,13 @@ async def async_update_unique_id(
|
||||
async def async_remove_dnd_from_virtual_group(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Remove entity DND from virtual group."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
unique_id = f"{serial_num}-do_not_disturb"
|
||||
unique_id = f"{serial_num}-{key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
DOMAIN, SWITCH_DOMAIN, unique_id
|
||||
)
|
||||
@@ -104,7 +107,7 @@ async def async_remove_unsupported_notification_sensors(
|
||||
):
|
||||
unique_id = f"{serial_num}-{notification_key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
|
||||
DOMAIN, SENSOR_DOMAIN, unique_id=unique_id
|
||||
)
|
||||
is_unsupported = not coordinator.data[serial_num].notifications_supported
|
||||
|
||||
|
||||
@@ -26,10 +26,9 @@ from homeassistant.const import (
|
||||
UnitOfVolumetricFlux,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AmbientStation, AmbientStationConfigEntry
|
||||
from . import AmbientStationConfigEntry
|
||||
from .const import ATTR_LAST_DATA, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX
|
||||
from .entity import AmbientWeatherEntity
|
||||
|
||||
@@ -683,22 +682,6 @@ async def async_setup_entry(
|
||||
class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity):
|
||||
"""Define an Ambient sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ambient: AmbientStation,
|
||||
mac_address: str,
|
||||
station_name: str,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(ambient, mac_address, station_name, description)
|
||||
|
||||
if description.key == TYPE_SOLARRADIATION_LX:
|
||||
# Since TYPE_SOLARRADIATION and TYPE_SOLARRADIATION_LX will have the same
|
||||
# name in the UI, we influence the entity ID of TYPE_SOLARRADIATION_LX here
|
||||
# to differentiate them:
|
||||
self.entity_id = f"sensor.{station_name}_solar_rad_lx"
|
||||
|
||||
@callback
|
||||
def update_from_latest_data(self) -> None:
|
||||
"""Fetch new state data for the sensor."""
|
||||
|
||||
@@ -14,10 +14,18 @@ from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DATA_REPAIR_DEFER_RELOAD,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEPRECATED_MODELS,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -27,6 +35,7 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Anthropic."""
|
||||
hass.data.setdefault(DOMAIN, {}).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
await async_migrate_integration(hass)
|
||||
return True
|
||||
|
||||
@@ -50,6 +59,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
if (model := subentry.data.get(CONF_CHAT_MODEL)) and model.startswith(
|
||||
tuple(DEPRECATED_MODELS)
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"model_deprecated",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
learn_more_url="https://platform.claude.com/docs/en/about-claude/model-deprecations",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="model_deprecated",
|
||||
)
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -62,6 +87,11 @@ async def async_update_options(
|
||||
hass: HomeAssistant, entry: AnthropicConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
defer_reload_entries: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault(
|
||||
DATA_REPAIR_DEFER_RELOAD, set()
|
||||
)
|
||||
if entry.entry_id in defer_reload_entries:
|
||||
return
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
|
||||
@@ -92,6 +92,40 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
await client.models.list(timeout=10.0)
|
||||
|
||||
|
||||
async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
try:
|
||||
models = (await client.models.list()).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in (
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-opus-20240229",
|
||||
)
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
|
||||
|
||||
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anthropic."""
|
||||
|
||||
@@ -401,42 +435,13 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
async def _get_model_list(self) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
try:
|
||||
client = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
anthropic.AsyncAnthropic,
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
)
|
||||
client = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
anthropic.AsyncAnthropic,
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
)
|
||||
models = (await client.models.list()).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in (
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-opus-20240229",
|
||||
)
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
)
|
||||
return await get_model_list(client)
|
||||
|
||||
async def _get_location_data(self) -> dict[str, str]:
|
||||
"""Get approximate location data of the user."""
|
||||
|
||||
@@ -22,6 +22,8 @@ CONF_WEB_SEARCH_REGION = "region"
|
||||
CONF_WEB_SEARCH_COUNTRY = "country"
|
||||
CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
DATA_REPAIR_DEFER_RELOAD = "repair_defer_reload"
|
||||
|
||||
DEFAULT = {
|
||||
CONF_CHAT_MODEL: "claude-haiku-4-5",
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
@@ -46,3 +48,10 @@ WEB_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3-5-haiku",
|
||||
"claude-3-7-sonnet",
|
||||
"claude-3-5-sonnet",
|
||||
"claude-3-opus",
|
||||
]
|
||||
|
||||
275
homeassistant/components/anthropic/repairs.py
Normal file
275
homeassistant/components/anthropic/repairs.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Issue repair flow for Anthropic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
|
||||
from .config_flow import get_model_list
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DATA_REPAIR_DEFER_RELOAD,
|
||||
DEFAULT,
|
||||
DEPRECATED_MODELS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
_subentry_iter: Iterator[tuple[str, str]] | None
|
||||
_current_entry_id: str | None
|
||||
_current_subentry_id: str | None
|
||||
_reload_pending: set[str]
|
||||
_pending_updates: dict[str, dict[str, str]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
super().__init__()
|
||||
self._subentry_iter = None
|
||||
self._current_entry_id = None
|
||||
self._current_subentry_id = None
|
||||
self._reload_pending = set()
|
||||
self._pending_updates = {}
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
previous_entry_id: str | None = None
|
||||
if user_input is not None:
|
||||
previous_entry_id = self._async_update_current_subentry(user_input)
|
||||
self._clear_current_target()
|
||||
|
||||
target = await self._async_next_target()
|
||||
next_entry_id = target[0].entry_id if target else None
|
||||
if previous_entry_id and previous_entry_id != next_entry_id:
|
||||
await self._async_apply_pending_updates(previous_entry_id)
|
||||
if target is None:
|
||||
await self._async_apply_all_pending_updates()
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
entry, subentry, model = target
|
||||
client = entry.runtime_data
|
||||
model_list = [
|
||||
model_option
|
||||
for model_option in await get_model_list(client)
|
||||
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
|
||||
]
|
||||
|
||||
if "opus" in model:
|
||||
suggested_model = "claude-opus-4-5"
|
||||
elif "haiku" in model:
|
||||
suggested_model = "claude-haiku-4-5"
|
||||
elif "sonnet" in model:
|
||||
suggested_model = "claude-sonnet-4-5"
|
||||
else:
|
||||
suggested_model = cast(str, DEFAULT[CONF_CHAT_MODEL])
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_CHAT_MODEL,
|
||||
default=suggested_model,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=model_list, custom_value=True)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=schema,
|
||||
description_placeholders={
|
||||
"entry_name": entry.title,
|
||||
"model": model,
|
||||
"subentry_name": subentry.title,
|
||||
"subentry_type": self._format_subentry_type(subentry.subentry_type),
|
||||
},
|
||||
)
|
||||
|
||||
def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]:
|
||||
"""Yield entry/subentry pairs that use deprecated models."""
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
continue
|
||||
for subentry in entry.subentries.values():
|
||||
model = subentry.data.get(CONF_CHAT_MODEL)
|
||||
if model and model.startswith(tuple(DEPRECATED_MODELS)):
|
||||
yield entry.entry_id, subentry.subentry_id
|
||||
|
||||
async def _async_next_target(
|
||||
self,
|
||||
) -> tuple[ConfigEntry, ConfigSubentry, str] | None:
|
||||
"""Return the next deprecated subentry target."""
|
||||
if self._subentry_iter is None:
|
||||
self._subentry_iter = self._iter_deprecated_subentries()
|
||||
|
||||
while True:
|
||||
try:
|
||||
entry_id, subentry_id = next(self._subentry_iter)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
subentry = entry.subentries.get(subentry_id)
|
||||
if subentry is None:
|
||||
continue
|
||||
|
||||
model = self._pending_model(entry_id, subentry_id)
|
||||
if model is None:
|
||||
model = subentry.data.get(CONF_CHAT_MODEL)
|
||||
if not model or not model.startswith(tuple(DEPRECATED_MODELS)):
|
||||
continue
|
||||
|
||||
self._current_entry_id = entry_id
|
||||
self._current_subentry_id = subentry_id
|
||||
return entry, subentry, model
|
||||
|
||||
def _async_update_current_subentry(self, user_input: dict[str, str]) -> str | None:
|
||||
"""Update the currently selected subentry."""
|
||||
if not self._current_entry_id or not self._current_subentry_id:
|
||||
return None
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(self._current_entry_id)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
subentry = entry.subentries.get(self._current_subentry_id)
|
||||
if subentry is None:
|
||||
return None
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL],
|
||||
}
|
||||
if updated_data == subentry.data:
|
||||
return entry.entry_id
|
||||
self._queue_pending_update(
|
||||
entry.entry_id,
|
||||
subentry.subentry_id,
|
||||
updated_data[CONF_CHAT_MODEL],
|
||||
)
|
||||
return entry.entry_id
|
||||
|
||||
def _clear_current_target(self) -> None:
|
||||
"""Clear current target tracking."""
|
||||
self._current_entry_id = None
|
||||
self._current_subentry_id = None
|
||||
|
||||
def _format_subentry_type(self, subentry_type: str) -> str:
|
||||
"""Return a user-friendly subentry type label."""
|
||||
if subentry_type == "conversation":
|
||||
return "Conversation agent"
|
||||
if subentry_type in ("ai_task", "ai_task_data"):
|
||||
return "AI task"
|
||||
return subentry_type
|
||||
|
||||
def _queue_pending_update(
|
||||
self, entry_id: str, subentry_id: str, model: str
|
||||
) -> None:
|
||||
"""Store a pending model update for a subentry."""
|
||||
self._pending_updates.setdefault(entry_id, {})[subentry_id] = model
|
||||
|
||||
def _pending_model(self, entry_id: str, subentry_id: str) -> str | None:
|
||||
"""Return a pending model update if one exists."""
|
||||
return self._pending_updates.get(entry_id, {}).get(subentry_id)
|
||||
|
||||
def _mark_entry_for_reload(self, entry_id: str) -> None:
|
||||
"""Prevent reload until repairs are complete for the entry."""
|
||||
self._reload_pending.add(entry_id)
|
||||
defer_reload_entries: set[str] = self.hass.data.setdefault(
|
||||
DOMAIN, {}
|
||||
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
defer_reload_entries.add(entry_id)
|
||||
|
||||
async def _async_reload_entry(self, entry_id: str) -> None:
|
||||
"""Reload an entry once all repairs are completed."""
|
||||
if entry_id not in self._reload_pending:
|
||||
return
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is not None and entry.state is not ConfigEntryState.LOADED:
|
||||
self._clear_defer_reload(entry_id)
|
||||
self._reload_pending.discard(entry_id)
|
||||
return
|
||||
|
||||
if entry is not None:
|
||||
await self.hass.config_entries.async_reload(entry_id)
|
||||
|
||||
self._clear_defer_reload(entry_id)
|
||||
self._reload_pending.discard(entry_id)
|
||||
|
||||
def _clear_defer_reload(self, entry_id: str) -> None:
|
||||
"""Remove entry from the deferred reload set."""
|
||||
defer_reload_entries: set[str] = self.hass.data.setdefault(
|
||||
DOMAIN, {}
|
||||
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
defer_reload_entries.discard(entry_id)
|
||||
|
||||
async def _async_apply_pending_updates(self, entry_id: str) -> None:
|
||||
"""Apply pending subentry updates for a single entry."""
|
||||
updates = self._pending_updates.pop(entry_id, None)
|
||||
if not updates:
|
||||
return
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
return
|
||||
|
||||
changed = False
|
||||
for subentry_id, model in updates.items():
|
||||
subentry = entry.subentries.get(subentry_id)
|
||||
if subentry is None:
|
||||
continue
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
CONF_CHAT_MODEL: model,
|
||||
}
|
||||
if updated_data == subentry.data:
|
||||
continue
|
||||
|
||||
if not changed:
|
||||
self._mark_entry_for_reload(entry_id)
|
||||
changed = True
|
||||
|
||||
self.hass.config_entries.async_update_subentry(
|
||||
entry,
|
||||
subentry,
|
||||
data=updated_data,
|
||||
)
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
await self._async_reload_entry(entry_id)
|
||||
|
||||
async def _async_apply_all_pending_updates(self) -> None:
|
||||
"""Apply all pending updates across entries."""
|
||||
for entry_id in list(self._pending_updates):
|
||||
await self._async_apply_pending_updates(entry_id)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
if issue_id == "model_deprecated":
|
||||
return ModelDeprecatedRepairFlow()
|
||||
raise HomeAssistantError("Unknown issue ID")
|
||||
@@ -109,5 +109,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"model_deprecated": {
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]"
|
||||
},
|
||||
"description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated. Select a supported model to continue.",
|
||||
"title": "Update model"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Model deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyatv.const import KeyboardFocusState
|
||||
from pyatv.const import FeatureName, FeatureState, KeyboardFocusState
|
||||
from pyatv.interface import AppleTV, KeyboardListener
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AppleTvConfigEntry
|
||||
from . import SIGNAL_CONNECTED, AppleTvConfigEntry
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
|
||||
@@ -21,10 +22,22 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Load Apple TV binary sensor based on a config entry."""
|
||||
# apple_tv config entries always have a unique id
|
||||
assert config_entry.unique_id is not None
|
||||
name: str = config_entry.data[CONF_NAME]
|
||||
manager = config_entry.runtime_data
|
||||
async_add_entities([AppleTVKeyboardFocused(name, config_entry.unique_id, manager)])
|
||||
cb: CALLBACK_TYPE
|
||||
|
||||
def setup_entities(atv: AppleTV) -> None:
|
||||
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
|
||||
assert config_entry.unique_id is not None
|
||||
name: str = config_entry.data[CONF_NAME]
|
||||
async_add_entities(
|
||||
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
|
||||
)
|
||||
cb()
|
||||
|
||||
cb = async_dispatcher_connect(
|
||||
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
|
||||
)
|
||||
config_entry.async_on_unload(cb)
|
||||
|
||||
|
||||
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
|
||||
|
||||
@@ -52,7 +52,7 @@ from .const import (
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f"}
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_PROTOCOL = "https"
|
||||
PROTOCOL_CHOICES = ["https", "http"]
|
||||
|
||||
@@ -16,12 +16,18 @@ CONNECTION_TIMEOUT = 120 # 2 minutes
|
||||
# Default TIMEOUT_FOR_UPLOAD is 128 seconds, which is too short for large backups
|
||||
TIMEOUT_FOR_UPLOAD = 43200 # 12 hours
|
||||
|
||||
# Reduced retry count for download operations
|
||||
# Default is 20 retries with exponential backoff, which can hang for 30+ minutes
|
||||
# when there are persistent connection errors (e.g., SSL failures)
|
||||
TRY_COUNT_DOWNLOAD = 3
|
||||
|
||||
|
||||
class B2Http(BaseB2Http): # type: ignore[misc]
|
||||
"""B2Http with extended timeouts for backup operations."""
|
||||
|
||||
CONNECTION_TIMEOUT = CONNECTION_TIMEOUT
|
||||
TIMEOUT_FOR_UPLOAD = TIMEOUT_FOR_UPLOAD
|
||||
TRY_COUNT_DOWNLOAD = TRY_COUNT_DOWNLOAD
|
||||
|
||||
|
||||
class B2Session(BaseB2Session): # type: ignore[misc]
|
||||
|
||||
@@ -40,6 +40,10 @@ CACHE_TTL = 300
|
||||
# This prevents uploads from hanging indefinitely
|
||||
UPLOAD_TIMEOUT = 43200 # 12 hours (matches B2 HTTP timeout)
|
||||
|
||||
# Timeout for metadata download operations (in seconds)
|
||||
# This prevents the backup system from hanging when B2 connections fail
|
||||
METADATA_DOWNLOAD_TIMEOUT = 60
|
||||
|
||||
|
||||
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata files."""
|
||||
@@ -413,12 +417,21 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
backups = {}
|
||||
for file_name, file_version in all_files_in_prefix.items():
|
||||
if file_name.endswith(METADATA_FILE_SUFFIX):
|
||||
backup = await self._hass.async_add_executor_job(
|
||||
self._process_metadata_file_sync,
|
||||
file_name,
|
||||
file_version,
|
||||
all_files_in_prefix,
|
||||
)
|
||||
try:
|
||||
backup = await asyncio.wait_for(
|
||||
self._hass.async_add_executor_job(
|
||||
self._process_metadata_file_sync,
|
||||
file_name,
|
||||
file_version,
|
||||
all_files_in_prefix,
|
||||
),
|
||||
timeout=METADATA_DOWNLOAD_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Timeout downloading metadata file %s", file_name
|
||||
)
|
||||
continue
|
||||
if backup:
|
||||
backups[backup.backup_id] = backup
|
||||
self._backup_list_cache = backups
|
||||
@@ -442,10 +455,18 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
if not file or not metadata_file_version:
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
metadata_content = await self._hass.async_add_executor_job(
|
||||
self._download_and_parse_metadata_sync,
|
||||
metadata_file_version,
|
||||
)
|
||||
try:
|
||||
metadata_content = await asyncio.wait_for(
|
||||
self._hass.async_add_executor_job(
|
||||
self._download_and_parse_metadata_sync,
|
||||
metadata_file_version,
|
||||
),
|
||||
timeout=METADATA_DOWNLOAD_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
raise BackupAgentError(
|
||||
f"Timeout downloading metadata for backup {backup_id}"
|
||||
) from None
|
||||
|
||||
_LOGGER.debug(
|
||||
"Successfully retrieved metadata for backup ID %s from file %s",
|
||||
@@ -468,16 +489,27 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
# Process metadata files sequentially to avoid exhausting executor pool
|
||||
for file_name, file_version in all_files_in_prefix.items():
|
||||
if file_name.endswith(METADATA_FILE_SUFFIX):
|
||||
(
|
||||
result_backup_file,
|
||||
result_metadata_file_version,
|
||||
) = await self._hass.async_add_executor_job(
|
||||
self._process_metadata_file_for_id_sync,
|
||||
file_name,
|
||||
file_version,
|
||||
backup_id,
|
||||
all_files_in_prefix,
|
||||
)
|
||||
try:
|
||||
(
|
||||
result_backup_file,
|
||||
result_metadata_file_version,
|
||||
) = await asyncio.wait_for(
|
||||
self._hass.async_add_executor_job(
|
||||
self._process_metadata_file_for_id_sync,
|
||||
file_name,
|
||||
file_version,
|
||||
backup_id,
|
||||
all_files_in_prefix,
|
||||
),
|
||||
timeout=METADATA_DOWNLOAD_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Timeout downloading metadata file %s while searching for backup %s",
|
||||
file_name,
|
||||
backup_id,
|
||||
)
|
||||
continue
|
||||
if result_backup_file and result_metadata_file_version:
|
||||
return result_backup_file, result_metadata_file_version
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .client import CloudClient
|
||||
@@ -93,7 +94,7 @@ def _convert_content_to_param(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": content.tool_call_id,
|
||||
"output": json.dumps(content.tool_result),
|
||||
"output": json_dumps(content.tool_result),
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -125,7 +126,7 @@ def _convert_content_to_param(
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": tool_call.tool_name,
|
||||
"arguments": json.dumps(tool_call.tool_args),
|
||||
"arguments": json_dumps(tool_call.tool_args),
|
||||
"call_id": tool_call.id,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -196,44 +196,46 @@ class R2BackupAgent(BackupAgent):
|
||||
)
|
||||
upload_id = multipart_upload["UploadId"]
|
||||
try:
|
||||
parts = []
|
||||
parts: list[dict[str, Any]] = []
|
||||
part_number = 1
|
||||
buffer_size = 0 # bytes
|
||||
buffer: list[bytes] = []
|
||||
buffer = bytearray() # bytes buffer to store the data
|
||||
|
||||
stream = await open_stream()
|
||||
async for chunk in stream:
|
||||
buffer_size += len(chunk)
|
||||
buffer.append(chunk)
|
||||
buffer.extend(chunk)
|
||||
|
||||
# upload parts of exactly MULTIPART_MIN_PART_SIZE_BYTES to ensure
|
||||
# all non-trailing parts have the same size (required by S3/R2)
|
||||
while len(buffer) >= MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
part_data = bytes(buffer[:MULTIPART_MIN_PART_SIZE_BYTES])
|
||||
del buffer[:MULTIPART_MIN_PART_SIZE_BYTES]
|
||||
|
||||
# If buffer size meets minimum part size, upload it as a part
|
||||
if buffer_size >= MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
_LOGGER.debug(
|
||||
"Uploading part number %d, size %d", part_number, buffer_size
|
||||
"Uploading part number %d, size %d",
|
||||
part_number,
|
||||
len(part_data),
|
||||
)
|
||||
part = await self._client.upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=b"".join(buffer),
|
||||
Body=part_data,
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
part_number += 1
|
||||
buffer_size = 0
|
||||
buffer = []
|
||||
|
||||
# Upload the final buffer as the last part (no minimum size requirement)
|
||||
if buffer:
|
||||
_LOGGER.debug(
|
||||
"Uploading final part number %d, size %d", part_number, buffer_size
|
||||
"Uploading final part number %d, size %d", part_number, len(buffer)
|
||||
)
|
||||
part = await self._client.upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=b"".join(buffer),
|
||||
Body=bytes(buffer),
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
"secret_access_key": "Secret access key"
|
||||
},
|
||||
"data_description": {
|
||||
"access_key_id": "Access key ID to connect to Cloudflare R2 (this is your Account ID)",
|
||||
"access_key_id": "Access key ID to connect to Cloudflare R2",
|
||||
"bucket": "Bucket must already exist and be writable by the provided credentials.",
|
||||
"endpoint_url": "Cloudflare R2 S3-compatible endpoint.",
|
||||
"prefix": "Optional folder path inside the bucket. Example: backups/homeassistant",
|
||||
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Docs]({auth_docs_url})"
|
||||
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Cloudflare documentation]({auth_docs_url})"
|
||||
},
|
||||
"title": "Add Cloudflare R2 bucket"
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class ComelitAlarmEntity(
|
||||
"""Update state after action."""
|
||||
self._area.human_status = area_state
|
||||
self._area.armed = armed
|
||||
await self.async_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.6.0"]
|
||||
"requirements": ["compit-inext-api==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -58,11 +58,12 @@ C4_TO_HA_HVAC_MODE = {
|
||||
|
||||
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
|
||||
|
||||
# Map the five known Control4 HVAC states to Home Assistant HVAC actions
|
||||
# Map Control4 HVAC states to Home Assistant HVAC actions
|
||||
C4_TO_HA_HVAC_ACTION = {
|
||||
"off": HVACAction.OFF,
|
||||
"heat": HVACAction.HEATING,
|
||||
"cool": HVACAction.COOLING,
|
||||
"idle": HVACAction.IDLE,
|
||||
"dry": HVACAction.DRYING,
|
||||
"fan": HVACAction.FAN,
|
||||
}
|
||||
@@ -236,8 +237,14 @@ class Control4Climate(Control4Entity, ClimateEntity):
|
||||
c4_state = data.get(CONTROL4_HVAC_STATE)
|
||||
if c4_state is None:
|
||||
return None
|
||||
# Convert state to lowercase for mapping
|
||||
action = C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
# Substring match for multi-stage systems that report
|
||||
# e.g. "Stage 1 Heat", "Stage 2 Cool"
|
||||
if action is None:
|
||||
if "heat" in str(c4_state).lower():
|
||||
action = HVACAction.HEATING
|
||||
elif "cool" in str(c4_state).lower():
|
||||
action = HVACAction.COOLING
|
||||
if action is None:
|
||||
_LOGGER.debug("Unknown HVAC state received from Control4: %s", c4_state)
|
||||
return action
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydaikin"],
|
||||
"requirements": ["pydaikin==2.17.1"],
|
||||
"requirements": ["pydaikin==2.17.2"],
|
||||
"zeroconf": ["_dkapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denonavr"],
|
||||
"requirements": ["denonavr==1.2.0"],
|
||||
"requirements": ["denonavr==1.3.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -17,6 +17,7 @@ from denonavr.const import (
|
||||
STATE_ON,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
STATE_STOPPED,
|
||||
)
|
||||
from denonavr.exceptions import (
|
||||
AvrCommandError,
|
||||
@@ -103,6 +104,7 @@ DENON_STATE_MAPPING = {
|
||||
STATE_OFF: MediaPlayerState.OFF,
|
||||
STATE_PLAYING: MediaPlayerState.PLAYING,
|
||||
STATE_PAUSED: MediaPlayerState.PAUSED,
|
||||
STATE_STOPPED: MediaPlayerState.IDLE,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class EheimDigitalUpdateCoordinator(
|
||||
main_device_added_event=self.main_device_added_event,
|
||||
)
|
||||
self.known_devices: set[str] = set()
|
||||
self.incomplete_devices: set[str] = set()
|
||||
self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set()
|
||||
|
||||
def add_platform_callback(
|
||||
@@ -70,11 +71,26 @@ class EheimDigitalUpdateCoordinator(
|
||||
This function is called from the library whenever a new device is added.
|
||||
"""
|
||||
|
||||
if device_address not in self.known_devices:
|
||||
if self.hub.devices[device_address].is_missing_data:
|
||||
self.incomplete_devices.add(device_address)
|
||||
return
|
||||
|
||||
if (
|
||||
device_address not in self.known_devices
|
||||
or device_address in self.incomplete_devices
|
||||
):
|
||||
for platform_callback in self.platform_callbacks:
|
||||
platform_callback({device_address: self.hub.devices[device_address]})
|
||||
if device_address in self.incomplete_devices:
|
||||
self.incomplete_devices.remove(device_address)
|
||||
|
||||
async def _async_receive_callback(self) -> None:
|
||||
if any(self.incomplete_devices):
|
||||
for device_address in self.incomplete_devices.copy():
|
||||
if not self.hub.devices[device_address].is_missing_data:
|
||||
await self._async_device_found(
|
||||
device_address, EheimDeviceType.VERSION_UNDEFINED
|
||||
)
|
||||
self.async_set_updated_data(self.hub.devices)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.5.0"],
|
||||
"requirements": ["eheimdigital==1.6.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.3"],
|
||||
"requirements": ["pyenphase==2.4.5"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.14.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.5.0"
|
||||
"bleak-esphome==3.6.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["essent-dynamic-pricing==0.2.7"],
|
||||
"requirements": ["essent-dynamic-pricing==0.3.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ class EvoChild(EvoEntity):
|
||||
|
||||
self._device_state_attrs = {
|
||||
"activeFaults": self._evo_device.active_faults,
|
||||
"setpoints": self._setpoints,
|
||||
"setpoints": self.setpoints,
|
||||
}
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"codeowners": ["@zxdavb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/evohome",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
|
||||
"loggers": ["evohomeasync", "evohomeasync2"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["evohome-async==1.0.6"]
|
||||
"requirements": ["evohome-async==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"""The Fressnapf Tracker integration."""
|
||||
|
||||
from fressnapftracker import AuthClient, FressnapfTrackerAuthenticationError
|
||||
import logging
|
||||
|
||||
from fressnapftracker import (
|
||||
ApiClient,
|
||||
AuthClient,
|
||||
Device,
|
||||
FressnapfTrackerAuthenticationError,
|
||||
FressnapfTrackerError,
|
||||
FressnapfTrackerInvalidTrackerResponseError,
|
||||
Tracker,
|
||||
)
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from .const import CONF_USER_ID, DOMAIN
|
||||
from .coordinator import (
|
||||
@@ -21,6 +32,43 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_valid_tracker(hass: HomeAssistant, device: Device) -> Tracker | None:
|
||||
"""Test if the tracker returns valid data and return it.
|
||||
|
||||
Malformed data might indicate the tracker is broken or hasn't been properly registered with the app.
|
||||
"""
|
||||
client = ApiClient(
|
||||
serial_number=device.serialnumber,
|
||||
device_token=device.token,
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
try:
|
||||
return await client.get_tracker()
|
||||
except FressnapfTrackerInvalidTrackerResponseError:
|
||||
_LOGGER.warning(
|
||||
"Tracker with serialnumber %s is invalid. Consider removing it via the App",
|
||||
device.serialnumber,
|
||||
)
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"invalid_fressnapf_tracker_{device.serialnumber}",
|
||||
issue_domain=DOMAIN,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/fressnapf_tracker/",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="invalid_fressnapf_tracker",
|
||||
translation_placeholders={
|
||||
"tracker_id": device.serialnumber,
|
||||
},
|
||||
)
|
||||
return None
|
||||
except FressnapfTrackerError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
|
||||
@@ -40,12 +88,15 @@ async def async_setup_entry(
|
||||
|
||||
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
|
||||
for device in devices:
|
||||
tracker = await _get_valid_tracker(hass, device)
|
||||
if tracker is None:
|
||||
continue
|
||||
coordinator = FressnapfTrackerDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
device,
|
||||
initial_data=tracker,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
coordinators.append(coordinator)
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
@@ -34,6 +34,7 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
hass: HomeAssistant,
|
||||
config_entry: FressnapfTrackerConfigEntry,
|
||||
device: Device,
|
||||
initial_data: Tracker,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
@@ -49,6 +50,7 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
device_token=device.token,
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
self.data = initial_data
|
||||
|
||||
async def _async_update_data(self) -> Tracker:
|
||||
try:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fressnapftracker==0.2.1"]
|
||||
"requirements": ["fressnapftracker==0.2.2"]
|
||||
}
|
||||
|
||||
@@ -92,5 +92,11 @@
|
||||
"not_seen_recently": {
|
||||
"message": "The flashlight cannot be activated when the tracker has not moved recently."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"invalid_fressnapf_tracker": {
|
||||
"description": "The Fressnapf GPS tracker with the serial number `{tracker_id}` is sending invalid data. This most likely indicates the tracker is broken or hasn't been properly registered with the app. Please remove the tracker via the Fressnapf app. You can then close this issue.",
|
||||
"title": "Invalid Fressnapf GPS tracker detected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +278,12 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
"call_deflections"
|
||||
] = await self.async_update_call_deflections()
|
||||
except FRITZ_EXCEPTIONS as ex:
|
||||
_LOGGER.debug(
|
||||
"Reload %s due to error '%s' to ensure proper re-login",
|
||||
self.config_entry.title,
|
||||
ex,
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, UnitOfTemperature
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
@@ -57,7 +59,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) ->
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> bool:
|
||||
"""Unloading the AVM FRITZ!SmartHome platforms."""
|
||||
await hass.async_add_executor_job(entry.runtime_data.fritz.logout)
|
||||
try:
|
||||
await hass.async_add_executor_job(entry.runtime_data.fritz.logout)
|
||||
except (RequestConnectionError, HTTPError) as ex:
|
||||
LOGGER.debug("logout failed with '%s', anyway continue with unload", ex)
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -121,26 +121,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
def _update_fritz_devices(self) -> FritzboxCoordinatorData:
|
||||
"""Update all fritzbox device data."""
|
||||
try:
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
|
||||
except RequestConnectionError as ex:
|
||||
raise UpdateFailed from ex
|
||||
except HTTPError:
|
||||
# If the device rebooted, login again
|
||||
try:
|
||||
self.fritz.login()
|
||||
except LoginError as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
|
||||
devices = self.fritz.get_devices()
|
||||
device_data = {}
|
||||
@@ -193,7 +178,18 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
async def _async_update_data(self) -> FritzboxCoordinatorData:
|
||||
"""Fetch all device data."""
|
||||
new_data = await self.hass.async_add_executor_job(self._update_fritz_devices)
|
||||
try:
|
||||
new_data = await self.hass.async_add_executor_job(
|
||||
self._update_fritz_devices
|
||||
)
|
||||
except (RequestConnectionError, HTTPError) as ex:
|
||||
LOGGER.debug(
|
||||
"Reload %s due to error '%s' to ensure proper re-login",
|
||||
self.config_entry.title,
|
||||
ex,
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
|
||||
raise UpdateFailed from ex
|
||||
|
||||
for device in new_data.devices.values():
|
||||
# create device registry entry for new main devices
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260128.5"]
|
||||
"requirements": ["home-assistant-frontend==20260128.6"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.3"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_air_quality_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google_air_quality_api==3.0.0"]
|
||||
"requirements": ["google_air_quality_api==3.0.1"]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"pitch": "Default pitch of the voice",
|
||||
"profiles": "Default audio profiles",
|
||||
"speed": "Default rate/speed of the voice",
|
||||
"stt_model": "Speech-to-Text model",
|
||||
"stt_model": "Speech-to-text model",
|
||||
"text_type": "Default text type",
|
||||
"voice": "Default voice name (overrides language and gender)"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import base64
|
||||
import codecs
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Callable
|
||||
from dataclasses import dataclass, replace
|
||||
import datetime
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
@@ -181,13 +182,25 @@ def _escape_decode(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _validate_tool_results(value: Any) -> Any:
|
||||
"""Recursively convert non-json-serializable types."""
|
||||
if isinstance(value, (datetime.time, datetime.date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, list):
|
||||
return [_validate_tool_results(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: _validate_tool_results(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _create_google_tool_response_parts(
|
||||
parts: list[conversation.ToolResultContent],
|
||||
) -> list[Part]:
|
||||
"""Create Google tool response parts."""
|
||||
return [
|
||||
Part.from_function_response(
|
||||
name=tool_result.tool_name, response=tool_result.tool_result
|
||||
name=tool_result.tool_name,
|
||||
response=_validate_tool_results(tool_result.tool_result),
|
||||
)
|
||||
for tool_result in parts
|
||||
]
|
||||
|
||||
@@ -38,7 +38,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
translation_key="highest_price_today",
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
value_fn=lambda api, data: api.get_highest_price_today(data),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_highest_price_today(data)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_lowest_price_day",
|
||||
@@ -46,7 +50,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
translation_placeholders={"time_range": "(06:00-18:00)"},
|
||||
value_fn=lambda api, data: api.get_lowest_price_day(data),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_lowest_price_day(data)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_lowest_price_night",
|
||||
@@ -54,14 +62,22 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
translation_placeholders={"time_range": "(18:00-06:00)"},
|
||||
value_fn=lambda api, data: api.get_lowest_price_night(data),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_lowest_price_night(data)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_current_price",
|
||||
translation_key="current_price",
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
value_fn=lambda api, data: api.get_current_price(data, dt_util.now().hour),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_current_price(data, dt_util.now().hour)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"],
|
||||
"requirements": ["growattServer==1.7.1"]
|
||||
"requirements": ["growattServer==1.9.0"]
|
||||
}
|
||||
|
||||
@@ -152,6 +152,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
await update_addon(
|
||||
self.hass, self._addon_slug, backup, self.title, self.installed_version
|
||||
)
|
||||
@@ -308,6 +310,8 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
await update_core(self.hass, version, backup)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -31,6 +31,7 @@ HOMEE_UNIT_TO_HA_UNIT = {
|
||||
"n/a": None,
|
||||
"text": None,
|
||||
"%": PERCENTAGE,
|
||||
"Lux": LIGHT_LUX,
|
||||
"lx": LIGHT_LUX,
|
||||
"klx": LIGHT_LUX,
|
||||
"1/min": REVOLUTIONS_PER_MINUTE,
|
||||
|
||||
@@ -161,6 +161,11 @@ class HomematicipHAP:
|
||||
_LOGGER.error("HMIP access point has lost connection with the cloud")
|
||||
self._ws_connection_closed.set()
|
||||
self.set_all_to_unavailable()
|
||||
elif self._ws_connection_closed.is_set():
|
||||
_LOGGER.info("HMIP access point has reconnected to the cloud")
|
||||
self._get_state_task = self.hass.async_create_task(self._try_get_state())
|
||||
self._get_state_task.add_done_callback(self.get_state_finished)
|
||||
self._ws_connection_closed.clear()
|
||||
|
||||
@callback
|
||||
def async_create_entity(self, *args, **kwargs) -> None:
|
||||
|
||||
@@ -556,16 +556,8 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state."""
|
||||
if self.functional_channel is None:
|
||||
return None
|
||||
|
||||
value = self.functional_channel.vaporAmount
|
||||
|
||||
# Handle case where value might be None
|
||||
if (
|
||||
self.functional_channel.vaporAmount is None
|
||||
or self.functional_channel.vaporAmount == ""
|
||||
):
|
||||
value = self._device.vaporAmount
|
||||
if value is None or value == "":
|
||||
return None
|
||||
|
||||
return round(value, 3)
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.7.1"]
|
||||
"requirements": ["aioautomower==2.7.3"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioimmich"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioimmich==0.11.1"]
|
||||
"requirements": ["aioimmich==0.12.0"]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["intellifire4py"],
|
||||
"requirements": ["intellifire4py==4.2.1"]
|
||||
"requirements": ["intellifire4py==4.3.1"]
|
||||
}
|
||||
|
||||
@@ -150,7 +150,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
|
||||
self._attr_state = state
|
||||
self._attr_is_volume_muted = volume_muted
|
||||
self._attr_volume_level = volume_level
|
||||
# Only update volume_level if the API provides it, otherwise preserve current value
|
||||
if volume_level is not None:
|
||||
self._attr_volume_level = volume_level
|
||||
self._attr_media_content_type = media_content_type
|
||||
self._attr_media_content_id = media_content_id
|
||||
self._attr_media_title = media_title
|
||||
@@ -190,7 +192,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
)
|
||||
features = MediaPlayerEntityFeature(0)
|
||||
|
||||
if "PlayMediaSource" in commands:
|
||||
if "PlayMediaSource" in commands or self.capabilities.get(
|
||||
"SupportsMediaControl", False
|
||||
):
|
||||
features |= (
|
||||
MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -201,10 +205,10 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SEARCH_MEDIA
|
||||
)
|
||||
|
||||
if "Mute" in commands:
|
||||
if "Mute" in commands and "Unmute" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
if "VolumeSet" in commands:
|
||||
if "VolumeSet" in commands or "SetVolume" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
return features
|
||||
@@ -219,11 +223,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
"""Send pause command."""
|
||||
self.coordinator.api_client.jellyfin.remote_pause(self.session_id)
|
||||
self._attr_state = MediaPlayerState.PAUSED
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
self.coordinator.api_client.jellyfin.remote_unpause(self.session_id)
|
||||
self._attr_state = MediaPlayerState.PLAYING
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def media_play_pause(self) -> None:
|
||||
"""Send the PlayPause command to the session."""
|
||||
@@ -233,6 +239,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
"""Send stop command."""
|
||||
self.coordinator.api_client.jellyfin.remote_stop(self.session_id)
|
||||
self._attr_state = MediaPlayerState.IDLE
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
@@ -247,6 +254,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
self.coordinator.api_client.jellyfin.remote_set_volume(
|
||||
self.session_id, int(volume * 100)
|
||||
)
|
||||
self._attr_volume_level = volume
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def mute_volume(self, mute: bool) -> None:
|
||||
"""Mute the volume."""
|
||||
@@ -254,6 +263,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
self.coordinator.api_client.jellyfin.remote_mute(self.session_id)
|
||||
else:
|
||||
self.coordinator.api_client.jellyfin.remote_unmute(self.session_id)
|
||||
self._attr_is_volume_muted = mute
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypck==0.9.9", "lcn-frontend==0.2.7"]
|
||||
"requirements": ["pypck==0.9.11", "lcn-frontend==0.2.7"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["librehardwaremonitor-api==1.8.4"]
|
||||
"requirements": ["librehardwaremonitor-api==1.9.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==12.1.3"]
|
||||
"requirements": ["ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==12.1.3"]
|
||||
"requirements": ["ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import datetime
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from mcp import McpError
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
@@ -63,10 +64,15 @@ async def mcp_client(
|
||||
# Method not Allowed likely means this is not a streamable HTTP server,
|
||||
# but it may be an SSE server. This is part of the MCP Transport
|
||||
# backwards compatibility specification.
|
||||
# We also handle other generic McpErrors since proxies may not respond
|
||||
# consistently with a 405.
|
||||
if (
|
||||
isinstance(main_error, httpx.HTTPStatusError)
|
||||
and main_error.response.status_code == 405
|
||||
):
|
||||
) or isinstance(main_error, McpError):
|
||||
_LOGGER.debug(
|
||||
"Streamable HTTP client failed, attempting SSE client: %s", main_error
|
||||
)
|
||||
try:
|
||||
async with (
|
||||
sse_client(url=url, headers=headers) as streams,
|
||||
|
||||
@@ -110,7 +110,7 @@ async def create_server(
|
||||
return [
|
||||
types.TextContent(
|
||||
type="text",
|
||||
text=json.dumps(tool_response),
|
||||
text=json.dumps(tool_response, ensure_ascii=False),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.12.08"],
|
||||
"requirements": ["yt-dlp[default]==2026.02.04"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"name": "Browse media"
|
||||
},
|
||||
"clear_playlist": {
|
||||
"description": "Removes all items from the playlist.",
|
||||
"description": "Removes all items from a media player's playlist.",
|
||||
"name": "Clear playlist"
|
||||
},
|
||||
"join": {
|
||||
@@ -284,15 +284,15 @@
|
||||
"name": "Next"
|
||||
},
|
||||
"media_pause": {
|
||||
"description": "Pauses.",
|
||||
"description": "Pauses playback on a media player.",
|
||||
"name": "[%key:common::action::pause%]"
|
||||
},
|
||||
"media_play": {
|
||||
"description": "Starts playing.",
|
||||
"description": "Starts playback on a media player.",
|
||||
"name": "Play"
|
||||
},
|
||||
"media_play_pause": {
|
||||
"description": "Toggles play/pause.",
|
||||
"description": "Toggles play/pause on a media player.",
|
||||
"name": "Play/Pause"
|
||||
},
|
||||
"media_previous_track": {
|
||||
@@ -310,7 +310,7 @@
|
||||
"name": "Seek"
|
||||
},
|
||||
"media_stop": {
|
||||
"description": "Stops playing.",
|
||||
"description": "Stops playback on a media player.",
|
||||
"name": "[%key:common::action::stop%]"
|
||||
},
|
||||
"play_media": {
|
||||
@@ -374,7 +374,7 @@
|
||||
"name": "Select sound mode"
|
||||
},
|
||||
"select_source": {
|
||||
"description": "Sends the media player the command to change input source.",
|
||||
"description": "Sends a media player the command to change the input source.",
|
||||
"fields": {
|
||||
"source": {
|
||||
"description": "Name of the source to switch to. Platform dependent.",
|
||||
@@ -398,23 +398,23 @@
|
||||
"name": "[%key:common::action::toggle%]"
|
||||
},
|
||||
"turn_off": {
|
||||
"description": "Turns off the power of the media player.",
|
||||
"description": "Turns off the power of a media player.",
|
||||
"name": "[%key:common::action::turn_off%]"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Turns on the power of the media player.",
|
||||
"description": "Turns on the power of a media player.",
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
},
|
||||
"unjoin": {
|
||||
"description": "Removes the player from a group. Only works on platforms which support player groups.",
|
||||
"description": "Removes a media player from a group. Only works on platforms which support player groups.",
|
||||
"name": "Unjoin"
|
||||
},
|
||||
"volume_down": {
|
||||
"description": "Turns down the volume.",
|
||||
"description": "Turns down the volume of a media player.",
|
||||
"name": "Turn down volume"
|
||||
},
|
||||
"volume_mute": {
|
||||
"description": "Mutes or unmutes the media player.",
|
||||
"description": "Mutes or unmutes a media player.",
|
||||
"fields": {
|
||||
"is_volume_muted": {
|
||||
"description": "Defines whether or not it is muted.",
|
||||
@@ -424,7 +424,7 @@
|
||||
"name": "Mute/unmute volume"
|
||||
},
|
||||
"volume_set": {
|
||||
"description": "Sets the volume level.",
|
||||
"description": "Sets the volume level of a media player.",
|
||||
"fields": {
|
||||
"volume_level": {
|
||||
"description": "The volume. 0 is inaudible, 1 is the maximum volume.",
|
||||
@@ -434,7 +434,7 @@
|
||||
"name": "Set volume"
|
||||
},
|
||||
"volume_up": {
|
||||
"description": "Turns up the volume.",
|
||||
"description": "Turns up the volume of a media player.",
|
||||
"name": "Turn up volume"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -177,15 +177,15 @@ class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
|
||||
|
||||
not_running = 0, 512, 535, 536, 537, 65535
|
||||
program_running = 513
|
||||
drying = 514
|
||||
drying = 514, 11018
|
||||
machine_iron = 515
|
||||
hand_iron_2 = 516
|
||||
normal = 517
|
||||
normal_plus = 518
|
||||
cooling_down = 519
|
||||
hand_iron_1 = 520
|
||||
anti_crease = 521
|
||||
finished = 522
|
||||
anti_crease = 521, 11029
|
||||
finished = 522, 11012
|
||||
extra_dry = 523
|
||||
hand_iron = 524
|
||||
moisten = 526
|
||||
@@ -193,12 +193,14 @@ class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
|
||||
timed_drying = 528
|
||||
warm_air = 529
|
||||
steam_smoothing = 530
|
||||
comfort_cooling = 531
|
||||
comfort_cooling = 531, 11055
|
||||
rinse_out_lint = 532
|
||||
rinses = 533
|
||||
smoothing = 534
|
||||
slightly_dry = 538
|
||||
safety_cooling = 539
|
||||
automatic_start = 11044
|
||||
perfect_dry_active = 11054
|
||||
|
||||
|
||||
class ProgramPhaseWasherDryer(MieleEnum, missing_to_none=True):
|
||||
@@ -265,6 +267,8 @@ class ProgramPhaseOven(MieleEnum, missing_to_none=True):
|
||||
heating_up = 3073
|
||||
process_running = 3074
|
||||
process_finished = 3078
|
||||
searing = 3080
|
||||
roasting = 3081
|
||||
energy_save = 3084
|
||||
pre_heating = 3099
|
||||
|
||||
@@ -357,6 +361,8 @@ class ProgramPhaseSteamOvenCombi(MieleEnum, missing_to_none=True):
|
||||
heating_up = 3073
|
||||
process_running = 3074, 7938
|
||||
process_finished = 3078, 7942
|
||||
searing = 3080
|
||||
roasting = 3081
|
||||
energy_save = 3084
|
||||
pre_heating = 3099
|
||||
|
||||
@@ -483,7 +489,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
|
||||
no_program = 0, -1
|
||||
intensive = 1, 26, 205
|
||||
maintenance = 2, 27, 214
|
||||
eco = 3, 28, 200
|
||||
eco = 3, 22, 28, 200
|
||||
automatic = 6, 7, 31, 32, 202
|
||||
solar_save = 9, 34
|
||||
gentle = 10, 35, 210
|
||||
@@ -505,30 +511,58 @@ class TumbleDryerProgramId(MieleEnum, missing_to_none=True):
|
||||
|
||||
no_program = 0, -1
|
||||
automatic_plus = 1
|
||||
cottons = 2, 20, 90
|
||||
minimum_iron = 3, 30
|
||||
woollens_handcare = 4, 40
|
||||
delicates = 5, 50
|
||||
warm_air = 6, 60
|
||||
cool_air = 7, 70
|
||||
express = 8, 80
|
||||
cottons = 2, 20, 90, 10001
|
||||
minimum_iron = 3, 30, 10016
|
||||
woollens_handcare = 4, 40, 10081
|
||||
woollens = 10040
|
||||
delicates = 5, 50, 10022
|
||||
warm_air = 6, 60, 10025
|
||||
cool_air = 7, 70, 10027
|
||||
express = 8, 80, 10028
|
||||
cottons_eco = 9, 99003
|
||||
proofing = 12, 120
|
||||
denim = 13, 130
|
||||
proofing = 12, 120, 10057
|
||||
denim = 13, 130, 10039
|
||||
shirts = 14, 99004
|
||||
sportswear = 15, 150
|
||||
outerwear = 16, 160
|
||||
silks_handcare = 17, 170
|
||||
sportswear = 15, 150, 10052
|
||||
outerwear = 16, 160, 10049
|
||||
silks_handcare = 17, 170, 10082
|
||||
standard_pillows = 19, 190
|
||||
basket_program = 22, 220
|
||||
basket_program = 22, 220, 10072
|
||||
cottons_hygiene = 11, 23
|
||||
smoothing = 24, 240
|
||||
bed_linen = 31, 99002
|
||||
eco = 66
|
||||
smoothing = 24, 240, 10073
|
||||
bed_linen = 31, 99002, 10047
|
||||
eco = 66, 10079
|
||||
gentle_smoothing = 10, 100
|
||||
gentle_denim = 131
|
||||
steam_smoothing = 99001
|
||||
large_pillows = 99005
|
||||
downs_duvets = 10050
|
||||
curtains = 10055
|
||||
quick_power_dry = 10032
|
||||
automatic = 10044
|
||||
quick_hygiene = 10076
|
||||
hygiene = 10080
|
||||
pillows_sanitize = 10092
|
||||
custom_program_1 = 13901
|
||||
custom_program_2 = 13902
|
||||
custom_program_3 = 13903
|
||||
custom_program_4 = 13904
|
||||
custom_program_5 = 13905
|
||||
custom_program_6 = 13906
|
||||
custom_program_7 = 13907
|
||||
custom_program_8 = 13908
|
||||
custom_program_9 = 13909
|
||||
custom_program_10 = 13910
|
||||
custom_program_11 = 13911
|
||||
custom_program_12 = 13912
|
||||
custom_program_13 = 13913
|
||||
custom_program_14 = 13914
|
||||
custom_program_15 = 13915
|
||||
custom_program_16 = 13916
|
||||
custom_program_17 = 13917
|
||||
custom_program_18 = 13918
|
||||
custom_program_19 = 13919
|
||||
custom_program_20 = 13920
|
||||
|
||||
|
||||
class OvenProgramId(MieleEnum, missing_to_none=True):
|
||||
@@ -806,6 +840,8 @@ class CoffeeSystemProgramId(MieleEnum, missing_to_none=True):
|
||||
24813, # add profile
|
||||
)
|
||||
appliance_rinse = 24750, 24759, 24773, 24787, 24788
|
||||
intermediate_rinsing = 24758
|
||||
automatic_maintenance = 24778
|
||||
descaling = 24751
|
||||
brewing_unit_degrease = 24753
|
||||
milk_pipework_rinse = 24754
|
||||
|
||||
@@ -288,6 +288,7 @@
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"auto_roast": "Auto roast",
|
||||
"automatic": "Automatic",
|
||||
"automatic_maintenance": "Automatic maintenance",
|
||||
"automatic_plus": "Automatic plus",
|
||||
"baguettes": "Baguettes",
|
||||
"barista_assistant": "BaristaAssistant",
|
||||
@@ -460,6 +461,7 @@
|
||||
"dissolve_gelatine": "Dissolve gelatine",
|
||||
"down_duvets": "Down duvets",
|
||||
"down_filled_items": "Down-filled items",
|
||||
"downs_duvets": "Downs/Duvets",
|
||||
"drain_spin": "Drain/spin",
|
||||
"drop_cookies_1_tray": "Drop cookies (1 tray)",
|
||||
"drop_cookies_2_trays": "Drop cookies (2 trays)",
|
||||
@@ -551,6 +553,7 @@
|
||||
"hygiene": "Hygiene",
|
||||
"intensive": "Intensive",
|
||||
"intensive_bake": "Intensive bake",
|
||||
"intermediate_rinsing": "Intermediate rinsing",
|
||||
"iridescent_shark_fillet": "Iridescent shark (fillet)",
|
||||
"japanese_tea": "Japanese tea",
|
||||
"jasmine_rice_rapid_steam_cooking": "Jasmine rice (rapid steam cooking)",
|
||||
@@ -663,6 +666,7 @@
|
||||
"pike_fillet": "Pike (fillet)",
|
||||
"pike_piece": "Pike (piece)",
|
||||
"pillows": "Pillows",
|
||||
"pillows_sanitize": "Pillows sanitize",
|
||||
"pinto_beans": "Pinto beans",
|
||||
"pizza_oil_cheese_dough_baking_tray": "Pizza, oil cheese dough (baking tray)",
|
||||
"pizza_oil_cheese_dough_round_baking_tine": "Pizza, oil cheese dough (round baking tine)",
|
||||
@@ -730,8 +734,8 @@
|
||||
"potatoes_waxy_whole_small": "Potatoes (waxy, whole, small)",
|
||||
"poularde_breast": "Poularde breast",
|
||||
"poularde_whole": "Poularde (whole)",
|
||||
"power_fresh": "PowerFresh",
|
||||
"power_wash": "PowerWash",
|
||||
"powerfresh": "PowerFresh",
|
||||
"prawns": "Prawns",
|
||||
"pre_ironing": "Pre-ironing",
|
||||
"proofing": "Proofing",
|
||||
@@ -744,7 +748,9 @@
|
||||
"pumpkin_soup": "Pumpkin soup",
|
||||
"pyrolytic": "Pyrolytic",
|
||||
"quiche_lorraine": "Quiche Lorraine",
|
||||
"quick_hygiene": "QuickHygiene",
|
||||
"quick_mw": "Quick MW",
|
||||
"quick_power_dry": "QuickPowerDry",
|
||||
"quick_power_wash": "QuickPowerWash",
|
||||
"quinces_diced": "Quinces (diced)",
|
||||
"quinoa": "Quinoa",
|
||||
@@ -1002,6 +1008,7 @@
|
||||
"normal": "Normal",
|
||||
"normal_plus": "Normal plus",
|
||||
"not_running": "Not running",
|
||||
"perfect_dry_active": "PerfectDry active",
|
||||
"pre_brewing": "Pre-brewing",
|
||||
"pre_dishwash": "Pre-cleaning",
|
||||
"pre_heating": "Pre-heating",
|
||||
@@ -1016,7 +1023,9 @@
|
||||
"rinse_hold": "Rinse hold",
|
||||
"rinse_out_lint": "Rinse out lint",
|
||||
"rinses": "Rinses",
|
||||
"roasting": "Roasting",
|
||||
"safety_cooling": "Safety cooling",
|
||||
"searing": "Searing",
|
||||
"slightly_dry": "Slightly dry",
|
||||
"slow_roasting": "Slow roasting",
|
||||
"smoothing": "Smoothing",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynintendoauth", "pynintendoparental"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2"]
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2.1"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
from nrgkick_api import ChargingStatus
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -632,11 +634,18 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
|
||||
key="vehicle_connected_since",
|
||||
translation_key="vehicle_connected_since",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda data: _seconds_to_stable_timestamp(
|
||||
cast(
|
||||
StateType,
|
||||
_get_nested_dict_value(data.values, "general", "vehicle_connect_time"),
|
||||
value_fn=lambda data: (
|
||||
_seconds_to_stable_timestamp(
|
||||
cast(
|
||||
StateType,
|
||||
_get_nested_dict_value(
|
||||
data.values, "general", "vehicle_connect_time"
|
||||
),
|
||||
)
|
||||
)
|
||||
if _get_nested_dict_value(data.values, "general", "status")
|
||||
!= ChargingStatus.STANDBY
|
||||
else None
|
||||
),
|
||||
),
|
||||
NRGkickSensorEntityDescription(
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
|
||||
from . import OllamaConfigEntry
|
||||
from .const import (
|
||||
@@ -93,7 +94,7 @@ def _convert_content(
|
||||
if isinstance(chat_content, conversation.ToolResultContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.TOOL.value,
|
||||
content=json.dumps(chat_content.tool_result),
|
||||
content=json_dumps(chat_content.tool_result),
|
||||
)
|
||||
if isinstance(chat_content, conversation.AssistantContent):
|
||||
return ollama.Message(
|
||||
|
||||
@@ -148,6 +148,12 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
expires_at = self._entry.data["token"]["expires_at"]
|
||||
_LOGGER.debug(
|
||||
"Starting backup upload, token expiry: %s (in %s seconds)",
|
||||
expires_at,
|
||||
expires_at - time(),
|
||||
)
|
||||
backup_filename, metadata_filename = suggested_filenames(backup)
|
||||
file = FileInfo(
|
||||
backup_filename,
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from time import time
|
||||
|
||||
from onedrive_personal_sdk import OneDriveClient
|
||||
from onedrive_personal_sdk.const import DriveState
|
||||
@@ -58,6 +59,12 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]):
|
||||
|
||||
async def _async_update_data(self) -> Drive:
|
||||
"""Fetch data from API endpoint."""
|
||||
expires_at = self.config_entry.data["token"]["expires_at"]
|
||||
_LOGGER.debug(
|
||||
"Token expiry: %s (in %s seconds)",
|
||||
expires_at,
|
||||
expires_at - time(),
|
||||
)
|
||||
|
||||
try:
|
||||
drive = await self._client.get_drive()
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.1.1"]
|
||||
"requirements": ["onedrive-personal-sdk==0.1.2"]
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
|
||||
from . import OpenRouterConfigEntry
|
||||
from .const import DOMAIN, LOGGER
|
||||
@@ -109,7 +110,7 @@ def _convert_content_to_chat_message(
|
||||
return ChatCompletionToolMessageParam(
|
||||
role="tool",
|
||||
tool_call_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
content=json_dumps(content.tool_result),
|
||||
)
|
||||
|
||||
role: Literal["user", "assistant", "system"] = content.role
|
||||
@@ -130,7 +131,7 @@ def _convert_content_to_chat_message(
|
||||
type="function",
|
||||
id=tool_call.id,
|
||||
function=Function(
|
||||
arguments=json.dumps(tool_call.tool_args),
|
||||
arguments=json_dumps(tool_call.tool_args),
|
||||
name=tool_call.tool_name,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import (
|
||||
@@ -183,7 +184,7 @@ def _convert_content_to_param(
|
||||
FunctionCallOutput(
|
||||
type="function_call_output",
|
||||
call_id=content.tool_call_id,
|
||||
output=json.dumps(content.tool_result),
|
||||
output=json_dumps(content.tool_result),
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -217,7 +218,7 @@ def _convert_content_to_param(
|
||||
ResponseFunctionToolCallParam(
|
||||
type="function_call",
|
||||
name=tool_call.tool_name,
|
||||
arguments=json.dumps(tool_call.tool_args),
|
||||
arguments=json_dumps(tool_call.tool_args),
|
||||
call_id=tool_call.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.7.1"]
|
||||
"requirements": ["python-otbr-api==2.8.0"]
|
||||
}
|
||||
|
||||
@@ -76,27 +76,29 @@ def get_upload_speed(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
|
||||
|
||||
def get_download_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
"""Get current download speed."""
|
||||
"""Get current download speed limit."""
|
||||
server_state = cast(Mapping, coordinator.data.get("server_state"))
|
||||
return cast(int, server_state.get("dl_rate_limit"))
|
||||
|
||||
|
||||
def get_upload_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
"""Get current upload speed."""
|
||||
"""Get current upload speed limit."""
|
||||
server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
|
||||
return cast(int, server_state.get("up_rate_limit"))
|
||||
|
||||
|
||||
def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
"""Get current download speed."""
|
||||
def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int | None:
|
||||
"""Get all-time download volume."""
|
||||
server_state = cast(Mapping, coordinator.data.get("server_state"))
|
||||
return cast(int, server_state.get("alltime_dl"))
|
||||
value = cast(int, server_state.get("alltime_dl"))
|
||||
return value or None
|
||||
|
||||
|
||||
def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
"""Get current download speed."""
|
||||
def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int | None:
|
||||
"""Get all-time upload volume."""
|
||||
server_state = cast(Mapping, coordinator.data.get("server_state"))
|
||||
return cast(int, server_state.get("alltime_ul"))
|
||||
value = cast(int, server_state.get("alltime_ul"))
|
||||
return value or None
|
||||
|
||||
|
||||
def get_global_ratio(coordinator: QBittorrentDataCoordinator) -> float:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyrainbird"],
|
||||
"requirements": ["pyrainbird==6.0.1"]
|
||||
"requirements": ["pyrainbird==6.0.5"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Calendar platform for a Remote Calendar."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from ical.event import Event
|
||||
from ical.timeline import Timeline, materialize_timeline
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -20,6 +21,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
# Every coordinator update refresh, we materialize a timeline of upcoming
|
||||
# events for determining state. This is done in the background to avoid blocking
|
||||
# the event loop. When a state update happens we can scan for active events on
|
||||
# the materialized timeline. These parameters control the maximum lookahead
|
||||
# window and number of events we materialize from the calendar.
|
||||
MAX_LOOKAHEAD_EVENTS = 20
|
||||
MAX_LOOKAHEAD_TIME = timedelta(days=365)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -48,12 +57,18 @@ class RemoteCalendarEntity(
|
||||
super().__init__(coordinator)
|
||||
self._attr_name = entry.data[CONF_CALENDAR_NAME]
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._event: CalendarEvent | None = None
|
||||
self._timeline: Timeline | None = None
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
return self._event
|
||||
if self._timeline is None:
|
||||
return None
|
||||
now = dt_util.now()
|
||||
events = self._timeline.active_after(now)
|
||||
if event := next(events, None):
|
||||
return _get_calendar_event(event)
|
||||
return None
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
@@ -79,14 +94,18 @@ class RemoteCalendarEntity(
|
||||
"""
|
||||
await super().async_update()
|
||||
|
||||
def next_event() -> CalendarEvent | None:
|
||||
def _get_timeline() -> Timeline | None:
|
||||
"""Return a materialized timeline with upcoming events."""
|
||||
now = dt_util.now()
|
||||
events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now)
|
||||
if event := next(events, None):
|
||||
return _get_calendar_event(event)
|
||||
return None
|
||||
timeline = self.coordinator.data.timeline_tz(now.tzinfo)
|
||||
return materialize_timeline(
|
||||
timeline,
|
||||
start=now,
|
||||
stop=now + MAX_LOOKAHEAD_TIME,
|
||||
max_number_of_events=MAX_LOOKAHEAD_EVENTS,
|
||||
)
|
||||
|
||||
self._event = await self.hass.async_add_executor_job(next_event)
|
||||
self._timeline = await self.hass.async_add_executor_job(_get_timeline)
|
||||
|
||||
|
||||
def _get_calendar_event(event: Event) -> CalendarEvent:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==12.1.3"]
|
||||
"requirements": ["ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.18.2"]
|
||||
"requirements": ["reolink-aio==0.19.0"]
|
||||
}
|
||||
|
||||
@@ -87,11 +87,12 @@ NUMBER_ENTITIES = (
|
||||
ReolinkNumberEntityDescription(
|
||||
key="zoom",
|
||||
cmd_key="GetZoomFocus",
|
||||
cmd_id=294,
|
||||
translation_key="zoom",
|
||||
mode=NumberMode.SLIDER,
|
||||
native_step=1,
|
||||
get_min_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["min"],
|
||||
get_max_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["max"],
|
||||
get_min_value=lambda api, ch: api.zoom_range(ch)["zoom"]["min"],
|
||||
get_max_value=lambda api, ch: api.zoom_range(ch)["zoom"]["max"],
|
||||
supported=lambda api, ch: api.supported(ch, "zoom"),
|
||||
value=lambda api, ch: api.get_zoom(ch),
|
||||
method=lambda api, ch, value: api.set_zoom(ch, int(value)),
|
||||
@@ -99,11 +100,12 @@ NUMBER_ENTITIES = (
|
||||
ReolinkNumberEntityDescription(
|
||||
key="focus",
|
||||
cmd_key="GetZoomFocus",
|
||||
cmd_id=294,
|
||||
translation_key="focus",
|
||||
mode=NumberMode.SLIDER,
|
||||
native_step=1,
|
||||
get_min_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["min"],
|
||||
get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"],
|
||||
get_min_value=lambda api, ch: api.zoom_range(ch)["focus"]["min"],
|
||||
get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["max"],
|
||||
supported=lambda api, ch: api.supported(ch, "focus"),
|
||||
value=lambda api, ch: api.get_focus(ch),
|
||||
method=lambda api, ch, value: api.set_focus(ch, int(value)),
|
||||
|
||||
@@ -61,6 +61,7 @@ class ReolinkHostSensorEntityDescription(
|
||||
SENSORS = (
|
||||
ReolinkSensorEntityDescription(
|
||||
key="ptz_pan_position",
|
||||
cmd_id=433,
|
||||
cmd_key="GetPtzCurPos",
|
||||
translation_key="ptz_pan_position",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -70,6 +71,7 @@ SENSORS = (
|
||||
),
|
||||
ReolinkSensorEntityDescription(
|
||||
key="ptz_tilt_position",
|
||||
cmd_id=433,
|
||||
cmd_key="GetPtzCurPos",
|
||||
translation_key="ptz_tilt_position",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
|
||||
@@ -29,17 +29,24 @@ from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from . import RoborockConfigEntry
|
||||
from .const import (
|
||||
CONF_BASE_URL,
|
||||
CONF_ENTRY_CODE,
|
||||
CONF_REGION,
|
||||
CONF_SHOW_BACKGROUND,
|
||||
CONF_USER_DATA,
|
||||
DEFAULT_DRAWABLES,
|
||||
DOMAIN,
|
||||
DRAWABLES,
|
||||
REGION_OPTIONS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -64,17 +71,35 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
username = user_input[CONF_USERNAME]
|
||||
region = user_input[CONF_REGION]
|
||||
self._username = username
|
||||
_LOGGER.debug("Requesting code for Roborock account")
|
||||
base_url = None
|
||||
if region != "auto":
|
||||
base_url = f"https://{region}iot.roborock.com"
|
||||
self._client = RoborockApiClient(
|
||||
username, session=async_get_clientsession(self.hass)
|
||||
username,
|
||||
base_url=base_url,
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
errors = await self._request_code()
|
||||
if not errors:
|
||||
return await self.async_step_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}),
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_REGION, default="auto"): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=REGION_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -114,6 +139,8 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_data = await self._client.code_login_v4(code)
|
||||
except RoborockInvalidCode:
|
||||
errors["base"] = "invalid_code"
|
||||
except RoborockAccountDoesNotExist:
|
||||
errors["base"] = "invalid_email_or_region"
|
||||
except RoborockException:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown_roborock"
|
||||
|
||||
@@ -11,7 +11,8 @@ CONF_ENTRY_CODE = "code"
|
||||
CONF_BASE_URL = "base_url"
|
||||
CONF_USER_DATA = "user_data"
|
||||
CONF_SHOW_BACKGROUND = "show_background"
|
||||
|
||||
CONF_REGION = "region"
|
||||
REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"]
|
||||
# Option Flow steps
|
||||
DRAWABLES = "drawables"
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"invalid_code": "The code you entered was incorrect, please check it and try again.",
|
||||
"invalid_email": "There is no account associated with the email you entered, please try again.",
|
||||
"invalid_email_format": "There is an issue with the formatting of your email - please try again.",
|
||||
"invalid_email_or_region": "Either there is no account associated with the email you entered, or there is no account in the selected region.",
|
||||
"too_frequent_code_requests": "You have attempted to request too many codes. Try again later.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unknown_roborock": "There was an unknown Roborock exception - please check your logs.",
|
||||
@@ -30,9 +31,11 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"region": "Roborock server region",
|
||||
"username": "[%key:common::config_flow::data::email%]"
|
||||
},
|
||||
"data_description": {
|
||||
"region": "The server region your Roborock account is registered in when setting up the app. Auto is recommended unless you are having issues.",
|
||||
"username": "The email address used to sign in to the Roborock app."
|
||||
},
|
||||
"description": "Enter your Roborock email address."
|
||||
@@ -545,6 +548,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"region": {
|
||||
"options": {
|
||||
"auto": "Auto",
|
||||
"cn": "CN",
|
||||
"eu": "EU",
|
||||
"ru": "RU",
|
||||
"us": "US"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_maps": {
|
||||
"description": "Retrieves the map and room information of your device.",
|
||||
|
||||
@@ -104,7 +104,6 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
config = coordinator.device.config
|
||||
self._status = coordinator.device.status
|
||||
|
||||
self._attr_min_temp = config[key]["min"]
|
||||
self._attr_max_temp = config[key]["max"]
|
||||
@@ -142,6 +141,11 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
|
||||
THERMOSTAT_TO_HA_MODE[mode] for mode in modes
|
||||
]
|
||||
|
||||
@property
|
||||
def _status(self) -> dict[str, Any]:
|
||||
"""Return the full device status."""
|
||||
return self.coordinator.device.status
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysmartthings==3.5.1"]
|
||||
"requirements": ["pysmartthings==3.5.3"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smarttub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["smarttub"],
|
||||
"requirements": ["python-smarttub==0.0.46"]
|
||||
"requirements": ["python-smarttub==0.0.47"]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "[%key:component::smarttub::config::step::user::data_description::email%]",
|
||||
"password": "[%key:component::smarttub::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "The SmartTub integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
@@ -17,6 +25,10 @@
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "The email address associated with your SmartTub account",
|
||||
"password": "The password for your SmartTub account"
|
||||
},
|
||||
"description": "Enter your SmartTub email address and password to log in",
|
||||
"title": "Login"
|
||||
}
|
||||
|
||||
@@ -659,23 +659,41 @@ class TelegramNotificationService:
|
||||
|
||||
media: InputMedia
|
||||
if media_type == InputMediaType.ANIMATION:
|
||||
media = InputMediaAnimation(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaAnimation(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
elif media_type == InputMediaType.AUDIO:
|
||||
media = InputMediaAudio(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaAudio(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
elif media_type == InputMediaType.DOCUMENT:
|
||||
media = InputMediaDocument(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaDocument(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
elif media_type == InputMediaType.PHOTO:
|
||||
media = InputMediaPhoto(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaPhoto(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
else:
|
||||
media = InputMediaVideo(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaVideo(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
|
||||
return await self._send_msg(
|
||||
self.bot.edit_message_media,
|
||||
"Error editing message media",
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
media=media,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
inline_message_id=inline_message_id,
|
||||
|
||||
@@ -237,9 +237,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# validate connection to Telegram API
|
||||
errors: dict[str, str] = {}
|
||||
user_input[CONF_API_ENDPOINT] = (
|
||||
user_input[SECTION_ADVANCED_SETTINGS][CONF_API_ENDPOINT],
|
||||
)
|
||||
user_input[CONF_API_ENDPOINT] = user_input[SECTION_ADVANCED_SETTINGS][
|
||||
CONF_API_ENDPOINT
|
||||
]
|
||||
user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
)
|
||||
|
||||
@@ -670,23 +670,41 @@ class WeatherExtraStoredData(ExtraStoredData):
|
||||
@classmethod
|
||||
def from_dict(cls, restored: dict[str, Any]) -> Self | None:
|
||||
"""Initialize a stored event state from a dict."""
|
||||
try:
|
||||
return cls(
|
||||
last_apparent_temperature=restored["last_apparent_temperature"],
|
||||
last_cloud_coverage=restored["last_cloud_coverage"],
|
||||
last_dew_point=restored["last_dew_point"],
|
||||
last_humidity=restored["last_humidity"],
|
||||
last_ozone=restored["last_ozone"],
|
||||
last_pressure=restored["last_pressure"],
|
||||
last_temperature=restored["last_temperature"],
|
||||
last_uv_index=restored["last_uv_index"],
|
||||
last_visibility=restored["last_visibility"],
|
||||
last_wind_bearing=restored["last_wind_bearing"],
|
||||
last_wind_gust_speed=restored["last_wind_gust_speed"],
|
||||
last_wind_speed=restored["last_wind_speed"],
|
||||
)
|
||||
except KeyError:
|
||||
return None
|
||||
for key, vtypes in (
|
||||
("last_apparent_temperature", (float, int)),
|
||||
("last_cloud_coverage", (float, int)),
|
||||
("last_dew_point", (float, int)),
|
||||
("last_humidity", (float, int)),
|
||||
("last_ozone", (float, int)),
|
||||
("last_pressure", (float, int)),
|
||||
("last_temperature", (float, int)),
|
||||
("last_uv_index", (float, int)),
|
||||
("last_visibility", (float, int)),
|
||||
("last_wind_bearing", (float, int, str)),
|
||||
("last_wind_gust_speed", (float, int)),
|
||||
("last_wind_speed", (float, int)),
|
||||
):
|
||||
# This is needed to safeguard against previous restore data that has strings
|
||||
# instead of floats or ints.
|
||||
if key not in restored or (
|
||||
(value := restored[key]) is not None and not isinstance(value, vtypes)
|
||||
):
|
||||
return None
|
||||
|
||||
return cls(
|
||||
last_apparent_temperature=restored["last_apparent_temperature"],
|
||||
last_cloud_coverage=restored["last_cloud_coverage"],
|
||||
last_dew_point=restored["last_dew_point"],
|
||||
last_humidity=restored["last_humidity"],
|
||||
last_ozone=restored["last_ozone"],
|
||||
last_pressure=restored["last_pressure"],
|
||||
last_temperature=restored["last_temperature"],
|
||||
last_uv_index=restored["last_uv_index"],
|
||||
last_visibility=restored["last_visibility"],
|
||||
last_wind_bearing=restored["last_wind_bearing"],
|
||||
last_wind_gust_speed=restored["last_wind_gust_speed"],
|
||||
last_wind_speed=restored["last_wind_speed"],
|
||||
)
|
||||
|
||||
|
||||
class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity):
|
||||
@@ -824,18 +842,18 @@ class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity
|
||||
def extra_restore_state_data(self) -> WeatherExtraStoredData:
|
||||
"""Return weather specific state data to be restored."""
|
||||
return WeatherExtraStoredData(
|
||||
last_apparent_temperature=self._rendered.get(CONF_APPARENT_TEMPERATURE),
|
||||
last_cloud_coverage=self._rendered.get(CONF_CLOUD_COVERAGE),
|
||||
last_dew_point=self._rendered.get(CONF_DEW_POINT),
|
||||
last_humidity=self._rendered.get(CONF_HUMIDITY),
|
||||
last_ozone=self._rendered.get(CONF_OZONE),
|
||||
last_pressure=self._rendered.get(CONF_PRESSURE),
|
||||
last_temperature=self._rendered.get(CONF_TEMPERATURE),
|
||||
last_uv_index=self._rendered.get(CONF_UV_INDEX),
|
||||
last_visibility=self._rendered.get(CONF_VISIBILITY),
|
||||
last_wind_bearing=self._rendered.get(CONF_WIND_BEARING),
|
||||
last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED),
|
||||
last_wind_speed=self._rendered.get(CONF_WIND_SPEED),
|
||||
last_apparent_temperature=self.native_apparent_temperature,
|
||||
last_cloud_coverage=self._attr_cloud_coverage,
|
||||
last_dew_point=self.native_dew_point,
|
||||
last_humidity=self.humidity,
|
||||
last_ozone=self.ozone,
|
||||
last_pressure=self.native_pressure,
|
||||
last_temperature=self.native_temperature,
|
||||
last_uv_index=self.uv_index,
|
||||
last_visibility=self.native_visibility,
|
||||
last_wind_bearing=self.wind_bearing,
|
||||
last_wind_gust_speed=self.native_wind_gust_speed,
|
||||
last_wind_speed=self.native_wind_speed,
|
||||
)
|
||||
|
||||
async def async_get_last_weather_data(self) -> WeatherExtraStoredData | None:
|
||||
|
||||
@@ -10,11 +10,7 @@ from typing import Any, cast
|
||||
import jwt
|
||||
from tesla_fleet_api import TeslaFleetApi
|
||||
from tesla_fleet_api.const import SERVERS
|
||||
from tesla_fleet_api.exceptions import (
|
||||
InvalidResponse,
|
||||
PreconditionFailed,
|
||||
TeslaFleetError,
|
||||
)
|
||||
from tesla_fleet_api.exceptions import PreconditionFailed, TeslaFleetError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
@@ -41,12 +37,9 @@ class OAuth2FlowHandler(
|
||||
"""Initialize config flow."""
|
||||
super().__init__()
|
||||
self.domain: str | None = None
|
||||
self.registration_status: dict[str, bool] = {}
|
||||
self.tesla_apis: dict[str, TeslaFleetApi] = {}
|
||||
self.failed_regions: list[str] = []
|
||||
self.data: dict[str, Any] = {}
|
||||
self.uid: str | None = None
|
||||
self.api: TeslaFleetApi | None = None
|
||||
self.apis: list[TeslaFleetApi] = []
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
@@ -64,7 +57,6 @@ class OAuth2FlowHandler(
|
||||
|
||||
self.data = data
|
||||
self.uid = token["sub"]
|
||||
server = SERVERS[token["ou_code"].lower()]
|
||||
|
||||
await self.async_set_unique_id(self.uid)
|
||||
if self.source == SOURCE_REAUTH:
|
||||
@@ -74,24 +66,28 @@ class OAuth2FlowHandler(
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# OAuth done, setup a Partner API connection
|
||||
# OAuth done, setup Partner API connections for all regions
|
||||
implementation = cast(TeslaUserImplementation, self.flow_impl)
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
self.api = TeslaFleetApi(
|
||||
access_token="",
|
||||
session=session,
|
||||
server=server,
|
||||
partner_scope=True,
|
||||
charging_scope=False,
|
||||
energy_scope=False,
|
||||
user_scope=False,
|
||||
vehicle_scope=False,
|
||||
)
|
||||
await self.api.get_private_key(self.hass.config.path("tesla_fleet.key"))
|
||||
await self.api.partner_login(
|
||||
implementation.client_id, implementation.client_secret
|
||||
)
|
||||
|
||||
for region, server_url in SERVERS.items():
|
||||
if region == "cn":
|
||||
continue
|
||||
api = TeslaFleetApi(
|
||||
session=session,
|
||||
access_token="",
|
||||
server=server_url,
|
||||
partner_scope=True,
|
||||
charging_scope=False,
|
||||
energy_scope=False,
|
||||
user_scope=False,
|
||||
vehicle_scope=False,
|
||||
)
|
||||
await api.get_private_key(self.hass.config.path("tesla_fleet.key"))
|
||||
await api.partner_login(
|
||||
implementation.client_id, implementation.client_secret
|
||||
)
|
||||
self.apis.append(api)
|
||||
|
||||
return await self.async_step_domain_input()
|
||||
|
||||
@@ -130,44 +126,67 @@ class OAuth2FlowHandler(
|
||||
async def async_step_domain_registration(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle domain registration for both regions."""
|
||||
"""Handle domain registration for all regions."""
|
||||
|
||||
assert self.api
|
||||
assert self.api.private_key
|
||||
assert self.apis
|
||||
assert self.apis[0].private_key
|
||||
assert self.domain
|
||||
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders = {
|
||||
"public_key_url": f"https://{self.domain}/.well-known/appspecific/com.tesla.3p.public-key.pem",
|
||||
"pem": self.api.public_pem,
|
||||
"pem": self.apis[0].public_pem,
|
||||
}
|
||||
|
||||
try:
|
||||
register_response = await self.api.partner.register(self.domain)
|
||||
except PreconditionFailed:
|
||||
return await self.async_step_domain_input(
|
||||
errors={CONF_DOMAIN: "precondition_failed"}
|
||||
)
|
||||
except InvalidResponse:
|
||||
successful_response: dict[str, Any] | None = None
|
||||
failed_regions: list[str] = []
|
||||
|
||||
for api in self.apis:
|
||||
try:
|
||||
register_response = await api.partner.register(self.domain)
|
||||
except PreconditionFailed:
|
||||
return await self.async_step_domain_input(
|
||||
errors={CONF_DOMAIN: "precondition_failed"}
|
||||
)
|
||||
except TeslaFleetError as e:
|
||||
LOGGER.warning(
|
||||
"Partner registration failed for %s: %s",
|
||||
api.server,
|
||||
e.message,
|
||||
)
|
||||
failed_regions.append(api.server or "unknown")
|
||||
else:
|
||||
if successful_response is None:
|
||||
successful_response = register_response
|
||||
|
||||
if successful_response is None:
|
||||
errors["base"] = "invalid_response"
|
||||
except TeslaFleetError as e:
|
||||
errors["base"] = "unknown_error"
|
||||
description_placeholders["error"] = e.message
|
||||
else:
|
||||
# Get public key from response
|
||||
registered_public_key = register_response.get("response", {}).get(
|
||||
"public_key"
|
||||
return self.async_show_form(
|
||||
step_id="domain_registration",
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
if not registered_public_key:
|
||||
errors["base"] = "public_key_not_found"
|
||||
elif (
|
||||
registered_public_key.lower()
|
||||
!= self.api.public_uncompressed_point.lower()
|
||||
):
|
||||
errors["base"] = "public_key_mismatch"
|
||||
else:
|
||||
return await self.async_step_registration_complete()
|
||||
if failed_regions:
|
||||
LOGGER.warning(
|
||||
"Partner registration succeeded on some regions but failed on: %s",
|
||||
", ".join(failed_regions),
|
||||
)
|
||||
|
||||
# Verify public key from the successful response
|
||||
registered_public_key = successful_response.get("response", {}).get(
|
||||
"public_key"
|
||||
)
|
||||
|
||||
if not registered_public_key:
|
||||
errors["base"] = "public_key_not_found"
|
||||
elif (
|
||||
registered_public_key.lower()
|
||||
!= self.apis[0].public_uncompressed_point.lower()
|
||||
):
|
||||
errors["base"] = "public_key_mismatch"
|
||||
else:
|
||||
return await self.async_step_registration_complete()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="domain_registration",
|
||||
|
||||
@@ -436,7 +436,6 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="vpp_backup_reserve_percent",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
SensorEntityDescription(key="version"),
|
||||
|
||||
@@ -1529,7 +1529,6 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="vpp_backup_reserve_percent",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
||||
@@ -364,7 +364,6 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
|
||||
TessieSensorEntityDescription(
|
||||
key="vpp_backup_reserve_percent",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ class DatasetEntry:
|
||||
return cast(tlv_parser.Channel, channel).channel
|
||||
|
||||
@cached_property
|
||||
def dataset(self) -> dict[MeshcopTLVType, tlv_parser.MeshcopTLVItem]:
|
||||
def dataset(self) -> dict[MeshcopTLVType | int, tlv_parser.MeshcopTLVItem]:
|
||||
"""Return the dataset in dict format."""
|
||||
return tlv_parser.parse_tlv(self.tlv)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thread",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.7.1", "pyroute2==0.7.5"],
|
||||
"requirements": ["python-otbr-api==2.8.0", "pyroute2==0.7.5"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_meshcop._udp.local."]
|
||||
}
|
||||
|
||||
@@ -93,4 +93,7 @@ COLLABORATORS: Final = "collaborators"
|
||||
|
||||
DOMAIN: Final = "todoist"
|
||||
|
||||
# Maximum number of items per page for Todoist API requests
|
||||
MAX_PAGE_SIZE: Final = 200
|
||||
|
||||
SERVICE_NEW_TASK: Final = "new_task"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""DataUpdateCoordinator for the Todoist component."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
@@ -12,6 +13,8 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import MAX_PAGE_SIZE
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -53,26 +56,30 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]):
|
||||
async def _async_update_data(self) -> list[Task]:
|
||||
"""Fetch tasks from the Todoist API."""
|
||||
try:
|
||||
tasks_async = await self.api.get_tasks()
|
||||
tasks_async = await self.api.get_tasks(limit=MAX_PAGE_SIZE)
|
||||
return await flatten_async_pages(tasks_async)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
return await flatten_async_pages(tasks_async)
|
||||
|
||||
async def async_get_projects(self) -> list[Project]:
|
||||
"""Return todoist projects fetched at most once."""
|
||||
if self._projects is None:
|
||||
projects_async = await self.api.get_projects()
|
||||
projects_async = await self.api.get_projects(limit=MAX_PAGE_SIZE)
|
||||
self._projects = await flatten_async_pages(projects_async)
|
||||
return self._projects
|
||||
|
||||
async def async_get_sections(self, project_id: str) -> list[Section]:
|
||||
"""Return todoist sections for a given project ID."""
|
||||
sections_async = await self.api.get_sections(project_id=project_id)
|
||||
sections_async = await self.api.get_sections(
|
||||
project_id=project_id, limit=MAX_PAGE_SIZE
|
||||
)
|
||||
return await flatten_async_pages(sections_async)
|
||||
|
||||
async def async_get_labels(self) -> list[Label]:
|
||||
"""Return todoist labels fetched at most once."""
|
||||
if self._labels is None:
|
||||
labels_async = await self.api.get_labels()
|
||||
labels_async = await self.api.get_labels(limit=MAX_PAGE_SIZE)
|
||||
self._labels = await flatten_async_pages(labels_async)
|
||||
return self._labels
|
||||
|
||||
@@ -45,9 +45,9 @@ def _task_api_data(item: TodoItem, api_data: Task | None = None) -> dict[str, An
|
||||
}
|
||||
if due := item.due:
|
||||
if isinstance(due, datetime.datetime):
|
||||
item_data["due_datetime"] = due.isoformat()
|
||||
item_data["due_datetime"] = due
|
||||
else:
|
||||
item_data["due_date"] = due.isoformat()
|
||||
item_data["due_date"] = due
|
||||
# In order to not lose any recurrence metadata for the task, we need to
|
||||
# ensure that we send the `due_string` param if the task has it set.
|
||||
# NOTE: It's ok to send stale data for non-recurring tasks. Any provided
|
||||
|
||||
@@ -35,4 +35,8 @@ class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
return super().available and self.zone_id in self.coordinator.data.zones
|
||||
return (
|
||||
super().available
|
||||
and self.zone_id in self.coordinator.data.zones
|
||||
and self.zone.alarm is None
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/touchline_sl",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["pytouchlinesl==0.5.0"]
|
||||
"requirements": ["pytouchlinesl==0.6.0"]
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ TUYA_HVAC_TO_HA = {
|
||||
"heat": HVACMode.HEAT,
|
||||
"hot": HVACMode.HEAT,
|
||||
"manual": HVACMode.HEAT_COOL,
|
||||
"off": HVACMode.OFF,
|
||||
"wet": HVACMode.DRY,
|
||||
"wind": HVACMode.FAN_ONLY,
|
||||
}
|
||||
@@ -442,7 +443,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
if hvac_mode_wrapper:
|
||||
self._attr_hvac_modes = [HVACMode.OFF]
|
||||
for mode in hvac_mode_wrapper.options:
|
||||
self._attr_hvac_modes.append(HVACMode(mode))
|
||||
if mode != HVACMode.OFF:
|
||||
# OFF is always added first
|
||||
self._attr_hvac_modes.append(HVACMode(mode))
|
||||
|
||||
elif switch_wrapper:
|
||||
self._attr_hvac_modes = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user