This commit is contained in:
Paulus Schoutsen 2024-09-06 13:39:52 -04:00 committed by GitHub
commit 444560543c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 541 additions and 236 deletions

View File

@ -262,7 +262,7 @@ class Airtouch5AC(Airtouch5ClimateEntity):
_LOGGER.debug("Argument `temperature` is missing in set_temperature")
return
await self._control(temp=temp)
await self._control(setpoint=SetpointControl.CHANGE_SETPOINT, temp=temp)
class Airtouch5Zone(Airtouch5ClimateEntity):

View File

@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
"requirements": ["pyatv==0.15.0"],
"requirements": ["pyatv==0.15.1"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_companion-link._tcp.local.",

View File

@ -36,10 +36,11 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
async def _async_setup(self) -> None:
try:
max_power = (await self.api.get_device_info()).maxPower
device_info = await self.api.get_device_info()
except (ConnectionError, TimeoutError):
raise UpdateFailed from None
self.api.max_power = max_power
self.api.max_power = device_info.maxPower
self.api.min_power = device_info.minPower
async def _async_update_data(self) -> ApSystemsSensorData:
output_data = await self.api.get_output_data()

View File

@ -26,7 +26,6 @@ async def async_setup_entry(
class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity):
"""Base sensor to be used with description."""
_attr_native_min_value = 30
_attr_native_step = 1
_attr_device_class = NumberDeviceClass.POWER
_attr_mode = NumberMode.BOX
@ -42,6 +41,7 @@ class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity):
self._api = data.coordinator.api
self._attr_unique_id = f"{data.device_id}_output_limit"
self._attr_native_max_value = data.coordinator.api.max_power
self._attr_native_min_value = data.coordinator.api.min_power
async def async_update(self) -> None:
"""Set the state with the value fetched from the inverter."""

View File

@ -56,7 +56,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
refresh_token = await api.authenticate(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except ApiException:
except (ApiException, TimeoutError):
errors["base"] = "cannot_connect"
except AuthenticationFailed:
errors["base"] = "invalid_auth"

View File

@ -56,7 +56,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
so entities can quickly look up their data.
"""
async with asyncio.timeout(10):
async with asyncio.timeout(30):
# Check if the refresh token is expired
expiry_time = (
self.refresh_token_creation_time
@ -72,7 +72,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
softeners = await self.aquacell_api.get_all_softeners()
except AuthenticationFailed as err:
raise ConfigEntryError from err
except AquacellApiException as err:
except (AquacellApiException, TimeoutError) as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
return {softener.dsn: softener for softener in softeners}

View File

@ -7,6 +7,9 @@ from typing import Any
import voluptuous as vol
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig,
)
from homeassistant.components.homeassistant.triggers import event as event_trigger
from homeassistant.const import (
CONF_DEVICE_ID,
@ -43,33 +46,46 @@ TRIGGERS_BY_EVENT_CLASS = {
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
}
SCHEMA_BY_EVENT_CLASS = {
EVENT_CLASS_BUTTON: DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_BUTTON]),
vol.Required(CONF_SUBTYPE): vol.In(
TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_BUTTON]
),
}
),
EVENT_CLASS_DIMMER: DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_DIMMER]),
vol.Required(CONF_SUBTYPE): vol.In(
TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_DIMMER]
),
}
),
}
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str}
)
async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate trigger config."""
return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( # type: ignore[no-any-return]
config
config = TRIGGER_SCHEMA(config)
event_class = config[CONF_TYPE]
event_type = config[CONF_SUBTYPE]
device_registry = dr.async_get(hass)
device = device_registry.async_get(config[CONF_DEVICE_ID])
assert device is not None
config_entries = [
hass.config_entries.async_get_entry(entry_id)
for entry_id in device.config_entries
]
bthome_config_entry = next(
iter(entry for entry in config_entries if entry and entry.domain == DOMAIN)
)
event_classes: list[str] = bthome_config_entry.data.get(
CONF_DISCOVERED_EVENT_CLASSES, []
)
if event_class not in event_classes:
raise InvalidDeviceAutomationConfig(
f"BTHome trigger {event_class} is not valid for device "
f"{device} ({config[CONF_DEVICE_ID]})"
)
if event_type not in TRIGGERS_BY_EVENT_CLASS.get(event_class.split("_")[0], ()):
raise InvalidDeviceAutomationConfig(
f"BTHome trigger {event_type} is not valid for device "
f"{device} ({config[CONF_DEVICE_ID]})"
)
return config
async def async_get_triggers(

View File

@ -119,7 +119,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity):
self._partition_number = partition_number
self._panic_type = panic_type
self._alarm_control_panel_option_default_code = code
self._attr_code_format = CodeFormat.NUMBER
self._attr_code_format = CodeFormat.NUMBER if not code else None
_LOGGER.debug("Setting up alarm: %s", alarm_name)
super().__init__(alarm_name, info, controller)

View File

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

View File

@ -39,7 +39,7 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
hass,
_LOGGER,
name="FYTA Coordinator",
update_interval=timedelta(seconds=60),
update_interval=timedelta(minutes=4),
)
self.fyta = fyta

View File

@ -226,7 +226,11 @@ class HueLight(HueBaseEntity, LightEntity):
flash = kwargs.get(ATTR_FLASH)
effect = effect_str = kwargs.get(ATTR_EFFECT)
if effect_str in (EFFECT_NONE, EFFECT_NONE.lower()):
effect = EffectStatus.NO_EFFECT
# ignore effect if set to "None" and we have no effect active
# the special effect "None" is only used to stop an active effect
# but sending it while no effect is active can actually result in issues
# https://github.com/home-assistant/core/issues/122165
effect = None if self.effect == EFFECT_NONE else EffectStatus.NO_EFFECT
elif effect_str is not None:
# work out if we got a regular effect or timed effect
effect = EffectStatus(effect_str)

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push",
"loggers": ["pypck"],
"requirements": ["pypck==0.7.21", "lcn-frontend==0.1.6"]
"requirements": ["pypck==0.7.22", "lcn-frontend==0.1.6"]
}

