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") _LOGGER.debug("Argument `temperature` is missing in set_temperature")
return return
await self._control(temp=temp) await self._control(setpoint=SetpointControl.CHANGE_SETPOINT, temp=temp)
class Airtouch5Zone(Airtouch5ClimateEntity): class Airtouch5Zone(Airtouch5ClimateEntity):

View File

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

View File

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

View File

@ -26,7 +26,6 @@ async def async_setup_entry(
class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity): class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity):
"""Base sensor to be used with description.""" """Base sensor to be used with description."""
_attr_native_min_value = 30
_attr_native_step = 1 _attr_native_step = 1
_attr_device_class = NumberDeviceClass.POWER _attr_device_class = NumberDeviceClass.POWER
_attr_mode = NumberMode.BOX _attr_mode = NumberMode.BOX
@ -42,6 +41,7 @@ class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity):
self._api = data.coordinator.api self._api = data.coordinator.api
self._attr_unique_id = f"{data.device_id}_output_limit" self._attr_unique_id = f"{data.device_id}_output_limit"
self._attr_native_max_value = data.coordinator.api.max_power 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: async def async_update(self) -> None:
"""Set the state with the value fetched from the inverter.""" """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( refresh_token = await api.authenticate(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD] user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
) )
except ApiException: except (ApiException, TimeoutError):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except AuthenticationFailed: except AuthenticationFailed:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"

View File

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

View File

@ -7,6 +7,9 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA 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.components.homeassistant.triggers import event as event_trigger
from homeassistant.const import ( from homeassistant.const import (
CONF_DEVICE_ID, CONF_DEVICE_ID,
@ -43,33 +46,46 @@ TRIGGERS_BY_EVENT_CLASS = {
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"}, EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
} }
SCHEMA_BY_EVENT_CLASS = { TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
EVENT_CLASS_BUTTON: DEVICE_TRIGGER_BASE_SCHEMA.extend( {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str}
{ )
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]
),
}
),
}
async def async_validate_trigger_config( async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType hass: HomeAssistant, config: ConfigType
) -> ConfigType: ) -> ConfigType:
"""Validate trigger config.""" """Validate trigger config."""
return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( # type: ignore[no-any-return] config = TRIGGER_SCHEMA(config)
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( async def async_get_triggers(

View File

@ -119,7 +119,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity):
self._partition_number = partition_number self._partition_number = partition_number
self._panic_type = panic_type self._panic_type = panic_type
self._alarm_control_panel_option_default_code = code 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) _LOGGER.debug("Setting up alarm: %s", alarm_name)
super().__init__(alarm_name, info, controller) super().__init__(alarm_name, info, controller)

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "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, hass,
_LOGGER, _LOGGER,
name="FYTA Coordinator", name="FYTA Coordinator",
update_interval=timedelta(seconds=60), update_interval=timedelta(minutes=4),
) )
self.fyta = fyta self.fyta = fyta

View File

@ -226,7 +226,11 @@ class HueLight(HueBaseEntity, LightEntity):
flash = kwargs.get(ATTR_FLASH) flash = kwargs.get(ATTR_FLASH)
effect = effect_str = kwargs.get(ATTR_EFFECT) effect = effect_str = kwargs.get(ATTR_EFFECT)
if effect_str in (EFFECT_NONE, EFFECT_NONE.lower()): 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: elif effect_str is not None:
# work out if we got a regular effect or timed effect # work out if we got a regular effect or timed effect
effect = EffectStatus(effect_str) effect = EffectStatus(effect_str)

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn", "documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pypck"], "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, MediaType,
RepeatMode, RepeatMode,
) )
from homeassistant.components.media_player.browse_media import (
async_process_play_media_url,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
@ -59,6 +62,7 @@ SOURCE_MAP: dict[PlayingMode, str] = {
PlayingMode.FM: "FM Radio", PlayingMode.FM: "FM Radio",
PlayingMode.RCA: "RCA", PlayingMode.RCA: "RCA",
PlayingMode.UDISK: "USB", PlayingMode.UDISK: "USB",
PlayingMode.FOLLOWER: "Follower",
} }
SOURCE_MAP_INV: dict[str, PlayingMode] = {v: k for k, v in SOURCE_MAP.items()} 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 self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None: ) -> None:
"""Play a piece of media.""" """Play a piece of media."""
media = await media_source.async_resolve_media( if media_source.is_media_source_id(media_id):
self.hass, media_id, self.entity_id play_item = await media_source.async_resolve_media(
) self.hass, media_id, self.entity_id
await self._bridge.player.play(media.url) )
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: def _update_properties(self) -> None:
"""Update the properties of the media player.""" """Update the properties of the media player."""

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps from functools import wraps
from typing import Any, Concatenate from typing import Any, Concatenate
from aiorussound import Controller from aiorussound import Controller, RussoundTcpConnectionHandler
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@ -53,7 +53,6 @@ class RussoundBaseEntity(Entity):
or f"{self._primary_mac_address}-{self._controller.controller_id}" or f"{self._primary_mac_address}-{self._controller.controller_id}"
) )
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
configuration_url=f"http://{self._instance.host}",
# Use MAC address of Russound device as identifier # Use MAC address of Russound device as identifier
identifiers={(DOMAIN, self._device_identifier)}, identifiers={(DOMAIN, self._device_identifier)},
manufacturer="Russound", manufacturer="Russound",
@ -61,6 +60,10 @@ class RussoundBaseEntity(Entity):
model=controller.controller_type, model=controller.controller_type,
sw_version=controller.firmware_version, 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: if controller.parent_controller:
self._attr_device_info["via_device"] = ( self._attr_device_info["via_device"] = (
DOMAIN, DOMAIN,
@ -79,8 +82,12 @@ class RussoundBaseEntity(Entity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """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: async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks.""" """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", "iot_class": "local_push",
"loggers": ["aiorussound"], "loggers": ["aiorussound"],
"quality_scale": "silver", "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.""" """Set up the Russound RIO platform."""
russ = entry.runtime_data russ = entry.runtime_data
await russ.init_sources()
sources = russ.sources
for source in sources.values():
await source.watch()
# Discover controllers # Discover controllers
controllers = await russ.enumerate_controllers() controllers = await russ.enumerate_controllers()
entities = [] entities = []
for controller in controllers.values(): for controller in controllers.values():
sources = controller.sources
for source in sources.values():
await source.watch()
for zone in controller.zones.values(): for zone in controller.zones.values():
await zone.watch() await zone.watch()
mp = RussoundZoneDevice(zone, sources) mp = RussoundZoneDevice(zone, sources)
@ -154,7 +156,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@property @property
def state(self) -> MediaPlayerState | None: def state(self) -> MediaPlayerState | None:
"""Return the state of the device.""" """Return the state of the device."""
status = self._zone.status status = self._zone.properties.status
if status == "ON": if status == "ON":
return MediaPlayerState.ON return MediaPlayerState.ON
if status == "OFF": if status == "OFF":
@ -174,22 +176,22 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@property @property
def media_title(self): def media_title(self):
"""Title of current playing media.""" """Title of current playing media."""
return self._current_source().song_name return self._current_source().properties.song_name
@property @property
def media_artist(self): def media_artist(self):
"""Artist of current playing media, music track only.""" """Artist of current playing media, music track only."""
return self._current_source().artist_name return self._current_source().properties.artist_name
@property @property
def media_album_name(self): def media_album_name(self):
"""Album name of current playing media, music track only.""" """Album name of current playing media, music track only."""
return self._current_source().album_name return self._current_source().properties.album_name
@property @property
def media_image_url(self): def media_image_url(self):
"""Image url of current playing media.""" """Image url of current playing media."""
return self._current_source().cover_art_url return self._current_source().properties.cover_art_url
@property @property
def volume_level(self): def volume_level(self):
@ -198,7 +200,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
Value is returned based on a range (0..50). Value is returned based on a range (0..50).
Therefore float divide by 50 to get to the required range. 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 @command
async def async_turn_off(self) -> None: 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: async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level.""" """Set the volume level."""
rvol = int(volume * 50.0) rvol = int(volume * 50.0)
await self._zone.set_volume(rvol) await self._zone.set_volume(str(rvol))
@command @command
async def async_select_source(self, source: str) -> None: async def async_select_source(self, source: str) -> None:

View File

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

View File

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

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import TYPE_CHECKING, Any
from sfrbox_api.bridge import SFRBox from sfrbox_api.bridge import SFRBox
from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError
@ -51,6 +51,8 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN):
except SFRBoxError: except SFRBoxError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
else: else:
if TYPE_CHECKING:
assert system_info is not None
await self.async_set_unique_id(system_info.mac_addr) await self.async_set_unique_id(system_info.mac_addr)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) 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) _SCAN_INTERVAL = timedelta(minutes=1)
class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT | None]):
"""Coordinator to manage data updates.""" """Coordinator to manage data updates."""
def __init__( def __init__(
@ -23,14 +23,14 @@ class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
hass: HomeAssistant, hass: HomeAssistant,
box: SFRBox, box: SFRBox,
name: str, name: str,
method: Callable[[SFRBox], Coroutine[Any, Any, _DataT]], method: Callable[[SFRBox], Coroutine[Any, Any, _DataT | None]],
) -> None: ) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
self.box = box self.box = box
self._method = method self._method = method
super().__init__(hass, _LOGGER, name=name, update_interval=_SCAN_INTERVAL) 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.""" """Update data."""
try: try:
return await self._method(self.box) return await self._method(self.box)

