Compare commits

..

1 Commits

Author SHA1 Message Date
mib1185
5ae036a7e2 sort common state strings 2025-02-25 20:44:51 +00:00
470 changed files with 8185 additions and 46125 deletions

View File

@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: translations
@@ -197,7 +197,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2025.02.0
uses: home-assistant/builder@2024.08.2
with:
args: |
$BUILD_ARGS \
@@ -263,7 +263,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2025.02.0
uses: home-assistant/builder@2024.08.2
with:
args: |
$BUILD_ARGS \
@@ -462,7 +462,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: translations

View File

@@ -942,7 +942,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: pytest_buckets
- name: Compile English translations
@@ -1271,7 +1271,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1410,7 +1410,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
pattern: coverage-*
- name: Upload coverage to Codecov

View File

@@ -138,17 +138,17 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Download env_file
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: requirements_diff
@@ -187,22 +187,22 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Download env_file
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: requirements_diff
- name: Download requirements_all_wheels
uses: actions/download-artifact@v4.1.9
uses: actions/download-artifact@v4.1.8
with:
name: requirements_all_wheels
@@ -218,7 +218,15 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
- name: Split requirements all
run: |
# We split requirements all into multiple files.
# This is to prevent the build from running out of memory when
# resolving packages on 32-bits systems (like armhf, armv7).
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
- name: Build wheels (part 1)
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
@@ -230,4 +238,32 @@ jobs:
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txt"
requirements: "requirements_all.txtaa"
- name: Build wheels (part 2)
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtab"
- name: Build wheels (part 3)
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtac"

1
.vscode/launch.json vendored
View File

@@ -38,6 +38,7 @@
"module": "pytest",
"justMyCode": false,
"args": [
"--timeout=10",
"--picked"
],
},

2
CODEOWNERS generated
View File

@@ -1401,8 +1401,6 @@ build.json @home-assistant/supervisor
/tests/components/smappee/ @bsmappee
/homeassistant/components/smart_meter_texas/ @grahamwetzler
/tests/components/smart_meter_texas/ @grahamwetzler
/homeassistant/components/smartthings/ @joostlek
/tests/components/smartthings/ @joostlek
/homeassistant/components/smarttub/ @mdz
/tests/components/smarttub/ @mdz
/homeassistant/components/smarty/ @z0mbieprocess

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"requirements": ["accuweather==4.1.0"],
"requirements": ["accuweather==4.0.0"],
"single_config_entry": true
}

View File

