Skip to content
Merged
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
35 changes: 29 additions & 6 deletions aioesphomeapi/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -2554,27 +2554,50 @@ message ListEntitiesInfraredResponse {
message InfraredRFTransmitRawTimingsRequest {
option (id) = 136;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_IR_RF";
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";

uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
uint32 carrier_frequency = 3; // Carrier frequency in Hz
uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
uint32 carrier_frequency = 3; // Carrier frequency in Hz
uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
repeated sint32 timings = 5 [packed = true, (packed_buffer) = true]; // Raw timings in microseconds (zigzag-encoded): positive = mark (LED/TX on), negative = space (LED/TX off)
uint32 modulation = 6; // RadioFrequencyModulation enum value (0 = OOK; ignored for IR entities)
}

// Event message for received infrared/RF data
message InfraredRFReceiveEvent {
option (id) = 137;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_IR_RF";
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
option (no_delay) = true;

uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector<int32_t>"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods
}

// ==================== RADIO FREQUENCY ====================

// Lists available radio frequency entity instances
message ListEntitiesRadioFrequencyResponse {
option (id) = 148;
option (base_class) = "InfoResponseProtoMessage";
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_RADIO_FREQUENCY";

string object_id = 1 [(max_data_length) = 120, (force) = true];
fixed32 key = 2 [(force) = true];
string name = 3 [(max_data_length) = 120, (force) = true];
string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 5;
EntityCategory entity_category = 6;
uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"];
uint32 capabilities = 8; // Bitmask of RadioFrequencyCapabilityFlags: bit 0 = transmitter, bit 1 = receiver
uint32 frequency_min = 9; // Minimum tunable frequency in Hz; if min == max (non-zero): fixed frequency; 0 = unspecified
uint32 frequency_max = 10; // Maximum tunable frequency in Hz; 0 = unspecified
uint32 supported_modulations = 11; // Bitmask of supported RadioFrequencyModulation values (bit N = modulation N supported)
}
Comment thread
kbx81 marked this conversation as resolved.

// ==================== SERIAL PROXY ====================

enum SerialProxyParity {
Expand Down
246 changes: 130 additions & 116 deletions aioesphomeapi/api_pb2.py

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions aioesphomeapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
LogLevel,
MediaPlayerCommand,
NoiseEncryptionSetKeyResponse as NoiseEncryptionSetKeyResponseModel,
RadioFrequencyModulation,
SerialProxyDataReceived as SerialProxyDataReceivedModel,
SerialProxyModemPins,
SerialProxyParity,
Expand Down Expand Up @@ -514,6 +515,25 @@ def infrared_rf_transmit_raw_timings(
req.timings.extend(timings)
self._get_connection().send_message(req)

def radio_frequency_transmit_raw_timings(
self,
key: int,
frequency: int,
timings: list[int],
modulation: RadioFrequencyModulation = RadioFrequencyModulation.OOK,
repeat_count: int = 1,
device_id: int = 0,
) -> None:
"""Send a radio frequency raw timings transmit request."""
req = InfraredRFTransmitRawTimingsRequest()
req.device_id = device_id
req.key = key
req.carrier_frequency = frequency
req.modulation = modulation
req.repeat_count = repeat_count
req.timings.extend(timings)
self._get_connection().send_message(req)

def serial_proxy_configure(
self,
instance: int,
Expand Down
2 changes: 2 additions & 0 deletions aioesphomeapi/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
ListEntitiesLockResponse,
ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesRadioFrequencyResponse,
ListEntitiesRequest,
ListEntitiesSelectResponse,
ListEntitiesSensorResponse,
Expand Down Expand Up @@ -536,6 +537,7 @@ def __init__(self, address: int, error: int) -> None:
145: BluetoothSetConnectionParamsRequest,
146: BluetoothSetConnectionParamsResponse,
147: SerialProxyRequestResponse,
148: ListEntitiesRadioFrequencyResponse,
}

MESSAGE_NUMBER_TO_PROTO = tuple(MESSAGE_TYPE_TO_PROTO.values())
32 changes: 32 additions & 0 deletions aioesphomeapi/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@ class InfraredRFReceiveEvent(APIModelBase):
timings: list[int] = field(default_factory=list) # pylint: disable=invalid-field-call


class RadioFrequencyModulation(enum.IntEnum):
OOK = 0


class RadioFrequencyCapability(enum.IntFlag):
TRANSMITTER = 1 << 0
RECEIVER = 1 << 1


class VoiceAssistantSubscriptionFlag(enum.IntFlag):
API_AUDIO = 1 << 2

Expand Down Expand Up @@ -1280,6 +1289,27 @@ class InfraredInfo(EntityInfo):
receiver_frequency: int = 0


# ==================== RADIO FREQUENCY ====================


@_frozen_dataclass_decorator
class RadioFrequencyInfo(EntityInfo):
capabilities: int = 0
frequency_min: int = 0 # Minimum tunable frequency in Hz (0 = unspecified; equal to frequency_max → fixed)
frequency_max: int = 0 # Maximum tunable frequency in Hz (0 = unspecified)
supported_modulations: int = (
0 # Bitmask: bit N set means RadioFrequencyModulation(N) is supported
)

def supports_modulation(self, modulation: RadioFrequencyModulation) -> bool:
"""Return True if the given modulation type is supported.

The supported_modulations bitmask uses bit N to represent
RadioFrequencyModulation value N (e.g. OOK=0 → bit 0).
"""
return bool(self.supported_modulations & (1 << int(modulation)))


# ==================== SERIAL PROXY ====================


Expand Down Expand Up @@ -1355,6 +1385,7 @@ class SerialProxyModemPins(APIModelBase):
"event": EventInfo,
"update": UpdateInfo,
"infrared": InfraredInfo,
"radio_frequency": RadioFrequencyInfo,
}


Expand Down Expand Up @@ -1948,6 +1979,7 @@ class VoiceAssistantTimerEventType(APIIntEnum):
UpdateInfo: "update",
WaterHeaterInfo: "water_heater",
InfraredInfo: "infrared",
RadioFrequencyInfo: "radio_frequency",
}


Expand Down
3 changes: 3 additions & 0 deletions aioesphomeapi/model_conversions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
ListEntitiesLockResponse,
ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesRadioFrequencyResponse,
ListEntitiesSelectResponse,
ListEntitiesSensorResponse,
ListEntitiesServicesResponse,
Expand Down Expand Up @@ -83,6 +84,7 @@
MediaPlayerInfo,
NumberInfo,
NumberState,
RadioFrequencyInfo,
SelectInfo,
SelectState,
SensorInfo,
Expand Down Expand Up @@ -146,6 +148,7 @@
ListEntitiesLockResponse: LockInfo,
ListEntitiesMediaPlayerResponse: MediaPlayerInfo,
ListEntitiesNumberResponse: NumberInfo,
ListEntitiesRadioFrequencyResponse: RadioFrequencyInfo,
ListEntitiesSelectResponse: SelectInfo,
ListEntitiesSensorResponse: SensorInfo,
ListEntitiesServicesResponse: None,
Expand Down
39 changes: 39 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
LightColorCapability,
LockCommand,
MediaPlayerCommand,
RadioFrequencyModulation,
SensorInfo,
SerialProxyDataReceived,
SerialProxyModemPins,
Expand Down Expand Up @@ -2826,6 +2827,44 @@ def capture_send(msg: Any) -> None:
assert list(sent_msg.timings) == timings


async def test_radio_frequency_transmit_raw_timings(
api_client: tuple[
APIClient, APIConnection, asyncio.Transport, APIPlaintextFrameHelper
],
) -> None:
"""Test radio_frequency_transmit_raw_timings sends the correct request."""
client, connection, _transport, _protocol = api_client
sent_messages: list[InfraredRFTransmitRawTimingsRequestPb] = []

original_send = connection.send_message

def capture_send(msg: Any) -> None:
if isinstance(msg, InfraredRFTransmitRawTimingsRequestPb):
sent_messages.append(msg)
original_send(msg)

connection.send_message = capture_send

timings = [500, -500, 1000, -1000, 500, -500]
client.radio_frequency_transmit_raw_timings(
key=111,
frequency=433920000,
timings=timings,
modulation=RadioFrequencyModulation.OOK,
repeat_count=2,
device_id=3,
)

assert len(sent_messages) == 1
sent_msg = sent_messages[0]
assert sent_msg.key == 111
assert sent_msg.device_id == 3
assert sent_msg.carrier_frequency == 433920000
assert sent_msg.modulation == RadioFrequencyModulation.OOK
assert sent_msg.repeat_count == 2
assert list(sent_msg.timings) == timings
Comment thread
bdraco marked this conversation as resolved.


# ==================== SERIAL PROXY ====================


Expand Down
59 changes: 59 additions & 0 deletions tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
ListEntitiesLockResponse,
ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesRadioFrequencyResponse,
ListEntitiesSelectResponse,
ListEntitiesSensorResponse,
ListEntitiesServicesArgument,
Expand Down Expand Up @@ -128,6 +129,9 @@
NoiseEncryptionSetKeyResponse as NoiseEncryptionSetKeyResponseModel,
NumberInfo,
NumberState,
RadioFrequencyCapability,
RadioFrequencyInfo,
RadioFrequencyModulation,
SelectInfo,
SelectState,
SensorInfo,
Expand Down Expand Up @@ -2021,6 +2025,61 @@ def test_infrared_info_in_type_to_name() -> None:
assert _TYPE_TO_NAME[InfraredInfo] == "infrared"


# ==================== RADIO FREQUENCY ====================


def test_radio_frequency_modulation_enum() -> None:
"""Test RadioFrequencyModulation enum values."""
assert RadioFrequencyModulation.OOK == 0


def test_radio_frequency_capability_flag() -> None:
"""Test RadioFrequencyCapability flag values."""
assert RadioFrequencyCapability.TRANSMITTER == 1
assert RadioFrequencyCapability.RECEIVER == 2


def test_radio_frequency_info_conversion() -> None:
"""Test RadioFrequencyInfo conversion from protobuf."""
pb = ListEntitiesRadioFrequencyResponse(
object_id="rf1",
key=200,
name="RF Transceiver",
capabilities=3,
frequency_min=433920000,
frequency_max=433920000,
supported_modulations=1,
)
info = RadioFrequencyInfo.from_pb(pb)
assert info.object_id == "rf1"
assert info.key == 200
assert info.name == "RF Transceiver"
assert info.capabilities == 3
assert info.frequency_min == 433920000
assert info.frequency_max == 433920000
assert info.supported_modulations == 1
assert info.supports_modulation(RadioFrequencyModulation.OOK) is True

# Test defaults (0 = unspecified)
pb_default = ListEntitiesRadioFrequencyResponse(
object_id="rf2",
key=201,
name="RF Transmitter",
capabilities=1,
)
info_default = RadioFrequencyInfo.from_pb(pb_default)
assert info_default.frequency_min == 0
assert info_default.frequency_max == 0
assert info_default.supported_modulations == 0
assert info_default.supports_modulation(RadioFrequencyModulation.OOK) is False


def test_radio_frequency_info_in_type_to_name() -> None:
"""Test that RadioFrequencyInfo is registered in _TYPE_TO_NAME."""
assert RadioFrequencyInfo in _TYPE_TO_NAME
assert _TYPE_TO_NAME[RadioFrequencyInfo] == "radio_frequency"


# ==================== SERIAL PROXY ====================


Expand Down
Loading