View File

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

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sfr_box", "documentation": "https://www.home-assistant.io/integrations/sfr_box",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "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 collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from sfrbox_api.models import DslInfo, SystemInfo, WanInfo from sfrbox_api.models import DslInfo, SystemInfo, WanInfo
@ -129,7 +130,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = (
"unknown", "unknown",
], ],
translation_key="dsl_line_status", 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]( SFRBoxSensorEntityDescription[DslInfo](
key="training", key="training",
@ -149,7 +150,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = (
"unknown", "unknown",
], ],
translation_key="dsl_training", 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], ...] = ( SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = (
@ -181,7 +182,7 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = (
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, 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], ...] = ( 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( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the sensors.""" """Set up the sensors."""
data: DomainData = hass.data[DOMAIN][entry.entry_id] 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] = [ entities: list[SFRBoxSensor] = [
SFRBoxSensor(data.system, description, data.system.data) SFRBoxSensor(data.system, description, system_info)
for description in SYSTEM_SENSOR_TYPES for description in SYSTEM_SENSOR_TYPES
] ]
entities.extend( entities.extend(
SFRBoxSensor(data.wan, description, data.system.data) SFRBoxSensor(data.wan, description, system_info)
for description in WAN_SENSOR_TYPES for description in WAN_SENSOR_TYPES
) )
if data.system.data.net_infra == "adsl": if system_info.net_infra == "adsl":
entities.extend( entities.extend(
SFRBoxSensor(data.dsl, description, data.system.data) SFRBoxSensor(data.dsl, description, system_info)
for description in DSL_SENSOR_TYPES for description in DSL_SENSOR_TYPES
) )
@ -251,4 +267,6 @@ class SFRBoxSensor[_T](CoordinatorEntity[SFRDataUpdateCoordinator[_T]], SensorEn
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the native value of the device.""" """Return the native value of the device."""
if self.coordinator.data is None:
return None
return self.entity_description.value_fn(self.coordinator.data) 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.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError 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.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL from .const import DOMAIN, LOGGER, SCAN_INTERVAL
@ -40,6 +42,7 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]):
self.unique_id: str | None = None self.unique_id: str | None = None
self.client = Api2(host=host, session=async_get_clientsession(hass)) self.client = Api2(host=host, session=async_get_clientsession(hass))
self.legacy_api: int = 0
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
"""Authenticate if needed during initial setup.""" """Authenticate if needed during initial setup."""
@ -60,11 +63,28 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]):
info = await self.client.get_info() info = await self.client.get_info()
self.unique_id = format_mac(info.MAC) 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: async def _async_update_data(self) -> SmData:
"""Fetch data from the SMLIGHT device.""" """Fetch data from the SMLIGHT device."""
try: try:
sensors = Sensors()
if not self.legacy_api:
sensors = await self.client.get_sensors()
return SmData( return SmData(
sensors=await self.client.get_sensors(), sensors=sensors,
info=await self.client.get_info(), info=await self.client.get_info(),
) )
except SmlightConnectionError as err: except SmlightConnectionError as err:

View File

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

View File

@ -45,5 +45,11 @@
"name": "RAM usage" "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 import config as conf_util
from homeassistant.config_entries import ConfigEntry 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.core import Event, HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import ConfigEntryError, HomeAssistantError
from homeassistant.helpers import discovery from homeassistant.helpers import discovery
from homeassistant.helpers.device import ( from homeassistant.helpers.device import (
async_remove_stale_devices_links_keep_current_device, 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.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration 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 from .coordinator import TriggerUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -67,6 +72,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.options.get(CONF_DEVICE_ID), 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( await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["template_type"],) 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: if domain == Platform.NUMBER:
schema |= { schema |= {
vol.Required(CONF_STATE): selector.TemplateSelector(), vol.Required(CONF_STATE): selector.TemplateSelector(),
vol.Required( vol.Required(CONF_MIN, default=DEFAULT_MIN_VALUE): selector.NumberSelector(
CONF_MIN, default=f"{{{{{DEFAULT_MIN_VALUE}}}}}" selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX),
): selector.TemplateSelector(), ),
vol.Required( vol.Required(CONF_MAX, default=DEFAULT_MAX_VALUE): selector.NumberSelector(
CONF_MAX, default=f"{{{{{DEFAULT_MAX_VALUE}}}}}" selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX),
): selector.TemplateSelector(), ),
vol.Required( vol.Required(CONF_STEP, default=DEFAULT_STEP): selector.NumberSelector(
CONF_STEP, default=f"{{{{{DEFAULT_STEP}}}}}" selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX),
): selector.TemplateSelector(), ),
vol.Optional(CONF_SET_VALUE): selector.ActionSelector(), vol.Optional(CONF_SET_VALUE): selector.ActionSelector(),
} }