@@ -14,7 +14,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON
from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant
from homeassistant.exceptions import ServiceNotFound, ServiceValidationError
from homeassistant.exceptions import ServiceNotFound
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import (
async_track_point_in_time,
@@ -195,8 +195,7 @@ class AlertEntity(Entity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Async Acknowledge alert."""
if not self._can_ack:
raise ServiceValidationError("This alert cannot be acknowledged")
LOGGER.debug("Acknowledged Alert: %s", self._attr_name)
self._ack = True
self.async_write_ha_state()

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from functools import partial
import anthropic
from homeassistant.config_entries import ConfigEntry
@@ -22,9 +20,7 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
"""Set up Anthropic from a config entry."""
client = await hass.async_add_executor_job(
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
)
client = anthropic.AsyncAnthropic(api_key=entry.data[CONF_API_KEY])
try:
await client.messages.create(
model="claude-3-haiku-20240307",

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from functools import partial
import logging
from types import MappingProxyType
from typing import Any
@@ -60,9 +59,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
client = await hass.async_add_executor_job(
partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY])
)
client = anthropic.AsyncAnthropic(api_key=data[CONF_API_KEY])
await client.messages.create(
model="claude-3-haiku-20240307",
max_tokens=1,

View File

@@ -1 +0,0 @@
"""Virtual integration: Apollo Automation."""

View File

@@ -1,6 +0,0 @@
{
"domain": "apollo_automation",
"name": "Apollo Automation",
"integration_type": "virtual",
"supported_by": "esphome"
}

View File

@@ -233,6 +233,7 @@ class AppleTVManager(DeviceListener):
pass
except Exception:
_LOGGER.exception("Failed to connect")
await self.disconnect()
async def _connect_loop(self) -> None:
"""Connect loop background task function."""

View File

@@ -1103,16 +1103,12 @@ class PipelineRun:
) & conversation.ConversationEntityFeature.CONTROL:
intent_filter = _async_local_fallback_intent_filter
# Try local intents
if (
intent_response is None
and self.pipeline.prefer_local_intents
and (
intent_response := await conversation.async_handle_intents(
self.hass,
user_input,
intent_filter=intent_filter,
)
# Try local intents first, if preferred.
elif self.pipeline.prefer_local_intents and (
intent_response := await conversation.async_handle_intents(
self.hass,
user_input,
intent_filter=intent_filter,
)
):
# Local intent matched

View File

@@ -14,7 +14,6 @@ from itertools import chain
import json
from pathlib import Path, PurePath
import shutil
import sys
import tarfile
import time
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
@@ -118,7 +117,6 @@ class BackupManagerState(StrEnum):
IDLE = "idle"
CREATE_BACKUP = "create_backup"
BLOCKED = "blocked"
RECEIVE_BACKUP = "receive_backup"
RESTORE_BACKUP = "restore_backup"
@@ -227,13 +225,6 @@ class RestoreBackupEvent(ManagerStateEvent):
state: RestoreBackupState
@dataclass(frozen=True, kw_only=True, slots=True)
class BlockedEvent(ManagerStateEvent):
"""Backup manager blocked, Home Assistant is starting."""
manager_state: BackupManagerState = BackupManagerState.BLOCKED
class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have."""
@@ -317,12 +308,6 @@ class DecryptOnDowloadNotSupported(BackupManagerError):
_message = "On-the-fly decryption is not supported for this backup."
class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup):
"""Raised when multiple exceptions occur."""
error_code = "multiple_errors"
class BackupManager:
"""Define the format that backup managers can have."""
@@ -348,7 +333,7 @@ class BackupManager:
self.remove_next_delete_event: Callable[[], None] | None = None
# Latest backup event and backup event subscribers
self.last_event: ManagerStateEvent = BlockedEvent()
self.last_event: ManagerStateEvent = IdleEvent()
self.last_non_idle_event: ManagerStateEvent | None = None
self._backup_event_subscriptions = hass.data[
DATA_BACKUP
@@ -362,19 +347,10 @@ class BackupManager:
self.known_backups.load(stored["backups"])
await self._reader_writer.async_validate_config(config=self.config)
await self._reader_writer.async_resume_restore_progress_after_restart(
on_progress=self.async_on_backup_event
)
async def set_manager_idle_after_start(hass: HomeAssistant) -> None:
"""Set manager to idle after start."""
self.async_on_backup_event(IdleEvent())
if self.state == BackupManagerState.BLOCKED:
# If we're not finishing a restore job, set the manager to idle after start
start.async_at_started(self.hass, set_manager_idle_after_start)
await self.load_platforms()
@property
@@ -1310,7 +1286,7 @@ class BackupManager:
if (current_state := self.state) != (new_state := event.manager_state):
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
self.last_event = event
if not isinstance(event, (BlockedEvent, IdleEvent)):
if not isinstance(event, IdleEvent):
self.last_non_idle_event = event
for subscription in self._backup_event_subscriptions:
subscription(event)
@@ -1629,24 +1605,10 @@ class CoreBackupReaderWriter(BackupReaderWriter):
)
finally:
# Inform integrations the backup is done
# If there's an unhandled exception, we keep it so we can rethrow it in case
# the post backup actions also fail.
unhandled_exc = sys.exception()
try:
try:
await manager.async_post_backup_actions()
except BackupManagerError as err:
raise BackupReaderWriterError(str(err)) from err
except Exception as err:
if not unhandled_exc:
raise
# If there's an unhandled exception, we wrap both that and the exception
# from the post backup actions in an ExceptionGroup so the caller is
# aware of both exceptions.
raise BackupManagerExceptionGroup(
f"Multiple errors when creating backup: {unhandled_exc}, {err}",
[unhandled_exc, err],
) from None
await manager.async_post_backup_actions()
except BackupManagerError as err:
raise BackupReaderWriterError(str(err)) from err
def _mkdir_and_generate_backup_contents(
self,
@@ -1658,13 +1620,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""Generate backup contents and return the size."""
if not tar_file_path:
tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar"
try:
make_backup_dir(tar_file_path.parent)
except OSError as err:
raise BackupReaderWriterError(
f"Failed to create dir {tar_file_path.parent}: "
f"{err} ({err.__class__.__name__})"
) from err
make_backup_dir(tar_file_path.parent)
excludes = EXCLUDE_FROM_BACKUP
if not database_included:
@@ -1702,14 +1658,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
file_filter=is_excluded_by_filter,
arcname="data",
)
try:
stat_result = tar_file_path.stat()
except OSError as err:
raise BackupReaderWriterError(
f"Error getting size of {tar_file_path}: "
f"{err} ({err.__class__.__name__})"
) from err
return (tar_file_path, stat_result.st_size)
return (tar_file_path, tar_file_path.stat().st_size)
async def async_receive_backup(
self,

View File

@@ -75,9 +75,6 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
# the player can have an ipv6 address, but the api is only available on ipv4
if discovery_info.ip_address.version != 4:
return self.async_abort(reason="no_ipv4_address")
if discovery_info.port is not None:
self._port = discovery_info.port

View File

@@ -19,8 +19,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"no_ipv4_address": "No IPv4 address found."
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.4.4",
"bluetooth-data-tools==1.23.4",
"dbus-fast==2.33.0",
"habluetooth==3.24.1"
"habluetooth==3.24.0"
]
}

View File

@@ -77,12 +77,9 @@ class BringEventEntity(BringBaseEntity, EventEntity):
attributes = asdict(activity.content)
attributes["last_activity_by"] = next(
(
x.name
for x in bring_list.users.users
if x.publicUuid == activity.content.publicUserUuid
),
None,
x.name
for x in bring_list.users.users
if x.publicUuid == activity.content.publicUserUuid
)
self._trigger_event(

View File

@@ -153,27 +153,6 @@ def _has_min_duration(
return validate
def _has_positive_interval(
start_key: str, end_key: str, duration_key: str
) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Verify that the time span between start and end is greater than zero."""
def validate(obj: dict[str, Any]) -> dict[str, Any]:
if (duration := obj.get(duration_key)) is not None:
if duration <= datetime.timedelta(seconds=0):
raise vol.Invalid(f"Expected positive duration ({duration})")
return obj
if (start := obj.get(start_key)) and (end := obj.get(end_key)):
if start >= end:
raise vol.Invalid(
f"Expected end time to be after start time ({start}, {end})"
)
return obj
return validate
def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Verify that all values are of the same type."""
@@ -302,7 +281,6 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All(
),
}
),
_has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION),
)
@@ -892,7 +870,6 @@ async def async_get_events_service(
end = start + service_call.data[EVENT_DURATION]
else:
end = service_call.data[EVENT_END_DATETIME]
calendar_event_list = await calendar.async_get_events(
calendar.hass, dt_util.as_local(start), dt_util.as_local(end)
)

View File

@@ -68,6 +68,7 @@ from .const import ( # noqa: F401
FAN_ON,
FAN_TOP,
HVAC_MODES,
INTENT_GET_TEMPERATURE,
INTENT_SET_TEMPERATURE,
PRESET_ACTIVITY,
PRESET_AWAY,

View File

@@ -126,6 +126,7 @@ DEFAULT_MAX_HUMIDITY = 99
DOMAIN = "climate"
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
SERVICE_SET_AUX_HEAT = "set_aux_heat"

View File

@@ -1,4 +1,4 @@
"""Intents for the climate integration."""
"""Intents for the client integration."""
from __future__ import annotations
@@ -11,6 +11,7 @@ from homeassistant.helpers import config_validation as cv, intent
from . import (
ATTR_TEMPERATURE,
DOMAIN,
INTENT_GET_TEMPERATURE,
INTENT_SET_TEMPERATURE,
SERVICE_SET_TEMPERATURE,
ClimateEntityFeature,
@@ -19,9 +20,49 @@ from . import (
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the climate intents."""
intent.async_register(hass, GetTemperatureIntent())
intent.async_register(hass, SetTemperatureIntent())
class GetTemperatureIntent(intent.IntentHandler):
"""Handle GetTemperature intents."""
intent_type = INTENT_GET_TEMPERATURE
description = "Gets the current temperature of a climate device or entity"
slot_schema = {
vol.Optional("area"): intent.non_empty_string,
vol.Optional("name"): intent.non_empty_string,
}
platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
name: str | None = None
if "name" in slots:
name = slots["name"]["value"]
area: str | None = None
if "area" in slots:
area = slots["area"]["value"]
match_constraints = intent.MatchTargetsConstraints(
name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=match_result.states)
return response
class SetTemperatureIntent(intent.IntentHandler):
"""Handle SetTemperature intents."""

View File

@@ -3,11 +3,11 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from aioelectricitymaps import (
ElectricityMaps,
ElectricityMapsError,
ElectricityMapsInvalidTokenError,
ElectricityMapsNoDataError,
)
@@ -36,8 +36,6 @@ TYPE_USE_HOME = "use_home_location"
TYPE_SPECIFY_COORDINATES = "specify_coordinates"
TYPE_SPECIFY_COUNTRY = "specify_country_code"
_LOGGER = logging.getLogger(__name__)
class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Co2signal."""
@@ -160,8 +158,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except ElectricityMapsNoDataError:
errors["base"] = "no_data"
except Exception:
_LOGGER.exception("Unexpected error occurred while checking API key")
except ElectricityMapsError:
errors["base"] = "unknown"
else:
if self.source == SOURCE_REAUTH:

View File

@@ -49,11 +49,7 @@ def async_get_chat_log(
raise RuntimeError(
"Cannot attach chat log delta listener unless initial caller"
)
if user_input is not None and (
(content := chat_log.content[-1]).role != "user"
# MyPy doesn't understand that content is a UserContent here
or content.content != user_input.text # type: ignore[union-attr]
):
if user_input is not None:
chat_log.async_add_user_content(UserContent(content=user_input.text))
yield chat_log

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.5"]
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
}

View File

@@ -24,14 +24,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
UnitOfTime,
)
from homeassistant.core import (
Event,
EventStateChangedData,
EventStateReportedData,
HomeAssistant,
State,
callback,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.device_registry import DeviceInfo
@@ -39,10 +32,7 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_state_report_event,
)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
@@ -210,33 +200,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
_LOGGER.warning("Could not restore last state: %s", err)
@callback
def on_state_reported(event: Event[EventStateReportedData]) -> None:
"""Handle constant sensor state."""
if self._attr_native_value == Decimal(0):
# If the derivative is zero, and the source sensor hasn't
# changed state, then we know it will still be zero.
return
new_state = event.data["new_state"]
if new_state is not None:
calc_derivative(
new_state, new_state.state, event.data["old_last_reported"]
)
@callback
def on_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle changed sensor state."""
new_state = event.data["new_state"]
old_state = event.data["old_state"]
if new_state is not None and old_state is not None:
calc_derivative(new_state, old_state.state, old_state.last_reported)
def calc_derivative(
new_state: State, old_value: str, old_last_reported: datetime
) -> None:
def calc_derivative(event: Event[EventStateChangedData]) -> None:
"""Handle the sensor state changes."""
if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in (
STATE_UNKNOWN,
STATE_UNAVAILABLE,
if (
(old_state := event.data["old_state"]) is None
or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
or (new_state := event.data["new_state"]) is None
or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
):
return
@@ -250,15 +220,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
self._state_list = [
(time_start, time_end, state)
for time_start, time_end, state in self._state_list
if (new_state.last_reported - time_end).total_seconds()
if (new_state.last_updated - time_end).total_seconds()
< self._time_window
]
try:
elapsed_time = (
new_state.last_reported - old_last_reported
new_state.last_updated - old_state.last_updated
).total_seconds()
delta_value = Decimal(new_state.state) - Decimal(old_value)
delta_value = Decimal(new_state.state) - Decimal(old_state.state)
new_derivative = (
delta_value
/ Decimal(elapsed_time)
@@ -270,7 +240,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
_LOGGER.warning("While calculating derivative: %s", err)
except DecimalException as err:
_LOGGER.warning(
"Invalid state (%s > %s): %s", old_value, new_state.state, err
"Invalid state (%s > %s): %s", old_state.state, new_state.state, err
)
except AssertionError as err:
_LOGGER.error("Could not calculate derivative: %s", err)
@@ -287,7 +257,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
# add latest derivative to the window list
self._state_list.append(
(old_last_reported, new_state.last_reported, new_derivative)
(old_state.last_updated, new_state.last_updated, new_derivative)
)
def calculate_weight(
@@ -307,19 +277,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
else:
derivative = Decimal("0.00")
for start, end, value in self._state_list:
weight = calculate_weight(start, end, new_state.last_reported)
weight = calculate_weight(start, end, new_state.last_updated)
derivative = derivative + (value * Decimal(weight))
self._attr_native_value = round(derivative, self._round_digits)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._sensor_source_id, on_state_changed
)
)
self.async_on_remove(
async_track_state_report_event(
self.hass, self._sensor_source_id, on_state_reported
self.hass, self._sensor_source_id, calc_derivative
)
)

View File

@@ -8,7 +8,6 @@ from devolo_plc_api.device_api import (
WifiGuestAccessGet,
)
from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork
from yarl import URL
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -44,7 +43,7 @@ class DevoloEntity(Entity):
self.entry = entry
self._attr_device_info = DeviceInfo(
configuration_url=URL.build(scheme="http", host=self.device.ip),
configuration_url=f"http://{self.device.ip}",
identifiers={(DOMAIN, str(self.device.serial_number))},
manufacturer="devolo",
model=self.device.product,

View File

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

View File

@@ -6,5 +6,5 @@
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"iot_class": "local_push",
"requirements": ["aioecowitt==2025.3.1"]
"requirements": ["aioecowitt==2024.2.1"]
}

View File

@@ -105,7 +105,6 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_NIGHT
| AlarmControlPanelEntityFeature.ARM_VACATION
)
_element: Area
@@ -205,7 +204,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
ArmedStatus.ARMED_STAY_INSTANT: AlarmControlPanelState.ARMED_HOME,
ArmedStatus.ARMED_TO_NIGHT: AlarmControlPanelState.ARMED_NIGHT,
ArmedStatus.ARMED_TO_NIGHT_INSTANT: AlarmControlPanelState.ARMED_NIGHT,
ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_VACATION,
ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_AWAY,
}
if self._element.alarm_state is None:

View File

@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
"requirements": ["sense-energy==0.13.7"]
"requirements": ["sense-energy==0.13.5"]
}

View File

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

View File

@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
}

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from functools import partial
from math import isfinite
from typing import Any, cast
from aioesphomeapi import (
@@ -239,13 +238,9 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
@esphome_state_property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
if (
not self._static_info.supports_current_humidity
or (val := self._state.current_humidity) is None
or not isfinite(val)
):
if not self._static_info.supports_current_humidity:
return None
return round(val)
return round(self._state.current_humidity)
@property
@esphome_float_state_property

View File

@@ -41,7 +41,6 @@ from .const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN,
@@ -509,10 +508,6 @@ class OptionsFlowHandler(OptionsFlow):
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
),
): bool,
vol.Required(
CONF_SUBSCRIBE_LOGS,
default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)

View File

