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
89 changes: 43 additions & 46 deletions homeassistant/components/bsblan/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
from typing import TYPE_CHECKING

from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
import voluptuous as vol

from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr

from .const import DOMAIN

Expand All @@ -33,37 +34,36 @@
SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule"


def _parse_time_value(value: time | str) -> time:
"""Parse a time value from either a time object or string.
# Schema for a single time slot
_SLOT_SCHEMA = vol.Schema(
{
vol.Required("start_time"): cv.time,
vol.Required("end_time"): cv.time,
}
)

Raises ServiceValidationError if the format is invalid.
"""
if isinstance(value, time):
return value

if isinstance(value, str):
try:
parts = value.split(":")
return time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_time_format",
) from None

raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_time_format",
)
SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): cv.string,
vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
}
)


def _convert_time_slots_to_day_schedule(
slots: list[dict[str, time]] | None,
) -> DaySchedule | None:
"""Convert list of time slot dicts to a DaySchedule object.

Example: [{"start_time": "06:00", "end_time": "08:00"},
{"start_time": "17:00", "end_time": "21:00"}]
Example: [{"start_time": time(6, 0), "end_time": time(8, 0)},
{"start_time": time(17, 0), "end_time": time(21, 0)}]
becomes: DaySchedule with two TimeSlot objects

None returns None (don't modify this day).
Expand All @@ -77,31 +77,27 @@ def _convert_time_slots_to_day_schedule(

time_slots = []
for slot in slots:
start = slot.get("start_time")
end = slot.get("end_time")

if start and end:
start_time = _parse_time_value(start)
end_time = _parse_time_value(end)

# Validate that end time is after start time
if end_time <= start_time:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_time_before_start_time",
translation_placeholders={
"start_time": start_time.strftime("%H:%M"),
"end_time": end_time.strftime("%H:%M"),
},
)

time_slots.append(TimeSlot(start=start_time, end=end_time))
LOGGER.debug(
"Created time slot: %s-%s",
start_time.strftime("%H:%M"),
end_time.strftime("%H:%M"),
start_time = slot["start_time"]
end_time = slot["end_time"]

# Validate that end time is after start time
if end_time <= start_time:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_time_before_start_time",
translation_placeholders={
"start_time": start_time.strftime("%H:%M"),
"end_time": end_time.strftime("%H:%M"),
},
)

time_slots.append(TimeSlot(start=start_time, end=end_time))
LOGGER.debug(
"Created time slot: %s-%s",
start_time.strftime("%H:%M"),
end_time.strftime("%H:%M"),
)

LOGGER.debug("Created DaySchedule with %d slots", len(time_slots))
return DaySchedule(slots=time_slots)

Expand Down Expand Up @@ -214,4 +210,5 @@ def async_setup_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_SET_HOT_WATER_SCHEDULE,
set_hot_water_schedule,
schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA,
)
2 changes: 1 addition & 1 deletion homeassistant/components/conversation/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"]
}
88 changes: 64 additions & 24 deletions homeassistant/components/pooldose/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,32 +114,72 @@ async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if not user_input:
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
if user_input is not None:
host = user_input[CONF_HOST]
serial_number, api_versions, errors = await self._validate_host(host)
if errors:
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
errors=errors,
# Handle API version info for error display; pass version info when available
# or None when api_versions is None to avoid displaying version details
description_placeholders={
"api_version_is": api_versions.get("api_version_is") or "",
"api_version_should": api_versions.get("api_version_should")
or "",
}
if api_versions
else None,
)

await self.async_set_unique_id(serial_number, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"PoolDose {serial_number}",
data={CONF_HOST: host},
)

host = user_input[CONF_HOST]
serial_number, api_versions, errors = await self._validate_host(host)
if errors:
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
errors=errors,
# Handle API version info for error display; pass version info when available
# or None when api_versions is None to avoid displaying version details
description_placeholders={
"api_version_is": api_versions.get("api_version_is") or "",
"api_version_should": api_versions.get("api_version_should") or "",
}
if api_versions
else None,
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
)

async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfigure to change the device host/IP for an existing entry."""
if user_input is not None:
host = user_input[CONF_HOST]
serial_number, api_versions, errors = await self._validate_host(host)
if errors:
return self.async_show_form(
step_id="reconfigure",
data_schema=SCHEMA_DEVICE,
errors=errors,
# Handle API version info for error display identical to other steps
description_placeholders={
"api_version_is": api_versions.get("api_version_is") or "",
"api_version_should": api_versions.get("api_version_should")
or "",
}
if api_versions
else None,
)

# Ensure new serial number matches the existing entry unique_id (serial number)
if serial_number != self._get_reconfigure_entry().unique_id:
return self.async_abort(reason="wrong_device")

# Update the existing config entry with the new host and schedule reload
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates={CONF_HOST: host}
)

