Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/bosch_shc/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["boschshcpy"],
"requirements": ["boschshcpy==0.6.4"],
"requirements": ["boschshcpy==0.6.10"],
"zeroconf": [
{
"name": "bosch shc*",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/google/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==9.1.1", "oauth2client==4.1.3", "ical==14.2.0"]
"requirements": ["gcal-sync==9.2.0", "oauth2client==4.1.3", "ical==14.2.0"]
}
3 changes: 2 additions & 1 deletion homeassistant/components/irm_kmi/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ def __init__(self, entry: IrmKmiConfigEntry) -> None:
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, entry)),
# self.hass is not set before the entity is added
manufacturer=IRM_KMI_NAME.get(preferred_language(coordinator.hass, entry)),
)
29 changes: 25 additions & 4 deletions homeassistant/components/iseo_argo_ble/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,27 @@

_LOGGER = logging.getLogger(__name__)

# OUI assigned to Iseo Serrature s.p.a.; locks advertise a public address.
_ISEO_OUI = "00:15:42"


def _generate_identity() -> ec.EllipticCurvePrivateKey:
"""Generate a fresh SECP224R1 private key for use as an Argo BT identity."""
return ec.generate_private_key(ec.SECP224R1())


def _is_iseo_lock(info: BluetoothServiceInfoBleak) -> bool:
"""Return True when the advertisement really comes from an ISEO lock.

The 0xF000-0xF03F device-type UUID the discovery matcher keys on sits in a
range the Bluetooth SIG leaves unassigned and other vendors reuse freely, so
the address has to carry ISEO's OUI as well.
"""
return info.address.lower().startswith(_ISEO_OUI) and is_iseo_advertisement(
list(info.service_uuids or [])
)


def _discover_locks(hass: HomeAssistant) -> list[BluetoothServiceInfoBleak]:
"""Query HA's bluetooth integration for nearby ISEO locks."""
all_devices = sorted(
Expand All @@ -52,7 +67,7 @@ def _discover_locks(hass: HomeAssistant) -> list[BluetoothServiceInfoBleak]:

found: list[BluetoothServiceInfoBleak] = []
for info in all_devices:
if not is_iseo_advertisement(list(info.service_uuids or [])):
if not _is_iseo_lock(info):
continue
_LOGGER.debug(
" %s name=%r rssi=%d — ISEO lock",
Expand Down Expand Up @@ -145,12 +160,18 @@ async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Called by HA when a matching BLE advertisement is seen."""
if not _is_iseo_lock(discovery_info):
_LOGGER.debug(
"Ignoring %s (%s): not an ISEO lock, service UUIDs %s",
discovery_info.name,
discovery_info.address,
list(discovery_info.service_uuids or []),
)
return self.async_abort(reason="not_iseo_device")

await self.async_set_unique_id(format_mac(discovery_info.address))
self._abort_if_unique_id_configured()

if not is_iseo_advertisement(list(discovery_info.service_uuids or [])):
return self.async_abort(reason="not_iseo_device")

priv = await self.hass.async_add_executor_job(_generate_identity)
priv_int = priv.private_numbers().private_value
new_uuid = uuid_module.uuid4().bytes
Expand Down
52 changes: 39 additions & 13 deletions homeassistant/components/liebherr/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""DataUpdateCoordinator for Liebherr integration."""

import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field, replace
import logging
from typing import override
from typing import cast, override

from pyliebherrhomeapi import (
DeviceControl,
Expand Down Expand Up @@ -157,20 +158,45 @@ def _apply_controls(self, controls: list[DeviceControl]) -> None:
self._replace_next_event = False
new_state = replace(self.data, controls=list(controls))
else:
merged: dict[tuple[type[DeviceControl], str, int | None], DeviceControl] = {
(
type(control),
control.name,
getattr(control, "zone_id", None),
): control
for control in self.data.controls
}
for control in controls:
key = (type(control), control.name, getattr(control, "zone_id", None))
merged[key] = control
new_state = replace(self.data, controls=list(merged.values()))
new_state = self._merged_state(controls)
self.async_set_updated_data(new_state)

@callback
def async_apply_control[ControlT: DeviceControl](
self, control: ControlT, updater: Callable[[ControlT], ControlT]
) -> None:
"""Optimistically update the latest cached version of a control."""
assert self.data is not None
key = (type(control), control.name, getattr(control, "zone_id", None))
for cached_control in self.data.controls:
cached_key = (
type(cached_control),
cached_control.name,
getattr(cached_control, "zone_id", None),
)
if cached_key == key:
self.data = self._merged_state(
[updater(cast(ControlT, cached_control))]
)
self.async_update_listeners()
return

def _merged_state(self, controls: list[DeviceControl]) -> DeviceState:
"""Return coordinator state with control updates merged."""
assert self.data is not None
merged: dict[tuple[type[DeviceControl], str, int | None], DeviceControl] = {
(
type(control),
control.name,
getattr(control, "zone_id", None),
): control
for control in self.data.controls
}
for control in controls:
key = (type(control), control.name, getattr(control, "zone_id", None))
merged[key] = control
return replace(self.data, controls=list(merged.values()))

@callback
def _handle_stream_connected(self) -> None:
"""Handle SSE (re)connect: next event carries the full state."""
Expand Down
15 changes: 9 additions & 6 deletions homeassistant/components/liebherr/entity.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Base entity for Liebherr integration."""

from collections.abc import Coroutine
from collections.abc import Callable, Coroutine
from typing import Any

from pyliebherrhomeapi import (
DeviceControl,
LiebherrConnectionError,
LiebherrTimeoutError,
TemperatureControl,
Expand Down Expand Up @@ -51,21 +52,23 @@ def __init__(
model_id=device.device_name,
)

async def _async_send_command(
async def _async_send_command[ControlT: DeviceControl](
self,
command: Coroutine[Any, Any, None],
control: ControlT | None = None,
updater: Callable[[ControlT], ControlT] | None = None,
) -> None:
"""Send a command with error handling.

State updates arrive via the SSE stream — no explicit refresh needed.
"""
"""Send a command and optimistically apply its successful result."""
try:
await command
except (LiebherrConnectionError, LiebherrTimeoutError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from err
if control is not None:
assert updater is not None
self.coordinator.async_apply_control(control, updater)


class LiebherrZoneEntity(LiebherrEntity):
Expand Down
12 changes: 10 additions & 2 deletions homeassistant/components/liebherr/light.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Light platform for Liebherr integration."""

from dataclasses import replace
import math
from typing import TYPE_CHECKING, Any, override

Expand Down Expand Up @@ -122,15 +123,22 @@ async def async_turn_on(self, **kwargs: Any) -> None:
self.coordinator.client.set_presentation_light(
device_id=self.coordinator.device_id,
target=target,
)
),
control,
lambda control: replace(control, value=target),
)

@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
control = self._light_control
if TYPE_CHECKING:
assert control is not None
await self._async_send_command(
self.coordinator.client.set_presentation_light(
device_id=self.coordinator.device_id,
target=0,
)
),
control,
lambda control: replace(control, value=0),
)
4 changes: 3 additions & 1 deletion homeassistant/components/liebherr/number.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Number platform for Liebherr integration."""

from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from itertools import pairwise
from typing import TYPE_CHECKING, override

Expand Down Expand Up @@ -214,4 +214,6 @@ async def async_set_native_value(self, value: float) -> None:
target=target,
unit=unit,
),
temp_control,
lambda control: replace(control, target=target, unit=unit),
)
19 changes: 17 additions & 2 deletions homeassistant/components/liebherr/select.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Select platform for Liebherr integration."""

