diff --git a/homeassistant/components/honeywell_string_lights/entity.py b/homeassistant/components/honeywell_string_lights/entity.py index 90816651311cd0..23ee65c59e1e58 100644 --- a/homeassistant/components/honeywell_string_lights/entity.py +++ b/homeassistant/components/honeywell_string_lights/entity.py @@ -1,18 +1,10 @@ """Common entity for Honeywell String Lights integration.""" -import logging - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Event, EventStateChangedData, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_state_change_event - -from .const import CONF_TRANSMITTER, DOMAIN -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN class HoneywellStringLightsEntity(Entity): @@ -22,53 +14,9 @@ class HoneywellStringLightsEntity(Entity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the entity.""" - self._transmitter = entry.data[CONF_TRANSMITTER] self._attr_unique_id = entry.entry_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Honeywell", model="String Lights", ) - - async def async_added_to_hass(self) -> None: - """Subscribe to transmitter entity state changes.""" - await super().async_added_to_hass() - - transmitter_entity_id = er.async_validate_entity_id( - er.async_get(self.hass), self._transmitter - ) - - @callback - def _async_transmitter_state_changed( - event: Event[EventStateChangedData], - ) -> None: - """Handle transmitter entity state changes.""" - new_state = event.data["new_state"] - transmitter_available = ( - new_state is not None and new_state.state != STATE_UNAVAILABLE - ) - if transmitter_available != self.available: - _LOGGER.info( - "Transmitter %s used by %s is %s", - transmitter_entity_id, - self.entity_id, - "available" if transmitter_available else "unavailable", - ) - - self._attr_available = transmitter_available - self.async_write_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [transmitter_entity_id], - _async_transmitter_state_changed, - ) - ) - - # Set initial availability based on current transmitter entity state - transmitter_state = self.hass.states.get(transmitter_entity_id) - self._attr_available = ( - transmitter_state is not None - and transmitter_state.state != STATE_UNAVAILABLE - ) diff --git a/homeassistant/components/honeywell_string_lights/light.py b/homeassistant/components/honeywell_string_lights/light.py index 24dfe7adc635d5..98881ac04a57c2 100644 --- a/homeassistant/components/honeywell_string_lights/light.py +++ b/homeassistant/components/honeywell_string_lights/light.py @@ -5,13 +5,16 @@ from rf_protocols.codes.honeywell.string_lights import CODES from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.components.radio_frequency import async_send_command +from homeassistant.components.radio_frequency import ( + RadioFrequencyTransmitterConsumerEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .const import CONF_TRANSMITTER from .entity import HoneywellStringLightsEntity PARALLEL_UPDATES = 1 @@ -26,14 +29,23 @@ async def async_setup_entry( async_add_entities([HoneywellStringLight(config_entry)]) -class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEntity): +class HoneywellStringLight( + HoneywellStringLightsEntity, + RadioFrequencyTransmitterConsumerEntity, + LightEntity, + RestoreEntity, +): """Representation of a Honeywell String Lights set controlled via RF.""" _attr_assumed_state = True _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_name = None - _attr_should_poll = False + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + super().__init__(entry) + self._rf_transmitter_entity_id = entry.data[CONF_TRANSMITTER] async def async_added_to_hass(self) -> None: """Restore last known state.""" @@ -43,19 +55,17 @@ async def async_added_to_hass(self) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - await self._async_send_command("turn_on") + await self._async_send_rf_command("turn_on") self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - await self._async_send_command("turn_off") + await self._async_send_rf_command("turn_off") self._attr_is_on = False self.async_write_ha_state() - async def _async_send_command(self, name: str) -> None: + async def _async_send_rf_command(self, name: str) -> None: """Load the named command and send it via the configured transmitter.""" command = await CODES.async_load_command(name) - await async_send_command( - self.hass, self._transmitter, command, context=self._context - ) + await self._send_command(command) diff --git a/homeassistant/components/novy_cooker_hood/entity.py b/homeassistant/components/novy_cooker_hood/entity.py index 817102bf75458c..6c609ba1c542dc 100644 --- a/homeassistant/components/novy_cooker_hood/entity.py +++ b/homeassistant/components/novy_cooker_hood/entity.py @@ -1,18 +1,10 @@ """Common entity for the Novy Cooker Hood integration.""" -import logging - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Event, EventStateChangedData, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_state_change_event - -from .const import CONF_TRANSMITTER, DOMAIN -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN class NovyCookerHoodEntity(Entity): @@ -20,55 +12,11 @@ class NovyCookerHoodEntity(Entity): _attr_assumed_state = True _attr_has_entity_name = True - _attr_should_poll = False def __init__(self, entry: ConfigEntry) -> None: """Initialize the entity.""" - self._transmitter = entry.data[CONF_TRANSMITTER] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Novy", model="Cooker Hood", ) - - async def async_added_to_hass(self) -> None: - """Subscribe to transmitter entity state changes.""" - await super().async_added_to_hass() - - transmitter_entity_id = er.async_validate_entity_id( - er.async_get(self.hass), self._transmitter - ) - - @callback - def _async_transmitter_state_changed( - event: Event[EventStateChangedData], - ) -> None: - """Handle transmitter entity state changes.""" - new_state = event.data["new_state"] - transmitter_available = ( - new_state is not None and new_state.state != STATE_UNAVAILABLE - ) - if transmitter_available != self.available: - _LOGGER.info( - "Transmitter %s used by %s is %s", - transmitter_entity_id, - self.entity_id, - "available" if transmitter_available else "unavailable", - ) - - self._attr_available = transmitter_available - self.async_write_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [transmitter_entity_id], - _async_transmitter_state_changed, - ) - ) - - transmitter_state = self.hass.states.get(transmitter_entity_id) - self._attr_available = ( - transmitter_state is not None - and transmitter_state.state != STATE_UNAVAILABLE - ) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index 7d6c477a4d3f20..63f325f93f5ab0 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -6,7 +6,9 @@ from rf_protocols.codes.novy.cooker_hood import get_codes_for_code from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature -from homeassistant.components.radio_frequency import async_send_command +from homeassistant.components.radio_frequency import ( + RadioFrequencyTransmitterConsumerEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -17,7 +19,7 @@ ) from .commands import COMMAND_MINUS, COMMAND_PLUS -from .const import CONF_CODE, SPEED_COUNT +from .const import CONF_CODE, CONF_TRANSMITTER, SPEED_COUNT from .entity import NovyCookerHoodEntity PARALLEL_UPDATES = 1 @@ -34,7 +36,12 @@ async def async_setup_entry( async_add_entities([NovyCookerHoodFan(config_entry)]) -class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): +class NovyCookerHoodFan( + NovyCookerHoodEntity, + RadioFrequencyTransmitterConsumerEntity, + FanEntity, + RestoreEntity, +): """Calibration-based fan: each change resets to off then climbs to target.""" _attr_name = None @@ -48,6 +55,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the fan.""" super().__init__(entry) + self._rf_transmitter_entity_id = entry.data[CONF_TRANSMITTER] self._codes = get_codes_for_code(entry.data[CONF_CODE]) self._level = 0 self._attr_unique_id = entry.entry_id @@ -104,7 +112,7 @@ async def async_increase_speed(self, percentage_step: int | None = None) -> None steps = self._steps_from_percentage(percentage_step) plus = await self._codes.async_load_command(COMMAND_PLUS) for _ in range(steps): - await self._async_send(plus) + await self._send_command(plus) self._level = min(SPEED_COUNT, self._level + steps) self.async_write_ha_state() @@ -113,7 +121,7 @@ async def async_decrease_speed(self, percentage_step: int | None = None) -> None steps = self._steps_from_percentage(percentage_step) minus = await self._codes.async_load_command(COMMAND_MINUS) for _ in range(steps): - await self._async_send(minus) + await self._send_command(minus) self._level = max(0, self._level - steps) self.async_write_ha_state() @@ -128,16 +136,10 @@ async def _async_set_level(self, level: int) -> None: """Reset to off with `SPEED_COUNT` minus presses, then climb to level.""" minus = await self._codes.async_load_command(COMMAND_MINUS) for _ in range(SPEED_COUNT): - await self._async_send(minus) + await self._send_command(minus) if level > 0: plus = await self._codes.async_load_command(COMMAND_PLUS) for _ in range(level): - await self._async_send(plus) + await self._send_command(plus) self._level = level self.async_write_ha_state() - - async def _async_send(self, command: Any) -> None: - """Send a single RF command via the configured transmitter.""" - await async_send_command( - self.hass, self._transmitter, command, context=self._context - ) diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py index 404aa39f06199b..30a23051d5c203 100644 --- a/homeassistant/components/novy_cooker_hood/light.py +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -5,7 +5,9 @@ from rf_protocols.codes.novy.cooker_hood import get_codes_for_code from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.components.radio_frequency import async_send_command +from homeassistant.components.radio_frequency import ( + RadioFrequencyTransmitterConsumerEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant @@ -13,7 +15,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from .commands import COMMAND_LIGHT -from .const import CONF_CODE +from .const import CONF_CODE, CONF_TRANSMITTER from .entity import NovyCookerHoodEntity PARALLEL_UPDATES = 1 @@ -28,7 +30,12 @@ async def async_setup_entry( async_add_entities([NovyCookerHoodLight(config_entry)]) -class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): +class NovyCookerHoodLight( + NovyCookerHoodEntity, + RadioFrequencyTransmitterConsumerEntity, + LightEntity, + RestoreEntity, +): """Novy cooker hood light toggled via a single RF press.""" _attr_color_mode = ColorMode.ONOFF @@ -38,6 +45,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the light.""" super().__init__(entry) + self._rf_transmitter_entity_id = entry.data[CONF_TRANSMITTER] self._codes = get_codes_for_code(entry.data[CONF_CODE]) self._attr_unique_id = entry.entry_id @@ -49,19 +57,17 @@ async def async_added_to_hass(self) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on by sending the toggle command.""" - await self._async_send_command(COMMAND_LIGHT) + await self._async_send_rf_command(COMMAND_LIGHT) self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off by sending the toggle command.""" - await self._async_send_command(COMMAND_LIGHT) + await self._async_send_rf_command(COMMAND_LIGHT) self._attr_is_on = False self.async_write_ha_state() - async def _async_send_command(self, name: str) -> None: + async def _async_send_rf_command(self, name: str) -> None: """Load the named command and send it via the configured transmitter.""" command = await self._codes.async_load_command(name) - await async_send_command( - self.hass, self._transmitter, command, context=self._context - ) + await self._send_command(command) diff --git a/homeassistant/components/radio_frequency/__init__.py b/homeassistant/components/radio_frequency/__init__.py index c19bd36bcc66ee..9e6ad90cad4e43 100644 --- a/homeassistant/components/radio_frequency/__init__.py +++ b/homeassistant/components/radio_frequency/__init__.py @@ -6,22 +6,24 @@ from rf_protocols import ModulationType, RadioFrequencyCommand from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import DATA_COMPONENT, DOMAIN from .entity import ( RadioFrequencyTransmitterEntity, RadioFrequencyTransmitterEntityDescription, ) +from .helpers import RadioFrequencyTransmitterConsumerEntity, async_send_command __all__ = [ "DOMAIN", "ModulationType", + "RadioFrequencyCommand", + "RadioFrequencyTransmitterConsumerEntity", "RadioFrequencyTransmitterEntity", "RadioFrequencyTransmitterEntityDescription", "async_get_transmitters", @@ -30,9 +32,6 @@ _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[RadioFrequencyTransmitterEntity]] = HassKey( - DOMAIN -) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -95,60 +94,3 @@ def async_get_transmitters( if entity.supports_modulation(modulation) and entity.supports_frequency(frequency) ] - - -async def async_send_command( - hass: HomeAssistant, - entity_id_or_uuid: str, - command: RadioFrequencyCommand, - context: Context | None = None, -) -> None: - """Send an RF command to the specified radio_frequency entity. - - Raises: - vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity - registry UUID. - HomeAssistantError: If the radio_frequency component is not loaded or the - resolved entity is not found. - """ - component = hass.data.get(DATA_COMPONENT) - if component is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="component_not_loaded", - ) - - ent_reg = er.async_get(hass) - entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) - entity = component.get_entity(entity_id) - if entity is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="entity_not_found", - translation_placeholders={"entity_id": entity_id}, - ) - - if not entity.supports_frequency(command.frequency): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unsupported_frequency", - translation_placeholders={ - "entity_id": entity_id, - "frequency": str(command.frequency), - }, - ) - - if not entity.supports_modulation(command.modulation): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unsupported_modulation", - translation_placeholders={ - "entity_id": entity_id, - "modulation": command.modulation, - }, - ) - - if context is not None: - entity.async_set_context(context) - - await entity.async_send_command_internal(command) diff --git a/homeassistant/components/radio_frequency/const.py b/homeassistant/components/radio_frequency/const.py index 04d50de7d8ed16..89678b3320d100 100644 --- a/homeassistant/components/radio_frequency/const.py +++ b/homeassistant/components/radio_frequency/const.py @@ -2,4 +2,12 @@ from typing import Final +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util.hass_dict import HassKey + +from .entity import RadioFrequencyTransmitterEntity + DOMAIN: Final = "radio_frequency" +DATA_COMPONENT: HassKey[EntityComponent[RadioFrequencyTransmitterEntity]] = HassKey( + DOMAIN +) diff --git a/homeassistant/components/radio_frequency/helpers.py b/homeassistant/components/radio_frequency/helpers.py new file mode 100644 index 00000000000000..df33fff16210a1 --- /dev/null +++ b/homeassistant/components/radio_frequency/helpers.py @@ -0,0 +1,134 @@ +"""Helper base entities for integrations that consume RF transmitters.""" + +import logging + +from rf_protocols import RadioFrequencyCommand + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import ( + Context, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event + +from .const import DATA_COMPONENT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_send_command( + hass: HomeAssistant, + entity_id_or_uuid: str, + command: RadioFrequencyCommand, + context: Context | None = None, +) -> None: + """Send an RF command to the specified radio_frequency entity. + + Raises: + vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity + registry UUID. + HomeAssistantError: If the radio_frequency component is not loaded or the + resolved entity is not found. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + ent_reg = er.async_get(hass) + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + entity = component.get_entity(entity_id) + if entity is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + if not entity.supports_frequency(command.frequency): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_frequency", + translation_placeholders={ + "entity_id": entity_id, + "frequency": str(command.frequency), + }, + ) + + if not entity.supports_modulation(command.modulation): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_modulation", + translation_placeholders={ + "entity_id": entity_id, + "modulation": command.modulation, + }, + ) + + if context is not None: + entity.async_set_context(context) + + await entity.async_send_command_internal(command) + + +class RadioFrequencyTransmitterConsumerEntity(Entity): + """Base entity for integrations that send commands via an RF transmitter. + + Tracks the availability of the underlying RF transmitter entity. + """ + + _attr_should_poll = False + _rf_transmitter_entity_id: str + + async def async_added_to_hass(self) -> None: + """Subscribe to RF entity state changes.""" + await super().async_added_to_hass() + + # Resolve UUID to entity ID if needed + self._rf_transmitter_entity_id = er.async_validate_entity_id( + er.async_get(self.hass), self._rf_transmitter_entity_id + ) + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._rf_transmitter_entity_id], + self._async_rf_state_changed, + ) + ) + + # Set initial availability based on current RF entity state + rf_state = self.hass.states.get(self._rf_transmitter_entity_id) + self._attr_available = ( + rf_state is not None and rf_state.state != STATE_UNAVAILABLE + ) + + async def _send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command through the RF transmitter entity.""" + await async_send_command( + self.hass, self._rf_transmitter_entity_id, command, context=self._context + ) + + @callback + def _async_rf_state_changed(self, event: Event[EventStateChangedData]) -> None: + """Handle RF entity state changes.""" + new_state = event.data["new_state"] + rf_available = new_state is not None and new_state.state != STATE_UNAVAILABLE + if rf_available != self.available: + _LOGGER.info( + "Radio frequency entity %s used by %s is %s", + self._rf_transmitter_entity_id, + self.entity_id, + "available" if rf_available else "unavailable", + ) + + self._attr_available = rf_available + self.async_write_ha_state()