@@ -5,7 +5,6 @@ from awesomeversion import AwesomeVersion
DOMAIN = "esphome"
CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
CONF_SUBSCRIBE_LOGS = "subscribe_logs"
CONF_DEVICE_NAME = "device_name"
CONF_NOISE_PSK = "noise_psk"
@@ -13,13 +12,11 @@ DEFAULT_ALLOW_SERVICE_CALLS = True
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
STABLE_BLE_VERSION_STR = "2025.2.2"
STABLE_BLE_VERSION_STR = "2023.8.0"
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
PROJECT_URLS = {
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
}
# ESPHome always uses .0 for the changelog URL
STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0"
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html"
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html"
DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy"

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
from functools import partial
import logging
import re
from typing import TYPE_CHECKING, Any, NamedTuple
from aioesphomeapi import (
@@ -17,7 +16,6 @@ from aioesphomeapi import (
HomeassistantServiceCall,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
ReconnectLogic,
RequiresEncryptionAPIError,
UserService,
@@ -35,7 +33,6 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
@@ -64,7 +61,6 @@ from .bluetooth import async_connect_scanner
from .const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_DEVICE_NAME,
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_URL,
DOMAIN,
@@ -78,38 +74,8 @@ from .domain_data import DomainData
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
SubscribeLogsResponse,
)
_LOGGER = logging.getLogger(__name__)
LOG_LEVEL_TO_LOGGER = {
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
LogLevel.LOG_LEVEL_WARN: logging.WARNING,
LogLevel.LOG_LEVEL_INFO: logging.INFO,
LogLevel.LOG_LEVEL_CONFIG: logging.INFO,
LogLevel.LOG_LEVEL_DEBUG: logging.DEBUG,
LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG,
LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG,
}
LOGGER_TO_LOG_LEVEL = {
logging.NOTSET: LogLevel.LOG_LEVEL_VERY_VERBOSE,
logging.DEBUG: LogLevel.LOG_LEVEL_VERY_VERBOSE,
logging.INFO: LogLevel.LOG_LEVEL_CONFIG,
logging.WARNING: LogLevel.LOG_LEVEL_WARN,
logging.ERROR: LogLevel.LOG_LEVEL_ERROR,
logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR,
}
# 7-bit and 8-bit C1 ANSI sequences
# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
ANSI_ESCAPE_78BIT = re.compile(
rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])"
)
@callback
def _async_check_firmware_version(
@@ -170,8 +136,6 @@ class ESPHomeManager:
"""Class to manage an ESPHome connection."""
__slots__ = (
"_cancel_subscribe_logs",
"_log_level",
"cli",
"device_id",
"domain_data",
@@ -205,8 +169,6 @@ class ESPHomeManager:
self.reconnect_logic: ReconnectLogic | None = None
self.zeroconf_instance = zeroconf_instance
self.entry_data = entry.runtime_data
self._cancel_subscribe_logs: CALLBACK_TYPE | None = None
self._log_level = LogLevel.LOG_LEVEL_NONE
async def on_stop(self, event: Event) -> None:
"""Cleanup the socket client on HA close."""
@@ -379,34 +341,6 @@ class ESPHomeManager:
# Re-connection logic will trigger after this
await self.cli.disconnect()
def _async_on_log(self, msg: SubscribeLogsResponse) -> None:
"""Handle a log message from the API."""
log: bytes = msg.message
_LOGGER.log(
LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG),
"%s: %s",
self.entry.title,
ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"),
)
@callback
def _async_get_equivalent_log_level(self) -> LogLevel:
"""Get the equivalent ESPHome log level for the current logger."""
return LOGGER_TO_LOG_LEVEL.get(
_LOGGER.getEffectiveLevel(), LogLevel.LOG_LEVEL_VERY_VERBOSE
)
@callback
def _async_subscribe_logs(self, log_level: LogLevel) -> None:
"""Subscribe to logs."""
if self._cancel_subscribe_logs is not None:
self._cancel_subscribe_logs()
self._cancel_subscribe_logs = None
self._log_level = log_level
self._cancel_subscribe_logs = self.cli.subscribe_logs(
self._async_on_log, self._log_level
)
async def _on_connnect(self) -> None:
"""Subscribe to states and list entities on successful API login."""
entry = self.entry
@@ -418,8 +352,6 @@ class ESPHomeManager:
cli = self.cli
stored_device_name = entry.data.get(CONF_DEVICE_NAME)
unique_id_is_mac_address = unique_id and ":" in unique_id
if entry.options.get(CONF_SUBSCRIBE_LOGS):
self._async_subscribe_logs(self._async_get_equivalent_log_level())
results = await asyncio.gather(
create_eager_task(cli.device_info()),
create_eager_task(cli.list_entities_services()),
@@ -571,10 +503,6 @@ class ESPHomeManager:
def _async_handle_logging_changed(self, _event: Event) -> None:
"""Handle when the logging level changes."""
self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG))
if self.entry.options.get(CONF_SUBSCRIBE_LOGS) and self._log_level != (
new_log_level := self._async_get_equivalent_log_level()
):
self._async_subscribe_logs(new_log_level)
async def async_start(self) -> None:
"""Start the esphome connection manager."""

View File

@@ -16,9 +16,9 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
"aioesphomeapi==29.2.0",
"aioesphomeapi==29.1.1",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.8.0"
"bleak-esphome==2.7.1"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@@ -54,8 +54,7 @@
"step": {
"init": {
"data": {
"allow_service_calls": "Allow the device to perform Home Assistant actions.",
"subscribe_logs": "Subscribe to logs from the device. When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
"allow_service_calls": "Allow the device to perform Home Assistant actions."
}
}
}

View File

@@ -11,7 +11,6 @@ from typing import Any
import evohomeasync as ec1
import evohomeasync2 as ec2
from evohomeasync2.const import (
SZ_DHW,
SZ_GATEWAY_ID,
SZ_GATEWAY_INFO,
SZ_GATEWAYS,
@@ -20,9 +19,8 @@ from evohomeasync2.const import (
SZ_TEMPERATURE_CONTROL_SYSTEMS,
SZ_TIME_ZONE,
SZ_USE_DAYLIGHT_SAVE_SWITCHING,
SZ_ZONES,
)
from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT
from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT
from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
@@ -115,19 +113,17 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
SZ_USE_DAYLIGHT_SAVE_SWITCHING
],
}
tcs_info: EvoTcsConfigResponseT = self.tcs.config # type: ignore[assignment]
tcs_info[SZ_ZONES] = [zone.config for zone in self.tcs.zones]
if self.tcs.hotwater:
tcs_info[SZ_DHW] = self.tcs.hotwater.config
gwy_info = {
SZ_GATEWAY_ID: self.loc.gateways[0].id,
SZ_TEMPERATURE_CONTROL_SYSTEMS: [tcs_info],
SZ_TEMPERATURE_CONTROL_SYSTEMS: [
self.loc.gateways[0].systems[0].config
],
}
config = {
SZ_LOCATION_INFO: loc_info,
SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}],
}
self.logger.debug("Config = %s", [config])
self.logger.debug("Config = %s", config)
async def call_client_api(
self,
@@ -207,18 +203,10 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
async def _update_v2_schedules(self) -> None:
for zone in self.tcs.zones:
try:
await zone.get_schedule()
except ec2.InvalidScheduleError as err:
self.logger.warning(
"Zone '%s' has an invalid/missing schedule: %r", zone.name, err
)
await zone.get_schedule()
if dhw := self.tcs.hotwater:
try:
await dhw.get_schedule()
except ec2.InvalidScheduleError as err:
self.logger.warning("DHW has an invalid/missing schedule: %r", err)
await dhw.get_schedule()
async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override]
"""Fetch the latest state of an entire TCC Location.

View File

@@ -6,7 +6,6 @@ import logging
from typing import Any
import evohomeasync2 as evo
from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -103,7 +102,7 @@ class EvoChild(EvoEntity):
self._evo_tcs = evo_device.tcs
self._schedule: list[DayOfWeekDhwT] | None = None
self._schedule: dict[str, Any] | None = None
self._setpoints: dict[str, Any] = {}
@property
@@ -124,9 +123,6 @@ class EvoChild(EvoEntity):
Only Zones & DHW controllers (but not the TCS) can have schedules.
"""
if not self._schedule:
return self._setpoints
this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint
next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint
@@ -156,10 +152,10 @@ class EvoChild(EvoEntity):
self._evo_device,
err,
)
self._schedule = []
self._schedule = {}
return
else:
self._schedule = schedule # type: ignore[assignment]
self._schedule = schedule or {} # mypy hint
_LOGGER.debug("Schedule['%s'] = %s", self.name, schedule)

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
"quality_scale": "legacy",
"requirements": ["evohome-async==1.0.4"]
"requirements": ["evohome-async==1.0.2"]
}

View File

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

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
"iot_class": "cloud_polling",
"requirements": ["ayla-iot-unofficial==1.4.7"]
"requirements": ["ayla-iot-unofficial==1.4.5"]
}

View File

@@ -65,9 +65,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
prompt_parts = [call.data[CONF_PROMPT]]
config_entry: GoogleGenerativeAIConfigEntry = (
hass.config_entries.async_loaded_entries(DOMAIN)[0]
)
config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries(
DOMAIN
)[0]
client = config_entry.runtime_data

View File