from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from dataclasses import dataclass, replace
from enum import StrEnum
from typing import TYPE_CHECKING, Any, override
from typing import TYPE_CHECKING, Any, cast, override

from pyliebherrhomeapi import (
BioFreshPlusControl,
Expand All @@ -29,6 +29,13 @@
type SelectControl = IceMakerControl | HydroBreezeControl | BioFreshPlusControl


def _replace_mode(control: SelectControl, mode: StrEnum) -> SelectControl:
"""Replace the current mode of a select control."""
if isinstance(control, IceMakerControl):
return replace(control, ice_maker_mode=mode)
return replace(control, current_mode=mode)


@dataclass(frozen=True, kw_only=True)
class LiebherrSelectEntityDescription(SelectEntityDescription):
"""Describes a Liebherr select entity."""
Expand Down Expand Up @@ -233,6 +240,14 @@ def available(self) -> bool:
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
mode = self.entity_description.mode_enum(option)
control = self._select_control
if TYPE_CHECKING:
assert isinstance(
control,
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
)
await self._async_send_command(
self.entity_description.set_fn(self.coordinator, self._zone_id, mode),
control,
lambda control: _replace_mode(cast(SelectControl, control), mode),
)
11 changes: 9 additions & 2 deletions homeassistant/components/liebherr/switch.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Switch platform for Liebherr integration."""

from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, override

from pyliebherrhomeapi import ToggleControl, ZonePosition
Expand Down Expand Up @@ -212,7 +212,14 @@ async def _async_call_set_fn(self, value: bool) -> None:

async def _async_set_value(self, value: bool) -> None:
"""Set the switch value."""
await self._async_send_command(self._async_call_set_fn(value))
control = self._toggle_control
if TYPE_CHECKING:
assert control is not None
await self._async_send_command(
self._async_call_set_fn(value),
control,
lambda control: replace(control, value=value),
)


class LiebherrZoneSwitch(LiebherrDeviceSwitch):
Expand Down
Loading
Loading