View File

@ -20,6 +20,9 @@ from homeassistant.components.media_player import (
MediaType,
RepeatMode,
)
from homeassistant.components.media_player.browse_media import (
async_process_play_media_url,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
@ -59,6 +62,7 @@ SOURCE_MAP: dict[PlayingMode, str] = {
PlayingMode.FM: "FM Radio",
PlayingMode.RCA: "RCA",
PlayingMode.UDISK: "USB",
PlayingMode.FOLLOWER: "Follower",
}
SOURCE_MAP_INV: dict[str, PlayingMode] = {v: k for k, v in SOURCE_MAP.items()}
@ -233,10 +237,14 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Play a piece of media."""
media = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
await self._bridge.player.play(media.url)
if media_source.is_media_source_id(media_id):
play_item = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
media_id = play_item.url
url = async_process_play_media_url(self.hass, media_id)
await self._bridge.player.play(url)
def _update_properties(self) -> None:
"""Update the properties of the media player."""

View File

@ -358,8 +358,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
await self._update_thermostat(
self.location,
device,
coolSetpoint=target_temp_high,
heatSetpoint=target_temp_low,
cool_setpoint=target_temp_high,
heat_setpoint=target_temp_low,
mode=mode,
)
except LYRIC_EXCEPTIONS as exception:
@ -371,11 +371,11 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
try:
if self.hvac_mode == HVACMode.COOL:
await self._update_thermostat(
self.location, device, coolSetpoint=temp
self.location, device, cool_setpoint=temp
)
else:
await self._update_thermostat(
self.location, device, heatSetpoint=temp
self.location, device, heat_setpoint=temp
)
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
@ -410,7 +410,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
self.location,
self.device,
mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
autoChangeoverActive=False,
auto_changeover_active=False,
)
# Sleep 3 seconds before proceeding
await asyncio.sleep(3)
@ -422,7 +422,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
self.location,
self.device,
mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
autoChangeoverActive=True,
auto_changeover_active=True,
)
else:
_LOGGER.debug(
@ -430,7 +430,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
HVAC_MODES[self.device.changeable_values.mode],
)
await self._update_thermostat(
self.location, self.device, autoChangeoverActive=True
self.location, self.device, auto_changeover_active=True
)
else:
_LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode])
@ -438,13 +438,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
self.location,
self.device,
mode=LYRIC_HVAC_MODES[hvac_mode],
autoChangeoverActive=False,
auto_changeover_active=False,
)
async def _async_set_hvac_mode_lcc(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode for LCC devices (e.g., T5,6)."""
_LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode])
# Set autoChangeoverActive to True if the mode being passed is Auto
# Set auto_changeover_active to True if the mode being passed is Auto
# otherwise leave unchanged.
if (
LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL
@ -458,7 +458,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
self.location,
self.device,
mode=LYRIC_HVAC_MODES[hvac_mode],
autoChangeoverActive=auto_changeover,
auto_changeover_active=auto_changeover,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
@ -466,7 +466,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
_LOGGER.debug("Set preset mode: %s", preset_mode)
try:
await self._update_thermostat(
self.location, self.device, thermostatSetpointStatus=preset_mode
self.location, self.device, thermostat_setpoint_status=preset_mode
)
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
@ -479,8 +479,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
await self._update_thermostat(
self.location,
self.device,
thermostatSetpointStatus=PRESET_HOLD_UNTIL,
nextPeriodTime=time_period,
thermostat_setpoint_status=PRESET_HOLD_UNTIL,
next_period_time=time_period,
)
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)

View File

@ -3,7 +3,7 @@
import asyncio
import logging
from aiorussound import Russound
from aiorussound import RussoundClient, RussoundTcpConnectionHandler
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
@ -16,7 +16,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
type RussoundConfigEntry = ConfigEntry[Russound]
type RussoundConfigEntry = ConfigEntry[RussoundClient]
async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> bool:
@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
russ = Russound(hass.loop, host, port)
russ = RussoundClient(RussoundTcpConnectionHandler(hass.loop, host, port))
@callback
def is_connected_updated(connected: bool) -> None:
@ -37,14 +37,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
port,
)
russ.add_connection_callback(is_connected_updated)
russ.connection_handler.add_connection_callback(is_connected_updated)
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()
except RUSSOUND_RIO_EXCEPTIONS as err:
raise ConfigEntryNotReady(f"Error while connecting to {host}:{port}") from err
entry.runtime_data = russ
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -6,7 +6,7 @@ import asyncio
import logging
from typing import Any
from aiorussound import Controller, Russound
from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@ -54,8 +54,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
controllers = None
russ = Russound(self.hass.loop, host, port)
russ = RussoundClient(
RussoundTcpConnectionHandler(self.hass.loop, host, port)
)
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()
@ -87,7 +88,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
port = import_data.get(CONF_PORT, 9621)
# Connection logic is repeated here since this method will be removed in future releases
russ = Russound(self.hass.loop, host, port)
russ = RussoundClient(RussoundTcpConnectionHandler(self.hass.loop, host, port))
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()

View File