@@ -64,18 +64,28 @@ async def async_setup_entry(
SUPPORTED_SCHEMA_KEYS = {
# Gemini API does not support all of the OpenAPI schema
# SoT: https://ai.google.dev/api/caching#Schema
"type",
"format",
"description",
"nullable",
"enum",
"max_items",
"min_items",
"example",
"property_ordering",
"pattern",
"minimum",
"default",
"any_of",
"max_length",
"title",
"min_properties",
"min_length",
"max_items",
"maximum",
"nullable",
"max_properties",
"type",
"description",
"enum",
"format",
"items",
"properties",
"required",
"items",
}
@@ -99,20 +109,11 @@ def _format_schema(schema: dict[str, Any]) -> Schema:
key = _camel_to_snake(key)
if key not in SUPPORTED_SCHEMA_KEYS:
continue
if key == "any_of":
val = [_format_schema(subschema) for subschema in val]
if key == "type":
val = val.upper()
elif key == "format":
# Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
# formats that are not supported are ignored
if schema.get("type") == "string" and val not in ("enum", "date-time"):
continue
if schema.get("type") == "number" and val not in ("float", "double"):
continue
if schema.get("type") == "integer" and val not in ("int32", "int64"):
continue
if schema.get("type") not in ("string", "number", "integer"):
continue
elif key == "items":
if key == "items":
val = _format_schema(val)
elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()}
@@ -276,13 +277,6 @@ class GoogleGenerativeAIConversationEntity(
):
return await self._async_handle_message(user_input, chat_log)
def _fix_tool_name(self, tool_name: str) -> str:
"""Fix tool name if needed."""
# The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool
# name. This makes sure when it incorrectly changes the name, that we change it
# back for HA to call.
return tool_name if tool_name != "HasListAddItem" else "HassListAddItem"
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
@@ -442,10 +436,7 @@ class GoogleGenerativeAIConversationEntity(
tool_name = tool_call.name
tool_args = _escape_decode(tool_call.args)
tool_calls.append(
llm.ToolInput(
tool_name=self._fix_tool_name(tool_name),
tool_args=tool_args,
)
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
chat_request = _create_google_tool_response_content(

View File

@@ -135,5 +135,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"iot_class": "local_push",
"requirements": ["govee-ble==0.43.1"]
"requirements": ["govee-ble==0.43.0"]
}

View File

@@ -20,4 +20,3 @@ MAX_ERRORS = 2
TARGET_TEMPERATURE_STEP = 1
UPDATE_INTERVAL = 60
MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import copy
from datetime import datetime, timedelta
import logging
from typing import Any
@@ -25,7 +24,6 @@ from .const import (
DISPATCH_DEVICE_DISCOVERED,
DOMAIN,
MAX_ERRORS,
MAX_EXPECTED_RESPONSE_TIME_INTERVAL,
UPDATE_INTERVAL,
)
@@ -50,6 +48,7 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
always_update=False,
)
self.device = device
self.device.add_handler(Response.DATA, self.device_state_updated)
self.device.add_handler(Response.RESULT, self.device_state_updated)
self._error_count: int = 0
@@ -89,9 +88,7 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# raise update failed if time for more than MAX_ERRORS has passed since last update
now = utcnow()
elapsed_success = now - self._last_response_time
if self.update_interval and elapsed_success >= timedelta(
seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL
):
if self.update_interval and elapsed_success >= self.update_interval:
if not self._last_error_time or (
(now - self.update_interval) >= self._last_error_time
):
@@ -99,19 +96,16 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._error_count += 1
_LOGGER.warning(
"Device %s took an unusually long time to respond, %s seconds",
"Device %s is unresponsive for %s seconds",
self.name,
elapsed_success,
)
else:
self._error_count = 0
if self.last_update_success and self._error_count >= MAX_ERRORS:
raise UpdateFailed(
f"Device {self.name} is unresponsive for too long and now unavailable"
)
self._last_response_time = utcnow()
return copy.deepcopy(self.device.raw_properties)
return self.device.raw_properties
async def push_state_update(self):
"""Send state updates to the physical device."""

View File

@@ -26,7 +26,6 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="todayEnergy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="total_output_power",
@@ -34,7 +33,6 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="invTodayPpv",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="total_energy_output",

View File

@@ -14,12 +14,7 @@ from pyheos import (
)
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_IGNORE,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import selector
@@ -146,10 +141,8 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
hostname = urlparse(discovery_info.ssdp_location).hostname
assert hostname is not None
# Abort early when discovery is ignored or host is part of the current system
if entry and (
entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry)
):
# Abort early when discovered host is part of the current system
if entry and hostname in _get_current_hosts(entry):
return self.async_abort(reason="single_instance_allowed")
# Connect to discovered host and get system information
@@ -205,7 +198,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Obtain host and validate connection."""
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured(error="single_instance_allowed")
# Try connecting to host if provided
errors: dict[str, str] = {}

View File

@@ -159,12 +159,13 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
async def _async_on_reconnected(self) -> None:
"""Handle when reconnected so resources are updated and entities marked available."""
await self._async_update_players()
await self._async_update_sources()
_LOGGER.warning("Successfully reconnected to HEOS host %s", self.host)
self.async_update_listeners()
async def _async_on_controller_event(
self, event: str, data: PlayerUpdateResult | None = None
self, event: str, data: PlayerUpdateResult | None
) -> None:
"""Handle a controller event, such as players or groups changed."""
if event == const.EVENT_PLAYERS_CHANGED:

View File

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

View File

@@ -11,6 +11,7 @@ from hko import HKO, HKOError
from homeassistant.components.weather import (
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_FOG,
ATTR_CONDITION_HAIL,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
@@ -144,7 +145,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Return the condition corresponding to the weather info."""
info = info.lower()
if WEATHER_INFO_RAIN in info:
return ATTR_CONDITION_RAINY
return ATTR_CONDITION_HAIL
if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info:
return ATTR_CONDITION_SNOWY_RAINY
if WEATHER_INFO_SNOW in info:

View File

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

View File

@@ -16,17 +16,11 @@ from aiohomeconnect.model import (
SettingKey,
)
from aiohomeconnect.model.error import HomeConnectError
import aiohttp
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, Platform
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_entry_oauth2_flow,
config_validation as cv,
@@ -617,14 +611,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
config_entry_auth = AsyncConfigEntryAuth(hass, session)
try:
await config_entry_auth.async_get_access_token()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
home_connect_client = HomeConnectClient(config_entry_auth)

View File

@@ -72,27 +72,22 @@ def _handle_paired_or_connected_appliance(
for entity in get_option_entities_for_appliance(entry, appliance)
if entity.unique_id not in known_entity_unique_ids
)
for event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
changed_options_listener_remove_callback = (
entry.runtime_data.async_add_listener(
partial(
_create_option_entities,
entry,
appliance,
known_entity_unique_ids,
get_option_entities_for_appliance,
async_add_entities,
),
(appliance.info.ha_id, event_key),
)
)
entry.async_on_unload(changed_options_listener_remove_callback)
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
changed_options_listener_remove_callback
changed_options_listener_remove_callback = (
entry.runtime_data.async_add_listener(
partial(
_create_option_entities,
entry,
appliance,
known_entity_unique_ids,
get_option_entities_for_appliance,
async_add_entities,
),
)
)
entry.async_on_unload(changed_options_listener_remove_callback)
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
changed_options_listener_remove_callback
)
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id

View File

