[remote_base] Accept protocols registered by external components (#19332)

This commit is contained in:
J. Nick Koston
2026-09-17 11:33:52 +12:00
committed by GitHub
parent ffb1ad288f
commit b001514cf3
4 changed files with 130 additions and 5 deletions
+10 -4
View File
@@ -169,6 +169,12 @@ def request_protocol(name: str) -> None:
cg.add_define(protocol_define(name))
def _request_protocol_if_in_tree(name: str) -> None:
"""Registry names from external components have no source file here and need no define."""
if _protocol_stem(name) in _PROTOCOL_STEMS:
request_protocol(name)
# Only the protocol sources a configuration uses are compiled
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS}
@@ -182,7 +188,7 @@ def register_binary_sensor(
def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable:
async def new_func(var: MockObj, config: ConfigType) -> None:
request_protocol(name)
_request_protocol_if_in_tree(name)
await coroutine(func)(var, config)
return registerer(new_func)
@@ -200,7 +206,7 @@ def register_trigger(name, type, data_type):
def decorator(func):
async def new_func(config):
request_protocol(name)
_request_protocol_if_in_tree(name)
var = cg.new_Pvariable(config[CONF_TRIGGER_ID])
await coroutine(func)(var, config)
await automation.build_automation(var, [(data_type, "x")], config)
@@ -218,7 +224,7 @@ def register_dumper(name, type, schema=None):
def decorator(func):
async def new_func(config, dumper_id):
request_protocol(name)
_request_protocol_if_in_tree(name)
var = cg.new_Pvariable(dumper_id)
await coroutine(func)(var, config)
return var
@@ -259,7 +265,7 @@ def register_action(name, type_, schema):
def decorator(func):
async def new_func(config, action_id, template_arg, args):
request_protocol(name)
_request_protocol_if_in_tree(name)
var = cg.new_Pvariable(action_id, template_arg)
await register_transmittable(var, config)
if CONF_REPEAT in config:
@@ -0,0 +1,36 @@
esphome:
name: test
esp32:
board: esp32dev
logger:
external_components:
- source:
type: local
path: ../external_components
fake_protocol:
remote_receiver:
- id: rcvr
pin: GPIO4
dump:
- fake
- nec
on_fake:
then:
- remote_transmitter.transmit_fake:
on_nec:
then:
- logger.log: nec
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
binary_sensor:
- platform: remote_receiver
name: Fake Input
fake:
@@ -0,0 +1,39 @@
"""External component registering a protocol that has no source file in remote_base."""
import esphome.codegen as cg
from esphome.components import remote_base
import esphome.config_validation as cv
from esphome.types import ConfigType
DEPENDENCIES = ["remote_base"]
ns = cg.esphome_ns.namespace("fake_protocol")
FakeData = ns.struct("FakeData")
FakeBinarySensor = ns.class_(
"FakeBinarySensor", remote_base.RemoteReceiverBinarySensorBase
)
FakeTrigger = ns.class_("FakeTrigger", remote_base.RemoteReceiverTrigger)
FakeAction = ns.class_("FakeAction", remote_base.RemoteTransmitterActionBase)
FakeDumper = ns.class_("FakeDumper", remote_base.RemoteReceiverDumperBase)
CONFIG_SCHEMA = cv.Schema({})
@remote_base.register_binary_sensor("fake", FakeBinarySensor, {})
def fake_binary_sensor(var: cg.MockObj, config: ConfigType) -> None:
pass
@remote_base.register_trigger("fake", FakeTrigger, FakeData)
def fake_trigger(var: cg.MockObj, config: ConfigType) -> None:
pass
@remote_base.register_dumper("fake", FakeDumper)
def fake_dumper(var: cg.MockObj, config: ConfigType) -> None:
pass
@remote_base.register_action("fake", FakeAction, {})
async def fake_action(var: cg.MockObj, config: ConfigType, args: list) -> None:
pass
@@ -1,13 +1,16 @@
"""Listener and dumper StaticVector sizes come from codegen slot counts."""
from collections.abc import Callable
from collections.abc import Callable, Generator
from pathlib import Path
import sys
import pytest
from esphome import loader
from esphome.automation import ACTION_REGISTRY
from esphome.components import remote_base
import esphome.config_validation as cv
from esphome.core import CORE
from ..helpers import get_define_value
@@ -74,6 +77,47 @@ def test_every_registry_name_maps_to_a_protocol_source() -> None:
assert remote_base._protocol_stem(name) in remote_base._PROTOCOL_STEMS, name
@pytest.fixture
def restore_protocol_registries() -> Generator[None]:
"""Loading an external protocol component adds to module-level registries; undo that.
The loader caches the component too, so drop it or a second load would skip the
decorators and leave the restored registries without the external names.
"""
registries = (
remote_base.BINARY_SENSOR_REGISTRY,
remote_base.TRIGGER_REGISTRY,
remote_base.DUMPER_REGISTRY,
ACTION_REGISTRY,
)
saved = [dict(registry) for registry in registries]
yield
for registry, entries in zip(registries, saved, strict=True):
registry.clear()
registry.update(entries)
loader._COMPONENT_CACHE.pop("fake_protocol", None)
sys.modules.pop("esphome.components.fake_protocol", None)
@pytest.mark.usefixtures("restore_protocol_registries")
def test_external_protocols_register_without_a_remote_base_source(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""An external protocol goes through all four decorators without a source file here, so no define is emitted."""
main_cpp = generate_main(
component_config_path("receiver_with_external_protocol.yaml")
)
defines = {define.name for define in CORE.defines}
assert "USE_REMOTE_PROTOCOL_NEC" in defines
assert "USE_REMOTE_PROTOCOL_FAKE" not in defines
for cls in ("FakeBinarySensor", "FakeTrigger", "FakeDumper", "FakeAction"):
assert f"fake_protocol::{cls}" in main_cpp, cls
# fake and nec dumpers; on_fake and on_nec triggers plus the fake binary sensor
assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2"
assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "3"
def test_request_protocol_rejects_unknown_names() -> None:
"""A misspelled protocol would otherwise surface only as a link error."""
with pytest.raises(ValueError, match="Unknown remote protocol 'toshiba'"):