await self.async_set_unique_id(serial_number, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"PoolDose {serial_number}",
data={CONF_HOST: host},
return self.async_show_form(
step_id="reconfigure",
# Pre-fill with current host from the entry being reconfigured
data_schema=self.add_suggested_values_to_schema(
SCHEMA_DEVICE, self._get_reconfigure_entry().data
),
)
2 changes: 1 addition & 1 deletion homeassistant/components/pooldose/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: This integration does not provide repair issues, as it is designed for a single PoolDose device with a fixed configuration.
Expand Down
12 changes: 11 additions & 1 deletion homeassistant/components/pooldose/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_device_info": "Unable to retrieve device information",
"no_serial_number": "No serial number found on the device"
"no_serial_number": "No serial number found on the device",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_device": "The provided device does not match the configured device"
},
"error": {
"api_not_set": "API version not found in device response. Device firmware may not be compatible with this integration.",
Expand All @@ -20,6 +22,14 @@
"description": "A PoolDose device was found on your network at {ip} with MAC address {mac}.\n\nDo you want to add {name} to Home Assistant?",
"title": "Confirm DHCP discovered PoolDose device"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::pooldose::config::step::user::data_description::host%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
Expand Down
9 changes: 5 additions & 4 deletions homeassistant/components/reolink/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util

from .entity import (
ReolinkChannelCoordinatorEntity,
Expand Down Expand Up @@ -157,16 +158,16 @@ def is_on(self) -> bool:

@property
def brightness(self) -> int | None:
"""Return the brightness of this light between 0.255."""
"""Return the brightness of this light between 1.255."""
assert self.entity_description.get_brightness_fn is not None

bright_pct = self.entity_description.get_brightness_fn(
self._host.api, self._channel
)
if bright_pct is None:
if not bright_pct:
return None

return round(255 * bright_pct / 100.0)
return color_util.value_to_brightness((1, 100), bright_pct)

@property
def color_temp_kelvin(self) -> int | None:
Expand All @@ -189,7 +190,7 @@ async def async_turn_on(self, **kwargs: Any) -> None:
if (
brightness := kwargs.get(ATTR_BRIGHTNESS)
) is not None and self.entity_description.set_brightness_fn is not None:
brightness_pct = int(brightness / 255.0 * 100)
brightness_pct = round(color_util.brightness_to_value((1, 100), brightness))
await self.entity_description.set_brightness_fn(
self._host.api, self._channel, brightness_pct
)
Expand Down
31 changes: 30 additions & 1 deletion homeassistant/components/sonos/speaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,23 @@ def _async_regroup(group: list[str]) -> None:
# as those "invisible" speakers will bypass the single speaker check
return

# Clear coordinator on speakers that are no longer in this group
old_members = set(self.sonos_group[1:])
new_members = set(sonos_group[1:])
removed_members = old_members - new_members
for removed_speaker in removed_members:
# Only clear if this speaker was coordinated by self and in the same group
if (
removed_speaker.coordinator == self
and removed_speaker.sonos_group is self.sonos_group
):
_LOGGER.debug(
"Zone %s Cleared coordinator [%s] (removed from group)",
removed_speaker.zone_name,
self.zone_name,
)
removed_speaker.clear_coordinator()

self.coordinator = None
self.sonos_group = sonos_group
self.sonos_group_entities = sonos_group_entities
Expand Down Expand Up @@ -990,6 +1007,19 @@ async def _async_handle_group_event(event: SonosEvent | None) -> None:

return _async_handle_group_event(event)

@callback
def clear_coordinator(self) -> None:
"""Clear coordinator from speaker."""
self.coordinator = None
self.sonos_group = [self]
entity_registry = er.async_get(self.hass)
speaker_entity_id = cast(
str,
entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, self.uid),
)
self.sonos_group_entities = [speaker_entity_id]
self.async_write_entity_states()

@soco_error()
def join(self, speakers: list[SonosSpeaker]) -> list[SonosSpeaker]:
"""Form a group with other players."""
Expand Down Expand Up @@ -1038,7 +1068,6 @@ def unjoin(self) -> None:
if self.sonos_group == [self]:
return
self.soco.unjoin()
self.coordinator = None

@staticmethod
async def unjoin_multi(
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/totalconnect/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/totalconnect",
"iot_class": "cloud_polling",
"loggers": ["total_connect_client"],
"requirements": ["total-connect-client==2025.5"]
"requirements": ["total-connect-client==2025.12.2"]
}
5 changes: 4 additions & 1 deletion homeassistant/components/velbus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bo
try:
await controller.connect()
except VelbusConnectionFailed as error:
raise ConfigEntryNotReady("Cannot connect to Velbus") from error
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_failed",
) from error

task = hass.async_create_task(velbus_scan_task(controller, hass, entry.entry_id))
entry.runtime_data = VelbusData(controller=controller, scan_task=task)
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/velbus/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def preset_mode(self) -> str | None:
)

@property
def current_temperature(self) -> int | None:
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._channel.get_state()

Expand Down
Loading
Loading