@ -2,7 +2,7 @@
import asyncio
from aiorussound import CommandException
from aiorussound import CommandError
from aiorussound.const import FeatureFlag
from homeassistant.components.media_player import MediaPlayerEntityFeature
@ -10,7 +10,7 @@ from homeassistant.components.media_player import MediaPlayerEntityFeature
DOMAIN = "russound_rio"
RUSSOUND_RIO_EXCEPTIONS = (
CommandException,
CommandError,
ConnectionRefusedError,
TimeoutError,
asyncio.CancelledError,

View File

@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aiorussound import Controller
from aiorussound import Controller, RussoundTcpConnectionHandler
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
@ -53,7 +53,6 @@ class RussoundBaseEntity(Entity):
or f"{self._primary_mac_address}-{self._controller.controller_id}"
)
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{self._instance.host}",
# Use MAC address of Russound device as identifier
identifiers={(DOMAIN, self._device_identifier)},
manufacturer="Russound",
@ -61,6 +60,10 @@ class RussoundBaseEntity(Entity):
model=controller.controller_type,
sw_version=controller.firmware_version,
)
if isinstance(self._instance.connection_handler, RussoundTcpConnectionHandler):
self._attr_device_info["configuration_url"] = (
f"http://{self._instance.connection_handler.host}"
)
if controller.parent_controller:
self._attr_device_info["via_device"] = (
DOMAIN,
@ -79,8 +82,12 @@ class RussoundBaseEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._instance.add_connection_callback(self._is_connected_updated)
self._instance.connection_handler.add_connection_callback(
self._is_connected_updated
)
async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks."""
self._instance.remove_connection_callback(self._is_connected_updated)
self._instance.connection_handler.remove_connection_callback(
self._is_connected_updated
)

View File

@ -7,5 +7,5 @@
"iot_class": "local_push",
"loggers": ["aiorussound"],
"quality_scale": "silver",
"requirements": ["aiorussound==2.3.2"]
"requirements": ["aiorussound==3.0.4"]
}

View File

@ -84,14 +84,16 @@ async def async_setup_entry(
"""Set up the Russound RIO platform."""
russ = entry.runtime_data
await russ.init_sources()
sources = russ.sources
for source in sources.values():
await source.watch()
# Discover controllers
controllers = await russ.enumerate_controllers()
entities = []
for controller in controllers.values():
sources = controller.sources
for source in sources.values():
await source.watch()
for zone in controller.zones.values():
await zone.watch()
mp = RussoundZoneDevice(zone, sources)
@ -154,7 +156,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the device."""
status = self._zone.status
status = self._zone.properties.status
if status == "ON":
return MediaPlayerState.ON
if status == "OFF":
@ -174,22 +176,22 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@property
def media_title(self):
"""Title of current playing media."""
return self._current_source().song_name
return self._current_source().properties.song_name
@property
def media_artist(self):
"""Artist of current playing media, music track only."""
return self._current_source().artist_name
return self._current_source().properties.artist_name
@property
def media_album_name(self):
"""Album name of current playing media, music track only."""
return self._current_source().album_name
return self._current_source().properties.album_name
@property
def media_image_url(self):
"""Image url of current playing media."""
return self._current_source().cover_art_url
return self._current_source().properties.cover_art_url
@property
def volume_level(self):
@ -198,7 +200,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
Value is returned based on a range (0..50).
Therefore float divide by 50 to get to the required range.
"""
return float(self._zone.volume or "0") / 50.0
return float(self._zone.properties.volume or "0") / 50.0
@command
async def async_turn_off(self) -> None:
@ -214,7 +216,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level."""
rvol = int(volume * 50.0)
await self._zone.set_volume(rvol)
await self._zone.set_volume(str(rvol))
@command
async def async_select_source(self, source: str) -> None:

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from sfrbox_api.bridge import SFRBox
from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError
@ -46,6 +47,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Preload system information
await data.system.async_config_entry_first_refresh()
system_info = data.system.data
if TYPE_CHECKING:
assert system_info is not None
# Preload other coordinators (based on net infrastructure)
tasks = [data.wan.async_config_entry_first_refresh()]

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo
@ -65,19 +66,22 @@ async def async_setup_entry(
) -> None:
"""Set up the sensors."""
data: DomainData = hass.data[DOMAIN][entry.entry_id]
system_info = data.system.data
if TYPE_CHECKING:
assert system_info is not None
entities: list[SFRBoxBinarySensor] = [
SFRBoxBinarySensor(data.wan, description, data.system.data)
SFRBoxBinarySensor(data.wan, description, system_info)
for description in WAN_SENSOR_TYPES
]
if (net_infra := data.system.data.net_infra) == "adsl":
if (net_infra := system_info.net_infra) == "adsl":
entities.extend(
SFRBoxBinarySensor(data.dsl, description, data.system.data)
SFRBoxBinarySensor(data.dsl, description, system_info)
for description in DSL_SENSOR_TYPES
)
elif net_infra == "ftth":
entities.extend(
SFRBoxBinarySensor(data.ftth, description, data.system.data)
SFRBoxBinarySensor(data.ftth, description, system_info)
for description in FTTH_SENSOR_TYPES
)
@ -111,4 +115,6 @@ class SFRBoxBinarySensor[_T](
@property
def is_on(self) -> bool | None:
"""Return the native value of the device."""
if self.coordinator.data is None:
return None
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
from functools import wraps
from typing import Any, Concatenate
from typing import TYPE_CHECKING, Any, Concatenate
from sfrbox_api.bridge import SFRBox
from sfrbox_api.exceptions import SFRBoxError
@ -69,10 +69,12 @@ async def async_setup_entry(
) -> None:
"""Set up the buttons."""
data: DomainData = hass.data[DOMAIN][entry.entry_id]
system_info = data.system.data
if TYPE_CHECKING:
assert system_info is not None
entities = [
SFRBoxButton(data.box, description, data.system.data)
for description in BUTTON_TYPES
SFRBoxButton(data.box, description, system_info) for description in BUTTON_TYPES
]
async_add_entities(entities)

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from typing import TYPE_CHECKING, Any
from sfrbox_api.bridge import SFRBox
from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError
@ -51,6 +51,8 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN):
except SFRBoxError:
errors["base"] = "cannot_connect"
else:
if TYPE_CHECKING:
assert system_info is not None
await self.async_set_unique_id(system_info.mac_addr)
self._abort_if_unique_id_configured()
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})

