mirror of
https://github.com/home-assistant/core.git
synced 2026-04-18 10:14:52 +00:00
Compare commits
5 Commits
radio-freq
...
radio-freq
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7c56a1f22 | ||
|
|
258dfdda8f | ||
|
|
dcc0745fd2 | ||
|
|
174c86fd36 | ||
|
|
e0d7e3702c |
@@ -36,6 +36,7 @@ base_platforms: &base_platforms
|
||||
- homeassistant/components/image_processing/**
|
||||
- homeassistant/components/infrared/**
|
||||
- homeassistant/components/lawn_mower/**
|
||||
- homeassistant/components/radio_frequency/**
|
||||
- homeassistant/components/light/**
|
||||
- homeassistant/components/lock/**
|
||||
- homeassistant/components/media_player/**
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -750,6 +750,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/homewizard/ @DCSBL
|
||||
/homeassistant/components/honeywell/ @rdfurman @mkmer
|
||||
/tests/components/honeywell/ @rdfurman @mkmer
|
||||
/homeassistant/components/honeywell_string_lights/ @balloob
|
||||
/tests/components/honeywell_string_lights/ @balloob
|
||||
/homeassistant/components/hr_energy_qube/ @MattieGit
|
||||
/tests/components/hr_energy_qube/ @MattieGit
|
||||
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
|
||||
@@ -1403,6 +1405,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/radarr/ @tkdrob
|
||||
/homeassistant/components/radio_browser/ @frenck
|
||||
/tests/components/radio_browser/ @frenck
|
||||
/homeassistant/components/radio_frequency/ @home-assistant/core
|
||||
/tests/components/radio_frequency/ @home-assistant/core
|
||||
/homeassistant/components/radiotherm/ @vinnyfuria
|
||||
/tests/components/radiotherm/ @vinnyfuria
|
||||
/homeassistant/components/rainbird/ @konikvranik @allenporter
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "honeywell",
|
||||
"name": "Honeywell",
|
||||
"integrations": ["lyric", "evohome", "honeywell"]
|
||||
"integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"]
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ from aioesphomeapi import (
|
||||
MediaPlayerInfo,
|
||||
MediaPlayerSupportedFormat,
|
||||
NumberInfo,
|
||||
RadioFrequencyInfo,
|
||||
SelectInfo,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
@@ -88,6 +89,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
FanInfo: Platform.FAN,
|
||||
InfraredInfo: Platform.INFRARED,
|
||||
LightInfo: Platform.LIGHT,
|
||||
RadioFrequencyInfo: Platform.RADIO_FREQUENCY,
|
||||
LockInfo: Platform.LOCK,
|
||||
MediaPlayerInfo: Platform.MEDIA_PLAYER,
|
||||
NumberInfo: Platform.NUMBER,
|
||||
|
||||
79
homeassistant/components/esphome/radio_frequency.py
Normal file
79
homeassistant/components/esphome/radio_frequency.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Radio Frequency platform for ESPHome."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from aioesphomeapi import (
|
||||
EntityState,
|
||||
RadioFrequencyCapability,
|
||||
RadioFrequencyInfo,
|
||||
RadioFrequencyModulation,
|
||||
)
|
||||
from rf_protocols import ModulationType, RadioFrequencyCommand
|
||||
|
||||
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
convert_api_error_ha_error,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = {
|
||||
ModulationType.OOK: RadioFrequencyModulation.OOK,
|
||||
}
|
||||
|
||||
|
||||
class EsphomeRadioFrequencyEntity(
|
||||
EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity
|
||||
):
|
||||
"""ESPHome radio frequency entity using native API."""
|
||||
|
||||
@property
|
||||
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
|
||||
"""Return supported frequency ranges from device info."""
|
||||
return [(self._static_info.frequency_min, self._static_info.frequency_max)]
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
|
||||
"""Send an RF command."""
|
||||
timings = [
|
||||
interval
|
||||
for timing in command.get_raw_timings()
|
||||
for interval in (timing.high_us, -timing.low_us)
|
||||
]
|
||||
_LOGGER.debug("Sending RF command: %s", timings)
|
||||
|
||||
self._client.radio_frequency_transmit_raw_timings(
|
||||
self._static_info.key,
|
||||
frequency=command.frequency,
|
||||
timings=timings,
|
||||
modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation],
|
||||
repeat_count=command.repeat_count + 1,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
platform_async_setup_entry,
|
||||
info_type=RadioFrequencyInfo,
|
||||
entity_type=EsphomeRadioFrequencyEntity,
|
||||
state_type=EntityState,
|
||||
info_filter=lambda info: bool(
|
||||
info.capabilities & RadioFrequencyCapability.TRANSMITTER
|
||||
),
|
||||
)
|
||||
20
homeassistant/components/honeywell_string_lights/__init__.py
Normal file
20
homeassistant/components/honeywell_string_lights/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""The Honeywell String Lights integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Honeywell String Lights from a config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Config flow for the Honeywell String Lights integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols import ModulationType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.radio_frequency import async_get_transmitters
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from .const import CONF_TRANSMITTER, DOMAIN, FREQUENCY
|
||||
|
||||
|
||||
class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Honeywell String Lights."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
try:
|
||||
transmitters = async_get_transmitters(
|
||||
self.hass, FREQUENCY, ModulationType.OOK
|
||||
)
|
||||
except HomeAssistantError:
|
||||
return self.async_abort(reason="no_transmitters")
|
||||
|
||||
if not transmitters:
|
||||
return self.async_abort(reason="no_compatible_transmitters")
|
||||
|
||||
if user_input is not None:
|
||||
registry = er.async_get(self.hass)
|
||||
entity_entry = registry.async_get(user_input[CONF_TRANSMITTER])
|
||||
assert entity_entry is not None
|
||||
await self.async_set_unique_id(entity_entry.id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title="Honeywell String Lights",
|
||||
data={CONF_TRANSMITTER: entity_entry.id},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TRANSMITTER): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(include_entities=transmitters),
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
101
homeassistant/components/honeywell_string_lights/const.py
Normal file
101
homeassistant/components/honeywell_string_lights/const.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Constants for the Honeywell String Lights integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from rf_protocols import Timing
|
||||
|
||||
DOMAIN: Final = "honeywell_string_lights"
|
||||
|
||||
CONF_TRANSMITTER: Final = "transmitter"
|
||||
|
||||
FREQUENCY: Final = 433_920_000
|
||||
REPEAT_COUNT: Final = 50
|
||||
|
||||
|
||||
def _parse_timings(raw: list[int]) -> list[Timing]:
|
||||
"""Convert raw alternating high/low microsecond values to Timing objects."""
|
||||
return [
|
||||
Timing(high_us=high, low_us=-low)
|
||||
for high, low in zip(raw[::2], raw[1::2], strict=True)
|
||||
]
|
||||
|
||||
|
||||
TURN_ON_TIMINGS: Final = _parse_timings(
|
||||
[
|
||||
2000,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
]
|
||||
)
|
||||
|
||||
TURN_OFF_TIMINGS: Final = _parse_timings(
|
||||
[
|
||||
2000,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
450,
|
||||
-550,
|
||||
1000,
|
||||
-550,
|
||||
]
|
||||
)
|
||||
86
homeassistant/components/honeywell_string_lights/light.py
Normal file
86
homeassistant/components/honeywell_string_lights/light.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Light platform for Honeywell String Lights."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols import OOKCommand
|
||||
|
||||
from homeassistant.components.light import ColorMode, LightEntity
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import (
|
||||
CONF_TRANSMITTER,
|
||||
DOMAIN,
|
||||
FREQUENCY,
|
||||
REPEAT_COUNT,
|
||||
TURN_OFF_TIMINGS,
|
||||
TURN_ON_TIMINGS,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Honeywell String Lights light platform."""
|
||||
async_add_entities([HoneywellStringLight(config_entry)])
|
||||
|
||||
|
||||
class HoneywellStringLight(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_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the light."""
|
||||
self._transmitter = config_entry.data[CONF_TRANSMITTER]
|
||||
self._attr_unique_id = config_entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
manufacturer="Honeywell",
|
||||
model="String Lights",
|
||||
name=config_entry.title,
|
||||
)
|
||||
self._attr_is_on = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last known state."""
|
||||
await super().async_added_to_hass()
|
||||
if (last_state := await self.async_get_last_state()) is not None:
|
||||
self._attr_is_on = last_state.state == STATE_ON
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
await self._async_send_command(TURN_ON_TIMINGS)
|
||||
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_TIMINGS)
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send_command(self, timings: list) -> None:
|
||||
"""Send an RF command using the configured transmitter."""
|
||||
command = OOKCommand(
|
||||
frequency=FREQUENCY,
|
||||
timings=timings,
|
||||
repeat_count=REPEAT_COUNT,
|
||||
)
|
||||
await async_send_command(self.hass, self._transmitter, command)
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "honeywell_string_lights",
|
||||
"name": "Honeywell String Lights",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["radio_frequency"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/honeywell_string_lights",
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["rf-protocols==0.0.1"]
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not register custom service actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration transmits RF commands and does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not register custom service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use runtime data.
|
||||
test-before-configure:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is a one-way broadcast with no device to contact.
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is a one-way broadcast with no device to contact.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has no options.
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is a one-way broadcast; the light uses assumed state.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is a one-way broadcast; the light uses assumed state.
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not authenticate.
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF devices cannot be discovered.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is one-way; there is no data update.
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry represents a single static device.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
The single entity represents the primary device function.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: |
|
||||
Light entities do not have device classes.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
The single entity represents the primary device function.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
The entity uses the device name.
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
Light uses the default icon for its state.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
No known repairable issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry represents a single static device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use a web session.
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.",
|
||||
"no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"transmitter": "Radio frequency transmitter"
|
||||
},
|
||||
"data_description": {
|
||||
"transmitter": "The radio frequency transmitter used to control the Honeywell String Lights."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
homeassistant/components/radio_frequency/__init__.py
Normal file
189
homeassistant/components/radio_frequency/__init__.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Provides functionality to interact with radio frequency devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import final
|
||||
|
||||
from rf_protocols import ModulationType, RadioFrequencyCommand
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"ModulationType",
|
||||
"RadioFrequencyTransmitterEntity",
|
||||
"RadioFrequencyTransmitterEntityDescription",
|
||||
"async_get_transmitters",
|
||||
"async_send_command",
|
||||
]
|
||||
|
||||
_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
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the radio_frequency domain."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[
|
||||
RadioFrequencyTransmitterEntity
|
||||
](_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
|
||||
await component.async_setup(config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_transmitters(
|
||||
hass: HomeAssistant,
|
||||
frequency: int,
|
||||
modulation: ModulationType,
|
||||
) -> list[str]:
|
||||
"""Get entity IDs of all RF transmitters supporting the given frequency.
|
||||
|
||||
An empty list means no compatible transmitters.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If no transmitters exist.
|
||||
"""
|
||||
component = hass.data.get(DATA_COMPONENT)
|
||||
if component is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="component_not_loaded",
|
||||
)
|
||||
|
||||
entities = list(component.entities)
|
||||
if not entities:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_transmitters",
|
||||
)
|
||||
|
||||
return [
|
||||
entity.entity_id
|
||||
for entity in entities
|
||||
if any(
|
||||
low <= frequency <= high for low, high in entity.supported_frequency_ranges
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
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:
|
||||
HomeAssistantError: If the radio_frequency 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 context is not None:
|
||||
entity.async_set_context(context)
|
||||
|
||||
await entity.async_send_command_internal(command)
|
||||
|
||||
|
||||
class RadioFrequencyTransmitterEntityDescription(
|
||||
EntityDescription, frozen_or_thawed=True
|
||||
):
|
||||
"""Describes radio frequency transmitter entities."""
|
||||
|
||||
|
||||
class RadioFrequencyTransmitterEntity(RestoreEntity):
|
||||
"""Base class for radio frequency transmitter entities."""
|
||||
|
||||
entity_description: RadioFrequencyTransmitterEntityDescription
|
||||
_attr_should_poll = False
|
||||
_attr_state: None = None
|
||||
|
||||
__last_command_sent: str | None = None
|
||||
|
||||
@property
|
||||
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
|
||||
"""Return list of (min_hz, max_hz) tuples."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@final
|
||||
def state(self) -> str | None:
|
||||
"""Return the entity state."""
|
||||
return self.__last_command_sent
|
||||
|
||||
@final
|
||||
async def async_send_command_internal(self, command: RadioFrequencyCommand) -> None:
|
||||
"""Send an RF command and update state.
|
||||
|
||||
Should not be overridden, handles setting last sent timestamp.
|
||||
"""
|
||||
await self.async_send_command(command)
|
||||
self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds")
|
||||
self.async_write_ha_state()
|
||||
|
||||
@final
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the radio frequency entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
|
||||
self.__last_command_sent = state.state
|
||||
|
||||
@abstractmethod
|
||||
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
|
||||
"""Send an RF command.
|
||||
|
||||
Args:
|
||||
command: The RF command to send.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If transmission fails.
|
||||
"""
|
||||
5
homeassistant/components/radio_frequency/const.py
Normal file
5
homeassistant/components/radio_frequency/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the Radio Frequency integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "radio_frequency"
|
||||
7
homeassistant/components/radio_frequency/icons.json
Normal file
7
homeassistant/components/radio_frequency/icons.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:radio-tower"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
homeassistant/components/radio_frequency/manifest.json
Normal file
9
homeassistant/components/radio_frequency/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"domain": "radio_frequency",
|
||||
"name": "Radio Frequency",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["rf-protocols==0.0.1"]
|
||||
}
|
||||
13
homeassistant/components/radio_frequency/strings.json
Normal file
13
homeassistant/components/radio_frequency/strings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"component_not_loaded": {
|
||||
"message": "Radio Frequency component not loaded"
|
||||
},
|
||||
"entity_not_found": {
|
||||
"message": "Radio Frequency entity `{entity_id}` not found"
|
||||
},
|
||||
"no_transmitters": {
|
||||
"message": "No Radio Frequency transmitters available"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"component_not_loaded": {
|
||||
"message": "Radio Frequency component not loaded"
|
||||
},
|
||||
"entity_not_found": {
|
||||
"message": "Radio Frequency entity `{entity_id}` not found"
|
||||
},
|
||||
"no_transmitters": {
|
||||
"message": "No Radio Frequency transmitters available"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -308,6 +308,7 @@ FLOWS = {
|
||||
"homewizard",
|
||||
"homeworks",
|
||||
"honeywell",
|
||||
"honeywell_string_lights",
|
||||
"hr_energy_qube",
|
||||
"html5",
|
||||
"huawei_lte",
|
||||
|
||||
1
homeassistant/generated/entity_platforms.py
generated
1
homeassistant/generated/entity_platforms.py
generated
@@ -36,6 +36,7 @@ class EntityPlatforms(StrEnum):
|
||||
MEDIA_PLAYER = "media_player"
|
||||
NOTIFY = "notify"
|
||||
NUMBER = "number"
|
||||
RADIO_FREQUENCY = "radio_frequency"
|
||||
REMOTE = "remote"
|
||||
SCENE = "scene"
|
||||
SELECT = "select"
|
||||
|
||||
@@ -2957,6 +2957,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"name": "Honeywell Total Connect Comfort (US)"
|
||||
},
|
||||
"honeywell_string_lights": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "assumed_state",
|
||||
"name": "Honeywell String Lights"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
1
requirements.txt
generated
1
requirements.txt
generated
@@ -47,6 +47,7 @@ python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.0
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
rf-protocols==0.0.1
|
||||
securetar==2026.4.1
|
||||
SQLAlchemy==2.0.41
|
||||
standard-aifc==3.13.0
|
||||
|
||||
4
requirements_all.txt
generated
4
requirements_all.txt
generated
@@ -2831,6 +2831,10 @@ renson-endura-delta==1.7.2
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.19.1
|
||||
|
||||
# homeassistant.components.honeywell_string_lights
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==0.0.1
|
||||
|
||||
# homeassistant.components.idteck_prox
|
||||
rfk101py==0.0.1
|
||||
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -2409,6 +2409,10 @@ renson-endura-delta==1.7.2
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.19.1
|
||||
|
||||
# homeassistant.components.honeywell_string_lights
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==0.0.1
|
||||
|
||||
# homeassistant.components.rflink
|
||||
rflink==0.0.67
|
||||
|
||||
|
||||
211
tests/components/esphome/test_radio_frequency.py
Normal file
211
tests/components/esphome/test_radio_frequency.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Test ESPHome radio frequency platform."""
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
APIConnectionError,
|
||||
RadioFrequencyCapability,
|
||||
RadioFrequencyInfo,
|
||||
RadioFrequencyModulation,
|
||||
)
|
||||
import pytest
|
||||
from rf_protocols import ModulationType, OOKCommand, Timing
|
||||
|
||||
from homeassistant.components import radio_frequency
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .conftest import MockESPHomeDevice, MockESPHomeDeviceType
|
||||
|
||||
ENTITY_ID = "radio_frequency.test_rf"
|
||||
|
||||
|
||||
async def _mock_rf_device(
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
mock_client: APIClient,
|
||||
capabilities: RadioFrequencyCapability = RadioFrequencyCapability.TRANSMITTER,
|
||||
frequency_min: int = 433_000_000,
|
||||
frequency_max: int = 434_000_000,
|
||||
supported_modulations: int = 1,
|
||||
) -> MockESPHomeDevice:
|
||||
entity_info = [
|
||||
RadioFrequencyInfo(
|
||||
object_id="rf",
|
||||
key=1,
|
||||
name="RF",
|
||||
capabilities=capabilities,
|
||||
frequency_min=frequency_min,
|
||||
frequency_max=frequency_max,
|
||||
supported_modulations=supported_modulations,
|
||||
)
|
||||
]
|
||||
return await mock_esphome_device(
|
||||
mock_client=mock_client, entity_info=entity_info, states=[]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capabilities", "entity_created"),
|
||||
[
|
||||
(RadioFrequencyCapability.TRANSMITTER, True),
|
||||
(RadioFrequencyCapability.RECEIVER, False),
|
||||
(
|
||||
RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER,
|
||||
True,
|
||||
),
|
||||
(RadioFrequencyCapability(0), False),
|
||||
],
|
||||
)
|
||||
async def test_radio_frequency_entity_transmitter(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
capabilities: RadioFrequencyCapability,
|
||||
entity_created: bool,
|
||||
) -> None:
|
||||
"""Test radio frequency entity with transmitter capability is created."""
|
||||
await _mock_rf_device(mock_esphome_device, mock_client, capabilities)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert (state is not None) == entity_created
|
||||
|
||||
|
||||
async def test_radio_frequency_multiple_entities_mixed_capabilities(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test multiple radio frequency entities with mixed capabilities."""
|
||||
entity_info = [
|
||||
RadioFrequencyInfo(
|
||||
object_id="rf_transmitter",
|
||||
key=1,
|
||||
name="RF Transmitter",
|
||||
capabilities=RadioFrequencyCapability.TRANSMITTER,
|
||||
),
|
||||
RadioFrequencyInfo(
|
||||
object_id="rf_receiver",
|
||||
key=2,
|
||||
name="RF Receiver",
|
||||
capabilities=RadioFrequencyCapability.RECEIVER,
|
||||
),
|
||||
RadioFrequencyInfo(
|
||||
object_id="rf_transceiver",
|
||||
key=3,
|
||||
name="RF Transceiver",
|
||||
capabilities=(
|
||||
RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER
|
||||
),
|
||||
),
|
||||
]
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=[],
|
||||
)
|
||||
|
||||
# Only transmitter and transceiver should be created
|
||||
assert hass.states.get("radio_frequency.test_rf_transmitter") is not None
|
||||
assert hass.states.get("radio_frequency.test_rf_receiver") is None
|
||||
assert hass.states.get("radio_frequency.test_rf_transceiver") is not None
|
||||
|
||||
|
||||
async def test_radio_frequency_send_command_success(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sending RF command successfully."""
|
||||
await _mock_rf_device(mock_esphome_device, mock_client)
|
||||
|
||||
command = OOKCommand(
|
||||
frequency=433_920_000,
|
||||
timings=[
|
||||
Timing(high_us=350, low_us=1050),
|
||||
Timing(high_us=350, low_us=350),
|
||||
],
|
||||
)
|
||||
await radio_frequency.async_send_command(hass, ENTITY_ID, command)
|
||||
|
||||
mock_client.radio_frequency_transmit_raw_timings.assert_called_once()
|
||||
call_args = mock_client.radio_frequency_transmit_raw_timings.call_args
|
||||
assert call_args[0][0] == 1 # key
|
||||
assert call_args[1]["frequency"] == 433_920_000
|
||||
assert call_args[1]["modulation"] == RadioFrequencyModulation.OOK
|
||||
assert call_args[1]["repeat_count"] == 1
|
||||
assert call_args[1]["device_id"] == 0
|
||||
assert call_args[1]["timings"] == [350, -1050, 350, -350]
|
||||
|
||||
|
||||
async def test_radio_frequency_send_command_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sending RF command with APIConnectionError raises HomeAssistantError."""
|
||||
await _mock_rf_device(mock_esphome_device, mock_client)
|
||||
|
||||
mock_client.radio_frequency_transmit_raw_timings.side_effect = APIConnectionError(
|
||||
"Connection lost"
|
||||
)
|
||||
|
||||
command = OOKCommand(
|
||||
frequency=433_920_000,
|
||||
timings=[Timing(high_us=350, low_us=1050)],
|
||||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError) as exc_info:
|
||||
await radio_frequency.async_send_command(hass, ENTITY_ID, command)
|
||||
assert exc_info.value.translation_domain == "esphome"
|
||||
assert exc_info.value.translation_key == "error_communicating_with_device"
|
||||
|
||||
|
||||
async def test_radio_frequency_entity_availability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test radio frequency entity becomes available after device reconnects."""
|
||||
mock_device = await _mock_rf_device(mock_esphome_device, mock_client)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
await mock_device.mock_disconnect(False)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
await mock_device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_radio_frequency_supported_frequency_ranges(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test supported frequency ranges are exposed from device info."""
|
||||
await _mock_rf_device(
|
||||
mock_esphome_device,
|
||||
mock_client,
|
||||
frequency_min=433_000_000,
|
||||
frequency_max=434_000_000,
|
||||
)
|
||||
|
||||
transmitters = radio_frequency.async_get_transmitters(
|
||||
hass, 433_920_000, ModulationType.OOK
|
||||
)
|
||||
assert len(transmitters) == 1
|
||||
|
||||
transmitters = radio_frequency.async_get_transmitters(
|
||||
hass, 868_000_000, ModulationType.OOK
|
||||
)
|
||||
assert len(transmitters) == 0
|
||||
1
tests/components/honeywell_string_lights/__init__.py
Normal file
1
tests/components/honeywell_string_lights/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Honeywell String Lights integration."""
|
||||
56
tests/components/honeywell_string_lights/conftest.py
Normal file
56
tests/components/honeywell_string_lights/conftest.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Common fixtures for the Honeywell String Lights tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.honeywell_string_lights.const import (
|
||||
CONF_TRANSMITTER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_transmitter(hass: HomeAssistant) -> MockRadioFrequencyEntity:
|
||||
"""Set up the radio_frequency component and register a mock transmitter."""
|
||||
assert await async_setup_component(hass, RF_DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity = MockRadioFrequencyEntity("test_rf_transmitter")
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([entity])
|
||||
return entity
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(
|
||||
hass: HomeAssistant, mock_transmitter: MockRadioFrequencyEntity
|
||||
) -> MockConfigEntry:
|
||||
"""Return a mock config entry for Honeywell String Lights."""
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_entry = entity_registry.async_get(mock_transmitter.entity_id)
|
||||
assert entity_entry is not None
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Honeywell String Lights",
|
||||
data={CONF_TRANSMITTER: entity_entry.id},
|
||||
unique_id=entity_entry.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Honeywell String Lights integration."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
89
tests/components/honeywell_string_lights/test_config_flow.py
Normal file
89
tests/components/honeywell_string_lights/test_config_flow.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Test the Honeywell String Lights config flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.honeywell_string_lights.const import (
|
||||
CONF_TRANSMITTER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity
|
||||
|
||||
|
||||
async def test_user_flow(
|
||||
hass: HomeAssistant, mock_transmitter: MockRadioFrequencyEntity
|
||||
) -> None:
|
||||
"""Test the user config flow creates an entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_TRANSMITTER: mock_transmitter.entity_id},
|
||||
)
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_entry = entity_registry.async_get(mock_transmitter.entity_id)
|
||||
assert entity_entry is not None
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Honeywell String Lights"
|
||||
assert result["data"] == {CONF_TRANSMITTER: entity_entry.id}
|
||||
assert result["result"].unique_id == entity_entry.id
|
||||
|
||||
|
||||
async def test_unique_id_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_transmitter: MockRadioFrequencyEntity,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test aborting when the same transmitter is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_TRANSMITTER: mock_transmitter.entity_id},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_no_transmitters(hass: HomeAssistant) -> None:
|
||||
"""Test the flow aborts when no RF transmitters are registered at all."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_transmitters"
|
||||
|
||||
|
||||
async def test_no_compatible_transmitters(hass: HomeAssistant) -> None:
|
||||
"""Test aborting when transmitters exist but none support 433.92 MHz OOK."""
|
||||
assert await async_setup_component(hass, RF_DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
incompatible = MockRadioFrequencyEntity(
|
||||
"incompatible", frequency_ranges=[(868_000_000, 869_000_000)]
|
||||
)
|
||||
await hass.data[DATA_COMPONENT].async_add_entities([incompatible])
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_compatible_transmitters"
|
||||
99
tests/components/honeywell_string_lights/test_light.py
Normal file
99
tests/components/honeywell_string_lights/test_light.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Tests for the Honeywell String Lights light platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rf_protocols import ModulationType, OOKCommand
|
||||
|
||||
from homeassistant.components.honeywell_string_lights.const import (
|
||||
FREQUENCY,
|
||||
REPEAT_COUNT,
|
||||
TURN_OFF_TIMINGS,
|
||||
TURN_ON_TIMINGS,
|
||||
)
|
||||
from homeassistant.components.light import (
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ASSUMED_STATE,
|
||||
ATTR_ENTITY_ID,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
|
||||
from tests.common import MockConfigEntry, mock_restore_cache
|
||||
from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity
|
||||
|
||||
ENTITY_ID = "light.honeywell_string_lights"
|
||||
|
||||
|
||||
async def test_turn_on_off_sends_commands(
|
||||
hass: HomeAssistant,
|
||||
mock_transmitter: MockRadioFrequencyEntity,
|
||||
init_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test turning the light on and off sends the correct RF commands."""
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes[ATTR_ASSUMED_STATE] is True
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_ON
|
||||
assert len(mock_transmitter.send_command_calls) == 1
|
||||
command = mock_transmitter.send_command_calls[0]
|
||||
assert isinstance(command, OOKCommand)
|
||||
assert command.frequency == FREQUENCY
|
||||
assert command.modulation is ModulationType.OOK
|
||||
assert command.repeat_count == REPEAT_COUNT
|
||||
assert command.timings == TURN_ON_TIMINGS
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_OFF
|
||||
assert len(mock_transmitter.send_command_calls) == 2
|
||||
assert mock_transmitter.send_command_calls[1].timings == TURN_OFF_TIMINGS
|
||||
|
||||
|
||||
async def test_restore_state(
|
||||
hass: HomeAssistant,
|
||||
mock_transmitter: MockRadioFrequencyEntity,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the light restores its previous on state."""
|
||||
mock_restore_cache(hass, [State(ENTITY_ID, STATE_ON)])
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant, init_integration: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test unloading the config entry removes the entity."""
|
||||
assert hass.states.get(ENTITY_ID) is not None
|
||||
|
||||
assert await hass.config_entries.async_unload(init_integration.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
1
tests/components/radio_frequency/__init__.py
Normal file
1
tests/components/radio_frequency/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Radio Frequency integration."""
|
||||
71
tests/components/radio_frequency/conftest.py
Normal file
71
tests/components/radio_frequency/conftest.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Common fixtures for the Radio Frequency tests."""
|
||||
|
||||
from typing import override
|
||||
|
||||
import pytest
|
||||
from rf_protocols import ModulationType, RadioFrequencyCommand, Timing
|
||||
|
||||
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
|
||||
from homeassistant.components.radio_frequency.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(hass: HomeAssistant) -> None:
|
||||
"""Set up the Radio Frequency integration for testing."""
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
class MockRadioFrequencyCommand(RadioFrequencyCommand):
|
||||
"""Mock RF command for testing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
frequency: int = 433_920_000,
|
||||
modulation: ModulationType = ModulationType.OOK,
|
||||
repeat_count: int = 0,
|
||||
) -> None:
|
||||
"""Initialize mock command."""
|
||||
super().__init__(
|
||||
frequency=frequency, modulation=modulation, repeat_count=repeat_count
|
||||
)
|
||||
|
||||
@override
|
||||
def get_raw_timings(self) -> list[Timing]:
|
||||
"""Return mock timings."""
|
||||
return [Timing(high_us=350, low_us=1050), Timing(high_us=350, low_us=350)]
|
||||
|
||||
|
||||
class MockRadioFrequencyEntity(RadioFrequencyTransmitterEntity):
|
||||
"""Mock radio frequency entity for testing."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Test RF transmitter"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
frequency_ranges: list[tuple[int, int]] | None = None,
|
||||
) -> None:
|
||||
"""Initialize mock entity."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._frequency_ranges = frequency_ranges or [(433_000_000, 434_000_000)]
|
||||
self.send_command_calls: list[RadioFrequencyCommand] = []
|
||||
|
||||
@property
|
||||
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
|
||||
"""Return supported frequency ranges."""
|
||||
return self._frequency_ranges
|
||||
|
||||
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
|
||||
"""Mock send command."""
|
||||
self.send_command_calls.append(command)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rf_entity() -> MockRadioFrequencyEntity:
|
||||
"""Return a mock radio frequency entity."""
|
||||
return MockRadioFrequencyEntity("test_rf_transmitter")
|
||||
171
tests/components/radio_frequency/test_init.py
Normal file
171
tests/components/radio_frequency/test_init.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Tests for the Radio Frequency integration setup."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from rf_protocols import ModulationType
|
||||
|
||||
from homeassistant.components.radio_frequency import (
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
async_get_transmitters,
|
||||
async_send_command,
|
||||
)
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .conftest import MockRadioFrequencyCommand, MockRadioFrequencyEntity
|
||||
|
||||
from tests.common import mock_restore_cache
|
||||
|
||||
|
||||
async def test_get_transmitters_component_not_loaded(hass: HomeAssistant) -> None:
|
||||
"""Test getting transmitters raises when the component is not loaded."""
|
||||
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
|
||||
async_get_transmitters(hass, 433_920_000, ModulationType.OOK)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_get_transmitters_no_entities(hass: HomeAssistant) -> None:
|
||||
"""Test getting transmitters raises when none are registered."""
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="No Radio Frequency transmitters available",
|
||||
):
|
||||
async_get_transmitters(hass, 433_920_000, ModulationType.OOK)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_get_transmitters_with_frequency_ranges(
|
||||
hass: HomeAssistant,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
) -> None:
|
||||
"""Test transmitter with frequency ranges filters correctly."""
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_rf_entity])
|
||||
|
||||
# 433.92 MHz is within 433-434 MHz range
|
||||
result = async_get_transmitters(hass, 433_920_000, ModulationType.OOK)
|
||||
assert result == [mock_rf_entity.entity_id]
|
||||
|
||||
# 868 MHz is outside the range
|
||||
result = async_get_transmitters(hass, 868_000_000, ModulationType.OOK)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_rf_entity_initial_state(
|
||||
hass: HomeAssistant,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
) -> None:
|
||||
"""Test radio frequency entity has no state before any command is sent."""
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_rf_entity])
|
||||
|
||||
state = hass.states.get("radio_frequency.test_rf_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_async_send_command_success(
|
||||
hass: HomeAssistant,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test sending command via async_send_command helper."""
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_rf_entity])
|
||||
|
||||
now = dt_util.utcnow()
|
||||
freezer.move_to(now)
|
||||
|
||||
command = MockRadioFrequencyCommand(frequency=433_920_000)
|
||||
await async_send_command(hass, mock_rf_entity.entity_id, command)
|
||||
|
||||
assert len(mock_rf_entity.send_command_calls) == 1
|
||||
assert mock_rf_entity.send_command_calls[0] is command
|
||||
|
||||
state = hass.states.get("radio_frequency.test_rf_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == now.isoformat(timespec="milliseconds")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_async_send_command_error_does_not_update_state(
|
||||
hass: HomeAssistant,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
) -> None:
|
||||
"""Test that state is not updated when async_send_command raises an error."""
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_rf_entity])
|
||||
|
||||
state = hass.states.get("radio_frequency.test_rf_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
command = MockRadioFrequencyCommand(frequency=433_920_000)
|
||||
|
||||
mock_rf_entity.async_send_command = AsyncMock(
|
||||
side_effect=HomeAssistantError("Transmission failed")
|
||||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Transmission failed"):
|
||||
await async_send_command(hass, mock_rf_entity.entity_id, command)
|
||||
|
||||
state = hass.states.get("radio_frequency.test_rf_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None:
|
||||
"""Test async_send_command raises error when entity not found."""
|
||||
command = MockRadioFrequencyCommand(frequency=433_920_000)
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="Radio Frequency entity `radio_frequency.nonexistent_entity` not found",
|
||||
):
|
||||
await async_send_command(hass, "radio_frequency.nonexistent_entity", command)
|
||||
|
||||
|
||||
async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> None:
|
||||
"""Test async_send_command raises error when component not loaded."""
|
||||
command = MockRadioFrequencyCommand(frequency=433_920_000)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
|
||||
await async_send_command(hass, "radio_frequency.some_entity", command)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("restored_value", "expected_state"),
|
||||
[
|
||||
("2026-01-01T12:00:00.000+00:00", "2026-01-01T12:00:00.000+00:00"),
|
||||
(STATE_UNAVAILABLE, STATE_UNKNOWN),
|
||||
],
|
||||
)
|
||||
async def test_rf_entity_state_restore(
|
||||
hass: HomeAssistant,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
restored_value: str,
|
||||
expected_state: str,
|
||||
) -> None:
|
||||
"""Test radio frequency entity state restore."""
|
||||
mock_restore_cache(
|
||||
hass, [State("radio_frequency.test_rf_transmitter", restored_value)]
|
||||
)
|
||||
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_rf_entity])
|
||||
|
||||
state = hass.states.get("radio_frequency.test_rf_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == expected_state
|
||||
Reference in New Issue
Block a user