@@ -4,8 +4,6 @@ from typing import cast
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
from .utils import bsh_key_to_translation_key
DOMAIN = "home_connect"
@@ -23,13 +21,6 @@ APPLIANCES_WITH_PROGRAMS = (
"WasherDryer",
)
UNIT_MAP = {
"seconds": UnitOfTime.SECONDS,
"ml": UnitOfVolume.MILLILITERS,
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
}
BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On"
BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off"
@@ -285,7 +276,6 @@ SPIN_SPEED_OPTIONS = {
"LaundryCare.Washer.EnumType.SpinSpeed.RPM400",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM600",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM800",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM900",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1000",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1200",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1400",

View File

@@ -47,6 +47,8 @@ _LOGGER = logging.getLogger(__name__)
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
EVENT_STREAM_RECONNECT_DELAY = 30
@dataclass(frozen=True, kw_only=True)
class HomeConnectApplianceData:
@@ -98,7 +100,6 @@ class HomeConnectCoordinator(
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
] = {}
self.device_registry = dr.async_get(self.hass)
self.data = {}
@cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
@@ -156,20 +157,10 @@ class HomeConnectCoordinator(
async def _event_listener(self) -> None:
"""Match event with listener for event type."""
retry_time = 10
while True:
try:
async for event_message in self.client.stream_all_events():
retry_time = 10
event_message_ha_id = event_message.ha_id
if (
event_message_ha_id in self.data
and not self.data[event_message_ha_id].info.connected
):
self.data[event_message_ha_id].info.connected = True
self._call_all_event_listeners_for_appliance(
event_message_ha_id
)
match event_message.type:
case EventType.STATUS:
statuses = self.data[event_message_ha_id].status
@@ -265,18 +256,20 @@ class HomeConnectCoordinator(
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
_LOGGER.debug(
"Non-breaking error (%s) while listening for events,"
" continuing in %s seconds",
" continuing in 30 seconds",
type(error).__name__,
retry_time,
)
await asyncio.sleep(retry_time)
retry_time = min(retry_time * 2, 3600)
await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY)
except HomeConnectApiError as error:
_LOGGER.error("Error while listening for events: %s", error)
self.hass.config_entries.async_schedule_reload(
self.config_entry.entry_id
)
break
# if there was a non-breaking error, we continue listening
# but we need to refresh the data to get the possible changes
# that happened while the event stream was interrupted
await self.async_refresh()
@callback
def _call_event_listener(self, event_message: EventMessage) -> None:
@@ -304,8 +297,6 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
except HomeConnectError as error:
for appliance_data in self.data.values():
appliance_data.info.connected = False
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_api_error",
@@ -314,7 +305,7 @@ class HomeConnectCoordinator(
return {
appliance.ha_id: await self._get_appliance_data(
appliance, self.data.get(appliance.ha_id)
appliance, self.data.get(appliance.ha_id) if self.data else None
)
for appliance in appliances.homeappliances
}
@@ -449,27 +440,13 @@ class HomeConnectCoordinator(
self, ha_id: str, program_key: ProgramKey
) -> dict[OptionKey, ProgramDefinitionOption]:
"""Get options with constraints for appliance."""
if program_key is ProgramKey.UNKNOWN:
return {}
try:
return {
option.key: option
for option in (
await self.client.get_available_program(
ha_id, program_key=program_key
)
).options
or []
}
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching options for %s: %s",
ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
return {}
return {
option.key: option
for option in (
await self.client.get_available_program(ha_id, program_key=program_key)
).options
or []
}
async def update_options(
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
@@ -479,7 +456,8 @@ class HomeConnectCoordinator(
events = self.data[ha_id].events
options_to_notify = options.copy()
options.clear()
options.update(await self.get_options_definitions(ha_id, program_key))
if program_key is not ProgramKey.UNKNOWN:
options.update(await self.get_options_definitions(ha_id, program_key))
for option in options.values():
option_value = option.constraints.default if option.constraints else None

View File

@@ -8,7 +8,6 @@ from typing import cast
from aiohomeconnect.model import EventKey, OptionKey
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -52,10 +51,8 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_native_value()
available = self._attr_available = self.appliance.info.connected
self.async_write_ha_state()
state = STATE_UNAVAILABLE if not available else self.state
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
@property
def bsh_key(self) -> str:
@@ -64,13 +61,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
@property
def available(self) -> bool:
"""Return True if entity is available.
Do not use self.last_update_success for available state
as event updates should take precedence over the coordinator
refresh.
"""
return self._attr_available
"""Return True if entity is available."""
return (
self.appliance.info.connected and self._attr_available and super().available
)
class HomeConnectOptionEntity(HomeConnectEntity):

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"requirements": ["aiohomeconnect==0.16.3"],
"requirements": ["aiohomeconnect==0.15.0"],
"single_config_entry": true
}

View File

@@ -11,6 +11,7 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -22,7 +23,6 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
UNIT_MAP,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity
@@ -32,6 +32,13 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
UNIT_MAP = {
"seconds": UnitOfTime.SECONDS,
"ml": UnitOfVolume.MILLILITERS,
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
}
NUMBERS = (
NumberEntityDescription(
key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,

View File

@@ -1,12 +1,10 @@
"""Provides a sensor for Home Connect."""
import contextlib
from dataclasses import dataclass
from datetime import timedelta
from typing import cast
from aiohomeconnect.model import EventKey, StatusKey
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -25,7 +23,6 @@ from .const import (
BSH_OPERATION_STATE_FINISHED,
BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_RUN,
UNIT_MAP,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
@@ -43,7 +40,6 @@ class HomeConnectSensorEntityDescription(
default_value: str | None = None
appliance_types: tuple[str, ...] | None = None
fetch_unit: bool = False
BSH_PROGRAM_SENSORS = (
@@ -187,8 +183,7 @@ SENSORS = (
key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="oven_current_cavity_temperature",
fetch_unit=True,
translation_key="current_cavity_temperature",
),
)
@@ -323,29 +318,6 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
case _:
self._attr_native_value = status
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
if self.entity_description.fetch_unit:
data = self.appliance.status[cast(StatusKey, self.bsh_key)]
if data.unit:
self._attr_native_unit_of_measurement = UNIT_MAP.get(
data.unit, data.unit
)
else:
await self.fetch_unit()
async def fetch_unit(self) -> None:
"""Fetch the unit of measurement."""
with contextlib.suppress(HomeConnectError):
data = await self.coordinator.client.get_status_value(
self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
)
if data.unit:
self._attr_native_unit_of_measurement = UNIT_MAP.get(
data.unit, data.unit
)
class HomeConnectProgramSensor(HomeConnectSensor):
"""Sensor class for Home Connect sensors that reports information related to the running program."""
@@ -386,13 +358,6 @@ class HomeConnectProgramSensor(HomeConnectSensor):
def update_native_value(self) -> None:
"""Update the program sensor's status."""
self.program_running = (
status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
) is not None and status.value in [
BSH_OPERATION_STATE_RUN,
BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_FINISHED,
]
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
if event:
self._update_native_value(event.value)

View File

@@ -461,7 +461,6 @@
"laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm",
@@ -1431,7 +1430,6 @@
"laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]",
@@ -1531,8 +1529,8 @@
"map3": "Map 3"
}
},
"oven_current_cavity_temperature": {
"name": "Current oven cavity temperature"
"current_cavity_temperature": {
"name": "Current cavity temperature"
},
"freezer_door_alarm": {
"name": "Freezer door alarm",

View File

@@ -437,21 +437,18 @@ def ws_expose_entity(
def ws_list_exposed_entities(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""List entities which are exposed to assistants."""
"""Expose an entity to an assistant."""
result: dict[str, Any] = {}
exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
entity_registry = er.async_get(hass)
for entity_id in chain(exposed_entities.entities, entity_registry.entities):
exposed_to = {}
result[entity_id] = {}
entity_settings = async_get_entity_settings(hass, entity_id)
for assistant, settings in entity_settings.items():
if "should_expose" not in settings or not settings["should_expose"]:
if "should_expose" not in settings:
continue
exposed_to[assistant] = True
if not exposed_to:
continue
result[entity_id] = exposed_to
result[entity_id][assistant] = settings["should_expose"]
connection.send_result(msg["id"], {"exposed_entities": result})

View File

@@ -1,12 +1,6 @@
{
"entity": {
"sensor": {
"brightness": {
"default": "mdi:brightness-5"
},
"brightness_instance": {
"default": "mdi:brightness-5"
},
"link_quality": {
"default": "mdi:signal"
},
@@ -15,7 +9,7 @@
}
},
"switch": {
"watchdog": {
"watchdog_on_off": {
"default": "mdi:dog"
},
"manual_operation": {

View File

@@ -40,22 +40,10 @@ def get_window_value(attribute: HomeeAttribute) -> str | None:
return vals.get(attribute.current_value)
def get_brightness_device_class(
attribute: HomeeAttribute, device_class: SensorDeviceClass | None
) -> SensorDeviceClass | None:
"""Return the device class for a brightness sensor."""
if attribute.unit == "%":
return None
return device_class
@dataclass(frozen=True, kw_only=True)
class HomeeSensorEntityDescription(SensorEntityDescription):
"""A class that describes Homee sensor entities."""
device_class_fn: Callable[
[HomeeAttribute, SensorDeviceClass | None], SensorDeviceClass | None
] = lambda attribute, device_class: device_class
value_fn: Callable[[HomeeAttribute], str | float | None] = (
lambda value: value.current_value
)
@@ -79,7 +67,6 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
AttributeType.BRIGHTNESS: HomeeSensorEntityDescription(
key="brightness",
device_class=SensorDeviceClass.ILLUMINANCE,
device_class_fn=get_brightness_device_class,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda attribute: attribute.current_value * 1000
@@ -316,9 +303,6 @@ class HomeeSensor(HomeeEntity, SensorEntity):
if attribute.instance > 0:
self._attr_translation_key = f"{self._attr_translation_key}_instance"
self._attr_translation_placeholders = {"instance": str(attribute.instance)}
self._attr_device_class = description.device_class_fn(
attribute, description.device_class
)
@property
def native_value(self) -> float | str | None:

View File

@@ -111,9 +111,6 @@
}
},
"sensor": {
"brightness": {
"name": "Illuminance"
},
"brightness_instance": {
"name": "Illuminance {instance}"
},

View File

@@ -154,6 +154,7 @@ class HKDevice:
self._pending_subscribes: set[tuple[int, int]] = set()
self._subscribe_timer: CALLBACK_TYPE | None = None
self._load_platforms_lock = asyncio.Lock()
self._full_update_requested: bool = False
@property
def entity_map(self) -> Accessories:
@@ -840,11 +841,48 @@ class HKDevice:
async def async_request_update(self, now: datetime | None = None) -> None:
"""Request an debounced update from the accessory."""
self._full_update_requested = True
await self._debounced_update.async_call()
async def async_update(self, now: datetime | None = None) -> None:
"""Poll state of all entities attached to this bridge/accessory."""
to_poll = self.pollable_characteristics
accessories = self.entity_map.accessories
if (
not self._full_update_requested
and len(accessories) == 1
and self.available
and not (to_poll - self.watchable_characteristics)
and self.pairing.is_available
and await self.pairing.controller.async_reachable(
self.unique_id, timeout=5.0
)
):
# If its a single accessory and all chars are watchable,
# only poll the firmware version to keep the connection alive
# https://github.com/home-assistant/core/issues/123412
#
# Firmware revision is used here since iOS does this to keep camera
# connections alive, and the goal is to not regress
# https://github.com/home-assistant/core/issues/116143
# by polling characteristics that are not normally polled frequently
# and may not be tested by the device vendor.
#
_LOGGER.debug(
"Accessory is reachable, limiting poll to firmware version: %s",
self.unique_id,
)
first_accessory = accessories[0]
accessory_info = first_accessory.services.first(
service_type=ServicesTypes.ACCESSORY_INFORMATION
)
assert accessory_info is not None
firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid
to_poll = {(first_accessory.aid, firmware_iid)}
self._full_update_requested = False
if not to_poll:
self.async_update_available_state()
_LOGGER.debug(

View File

@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==3.2.8"],
"requirements": ["aiohomekit==3.2.7"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}

View File

@@ -3,7 +3,6 @@
"step": {
"init": {
"title": "Pick Homematic IP access point",
"description": "If you are about to register a **Homematic IP HCU1**, please press the button on top of the device before you continue.\n\nThe registration process must be completed within 5 minutes.",
"data": {
"hapid": "Access point ID (SGTIN)",
"pin": "[%key:common::config_flow::data::pin%]",

View File

@@ -94,12 +94,7 @@ async def async_setup_devices(bridge: HueBridge):
add_device(hue_resource)
# create/update all current devices found in controllers
# sort the devices to ensure bridges are added first
hue_devices = list(dev_controller)
hue_devices.sort(
key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2
)
known_devices = [add_device(hue_device) for hue_device in hue_devices]
known_devices = [add_device(hue_device) for hue_device in dev_controller]
known_devices += [add_device(hue_room) for hue_room in api.groups.room]
known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone]

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
"requirements": ["pydrawise==2025.3.0"]
"requirements": ["pydrawise==2025.2.0"]
}

View File

@@ -280,7 +280,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
if self.custom_event_template is not None:
try:
data["custom"] = self.custom_event_template.async_render(
data | {"text": message.text}, parse_result=True
data, parse_result=True
)
_LOGGER.debug(
"IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s",

View File

@@ -28,5 +28,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"iot_class": "local_push",
"requirements": ["inkbird-ble==0.7.1"]
"requirements": ["inkbird-ble==0.7.0"]
}

View File

@@ -9,7 +9,6 @@ from aiohttp import web
import voluptuous as vol
from homeassistant.components import http
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN as COVER_DOMAIN,
@@ -141,7 +140,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
intent.async_register(hass, GetCurrentDateIntentHandler())
intent.async_register(hass, GetCurrentTimeIntentHandler())
intent.async_register(hass, RespondIntentHandler())
intent.async_register(hass, GetTemperatureIntent())
return True
@@ -446,48 +444,6 @@ class RespondIntentHandler(intent.IntentHandler):
return response
class GetTemperatureIntent(intent.IntentHandler):
"""Handle GetTemperature intents."""
intent_type = intent.INTENT_GET_TEMPERATURE
description = "Gets the current temperature of a climate device or entity"
slot_schema = {
vol.Optional("area"): intent.non_empty_string,
vol.Optional("name"): intent.non_empty_string,
}
platforms = {CLIMATE_DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
name: str | None = None
if "name" in slots:
name = slots["name"]["value"]
area: str | None = None
if "area" in slots:
area = slots["area"]["value"]
match_constraints = intent.MatchTargetsConstraints(
name=name,
area_name=area,
domains=[CLIMATE_DOMAIN],
assistant=intent_obj.assistant,
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=match_result.states)
return response
async def _async_process_intent(
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
) -> None:

View File

@@ -11,6 +11,7 @@
},
"config_subentries": {
"entity": {
"title": "Add entity",
"step": {
"add_sensor": {
"description": "Configure the new sensor",
@@ -26,12 +27,7 @@
"state": "Initial state"
}
}
},
"initiate_flow": {
"user": "Add sensor",
"reconfigure": "Reconfigure sensor"
},
"entry_type": "Sensor"
}
}
},
"options": {

View File

@@ -11,7 +11,7 @@
"loggers": ["xknx", "xknxproject"],
"requirements": [
"xknx==3.6.0",
"xknxproject==3.8.2",
"xknxproject==3.8.1",
"knx-frontend==2025.1.30.194235"
],
"single_config_entry": true

View File

@@ -110,9 +110,7 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_hvac_modes = [HVACMode.OFF]
self._attr_hvac_mode = HVACMode.OFF
self._attr_preset_modes = []
self._attr_temperature_unit = (
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
)
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
self._requested_hvac_mode: str | None = None
# Set up HVAC modes.
@@ -184,11 +182,6 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_target_temperature_high = self.data.target_temp_high
self._attr_target_temperature_low = self.data.target_temp_low
# Update unit.
self._attr_temperature_unit = (
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
)
_LOGGER.debug(
"[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s",
self.coordinator.device_name,

View File

@@ -3,8 +3,6 @@
from datetime import timedelta
from typing import Final
from homeassistant.const import UnitOfTemperature
# Config flow
DOMAIN = "lg_thinq"
COMPANY = "LGE"
@@ -20,10 +18,3 @@ MQTT_SUBSCRIPTION_INTERVAL: Final = timedelta(days=1)
# MQTT: Message types
DEVICE_PUSH_MESSAGE: Final = "DEVICE_PUSH"
DEVICE_STATUS_MESSAGE: Final = "DEVICE_STATUS"
# Unit conversion map
DEVICE_UNIT_TO_HA: dict[str, str] = {
"F": UnitOfTemperature.FAHRENHEIT,
"C": UnitOfTemperature.CELSIUS,
}
REVERSE_DEVICE_UNIT_TO_HA = {v: k for k, v in DEVICE_UNIT_TO_HA.items()}

View File

@@ -2,21 +2,19 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import TYPE_CHECKING, Any
from thinqconnect import ThinQAPIException
from thinqconnect.integration import HABridge
from homeassistant.const import EVENT_CORE_CONFIG_UPDATE
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
if TYPE_CHECKING:
from . import ThinqConfigEntry
from .const import DOMAIN, REVERSE_DEVICE_UNIT_TO_HA
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -56,40 +54,6 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id
)
# Set your preferred temperature unit. This will allow us to retrieve
# temperature values from the API in a converted value corresponding to
# preferred unit.
self._update_preferred_temperature_unit()
# Add a callback to handle core config update.
self.unit_system: str | None = None
self.hass.bus.async_listen(
event_type=EVENT_CORE_CONFIG_UPDATE,
listener=self._handle_update_config,
event_filter=self.async_config_update_filter,
)
async def _handle_update_config(self, _: Event) -> None:
"""Handle update core config."""
self._update_preferred_temperature_unit()
await self.async_refresh()
@callback
def async_config_update_filter(self, event_data: Mapping[str, Any]) -> bool:
"""Filter out unwanted events."""
if (unit_system := event_data.get("unit_system")) != self.unit_system:
self.unit_system = unit_system
return True
return False
def _update_preferred_temperature_unit(self) -> None:
"""Update preferred temperature unit."""
self.api.set_preferred_temperature_unit(
REVERSE_DEVICE_UNIT_TO_HA.get(self.hass.config.units.temperature_unit)
)
async def _async_update_data(self) -> dict[str, Any]:
"""Request to the server to update the status from full response data."""
try:

View File

@@ -10,19 +10,25 @@ from thinqconnect import ThinQAPIException
from thinqconnect.devices.const import Location
from thinqconnect.integration import PropertyState
from homeassistant.const import UnitOfTemperature
from homeassistant.core import callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import COMPANY, DEVICE_UNIT_TO_HA, DOMAIN
from .const import COMPANY, DOMAIN
from .coordinator import DeviceDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
EMPTY_STATE = PropertyState()
UNIT_CONVERSION_MAP: dict[str, str] = {
"F": UnitOfTemperature.FAHRENHEIT,
"C": UnitOfTemperature.CELSIUS,
}
class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
"""The base implementation of all lg thinq entities."""
@@ -69,7 +75,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
if unit is None:
return None
return DEVICE_UNIT_TO_HA.get(unit)
return UNIT_CONVERSION_MAP.get(unit)
def _update_status(self) -> None:
"""Update status itself.

View File

@@ -66,7 +66,7 @@
}
},
"set_state": {
"name": "Set state",
"name": "Set State",
"description": "Sets a color/brightness and possibly turn the light on/off.",
"fields": {
"infrared": {
@@ -209,11 +209,11 @@
},
"palette": {
"name": "Palette",
"description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect. Overrides the 'Theme' attribute."
"description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute."
},
"theme": {
"name": "[%key:component::lifx::entity::select::theme::name%]",
"description": "Predefined color theme to use for the effect. Overridden by the 'Palette' attribute."
"description": "Predefined color theme to use for the effect. Overridden by the palette attribute."
},
"power_on": {
"name": "Power on",
@@ -243,7 +243,7 @@
},
"palette": {
"name": "Palette",
"description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect."
"description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect."
},
"power_on": {
"name": "Power on",
@@ -256,16 +256,16 @@
"description": "Stops a running effect."
},
"paint_theme": {
"name": "Paint theme",
"description": "Paints either a provided theme or custom palette across one or more LIFX lights.",
"name": "Paint Theme",
"description": "Paint either a provided theme or custom palette across one or more LIFX lights.",
"fields": {
"palette": {
"name": "Palette",
"description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to paint across the target lights. Overrides the 'Theme' attribute."
"description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute."
},
"theme": {
"name": "[%key:component::lifx::entity::select::theme::name%]",
"description": "Predefined color theme to paint. Overridden by the 'Palette' attribute."
"description": "Predefined color theme to paint. Overridden by the palette attribute."
},
"transition": {
"name": "Transition",

View File

@@ -1,6 +1,6 @@
{
"domain": "matter",
"name": "Matter",
"name": "Matter (BETA)",
"after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/matter"],
"config_flow": true,

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2025.02.19"],
"requirements": ["yt-dlp[default]==2025.01.26"],
"single_config_entry": true
}

View File

@@ -218,16 +218,10 @@ ABBREVIATIONS = {
"sup_vol": "support_volume_set",
"sup_feat": "supported_features",
"sup_clrm": "supported_color_modes",
"swing_h_mode_cmd_tpl": "swing_horizontal_mode_command_template",
"swing_h_mode_cmd_t": "swing_horizontal_mode_command_topic",
"swing_h_mode_stat_tpl": "swing_horizontal_mode_state_template",
"swing_h_mode_stat_t": "swing_horizontal_mode_state_topic",
"swing_h_modes": "swing_horizontal_modes",
"swing_mode_cmd_tpl": "swing_mode_command_template",
"swing_mode_cmd_t": "swing_mode_command_topic",
"swing_mode_stat_tpl": "swing_mode_state_template",
"swing_mode_stat_t": "swing_mode_state_topic",
"swing_modes": "swing_modes",
"temp_cmd_tpl": "temperature_command_template",
"temp_cmd_t": "temperature_command_topic",
"temp_hi_cmd_tpl": "temperature_high_command_template",

View File

@@ -15,7 +15,6 @@ import socket
import ssl
import time
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import certifi
@@ -293,7 +292,7 @@ class MqttClientSetup:
"""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
# pylint: disable-next=import-outside-toplevel
from .async_client import AsyncMQTTClient
@@ -310,10 +309,9 @@ class MqttClientSetup:
clean_session = True
if (client_id := config.get(CONF_CLIENT_ID)) is None:
# PAHO MQTT relies on the MQTT server to generate random client ID
# for protocol version 3.1, however, that feature is not mandatory
# so we generate our own.
client_id = mqtt._base62(uuid4().int, padding=22) # noqa: SLF001
# PAHO MQTT relies on the MQTT server to generate random client IDs.
# However, that feature is not mandatory so we generate our own.
client_id = None
transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
self._client = AsyncMQTTClient(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,

View File

@@ -113,19 +113,11 @@ CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic"
CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template"
CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template"
CONF_PRESET_MODES_LIST = "preset_modes"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic"
CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes"
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template"
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic"
CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template"
CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic"
CONF_SWING_MODE_LIST = "swing_modes"
CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template"
CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic"
CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template"
CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic"
CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template"
@@ -153,8 +145,6 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset(
climate.ATTR_MIN_TEMP,
climate.ATTR_PRESET_MODE,
climate.ATTR_PRESET_MODES,
climate.ATTR_SWING_HORIZONTAL_MODE,
climate.ATTR_SWING_HORIZONTAL_MODES,
climate.ATTR_SWING_MODE,
climate.ATTR_SWING_MODES,
climate.ATTR_TARGET_TEMP_HIGH,
@@ -172,7 +162,6 @@ VALUE_TEMPLATE_KEYS = (
CONF_MODE_STATE_TEMPLATE,
CONF_ACTION_TEMPLATE,
CONF_PRESET_MODE_VALUE_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE,
CONF_SWING_MODE_STATE_TEMPLATE,
CONF_TEMP_HIGH_STATE_TEMPLATE,
CONF_TEMP_LOW_STATE_TEMPLATE,
@@ -185,7 +174,6 @@ COMMAND_TEMPLATE_KEYS = {
CONF_MODE_COMMAND_TEMPLATE,
CONF_POWER_COMMAND_TEMPLATE,
CONF_PRESET_MODE_COMMAND_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE,
CONF_SWING_MODE_COMMAND_TEMPLATE,
CONF_TEMP_COMMAND_TEMPLATE,
CONF_TEMP_HIGH_COMMAND_TEMPLATE,
@@ -206,8 +194,6 @@ TOPIC_KEYS = (
CONF_POWER_COMMAND_TOPIC,
CONF_PRESET_MODE_COMMAND_TOPIC,
CONF_PRESET_MODE_STATE_TOPIC,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC,
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC,
CONF_SWING_MODE_COMMAND_TOPIC,
CONF_SWING_MODE_STATE_TOPIC,
CONF_TEMP_COMMAND_TOPIC,
@@ -316,13 +302,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(
CONF_SWING_HORIZONTAL_MODE_LIST, default=[SWING_ON, SWING_OFF]
): cv.ensure_list,
vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE): cv.template,
vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(
@@ -536,7 +515,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
_attr_fan_mode: str | None = None
_attr_hvac_mode: HVACMode | None = None
_attr_swing_horizontal_mode: str | None = None
_attr_swing_mode: str | None = None
_default_name = DEFAULT_NAME
_entity_id_format = climate.ENTITY_ID_FORMAT
@@ -565,7 +543,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
if (precision := config.get(CONF_PRECISION)) is not None:
self._attr_precision = precision
self._attr_fan_modes = config[CONF_FAN_MODE_LIST]
self._attr_swing_horizontal_modes = config[CONF_SWING_HORIZONTAL_MODE_LIST]
self._attr_swing_modes = config[CONF_SWING_MODE_LIST]
self._attr_target_temperature_step = config[CONF_TEMP_STEP]
@@ -591,11 +568,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic:
self._attr_fan_mode = FAN_LOW
if (
self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None
or self._optimistic
):
self._attr_swing_horizontal_mode = SWING_OFF
if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None or self._optimistic:
self._attr_swing_mode = SWING_OFF
if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic:
@@ -657,11 +629,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
):
support |= ClimateEntityFeature.FAN_MODE
if (self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is not None) or (
self._topic[CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC] is not None
):
support |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
if (self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or (
self._topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None
):
@@ -777,16 +744,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
),
{"_attr_fan_mode"},
)
self.add_subscription(
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC,
partial(
self._handle_mode_received,
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE,
"_attr_swing_horizontal_mode",
CONF_SWING_HORIZONTAL_MODE_LIST,
),
{"_attr_swing_horizontal_mode"},
)
self.add_subscription(
CONF_SWING_MODE_STATE_TOPIC,
partial(
@@ -825,20 +782,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
self.async_write_ha_state()
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Set new swing horizontal mode."""
payload = self._command_templates[CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE](
swing_horizontal_mode
)
await self._publish(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, payload)
if (
self._optimistic
or self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None
):
self._attr_swing_horizontal_mode = swing_horizontal_mode
self.async_write_ha_state()
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new swing mode."""
payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](swing_mode)

View File

@@ -31,6 +31,7 @@ from homeassistant.components.light import (
LightEntity,
LightEntityFeature,
brightness_supported,
color_supported,
valid_supported_color_modes,
)
from homeassistant.const import (
@@ -216,10 +217,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
self._attr_color_mode = next(iter(self.supported_color_modes))
else:
self._attr_color_mode = ColorMode.UNKNOWN
elif config.get(CONF_BRIGHTNESS):
# Brightness is supported and no supported_color_modes are set,
# so set brightness as the supported color mode.
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def _update_color(self, values: dict[str, Any]) -> None:
color_mode: str = values["color_mode"]
@@ -292,7 +289,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
elif values["state"] is None:
self._attr_is_on = None
if "color_mode" in values:
if color_supported(self.supported_color_modes) and "color_mode" in values:
self._update_color(values)
if brightness_supported(self.supported_color_modes):

View File

@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING
from music_assistant_client import MusicAssistantClient
from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion
from music_assistant_models.enums import EventType
from music_assistant_models.errors import ActionUnavailable, MusicAssistantError
from music_assistant_models.errors import MusicAssistantError
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
@@ -23,7 +23,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
from .actions import get_music_assistant_client, register_actions
from .actions import register_actions
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
@@ -137,18 +137,6 @@ async def async_setup_entry(
mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED)
)
# check if any playerconfigs have been removed while we were disconnected
all_player_configs = await mass.config.get_player_configs()
player_ids = {player.player_id for player in all_player_configs}
dev_reg = dr.async_get(hass)
dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
for device in dev_entries:
for identifier in device.identifiers:
if identifier[0] == DOMAIN and identifier[1] not in player_ids:
dev_reg.async_update_device(
device.id, remove_config_entry_id=entry.entry_id
)
return True
@@ -186,31 +174,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await mass_entry_data.mass.disconnect()
return unload_ok
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
player_id = next(
(
identifier[1]
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
),
None,
)
if player_id is None:
# this should not be possible at all, but guard it anyways
return False
mass = get_music_assistant_client(hass, config_entry.entry_id)
if mass.players.get(player_id) is None:
# player is already removed on the server, this is an orphaned device
return True
# try to remove the player from the server
try:
await mass.config.remove_player_config(player_id)
except ActionUnavailable:
return False
else:
return True

View File

@@ -23,7 +23,6 @@ from .const import (
ATTR_ALBUM_TYPE,
ATTR_ALBUMS,
ATTR_ARTISTS,
ATTR_AUDIOBOOKS,
ATTR_CONFIG_ENTRY_ID,
ATTR_FAVORITE,
ATTR_ITEMS,
@@ -33,7 +32,6 @@ from .const import (
ATTR_OFFSET,
ATTR_ORDER_BY,
ATTR_PLAYLISTS,
ATTR_PODCASTS,
ATTR_RADIO,
ATTR_SEARCH,
ATTR_SEARCH_ALBUM,
@@ -50,15 +48,6 @@ from .schemas import (
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from music_assistant_models.media_items import (
Album,
Artist,
Audiobook,
Playlist,
Podcast,
Radio,
Track,
)
from . import MusicAssistantConfigEntry
@@ -165,14 +154,6 @@ async def handle_search(call: ServiceCall) -> ServiceResponse:
media_item_dict_from_mass_item(mass, item)
for item in search_results.radio
],
ATTR_AUDIOBOOKS: [
media_item_dict_from_mass_item(mass, item)
for item in search_results.audiobooks
],
ATTR_PODCASTS: [
media_item_dict_from_mass_item(mass, item)
for item in search_results.podcasts
],
}
)
return response
@@ -192,15 +173,6 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
"offset": offset,
"order_by": order_by,
}
library_result: (
list[Album]
| list[Artist]
| list[Track]
| list[Radio]
| list[Playlist]
| list[Audiobook]
| list[Podcast]
)
if media_type == MediaType.ALBUM:
library_result = await mass.music.get_library_albums(
**base_params,
@@ -209,7 +181,7 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
elif media_type == MediaType.ARTIST:
library_result = await mass.music.get_library_artists(
**base_params,
album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)),
album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY),
)
elif media_type == MediaType.TRACK:
library_result = await mass.music.get_library_tracks(
@@ -223,14 +195,6 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
library_result = await mass.music.get_library_playlists(
**base_params,
)
elif media_type == MediaType.AUDIOBOOK:
library_result = await mass.music.get_library_audiobooks(
**base_params,
)
elif media_type == MediaType.PODCAST:
library_result = await mass.music.get_library_podcasts(
**base_params,
)
else:
raise ServiceValidationError(f"Unsupported media type {media_type}")

View File

@@ -34,8 +34,6 @@ ATTR_ARTISTS = "artists"
ATTR_ALBUMS = "albums"
ATTR_TRACKS = "tracks"
ATTR_PLAYLISTS = "playlists"
ATTR_AUDIOBOOKS = "audiobooks"
ATTR_PODCASTS = "podcasts"
ATTR_RADIO = "radio"
ATTR_ITEMS = "items"
ATTR_RADIO_MODE = "radio_mode"

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
"loggers": ["music_assistant"],
"requirements": ["music-assistant-client==1.1.1"],
"requirements": ["music-assistant-client==1.0.8"],
"zeroconf": ["_mass._tcp.local."]
}

View File

@@ -166,8 +166,6 @@ async def build_playlist_items_listing(
) -> BrowseMedia:
"""Build Playlist items browse listing."""
playlist = await mass.music.get_item_by_uri(identifier)
if TYPE_CHECKING:
assert playlist.uri is not None
return BrowseMedia(
media_class=MediaClass.PLAYLIST,
@@ -221,9 +219,6 @@ async def build_artist_items_listing(
artist = await mass.music.get_item_by_uri(identifier)
albums = await mass.music.get_artist_albums(artist.item_id, artist.provider)
if TYPE_CHECKING:
assert artist.uri is not None
return BrowseMedia(
media_class=MediaType.ARTIST,
media_content_id=artist.uri,
@@ -272,9 +267,6 @@ async def build_album_items_listing(
album = await mass.music.get_item_by_uri(identifier)
tracks = await mass.music.get_album_tracks(album.item_id, album.provider)
if TYPE_CHECKING:
assert album.uri is not None
return BrowseMedia(
media_class=MediaType.ALBUM,
media_content_id=album.uri,
@@ -348,9 +340,6 @@ def build_item(
title = item.name
img_url = mass.get_media_item_image_url(item)
if TYPE_CHECKING:
assert item.uri is not None
return BrowseMedia(
media_class=media_class or item.media_type.value,
media_content_id=item.uri,

View File

@@ -9,7 +9,6 @@ import functools
import os
from typing import TYPE_CHECKING, Any, Concatenate
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
EventType,
MediaType,
@@ -21,7 +20,6 @@ from music_assistant_models.enums import (
from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
from music_assistant_models.player_queue import PlayerQueue
import voluptuous as vol
from homeassistant.components import media_source
@@ -80,15 +78,21 @@ from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from music_assistant_models.player import Player
from music_assistant_models.player_queue import PlayerQueue
SUPPORTED_FEATURES_BASE = (
MediaPlayerEntityFeature.STOP
SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
@@ -208,7 +212,11 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Initialize MediaPlayer entity."""
super().__init__(mass, player_id)
self._attr_icon = self.player.icon.replace("mdi-", "mdi:")
self._set_supported_features()
self._attr_supported_features = SUPPORTED_FEATURES
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING
if PlayerFeature.VOLUME_MUTE in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
self._prev_time: float = 0
@@ -233,19 +241,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
)
)
# we subscribe to the player config changed event to update
# the supported features of the player
async def player_config_changed(event: MassEvent) -> None:
self._set_supported_features()
await self.async_on_update()
self.async_write_ha_state()
self.async_on_remove(
self.mass.subscribe(
player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id
)
)
@property
def active_queue(self) -> PlayerQueue | None:
"""Return the active queue for this player (if any)."""
@@ -276,26 +271,22 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self._attr_state = MediaPlayerState(player.state.value)
else:
self._attr_state = MediaPlayerState(STATE_OFF)
group_members: list[str] = []
group_members_entity_ids: list[str] = []
if player.group_childs:
group_members = player.group_childs
elif player.synced_to and (parent := self.mass.players.get(player.synced_to)):
group_members = parent.group_childs
# translate MA group_childs to HA group_members as entity id's
entity_registry = er.async_get(self.hass)
group_members_entity_ids: list[str] = [
entity_id
for child_id in group_members
if (
entity_id := entity_registry.async_get_entity_id(
self.platform.domain, DOMAIN, child_id
# translate MA group_childs to HA group_members as entity id's
entity_registry = er.async_get(self.hass)
group_members_entity_ids = [
entity_id
for child_id in player.group_childs
if (
entity_id := entity_registry.async_get_entity_id(
self.platform.domain, DOMAIN, child_id
)
)
)
]
self._attr_group_members = group_members_entity_ids
]
# NOTE: we sort the group_members for now,
# until the MA API returns them sorted (group_childs is now a set)
self._attr_group_members = sorted(group_members_entity_ids)
self._attr_volume_level = (
player.volume_level / 100 if player.volume_level is not None else None
)
@@ -482,8 +473,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
album=album,
media_type=MediaType(media_type) if media_type else None,
):
if TYPE_CHECKING:
assert item.uri is not None
media_uris.append(item.uri)
if not media_uris:
@@ -691,20 +680,3 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
if isinstance(queue_option, MediaPlayerEnqueue):
queue_option = QUEUE_OPTION_MAP.get(queue_option)
return queue_option
def _set_supported_features(self) -> None:
"""Set supported features based on player capabilities."""
supported_features = SUPPORTED_FEATURES_BASE
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.GROUPING
if PlayerFeature.PAUSE in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.PAUSE
if self.player.mute_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
if self.player.volume_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.VOLUME_STEP
supported_features |= MediaPlayerEntityFeature.VOLUME_SET
if self.player.power_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.TURN_ON
supported_features |= MediaPlayerEntityFeature.TURN_OFF
self._attr_supported_features = supported_features

View File

@@ -15,7 +15,6 @@ from .const import (
ATTR_ALBUM,
ATTR_ALBUMS,
ATTR_ARTISTS,
ATTR_AUDIOBOOKS,
ATTR_BIT_DEPTH,
ATTR_CONTENT_TYPE,
ATTR_CURRENT_INDEX,
@@ -32,7 +31,6 @@ from .const import (
ATTR_OFFSET,
ATTR_ORDER_BY,
ATTR_PLAYLISTS,
ATTR_PODCASTS,
ATTR_PROVIDER,
ATTR_QUEUE_ID,
ATTR_QUEUE_ITEM_ID,
@@ -67,20 +65,20 @@ MEDIA_ITEM_SCHEMA = vol.Schema(
def media_item_dict_from_mass_item(
mass: MusicAssistantClient,
item: MediaItemType | ItemMapping,
) -> dict[str, Any]:
item: MediaItemType | ItemMapping | None,
) -> dict[str, Any] | None:
"""Parse a Music Assistant MediaItem."""
base: dict[str, Any] = {
if not item:
return None
base = {
ATTR_MEDIA_TYPE: item.media_type,
ATTR_URI: item.uri,
ATTR_NAME: item.name,
ATTR_VERSION: item.version,
ATTR_IMAGE: mass.get_media_item_image_url(item),
}
artists: list[ItemMapping] | None
if artists := getattr(item, "artists", None):
base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists]
album: ItemMapping | None
if album := getattr(item, "album", None):
base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album)
return base
@@ -103,12 +101,6 @@ SEARCH_RESULT_SCHEMA = vol.Schema(
vol.Required(ATTR_RADIO): vol.All(
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
),
vol.Required(ATTR_AUDIOBOOKS): vol.All(
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
),
vol.Required(ATTR_PODCASTS): vol.All(
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
),
},
)
@@ -159,11 +151,7 @@ def queue_item_dict_from_mass_item(
ATTR_QUEUE_ITEM_ID: item.queue_item_id,
ATTR_NAME: item.name,
ATTR_DURATION: item.duration,
ATTR_MEDIA_ITEM: (
media_item_dict_from_mass_item(mass, item.media_item)
if item.media_item
else None
),
ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item),
}
if streamdetails := item.streamdetails:
base[ATTR_STREAM_TITLE] = streamdetails.stream_title

View File

@@ -21,10 +21,7 @@ play_media:
options:
- artist
- album
- audiobook
- folder
- playlist
- podcast
- track
- radio
artist:
@@ -121,9 +118,7 @@ search:
options:
- artist
- album
- audiobook
- playlist
- podcast
- track
- radio
artist:
@@ -165,9 +160,7 @@ get_library:
options:
- artist
- album
- audiobook
- playlist
- podcast
- track
- radio
favorite:

View File

@@ -195,11 +195,8 @@
"options": {
"artist": "Artist",
"album": "Album",
"audiobook": "Audiobook",
"folder": "Folder",
"track": "Track",
"playlist": "Playlist",
"podcast": "Podcast",
"radio": "Radio"
}
},

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/neato",
"iot_class": "cloud_polling",
"loggers": ["pybotvac"],
"requirements": ["pybotvac==0.0.26"]
"requirements": ["pybotvac==0.0.25"]
}

View File

@@ -20,7 +20,7 @@ from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle, dt as dt_util
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
@@ -119,8 +119,6 @@ class NSDepartureSensor(SensorEntity):
self._time = time
self._state = None
self._trips = None
self._first_trip = None
self._next_trip = None
@property
def name(self):
@@ -135,44 +133,44 @@ class NSDepartureSensor(SensorEntity):
@property
def extra_state_attributes(self):
"""Return the state attributes."""
if not self._trips or self._first_trip is None:
if not self._trips:
return None
if self._first_trip.trip_parts:
route = [self._first_trip.departure]
route.extend(k.destination for k in self._first_trip.trip_parts)
if self._trips[0].trip_parts:
route = [self._trips[0].departure]
route.extend(k.destination for k in self._trips[0].trip_parts)
# Static attributes
attributes = {
"going": self._first_trip.going,
"going": self._trips[0].going,
"departure_time_planned": None,
"departure_time_actual": None,
"departure_delay": False,
"departure_platform_planned": self._first_trip.departure_platform_planned,
"departure_platform_actual": self._first_trip.departure_platform_actual,
"departure_platform_planned": self._trips[0].departure_platform_planned,
"departure_platform_actual": self._trips[0].departure_platform_actual,
"arrival_time_planned": None,
"arrival_time_actual": None,
"arrival_delay": False,
"arrival_platform_planned": self._first_trip.arrival_platform_planned,
"arrival_platform_actual": self._first_trip.arrival_platform_actual,
"arrival_platform_planned": self._trips[0].arrival_platform_planned,
"arrival_platform_actual": self._trips[0].arrival_platform_actual,
"next": None,
"status": self._first_trip.status.lower(),
"transfers": self._first_trip.nr_transfers,
"status": self._trips[0].status.lower(),
"transfers": self._trips[0].nr_transfers,
"route": route,
"remarks": None,
}
# Planned departure attributes
if self._first_trip.departure_time_planned is not None:
attributes["departure_time_planned"] = (
self._first_trip.departure_time_planned.strftime("%H:%M")
)
if self._trips[0].departure_time_planned is not None:
attributes["departure_time_planned"] = self._trips[
0
].departure_time_planned.strftime("%H:%M")
# Actual departure attributes
if self._first_trip.departure_time_actual is not None:
attributes["departure_time_actual"] = (
self._first_trip.departure_time_actual.strftime("%H:%M")
)
if self._trips[0].departure_time_actual is not None:
attributes["departure_time_actual"] = self._trips[
0
].departure_time_actual.strftime("%H:%M")
# Delay departure attributes
if (
@@ -184,16 +182,16 @@ class NSDepartureSensor(SensorEntity):
attributes["departure_delay"] = True
# Planned arrival attributes
if self._first_trip.arrival_time_planned is not None:
attributes["arrival_time_planned"] = (
self._first_trip.arrival_time_planned.strftime("%H:%M")
)
if self._trips[0].arrival_time_planned is not None:
attributes["arrival_time_planned"] = self._trips[
0
].arrival_time_planned.strftime("%H:%M")
# Actual arrival attributes
if self._first_trip.arrival_time_actual is not None:
attributes["arrival_time_actual"] = (
self._first_trip.arrival_time_actual.strftime("%H:%M")
)
if self._trips[0].arrival_time_actual is not None:
attributes["arrival_time_actual"] = self._trips[
0
].arrival_time_actual.strftime("%H:%M")
# Delay arrival attributes
if (
@@ -204,14 +202,15 @@ class NSDepartureSensor(SensorEntity):
attributes["arrival_delay"] = True
# Next attributes
if self._next_trip.departure_time_actual is not None:
attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M")
elif self._next_trip.departure_time_planned is not None:
attributes["next"] = self._next_trip.departure_time_planned.strftime(
"%H:%M"
)
else:
attributes["next"] = None
if len(self._trips) > 1:
if self._trips[1].departure_time_actual is not None:
attributes["next"] = self._trips[1].departure_time_actual.strftime(
"%H:%M"
)
elif self._trips[1].departure_time_planned is not None:
attributes["next"] = self._trips[1].departure_time_planned.strftime(
"%H:%M"
)
return attributes
@@ -226,7 +225,6 @@ class NSDepartureSensor(SensorEntity):
):
self._state = None
self._trips = None
self._first_trip = None
return
# Set the search parameter to search from a specific trip time
@@ -238,51 +236,19 @@ class NSDepartureSensor(SensorEntity):
.strftime("%d-%m-%Y %H:%M")
)
else:
trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M")
trip_time = datetime.now().strftime("%d-%m-%Y %H:%M")
try:
self._trips = self._nsapi.get_trips(
trip_time, self._departure, self._via, self._heading, True, 0, 2
)
if self._trips:
all_times = []
# If a train is delayed we can observe this through departure_time_actual.
for trip in self._trips:
if trip.departure_time_actual is None:
all_times.append(trip.departure_time_planned)
else:
all_times.append(trip.departure_time_actual)
# Remove all trains that already left.
filtered_times = [
(i, time)
for i, time in enumerate(all_times)
if time > dt_util.now()
]
if len(filtered_times) > 0:
sorted_times = sorted(filtered_times, key=lambda x: x[1])
self._first_trip = self._trips[sorted_times[0][0]]
self._state = sorted_times[0][1].strftime("%H:%M")
# Filter again to remove trains that leave at the exact same time.
filtered_times = [
(i, time)
for i, time in enumerate(all_times)
if time > sorted_times[0][1]
]
if len(filtered_times) > 0:
sorted_times = sorted(filtered_times, key=lambda x: x[1])
self._next_trip = self._trips[sorted_times[0][0]]
else:
self._next_trip = None
if self._trips[0].departure_time_actual is None:
planned_time = self._trips[0].departure_time_planned
self._state = planned_time.strftime("%H:%M")
else:
self._first_trip = None
self._state = None
actual_time = self._trips[0].departure_time_actual
self._state = actual_time.strftime("%H:%M")
except (
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,

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