View File

@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__)
_SCAN_INTERVAL = timedelta(minutes=1)
class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT | None]):
"""Coordinator to manage data updates."""
def __init__(
@ -23,14 +23,14 @@ class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
hass: HomeAssistant,
box: SFRBox,
name: str,
method: Callable[[SFRBox], Coroutine[Any, Any, _DataT]],
method: Callable[[SFRBox], Coroutine[Any, Any, _DataT | None]],
) -> None:
"""Initialize coordinator."""
self.box = box
self._method = method
super().__init__(hass, _LOGGER, name=name, update_interval=_SCAN_INTERVAL)
async def _async_update_data(self) -> _DataT:
async def _async_update_data(self) -> _DataT | None:
"""Update data."""
try:
return await self._method(self.box)

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import dataclasses
from typing import Any
from typing import TYPE_CHECKING, Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
@ -12,9 +12,18 @@ from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .models import DomainData
if TYPE_CHECKING:
from _typeshed import DataclassInstance
TO_REDACT = {"mac_addr", "serial_number", "ip_addr", "ipv6_addr"}
def _async_redact_data(obj: DataclassInstance | None) -> dict[str, Any] | None:
if obj is None:
return None
return async_redact_data(dataclasses.asdict(obj), TO_REDACT)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
@ -27,21 +36,9 @@ async def async_get_config_entry_diagnostics(
"data": dict(entry.data),
},
"data": {
"dsl": async_redact_data(
dataclasses.asdict(await data.system.box.dsl_get_info()),
TO_REDACT,
),
"ftth": async_redact_data(
dataclasses.asdict(await data.system.box.ftth_get_info()),
TO_REDACT,
),
"system": async_redact_data(
dataclasses.asdict(await data.system.box.system_get_info()),
TO_REDACT,
),
"wan": async_redact_data(
dataclasses.asdict(await data.system.box.wan_get_info()),
TO_REDACT,
),
"dsl": _async_redact_data(await data.system.box.dsl_get_info()),
"ftth": _async_redact_data(await data.system.box.ftth_get_info()),
"system": _async_redact_data(await data.system.box.system_get_info()),
"wan": _async_redact_data(await data.system.box.wan_get_info()),
},
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sfr_box",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["sfrbox-api==0.0.8"]
"requirements": ["sfrbox-api==0.0.10"]
}

View File