View File

@ -28,11 +28,14 @@ PLATFORMS = [
Platform.WEATHER, Platform.WEATHER,
] ]
CONF_AVAILABILITY = "availability"
CONF_ATTRIBUTES = "attributes"
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" 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_PICTURE = "picture"
CONF_PRESS = "press" CONF_PRESS = "press"
CONF_OBJECT_ID = "object_id" CONF_STEP = "step"
CONF_TURN_OFF = "turn_off" CONF_TURN_OFF = "turn_off"
CONF_TURN_ON = "turn_on" 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 homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator from . import TriggerUpdateCoordinator
from .const import DOMAIN from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN
from .template_entity import ( from .template_entity import (
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_ICON_SCHEMA, TEMPLATE_ENTITY_ICON_SCHEMA,
@ -42,9 +42,6 @@ from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_SET_VALUE = "set_value" CONF_SET_VALUE = "set_value"
CONF_MIN = "min"
CONF_MAX = "max"
CONF_STEP = "step"
DEFAULT_NAME = "Template Number" DEFAULT_NAME = "Template Number"
DEFAULT_OPTIMISTIC = False DEFAULT_OPTIMISTIC = False

View File

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

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
from typing import Any from typing import Any
@ -130,34 +129,7 @@ def _discovery(config_info):
zones.extend(recv.zone_controllers()) zones.extend(recv.zone_controllers())
else: else:
_LOGGER.debug("Config Zones") _LOGGER.debug("Config Zones")
zones = None zones = rxv.RXV(config_info.ctrl_url, config_info.name).zone_controllers()
# 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()
_LOGGER.debug("Returned _discover zones: %s", zones) _LOGGER.debug("Returned _discover zones: %s", zones)
return zones return zones

View File

@ -24,7 +24,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024 MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 9 MINOR_VERSION: Final = 9
PATCH_VERSION: Final = "0" PATCH_VERSION: Final = "1"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) 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 hass-nabucasa==0.81.1
hassil==1.7.4 hassil==1.7.4
home-assistant-bluetooth==1.12.2 home-assistant-bluetooth==1.12.2
home-assistant-frontend==20240904.0 home-assistant-frontend==20240906.0
home-assistant-intents==2024.9.4 home-assistant-intents==2024.9.4
httpx==0.27.0 httpx==0.27.0
ifaddr==0.2.0 ifaddr==0.2.0

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,19 @@
"""Test BTHome BLE events.""" """Test BTHome BLE events."""
import pytest
from homeassistant.components import automation from homeassistant.components import automation
from homeassistant.components.bluetooth import DOMAIN as BLUETOOTH_DOMAIN from homeassistant.components.bluetooth import DOMAIN as BLUETOOTH_DOMAIN
from homeassistant.components.bthome.const import CONF_SUBTYPE, DOMAIN from homeassistant.components.bthome.const import CONF_SUBTYPE, DOMAIN
from homeassistant.components.device_automation import DeviceAutomationType 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.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -121,6 +130,117 @@ async def test_get_triggers_button(
await hass.async_block_till_done() 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( async def test_get_triggers_dimmer(
hass: HomeAssistant, device_registry: dr.DeviceRegistry hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None: ) -> None:
@ -235,7 +355,7 @@ async def test_if_fires_on_motion_detected(
make_bthome_v2_adv(mac, b"\x40\x3a\x03"), make_bthome_v2_adv(mac, b"\x40\x3a\x03"),
) )
# # wait for the event # wait for the event
await hass.async_block_till_done() await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={get_device_id(mac)}) 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 len(mock_bridge_v2.mock_requests) == 6
assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500 assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500
# test enable effect # test enable an effect
await hass.services.async_call( await hass.services.async_call(
"light", "light",
"turn_on", "turn_on",
@ -184,8 +184,20 @@ async def test_light_turn_on_service(
) )
assert len(mock_bridge_v2.mock_requests) == 7 assert len(mock_bridge_v2.mock_requests) == 7
assert mock_bridge_v2.mock_requests[6]["json"]["effects"]["effect"] == "candle" 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 # test disable effect
# it should send a request with effect set to "no_effect"
await hass.services.async_call( await hass.services.async_call(
"light", "light",
"turn_on", "turn_on",
@ -194,6 +206,28 @@ async def test_light_turn_on_service(
) )
assert len(mock_bridge_v2.mock_requests) == 8 assert len(mock_bridge_v2.mock_requests) == 8
assert mock_bridge_v2.mock_requests[7]["json"]["effects"]["effect"] == "no_effect" 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 # test timed effect
await hass.services.async_call( 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}, {"entity_id": test_light_id, "effect": "sunrise", "transition": 6},
blocking=True, blocking=True,
) )
assert len(mock_bridge_v2.mock_requests) == 9 assert len(mock_bridge_v2.mock_requests) == 10
assert ( 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 # test enabling effect should ignore color temperature
await hass.services.async_call( 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}, {"entity_id": test_light_id, "effect": "candle", "color_temp": 500},
blocking=True, blocking=True,
) )
assert len(mock_bridge_v2.mock_requests) == 10 assert len(mock_bridge_v2.mock_requests) == 11
assert mock_bridge_v2.mock_requests[9]["json"]["effects"]["effect"] == "candle" assert mock_bridge_v2.mock_requests[10]["json"]["effects"]["effect"] == "candle"
assert "color_temperature" not in mock_bridge_v2.mock_requests[9]["json"] assert "color_temperature" not in mock_bridge_v2.mock_requests[10]["json"]
# test enabling effect should ignore xy color # test enabling effect should ignore xy color
await hass.services.async_call( 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]}, {"entity_id": test_light_id, "effect": "candle", "xy_color": [0.123, 0.123]},
blocking=True, blocking=True,
) )
assert len(mock_bridge_v2.mock_requests) == 11 assert len(mock_bridge_v2.mock_requests) == 12
assert mock_bridge_v2.mock_requests[10]["json"]["effects"]["effect"] == "candle" assert mock_bridge_v2.mock_requests[11]["json"]["effects"]["effect"] == "candle"
assert "xy_color" not in mock_bridge_v2.mock_requests[9]["json"] assert "xy_color" not in mock_bridge_v2.mock_requests[11]["json"]
async def test_light_turn_off_service( async def test_light_turn_off_service(

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@
'primary_config_entry': <ANY>, 'primary_config_entry': <ANY>,
'serial_number': None, 'serial_number': None,
'suggested_area': 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, 'via_device_id': None,
}) })
# --- # ---

