Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
40c5925
Bump PySwitchbot to 2.7.0 (#180978)
Onero-testdev Sep 1, 2026
77bfb85
Update pnpm to 11.24.0 (#180974)
renovate[bot] Sep 1, 2026
b49acde
Fix collection_image unavailable handling (#180973)
karwosts Sep 1, 2026
4555ef3
Bump python-duco-connectivity to 0.14.0 (#180964)
ronaldvdmeer Sep 1, 2026
c5f8716
Fix light color mode in microbees (#180982)
emontnemery Sep 1, 2026
7b2dae2
Fix Mysensors device id tracking (#180959)
MartinHjelmare Sep 1, 2026
8f7a82c
Add diagnostics to Zonneplan (#180954)
erwindouna Sep 1, 2026
c194c3b
Delegate Duco bypass target policy to the library (#180980)
ronaldvdmeer Sep 1, 2026
b188ff4
Populate Satel Integra model and firmware (#180962)
Tommatheussen Sep 1, 2026
e35773d
Improve device registry storage tests (#180984)
emontnemery Sep 1, 2026
3326ffa
Add more miele dishwasher codes (#180986)
astrandb Sep 1, 2026
6540e01
Fix Music Assistant shared player icons (#180979)
meiser79 Sep 1, 2026
45c2434
Add reauthentication flow to the Imou integration (#180975)
Imou-OpenPlatform Sep 1, 2026
262dc55
Make Subaru buttons and locks coordinator-backed (#180946)
jpettitt Sep 1, 2026
76a38d4
Add panel information to diagnostics for Satel Integra (#180987)
Tommatheussen Sep 1, 2026
bb35e5e
Keep Duco filter time pollable after missing result (#180985)
ronaldvdmeer Sep 1, 2026
96ee7c4
Add reauth flow to Zonneplan (#180951)
erwindouna Sep 1, 2026
8d9a9b0
Start Imou reauthentication when the cloud rejects the credentials (#…
Imou-OpenPlatform Sep 1, 2026
b78775e
Bump hdfury to 1.6.1 (#180990)
glenndehaan Sep 1, 2026
3962dd1
Add MySensors config entry runtime_data (#180993)
MartinHjelmare Sep 1, 2026
f874e62
Add AC buttons to LG Infrared (#180842)
Dr-Blank Sep 1, 2026
649efe2
Close the Firestore clients when the Vistapool auth is done (#180866)
fdebrus Sep 1, 2026
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
121 changes: 60 additions & 61 deletions homeassistant/components/collection_image/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
import random
from typing import override

from homeassistant.components.image import ImageEntity
from homeassistant.components.image import DEFAULT_CONTENT_TYPE, ImageEntity
from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaClass,
async_process_play_media_url,
)
Expand Down Expand Up @@ -51,8 +52,6 @@ async def async_setup_entry(
class CollectionImageImageEntity(ImageEntity):
"""Implement the image entity for Collection Image."""

_unavailable_logged: bool = False

path: Path | None

def __init__(
Expand All @@ -69,80 +68,80 @@ def __init__(
self._attr_name = name
self.media_content_id = media_content_id

async def get_next_image(self) -> None:
"""Update the image entity with the next image from the source media."""

def set_unavailable(self) -> None:
"""Set the entity to unavailable state."""
self._attr_available = False
self.path = None
self._attr_image_url = UNDEFINED
self._cached_image = None
self.async_write_ha_state()

def set_unavailable() -> None:
self._unavailable_logged = True
self._attr_available = False
self.path = None
self._attr_image_url = UNDEFINED
self.async_write_ha_state()

async def get_valid_images(self) -> list[BrowseMedia]:
"""Given the configured media directory for the entity, get a list of all child images."""
try:
media = await async_browse_media(self.hass, self.media_content_id)
except BrowseError as err:
if not self._unavailable_logged:
_LOGGER.info("%s: %s", self.entity_id, str(err))
set_unavailable()
return
_LOGGER.warning("%s: %s", self.entity_id, str(err))
return []

if media.children and (
filtered := [
item for item in media.children if item.media_class == MediaClass.IMAGE
]
):
child = random.choice(filtered)
try:
resolved = await async_resolve_media(
self.hass, child.media_content_id, self.entity_id
)
except Unresolvable as err:
if not self._unavailable_logged:
_LOGGER.info("%s: %s", self.entity_id, str(err))
set_unavailable()
return

if resolved.url:
self.path = None
self._attr_image_url = async_process_play_media_url(
self.hass, resolved.url
)
else:
self.path = resolved.path
self._attr_image_url = UNDEFINED

self._attr_content_type = resolved.mime_type
self._attr_available = True
self._attr_image_last_updated = dt_util.utcnow()
if self._unavailable_logged:
_LOGGER.info(
"%s: Has become available again",
self.entity_id,
)
self._unavailable_logged = False
self.async_write_ha_state()
return

if not self._unavailable_logged:
_LOGGER.info(
images = [
item
for item in (media.children or [])
if item.media_class == MediaClass.IMAGE
]
if not images:
_LOGGER.warning(
"%s: No valid images in %s",
self.entity_id,
self.media_content_id,
)
set_unavailable()
return
return images

async def get_random_image(self) -> None:
"""Update the image entity with a random image from the source media."""

filtered = await self.get_valid_images()
if not filtered:
self.set_unavailable()
return

child = random.choice(filtered)
self._attr_available = True
await self.update_image(child.media_content_id)

async def update_image(self, image_id: str):
"""Update the entity from the image_id."""
self._cached_image = None
try:
resolved = await async_resolve_media(self.hass, image_id, self.entity_id)
except Unresolvable as err:
_LOGGER.warning("%s: %s", self.entity_id, str(err))
self._attr_image_last_updated = None
self.path = None
self._attr_image_url = UNDEFINED
self._attr_content_type = DEFAULT_CONTENT_TYPE
self.async_write_ha_state()
return

if resolved.url:
self.path = None
self._attr_image_url = async_process_play_media_url(self.hass, resolved.url)
else:
self.path = resolved.path
self._attr_image_url = UNDEFINED

self._attr_content_type = resolved.mime_type
self._attr_image_last_updated = dt_util.utcnow()
self.async_write_ha_state()

@override
async def async_added_to_hass(self) -> None:
"""Initialize the first image after entity has been created."""

async def get_next_image_on_start(_hass: HomeAssistant) -> None:
await self.get_next_image()
async def get_random_image_on_start(_hass: HomeAssistant) -> None:
await self.get_random_image()

self.async_on_remove(async_at_started(self.hass, get_next_image_on_start))
self.async_on_remove(async_at_started(self.hass, get_random_image_on_start))

@override
def image(self) -> bytes | None:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/collection_image/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_SHUFFLE,
entity_domain=IMAGE_DOMAIN,
schema={},
func="get_next_image",
func="get_random_image",
)
66 changes: 24 additions & 42 deletions homeassistant/components/duco/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
DucoConnectionError,
DucoError,
DucoResponseError,
DucoUnsupportedCapabilityError,
)
from duco_connectivity.models import (
BoardInfo,
Expand Down Expand Up @@ -52,9 +51,6 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):

config_entry: DucoConfigEntry
board_info: BoardInfo
_supports_time_filter_remain: bool
_supports_ventilation_temperatures: bool
_supports_bypass_supply_temperature_targets: bool
_configured_node_names: dict[int, str]

def __init__(
Expand All @@ -73,9 +69,6 @@ def __init__(
)
self.client = client
self._configured_node_names = {}
self._supports_time_filter_remain = True
self._supports_ventilation_temperatures = True
self._supports_bypass_supply_temperature_targets = True

async def _async_load_node_names(self) -> None:
"""Load configured Duco node names during setup."""
Expand Down Expand Up @@ -183,48 +176,37 @@ async def _async_update_data(self) -> DucoData:

# Heat recovery info only backs the optional filter timer sensor, so
# failures on this supplemental endpoint should not make the primary
# node entities unavailable.
# node entities unavailable. A None result leaves the sensor absent
# but keeps the helper pollable so data can appear on a later refresh.
time_filter_remain = None
if self._supports_time_filter_remain:
with suppress(DucoError):
time_filter_remain = await self.client.async_get_time_filter_remaining()
self._supports_time_filter_remain = time_filter_remain is not None
with suppress(DucoError):
time_filter_remain = await self.client.async_get_time_filter_remaining()

ventilation_temperatures = (
self.data.ventilation_temperatures if self.data else None
)
if self._supports_ventilation_temperatures:
try:
ventilation_temperatures = (
await self.client.async_get_ventilation_temperature_info()
)
except DucoUnsupportedCapabilityError:
ventilation_temperatures = None
self._supports_ventilation_temperatures = False
except DucoError as err:
_LOGGER.debug(
"Could not fetch Duco ventilation temperatures", exc_info=err
)
try:
ventilation_temperatures = (
await self.client.async_get_ventilation_temperature_info()
)
except DucoError as err:
_LOGGER.debug("Could not fetch Duco ventilation temperatures", exc_info=err)

bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget] = {}
if self._supports_bypass_supply_temperature_targets:
try:
bypass_supply_temperature_targets = (
await self.client.async_get_bypass_supply_temperature_targets()
)
except DucoUnsupportedCapabilityError:
bypass_supply_temperature_targets = {}
self._supports_bypass_supply_temperature_targets = False
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except DucoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
) from err
try:
bypass_supply_temperature_targets = (
await self.client.async_get_bypass_supply_temperature_targets()
)
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except DucoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
) from err

return DucoData(
nodes={node.node_id: node for node in nodes},
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/duco/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco_connectivity"],
"quality_scale": "platinum",
"requirements": ["python-duco-connectivity==0.13.1"],
"requirements": ["python-duco-connectivity==0.14.0"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
Expand Down
48 changes: 10 additions & 38 deletions homeassistant/components/duco/number.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Number platform for the Duco integration."""

from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal
import logging
from typing import override

Expand Down Expand Up @@ -56,14 +55,6 @@ def _async_add_new_entities() -> None:
if (description.key, zone_id) in known_entities:
continue

# Skip incomplete metadata because guessing valid limits would expose an invalid control.
if (
target.minimum is None
or target.maximum is None
or target.increment is None
):
continue

known_entities.add((description.key, zone_id))
new_entities.append(
DucoBypassSupplyTemperatureTargetNumber(
Expand Down Expand Up @@ -130,32 +121,18 @@ def native_value(self) -> float | None:
)
return target.value if target else None

def _normalize_step_value(self, value: float) -> float:
"""Normalize converted temperature values to the nearest supported native step."""
if self.unit_of_measurement == self.native_unit_of_measurement:
return value

# Home Assistant converts service values from the configured temperature
# unit first, which can land between valid Duco Celsius increments.
minimum = Decimal(str(self.native_min_value))
step = Decimal(str(self.native_step))
steps = ((Decimal(str(value)) - minimum) / step).to_integral_value(
rounding=ROUND_HALF_UP
)
# Rounding up may overshoot when the range is not a whole number of steps.
max_steps = (
(Decimal(str(self.native_max_value)) - minimum) / step
).to_integral_value(rounding=ROUND_DOWN)
return float(minimum + (min(steps, max_steps) * step))

@override
async def async_set_native_value(self, value: float) -> None:
"""Set the bypass supply temperature target."""
value = self._normalize_step_value(value)
if (
(Decimal(str(value)) - Decimal(str(self.native_min_value)))
/ Decimal(str(self.native_step))
) % 1 != 0:
target = self.coordinator.data.bypass_supply_temperature_targets[self._zone_id]

try:
if self.unit_of_measurement != self.native_unit_of_measurement:
value = target.normalize_value(value)
await self.coordinator.client.async_set_bypass_supply_temperature_target(
self._zone_id, value, target=target
)
except ValueError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_bypass_supply_temperature_target_step",
Expand All @@ -164,12 +141,7 @@ async def async_set_native_value(self, value: float) -> None:
"minimum": str(self.native_min_value),
"increment": str(self.native_step),
},
)

try:
await self.coordinator.client.async_set_bypass_supply_temperature_target(
self._zone_id, value
)
) from err
except DucoRateLimitError as err:
_LOGGER.warning(
"Duco write rate limit exceeded for bypass target zone %s",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/hdfury/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["hdfury==1.6.0"],
"requirements": ["hdfury==1.6.1"],
"zeroconf": [
{ "name": "diva-*", "type": "_http._tcp.local." },
{ "name": "vertex2-*", "type": "_http._tcp.local." },
Expand Down
Loading
Loading