@ -2,6 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from sfrbox_api.models import DslInfo, SystemInfo, WanInfo
@ -129,7 +130,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = (
"unknown",
],
translation_key="dsl_line_status",
value_fn=lambda x: x.line_status.lower().replace(" ", "_"),
value_fn=lambda x: _value_to_option(x.line_status),
),
SFRBoxSensorEntityDescription[DslInfo](
key="training",
@ -149,7 +150,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = (
"unknown",
],
translation_key="dsl_training",
value_fn=lambda x: x.training.lower().replace(" ", "_").replace(".", "_"),
value_fn=lambda x: _value_to_option(x.training),
),
)
SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = (
@ -181,7 +182,7 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda x: None if x.temperature is None else x.temperature / 1000,
value_fn=lambda x: _get_temperature(x.temperature),
),
)
WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = (
@ -203,23 +204,38 @@ WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = (
)
def _value_to_option(value: str | None) -> str | None:
if value is None:
return value
return value.lower().replace(" ", "_").replace(".", "_")
def _get_temperature(value: float | None) -> float | None:
if value is None or value < 1000:
return value
return value / 1000
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the sensors."""
data: DomainData = hass.data[DOMAIN][entry.entry_id]
system_info = data.system.data
if TYPE_CHECKING:
assert system_info is not None
entities: list[SFRBoxSensor] = [
SFRBoxSensor(data.system, description, data.system.data)
SFRBoxSensor(data.system, description, system_info)
for description in SYSTEM_SENSOR_TYPES
]
entities.extend(
SFRBoxSensor(data.wan, description, data.system.data)
SFRBoxSensor(data.wan, description, system_info)
for description in WAN_SENSOR_TYPES
)
if data.system.data.net_infra == "adsl":
if system_info.net_infra == "adsl":
entities.extend(
SFRBoxSensor(data.dsl, description, data.system.data)
SFRBoxSensor(data.dsl, description, system_info)
for description in DSL_SENSOR_TYPES
)
@ -251,4 +267,6 @@ class SFRBoxSensor[_T](CoordinatorEntity[SFRDataUpdateCoordinator[_T]], SensorEn
@property
def native_value(self) -> StateType:
"""Return the native value of the device."""
if self.coordinator.data is None:
return None
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -9,8 +9,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
@ -40,6 +42,7 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]):
self.unique_id: str | None = None
self.client = Api2(host=host, session=async_get_clientsession(hass))
self.legacy_api: int = 0
async def _async_setup(self) -> None:
"""Authenticate if needed during initial setup."""
@ -60,11 +63,28 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]):
info = await self.client.get_info()
self.unique_id = format_mac(info.MAC)
if info.legacy_api:
self.legacy_api = info.legacy_api
ir.async_create_issue(
self.hass,
DOMAIN,
"unsupported_firmware",
is_fixable=False,
is_persistent=False,
learn_more_url="https://smlight.tech/flasher/#SLZB-06",
severity=IssueSeverity.ERROR,
translation_key="unsupported_firmware",
)
async def _async_update_data(self) -> SmData:
"""Fetch data from the SMLIGHT device."""
try:
sensors = Sensors()
if not self.legacy_api:
sensors = await self.client.get_sensors()
return SmData(
sensors=await self.client.get_sensors(),
sensors=sensors,
info=await self.client.get_info(),
)
except SmlightConnectionError as err:

View File

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pysmlight==0.0.13"],
"requirements": ["pysmlight==0.0.14"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."

View File

@ -45,5 +45,11 @@
"name": "RAM usage"
}
}
},
"issues": {
"unsupported_firmware": {
"title": "SLZB core firmware update required",
"description": "Your SMLIGHT SLZB-06x device is running an unsupported core firmware version. Please update it to the latest version to enjoy all the features of this integration."
}
}
}

View File

@ -7,9 +7,14 @@ import logging
from homeassistant import config as conf_util
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_UNIQUE_ID, SERVICE_RELOAD
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_NAME,
CONF_UNIQUE_ID,
SERVICE_RELOAD,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import ConfigEntryError, HomeAssistantError
from homeassistant.helpers import discovery
from homeassistant.helpers.device import (
async_remove_stale_devices_links_keep_current_device,
@ -19,7 +24,7 @@ from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration
from .const import CONF_TRIGGER, DOMAIN, PLATFORMS
from .const import CONF_MAX, CONF_MIN, CONF_STEP, CONF_TRIGGER, DOMAIN, PLATFORMS
from .coordinator import TriggerUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@ -67,6 +72,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.options.get(CONF_DEVICE_ID),
)
for key in (CONF_MAX, CONF_MIN, CONF_STEP):
if key not in entry.options:
continue
if isinstance(entry.options[key], str):
raise ConfigEntryError(
f"The '{entry.options.get(CONF_NAME) or ""}' number template needs to "
f"be reconfigured, {key} must be a number, got '{entry.options[key]}'"
)
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["template_type"],)
)

View File

@ -107,15 +107,15 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
if domain == Platform.NUMBER:
schema |= {
vol.Required(CONF_STATE): selector.TemplateSelector(),
vol.Required(
CONF_MIN, default=f"{{{{{DEFAULT_MIN_VALUE}}}}}"
): selector.TemplateSelector(),
vol.Required(
CONF_MAX, default=f"{{{{{DEFAULT_MAX_VALUE}}}}}"
): selector.TemplateSelector(),
vol.Required(
CONF_STEP, default=f"{{{{{DEFAULT_STEP}}}}}"
): selector.TemplateSelector(),
vol.Required(CONF_MIN, default=DEFAULT_MIN_VALUE): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX),
),
vol.Required(CONF_MAX, default=DEFAULT_MAX_VALUE): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX),
),
vol.Required(CONF_STEP, default=DEFAULT_STEP): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX),
),
vol.Optional(CONF_SET_VALUE): selector.ActionSelector(),
}

View File

@ -28,11 +28,14 @@ PLATFORMS = [
Platform.WEATHER,
]
CONF_AVAILABILITY = "availability"
CONF_ATTRIBUTES = "attributes"
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
CONF_ATTRIBUTES = "attributes"
CONF_AVAILABILITY = "availability"
CONF_MAX = "max"
CONF_MIN = "min"
CONF_OBJECT_ID = "object_id"
CONF_PICTURE = "picture"
CONF_PRESS = "press"
CONF_OBJECT_ID = "object_id"
CONF_STEP = "step"
CONF_TURN_OFF = "turn_off"
CONF_TURN_ON = "turn_on"

View File

@ -31,7 +31,7 @@ from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import DOMAIN
from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN
from .template_entity import (
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_ICON_SCHEMA,
@ -42,9 +42,6 @@ from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
CONF_SET_VALUE = "set_value"
CONF_MIN = "min"
CONF_MAX = "max"
CONF_STEP = "step"
DEFAULT_NAME = "Template Number"
DEFAULT_OPTIMISTIC = False

View File

@ -36,8 +36,10 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_setup(self) -> None:
"""Set up connection to Yale."""
try:
self.yale = YaleSmartAlarmClient(
self.entry.data[CONF_USERNAME], self.entry.data[CONF_PASSWORD]
self.yale = await self.hass.async_add_executor_job(
YaleSmartAlarmClient,
self.entry.data[CONF_USERNAME],
self.entry.data[CONF_PASSWORD],
)
except AuthenticationError as error:
raise ConfigEntryAuthFailed from error

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import contextlib
import logging
from typing import Any
@ -130,34 +129,7 @@ def _discovery(config_info):
zones.extend(recv.zone_controllers())
else:
_LOGGER.debug("Config Zones")
zones = None
# Fix for upstream issues in rxv.find() with some hardware.
with contextlib.suppress(AttributeError, ValueError):
for recv in rxv.find(DISCOVER_TIMEOUT):
_LOGGER.debug(
"Found Serial %s %s %s",
recv.serial_number,
recv.ctrl_url,
recv.zone,
)
if recv.ctrl_url == config_info.ctrl_url:
_LOGGER.debug(
"Config Zones Matched Serial %s: %s",
recv.ctrl_url,
recv.serial_number,
)
zones = rxv.RXV(
config_info.ctrl_url,
friendly_name=config_info.name,
serial_number=recv.serial_number,
model_name=recv.model_name,
).zone_controllers()
break
if not zones:
_LOGGER.debug("Config Zones Fallback")
zones = rxv.RXV(config_info.ctrl_url, config_info.name).zone_controllers()
zones = rxv.RXV(config_info.ctrl_url, config_info.name).zone_controllers()
_LOGGER.debug("Returned _discover zones: %s", zones)
return zones

View File

@ -24,7 +24,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 9
PATCH_VERSION: Final = "0"
PATCH_VERSION: Final = "1"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

@ -31,7 +31,7 @@ habluetooth==3.4.0
hass-nabucasa==0.81.1
hassil==1.7.4
home-assistant-bluetooth==1.12.2
home-assistant-frontend==20240904.0
home-assistant-frontend==20240906.0
home-assistant-intents==2024.9.4
httpx==0.27.0
ifaddr==0.2.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2024.9.0"
version = "2024.9.1"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"

View File

@ -350,7 +350,7 @@ aioridwell==2024.01.0
aioruckus==0.41
# homeassistant.components.russound_rio
aiorussound==2.3.2
aiorussound==3.0.4
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@ -1102,7 +1102,7 @@ hole==0.8.0
holidays==0.56
# homeassistant.components.frontend
home-assistant-frontend==20240904.0
home-assistant-frontend==20240906.0
# homeassistant.components.conversation
home-assistant-intents==2024.9.4
@ -1744,7 +1744,7 @@ pyatag==0.3.5.3
pyatmo==8.1.0
# homeassistant.components.apple_tv
pyatv==0.15.0
pyatv==0.15.1
# homeassistant.components.aussie_broadband
pyaussiebb==0.0.15
@ -2109,7 +2109,7 @@ pyownet==0.10.0.post1
pypca==0.0.7
# homeassistant.components.lcn
pypck==0.7.21
pypck==0.7.22
# homeassistant.components.pjlink
pypjlink2==1.2.1
@ -2214,7 +2214,7 @@ pysmartthings==0.7.8
pysml==0.0.12
# homeassistant.components.smlight
pysmlight==0.0.13
pysmlight==0.0.14
# homeassistant.components.snmp
pysnmp==6.2.5
@ -2595,7 +2595,7 @@ sensorpush-ble==1.6.2
sentry-sdk==1.40.3
# homeassistant.components.sfr_box
sfrbox-api==0.0.8
sfrbox-api==0.0.10
# homeassistant.components.sharkiq
sharkiq==1.0.2

View File

@ -332,7 +332,7 @@ aioridwell==2024.01.0
aioruckus==0.41
# homeassistant.components.russound_rio
aiorussound==2.3.2
aiorussound==3.0.4
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@ -925,7 +925,7 @@ hole==0.8.0
holidays==0.56
# homeassistant.components.frontend
home-assistant-frontend==20240904.0
home-assistant-frontend==20240906.0
# homeassistant.components.conversation
home-assistant-intents==2024.9.4
@ -1412,7 +1412,7 @@ pyatag==0.3.5.3
pyatmo==8.1.0
# homeassistant.components.apple_tv
pyatv==0.15.0
pyatv==0.15.1
# homeassistant.components.aussie_broadband
pyaussiebb==0.0.15
@ -1687,7 +1687,7 @@ pyoverkiz==1.13.14
pyownet==0.10.0.post1
# homeassistant.components.lcn
pypck==0.7.21
pypck==0.7.22
# homeassistant.components.pjlink
pypjlink2==1.2.1
@ -1768,7 +1768,7 @@ pysmartthings==0.7.8
pysml==0.0.12
# homeassistant.components.smlight
pysmlight==0.0.13
pysmlight==0.0.14
# homeassistant.components.snmp
pysnmp==6.2.5
@ -2053,7 +2053,7 @@ sensorpush-ble==1.6.2
sentry-sdk==1.40.3
# homeassistant.components.sfr_box
sfrbox-api==0.0.8
sfrbox-api==0.0.10
# homeassistant.components.sharkiq
sharkiq==1.0.2

View File

@ -79,6 +79,7 @@ async def test_full_flow(
("exception", "error"),
[
(ApiException, "cannot_connect"),
(TimeoutError, "cannot_connect"),
(AuthenticationFailed, "invalid_auth"),
(Exception, "unknown"),
],

View File

@ -1,10 +1,19 @@
"""Test BTHome BLE events."""
import pytest
from homeassistant.components import automation
from homeassistant.components.bluetooth import DOMAIN as BLUETOOTH_DOMAIN
from homeassistant.components.bthome.const import CONF_SUBTYPE, DOMAIN
from homeassistant.components.device_automation import DeviceAutomationType
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_DOMAIN,
CONF_PLATFORM,
CONF_TYPE,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
@ -121,6 +130,117 @@ async def test_get_triggers_button(
await hass.async_block_till_done()
async def test_get_triggers_multiple_buttons(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test that we get the expected triggers for multiple buttons device."""
mac = "A4:C1:38:8D:18:B2"
entry = await _async_setup_bthome_device(hass, mac)
events = async_capture_events(hass, "bthome_ble_event")
# Emit button_1 long press and button_2 press events
# so it creates the device in the registry
inject_bluetooth_service_info_bleak(
hass,
make_bthome_v2_adv(mac, b"\x40\x3a\x04\x3a\x01"),
)
# wait for the event
await hass.async_block_till_done()
assert len(events) == 2
device = device_registry.async_get_device(identifiers={get_device_id(mac)})
assert device
expected_trigger1 = {
CONF_PLATFORM: "device",
CONF_DOMAIN: DOMAIN,
CONF_DEVICE_ID: device.id,
CONF_TYPE: "button_1",
CONF_SUBTYPE: "long_press",
"metadata": {},
}
expected_trigger2 = {
CONF_PLATFORM: "device",
CONF_DOMAIN: DOMAIN,
CONF_DEVICE_ID: device.id,
CONF_TYPE: "button_2",
CONF_SUBTYPE: "press",
"metadata": {},
}
triggers = await async_get_device_automations(
hass, DeviceAutomationType.TRIGGER, device.id
)
assert expected_trigger1 in triggers
assert expected_trigger2 in triggers
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.parametrize(
("event_class", "event_type", "expected"),
[
("button_1", "long_press", STATE_ON),
("button_2", "press", STATE_ON),
("button_3", "long_press", STATE_UNAVAILABLE),
("button", "long_press", STATE_UNAVAILABLE),
("button_1", "invalid_press", STATE_UNAVAILABLE),
],
)
async def test_validate_trigger_config(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
event_class: str,
event_type: str,
expected: str,
) -> None:
"""Test unsupported trigger does not return a trigger config."""
mac = "A4:C1:38:8D:18:B2"
entry = await _async_setup_bthome_device(hass, mac)
# Emit button_1 long press and button_2 press events
# so it creates the device in the registry
inject_bluetooth_service_info_bleak(
hass,
make_bthome_v2_adv(mac, b"\x40\x3a\x04\x3a\x01"),
)
# wait for the event
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={get_device_id(mac)})
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
CONF_PLATFORM: "device",
CONF_DOMAIN: DOMAIN,
CONF_DEVICE_ID: device.id,
CONF_TYPE: event_class,
CONF_SUBTYPE: event_type,
},
"action": {
"service": "test.automation",
"data_template": {"some": "test_trigger_button_long_press"},
},
},
]
},
)
await hass.async_block_till_done()
automations = hass.states.async_entity_ids(automation.DOMAIN)
assert len(automations) == 1
assert hass.states.get(automations[0]).state == expected
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_get_triggers_dimmer(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
@ -235,7 +355,7 @@ async def test_if_fires_on_motion_detected(
make_bthome_v2_adv(mac, b"\x40\x3a\x03"),
)
# # wait for the event
# wait for the event
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={get_device_id(mac)})