View File

@ -3,15 +3,17 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError from pysmlight import Info
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, SmlightError
import pytest import pytest
from syrupy.assertion import SnapshotAssertion 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.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.issue_registry import IssueRegistry
from .conftest import setup_integration from .conftest import setup_integration
@ -92,3 +94,33 @@ async def test_device_info(
) )
assert device_entry is not None assert device_entry is not None
assert device_entry == snapshot 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"}, {"one": "30.0", "two": "20.0"},
{}, {},
{ {
"min": "{{ 0 }}", "min": "0",
"max": "{{ 100 }}", "max": "100",
"step": "{{ 0.1 }}", "step": "0.1",
"set_value": { "set_value": {
"action": "input_number.set_value", "action": "input_number.set_value",
"target": {"entity_id": "input_number.test"}, "target": {"entity_id": "input_number.test"},
@ -108,9 +108,9 @@ from tests.typing import WebSocketGenerator
}, },
}, },
{ {
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
"set_value": { "set_value": {
"action": "input_number.set_value", "action": "input_number.set_value",
"target": {"entity_id": "input_number.test"}, "target": {"entity_id": "input_number.test"},
@ -258,14 +258,14 @@ async def test_config_flow(
"number", "number",
{"state": "{{ states('number.one') }}"}, {"state": "{{ states('number.one') }}"},
{ {
"min": "{{ 0 }}", "min": "0",
"max": "{{ 100 }}", "max": "100",
"step": "{{ 0.1 }}", "step": "0.1",
}, },
{ {
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
}, },
), ),
( (
@ -451,9 +451,9 @@ def get_suggested(schema, key):
["30.0", "20.0"], ["30.0", "20.0"],
{"one": "30.0", "two": "20.0"}, {"one": "30.0", "two": "20.0"},
{ {
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
"set_value": { "set_value": {
"action": "input_number.set_value", "action": "input_number.set_value",
"target": {"entity_id": "input_number.test"}, "target": {"entity_id": "input_number.test"},
@ -461,9 +461,9 @@ def get_suggested(schema, key):
}, },
}, },
{ {
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
"set_value": { "set_value": {
"action": "input_number.set_value", "action": "input_number.set_value",
"target": {"entity_id": "input_number.test"}, "target": {"entity_id": "input_number.test"},
@ -1230,14 +1230,14 @@ async def test_option_flow_sensor_preview_config_entry_removed(
"number", "number",
{"state": "{{ states('number.one') }}"}, {"state": "{{ states('number.one') }}"},
{ {
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
}, },
{ {
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
}, },
), ),
( (

View File

@ -319,9 +319,9 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None:
"template_type": "number", "template_type": "number",
"name": "My template", "name": "My template",
"state": "{{ 10 }}", "state": "{{ 10 }}",
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
"set_value": { "set_value": {
"action": "input_number.set_value", "action": "input_number.set_value",
"target": {"entity_id": "input_number.test"}, "target": {"entity_id": "input_number.test"},
@ -330,9 +330,9 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None:
}, },
{ {
"state": "{{ 11 }}", "state": "{{ 11 }}",
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
"set_value": { "set_value": {
"action": "input_number.set_value", "action": "input_number.set_value",
"target": {"entity_id": "input_number.test"}, "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", "name": "My template",
"template_type": "number", "template_type": "number",
"state": "{{ 10 }}", "state": "{{ 10 }}",
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
"set_value": { "set_value": {
"action": "input_number.set_value", "action": "input_number.set_value",
"target": {"entity_id": "input_number.test"}, "target": {"entity_id": "input_number.test"},
@ -524,9 +524,9 @@ async def test_device_id(
"name": "My template", "name": "My template",
"template_type": "number", "template_type": "number",
"state": "{{ 10 }}", "state": "{{ 10 }}",
"min": "{{ 0 }}", "min": 0,
"max": "{{ 100 }}", "max": 100,
"step": "{{ 0.1 }}", "step": 0.1,
"set_value": { "set_value": {
"action": "input_number.set_value", "action": "input_number.set_value",
"target": {"entity_id": "input_number.test"}, "target": {"entity_id": "input_number.test"},