Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions homeassistant/components/broadlink/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
DOMAINS_AND_TYPES = {
Platform.CLIMATE: {"HYS"},
Platform.LIGHT: {"LB1", "LB2"},
Platform.RADIO_FREQUENCY: {"RM4PRO", "RMPRO"},
Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
Platform.SELECT: {"HYS"},
Platform.SENSOR: {
Expand Down
139 changes: 139 additions & 0 deletions homeassistant/components/broadlink/radio_frequency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Radio Frequency platform for Broadlink."""

from __future__ import annotations

import logging

from broadlink.exceptions import BroadlinkException
from rf_protocols import ModulationType, RadioFrequencyCommand

from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from .const import DOMAIN
from .device import BroadlinkDevice
from .entity import BroadlinkEntity

_LOGGER = logging.getLogger(__name__)

PARALLEL_UPDATES = 0

_TICK_US = 32.84

_RF_433_TYPE_BYTE = 0xB2
_RF_315_TYPE_BYTE = 0xB4

_RF_433_RANGE = (433_050_000, 434_790_000)
_RF_315_RANGE = (314_950_000, 315_250_000)

SUPPORTED_FREQUENCY_RANGES: list[tuple[int, int]] = [_RF_433_RANGE, _RF_315_RANGE]


def _type_byte_for_frequency(frequency: int) -> int:
"""Return the Broadlink RF type byte for a given carrier frequency."""
if _RF_433_RANGE[0] <= frequency <= _RF_433_RANGE[1]:
return _RF_433_TYPE_BYTE
if _RF_315_RANGE[0] <= frequency <= _RF_315_RANGE[1]:
return _RF_315_TYPE_BYTE
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="frequency_not_supported",
translation_placeholders={"frequency": str(frequency)},
)


def encode_rf_packet(
*,
type_byte: int,
repeat_count: int,
timings_us: list[int],
) -> bytes:
"""Encode raw OOK timings as a Broadlink RF pulse-length packet.

The layout is::

byte 0 type byte (0xB2 for 433 MHz, 0xB4 for 315 MHz)
byte 1 repeat count (additional transmissions after the first)
bytes 2..3 payload length (little-endian), counted from byte 4
bytes 4..N-1 pulses: 1 byte when ticks < 256, otherwise
0x00 followed by a 2-byte big-endian tick count

Each pulse is expressed as multiples of 32.84 µs ticks, which is the
timing resolution of the Broadlink RF front-end.
"""
buf = bytearray([type_byte, repeat_count & 0xFF, 0, 0])
for duration in timings_us:
ticks = round(abs(duration) / _TICK_US)
div, mod = divmod(ticks, 256)
if div:
buf.append(0x00)
buf.append(div)
buf.append(mod)
Comment on lines +67 to +74
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add explicit bounds checks for repeat_count (0–255) and tick counts (<= 0xFFFF) instead of truncating/overflowing into invalid bytes, and raise a clear error when inputs exceed Broadlink’s wire format limits.

Suggested change
buf = bytearray([type_byte, repeat_count & 0xFF, 0, 0])
for duration in timings_us:
ticks = round(abs(duration) / _TICK_US)
div, mod = divmod(ticks, 256)
if div:
buf.append(0x00)
buf.append(div)
buf.append(mod)
if not 0 <= repeat_count <= 0xFF:
raise HomeAssistantError(
f"Broadlink RF repeat_count must be between 0 and 255, got {repeat_count}"
)
buf = bytearray([type_byte, repeat_count, 0, 0])
for duration in timings_us:
ticks = round(abs(duration) / _TICK_US)
if ticks > 0xFFFF:
raise HomeAssistantError(
"Broadlink RF timing exceeds the maximum encodable duration: "
f"{duration} µs -> {ticks} ticks"
)
if ticks < 256:
buf.append(ticks)
else:
buf.append(0x00)
buf.extend(ticks.to_bytes(2, "big"))

Copilot uses AI. Check for mistakes.
payload_len = len(buf) - 4
Comment on lines +68 to +75
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate that each encoded pulse has at least 1 tick (and never 0), since 0 is reserved as the escape marker in the Broadlink pulse format and a rounded-to-zero duration would produce a malformed payload.

Copilot uses AI. Check for mistakes.
buf[2] = payload_len & 0xFF
buf[3] = (payload_len >> 8) & 0xFF
return bytes(buf)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Broadlink radio frequency transmitter."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
device: BroadlinkDevice = hass.data[DOMAIN].devices[config_entry.entry_id]
async_add_entities([BroadlinkRadioFrequency(device)])


class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity):
"""Representation of a Broadlink RF transmitter."""

_attr_has_entity_name = True
_attr_name = None

def __init__(self, device: BroadlinkDevice) -> None:
"""Initialize the entity."""
super().__init__(device)
self._attr_unique_id = device.unique_id

@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return the Broadlink-supported narrow RF bands."""
return SUPPORTED_FREQUENCY_RANGES

async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Encode an OOK command and transmit it via the Broadlink device."""
if command.modulation is not ModulationType.OOK:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="modulation_not_supported",
translation_placeholders={"modulation": str(command.modulation)},
)

type_byte = _type_byte_for_frequency(command.frequency)
packet = encode_rf_packet(
type_byte=type_byte,
repeat_count=command.repeat_count,
timings_us=command.get_raw_timings(),
)
_LOGGER.debug(
"Transmitting RF packet: %d bytes on %d Hz (repeat=%d)",
len(packet),
command.frequency,
command.repeat_count,
)

device = self._device
try:
await device.async_request(device.api.send_data, packet)
except (BroadlinkException, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="transmit_failed",
translation_placeholders={"error": str(err)},
) from err
11 changes: 11 additions & 0 deletions homeassistant/components/broadlink/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@
}
}
},
"exceptions": {
"frequency_not_supported": {
"message": "Broadlink devices cannot transmit on {frequency} Hz"
},
"modulation_not_supported": {
"message": "Broadlink devices only support OOK modulation, got {modulation}"
},
Comment on lines +52 to +57
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove or consolidate these Broadlink-specific unsupported frequency/modulation exception translations, since the radio_frequency domain rejects unsupported frequencies/modulations before dispatching to the entity in normal usage, making these messages unlikely to ever be surfaced.

Suggested change
"frequency_not_supported": {
"message": "Broadlink devices cannot transmit on {frequency} Hz"
},
"modulation_not_supported": {
"message": "Broadlink devices only support OOK modulation, got {modulation}"
},

Copilot uses AI. Check for mistakes.
"transmit_failed": {
"message": "Failed to transmit RF command: {error}"
}
},
"entity": {
"select": {
"day_of_week": {
Expand Down
Loading
Loading