View File

@ -175,7 +175,7 @@ async def test_light_turn_on_service(
assert len(mock_bridge_v2.mock_requests) == 6
assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500
# test enable effect
# test enable an effect
await hass.services.async_call(
"light",
"turn_on",
@ -184,8 +184,20 @@ async def test_light_turn_on_service(
)
assert len(mock_bridge_v2.mock_requests) == 7
assert mock_bridge_v2.mock_requests[6]["json"]["effects"]["effect"] == "candle"
# fire event to update effect in HA state
event = {
"id": "3a6710fa-4474-4eba-b533-5e6e72968feb",
"type": "light",
"effects": {"status": "candle"},
}
mock_bridge_v2.api.emit_event("update", event)
await hass.async_block_till_done()
test_light = hass.states.get(test_light_id)
assert test_light is not None
assert test_light.attributes["effect"] == "candle"
# test disable effect
# it should send a request with effect set to "no_effect"
await hass.services.async_call(
"light",
"turn_on",
@ -194,6 +206,28 @@ async def test_light_turn_on_service(
)
assert len(mock_bridge_v2.mock_requests) == 8
assert mock_bridge_v2.mock_requests[7]["json"]["effects"]["effect"] == "no_effect"
# fire event to update effect in HA state
event = {
"id": "3a6710fa-4474-4eba-b533-5e6e72968feb",
"type": "light",
"effects": {"status": "no_effect"},
}
mock_bridge_v2.api.emit_event("update", event)
await hass.async_block_till_done()
test_light = hass.states.get(test_light_id)
assert test_light is not None
assert test_light.attributes["effect"] == "None"
# test turn on with useless effect
# it should send a effect in the request if the device has no effect active
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": test_light_id, "effect": "None"},
blocking=True,
)
assert len(mock_bridge_v2.mock_requests) == 9
assert "effects" not in mock_bridge_v2.mock_requests[8]["json"]
# test timed effect
await hass.services.async_call(
@ -202,11 +236,11 @@ async def test_light_turn_on_service(
{"entity_id": test_light_id, "effect": "sunrise", "transition": 6},
blocking=True,
)
assert len(mock_bridge_v2.mock_requests) == 9
assert len(mock_bridge_v2.mock_requests) == 10
assert (
mock_bridge_v2.mock_requests[8]["json"]["timed_effects"]["effect"] == "sunrise"
mock_bridge_v2.mock_requests[9]["json"]["timed_effects"]["effect"] == "sunrise"
)
assert mock_bridge_v2.mock_requests[8]["json"]["timed_effects"]["duration"] == 6000
assert mock_bridge_v2.mock_requests[9]["json"]["timed_effects"]["duration"] == 6000
# test enabling effect should ignore color temperature
await hass.services.async_call(
@ -215,9 +249,9 @@ async def test_light_turn_on_service(
{"entity_id": test_light_id, "effect": "candle", "color_temp": 500},
blocking=True,
)
assert len(mock_bridge_v2.mock_requests) == 10
assert mock_bridge_v2.mock_requests[9]["json"]["effects"]["effect"] == "candle"
assert "color_temperature" not in mock_bridge_v2.mock_requests[9]["json"]
assert len(mock_bridge_v2.mock_requests) == 11
assert mock_bridge_v2.mock_requests[10]["json"]["effects"]["effect"] == "candle"
assert "color_temperature" not in mock_bridge_v2.mock_requests[10]["json"]
# test enabling effect should ignore xy color
await hass.services.async_call(
@ -226,9 +260,9 @@ async def test_light_turn_on_service(
{"entity_id": test_light_id, "effect": "candle", "xy_color": [0.123, 0.123]},
blocking=True,
)
assert len(mock_bridge_v2.mock_requests) == 11
assert mock_bridge_v2.mock_requests[10]["json"]["effects"]["effect"] == "candle"
assert "xy_color" not in mock_bridge_v2.mock_requests[9]["json"]
assert len(mock_bridge_v2.mock_requests) == 12
assert mock_bridge_v2.mock_requests[11]["json"]["effects"]["effect"] == "candle"
assert "xy_color" not in mock_bridge_v2.mock_requests[11]["json"]
async def test_light_turn_off_service(

View File

@ -37,10 +37,10 @@ def mock_russound() -> Generator[AsyncMock]:
"""Mock the Russound RIO client."""
with (
patch(
"homeassistant.components.russound_rio.Russound", autospec=True
"homeassistant.components.russound_rio.RussoundClient", autospec=True
) as mock_client,
patch(
"homeassistant.components.russound_rio.config_flow.Russound",
"homeassistant.components.russound_rio.config_flow.RussoundClient",
return_value=mock_client,
),
):

View File

@ -31,7 +31,7 @@
'product_id': 'NB6VAC-FXC-r0',
'refclient': '',
'serial_number': '**REDACTED**',
'temperature': 27560,
'temperature': 27560.0,
'uptime': 2353575,
'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8',
'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p',
@ -90,7 +90,7 @@
'product_id': 'NB6VAC-FXC-r0',
'refclient': '',
'serial_number': '**REDACTED**',
'temperature': 27560,
'temperature': 27560.0,
'uptime': 2353575,
'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8',
'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p',

View File

@ -3,10 +3,12 @@
"device_ip": "192.168.1.161",
"fs_total": 3456,
"fw_channel": "dev",
"legacy_api": 0,
"hostname": "SLZB-06p7",
"MAC": "AA:BB:CC:DD:EE:FF",
"model": "SLZB-06p7",
"ram_total": 296,
"sw_version": "v2.3.1.dev",
"sw_version": "v2.3.6",
"wifi_mode": 0,
"zb_flash_size": 704,
"zb_hw": "CC2652P7",

View File

@ -27,7 +27,7 @@
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': 'core: v2.3.1.dev / zigbee: -1',
'sw_version': 'core: v2.3.6 / zigbee: -1',
'via_device_id': None,
})
# ---

View File

@ -3,15 +3,17 @@
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError
from pysmlight import Info
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, SmlightError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.smlight.const import SCAN_INTERVAL
from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.issue_registry import IssueRegistry
from .conftest import setup_integration
@ -92,3 +94,33 @@ async def test_device_info(
)
assert device_entry is not None
assert device_entry == snapshot
async def test_device_legacy_firmware(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
device_registry: dr.DeviceRegistry,
issue_registry: IssueRegistry,
) -> None:
"""Test device setup for old firmware version that dont support required API."""
LEGACY_VERSION = "v2.3.1"
mock_smlight_client.get_sensors.side_effect = SmlightError
mock_smlight_client.get_info.return_value = Info(
legacy_api=1, sw_version=LEGACY_VERSION, MAC="AA:BB:CC:DD:EE:FF"
)
entry = await setup_integration(hass, mock_config_entry)
assert entry.unique_id == "aa:bb:cc:dd:ee:ff"
device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
)
assert LEGACY_VERSION in device_entry.sw_version
issue = issue_registry.async_get_issue(
domain=DOMAIN, issue_id="unsupported_firmware"
)
assert issue is not None
assert issue.domain == DOMAIN
assert issue.issue_id == "unsupported_firmware"

View File

@ -98,9 +98,9 @@ from tests.typing import WebSocketGenerator
{"one": "30.0", "two": "20.0"},
{},
{
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": "0",
"max": "100",
"step": "0.1",
"set_value": {
"action": "input_number.set_value",
"target": {"entity_id": "input_number.test"},
@ -108,9 +108,9 @@ from tests.typing import WebSocketGenerator
},
},
{
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
"set_value": {
"action": "input_number.set_value",
"target": {"entity_id": "input_number.test"},
@ -258,14 +258,14 @@ async def test_config_flow(
"number",
{"state": "{{ states('number.one') }}"},
{
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": "0",
"max": "100",
"step": "0.1",
},
{
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
},
),
(
@ -451,9 +451,9 @@ def get_suggested(schema, key):
["30.0", "20.0"],
{"one": "30.0", "two": "20.0"},
{
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
"set_value": {
"action": "input_number.set_value",
"target": {"entity_id": "input_number.test"},
@ -461,9 +461,9 @@ def get_suggested(schema, key):
},
},
{
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
"set_value": {
"action": "input_number.set_value",
"target": {"entity_id": "input_number.test"},
@ -1230,14 +1230,14 @@ async def test_option_flow_sensor_preview_config_entry_removed(
"number",
{"state": "{{ states('number.one') }}"},
{
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
},
{
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
},
),
(

View File

@ -319,9 +319,9 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None:
"template_type": "number",
"name": "My template",
"state": "{{ 10 }}",
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
"set_value": {
"action": "input_number.set_value",
"target": {"entity_id": "input_number.test"},
@ -330,9 +330,9 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None:
},
{
"state": "{{ 11 }}",
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
"set_value": {
"action": "input_number.set_value",
"target": {"entity_id": "input_number.test"},
@ -454,3 +454,40 @@ async def test_change_device(
)
== []
)
async def test_fail_non_numerical_number_settings(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that non numerical number options causes config entry setup to fail.
Support for non numerical max, min and step was added in HA Core 2024.9.0 and
removed in HA Core 2024.9.1.
"""
options = {
"template_type": "number",
"name": "My template",
"state": "{{ 10 }}",
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"set_value": {
"action": "input_number.set_value",
"target": {"entity_id": "input_number.test"},
"data": {"value": "{{ value }}"},
},
}
# Setup the config entry
template_config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options=options,
title="Template",
)
template_config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(template_config_entry.entry_id)
assert (
"The 'My template' number template needs to be reconfigured, "
"max must be a number, got '{{ 100 }}'" in caplog.text
)

View File

@ -58,9 +58,9 @@ async def test_setup_config_entry(
"name": "My template",
"template_type": "number",
"state": "{{ 10 }}",
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
"set_value": {
"action": "input_number.set_value",
"target": {"entity_id": "input_number.test"},
@ -524,9 +524,9 @@ async def test_device_id(
"name": "My template",
"template_type": "number",
"state": "{{ 10 }}",
"min": "{{ 0 }}",
"max": "{{ 100 }}",
"step": "{{ 0.1 }}",
"min": 0,
"max": 100,
"step": 0.1,
"set_value": {
"action": "input_number.set_value",
"target": {"entity_id": "input_number.test"},