remote_base Protocol Methods Are No Longer Virtual and Receiver Lists Are Sized From Codegen
RemoteProtocol<T> no longer declares encode(), decode() and dump() as virtual, the receiver’s listener and dumper lists are fixed size StaticVectors sized during code generation, and a protocol’s source file is only compiled when the configuration uses it. External components that derive from RemoteProtocol, that register listeners or dumpers from C++, or that use a protocol class from their own C++ need small changes.
This is a developer breaking change for external components in ESPHome 2026.10.0 and later.
Background
Section titled “Background”PR #19084: Make protocol methods non-virtual and size receiver lists from codegen
Every protocol class was derived from RemoteProtocol<T>, but nothing ever used one through a base pointer: dumpers, triggers, binary sensors and transmit actions are all templates on the concrete protocol type. The virtual methods only cost a vtable per protocol and kept the linker from dropping the encode(), decode() and dump() bodies a build never calls. The receiver kept its listeners and dumpers in std::vectors that grew on the heap during setup, even though code generation knows exactly how many there are. The CI memory report on the PR measured 14.6 KB less flash on the ESP8266 test build with a receiver and the ir_rf_proxy platforms, and 11.9 KB less on the ESP32 IDF build; the rc_switch protocol table also moves from RAM into flash.
Why a clean break
Section titled “Why a clean break”Every protocol added to remote_base has cost every user of the component, whether their configuration used it or not: with the methods virtual and every source file always compiled, each protocol’s vtable, its encode(), decode() and dump() bodies and its dumper and trigger instantiations all stayed in the image. There are 35 protocols in tree and more arrive most releases, so that per-protocol tax kept growing for every remote_receiver and remote_transmitter user, most of whom use one or two protocols. Making the methods non-virtual and compiling only the requested sources removes the tax. There is no way to keep override compiling on a method that is no longer virtual, so a deprecation window is not possible and the change lands as a clean break with this guide.
What’s Changing
Section titled “What’s Changing”Four things, each with its own migration step below:
RemoteProtocol<T>only supplies theProtocolDataalias and no longer declares the protocol methods.encode(),decode()anddump()are plain member functions, checked by C++20 concepts wherever a protocol is used.RemoteReceiverBase::register_listener()andregister_dumper()only accept a registration when code generation counted a slot for it; with no slot counted the call fails astatic_assert. A registration from C++setup()has no slot.- A protocol’s
*_protocol.cppinremote_baseis only compiled when something in the configuration requests it: a dumper, a trigger, a binary sensor, a transmit action, or an explicit request from a component’sto_code(). A protocol that ships in your own component is compiled as before. - The protected
call_listeners_()andcall_dumpers_()onRemoteReceiverBaseare merged intocall_listeners_dumpers_(), which already existed.
Who This Affects
Section titled “Who This Affects”External components that:
- derive a protocol class from
remote_base::RemoteProtocol<T>and mark its methodsoverride - call
register_listener()orregister_dumper()on a remote receiver from C++ - use a protocol class bundled in
remote_base, such asremote_base::NECProtocol, from their own C++ without a dumper, trigger, binary sensor or transmit action for it in the configuration - call
encode(),decode()ordump()through aRemoteProtocol<T>pointer or reference; the base no longer declares them, so the error isno member named 'decode'rather than a message aboutoverride
Standard YAML configurations are not affected, with one exception: a lambda that calls id(tx).transmit<remote_base::NECProtocol>(data) needs that protocol referenced somewhere else in the configuration, see below.
Migration Guide
Section titled “Migration Guide”Protocol classes: drop override
Section titled “Protocol classes: drop override”The methods are no longer virtual, so the compiler reports each override:
// Beforeclass MyProtocol : public RemoteProtocol<MyData> { public: void encode(RemoteTransmitData *dst, const MyData &data) override; optional<MyData> decode(RemoteReceiveData src) override; void dump(const MyData &data) override;};
// Afterclass MyProtocol : public RemoteProtocol<MyData> { public: void encode(RemoteTransmitData *dst, const MyData &data); optional<MyData> decode(RemoteReceiveData src); void dump(const MyData &data);};The signatures are unchanged. The RemoteProtocolDecoder, RemoteProtocolDumper and RemoteProtocolEncoder concepts check them wherever the protocol is used, so a mismatch is reported at the DECLARE_REMOTE_PROTOCOL(...) line, normally in the protocol’s own header; a protocol declared without that macro sees it at its first use.
Listeners and dumpers: register from Python
Section titled “Listeners and dumpers: register from Python”The receiver’s lists are sized during code generation. A component that registered itself from C++ has no slot:
// Before, in setup()this->receiver_->register_listener(this);Register from to_code() instead, so the slot is counted. For a component whose schema uses CONF_RECEIVER_ID, one call does both halves:
from esphome.components import remote_base
async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) # sets the receiver on the entity and registers it as a listener await remote_base.attach_receiver(var, config)attach_receiver(var, config, key=...) accepts a different config key, remote_base.register_listener(var, config) registers without calling set_receiver(), and remote_base.add_listener(receiver, var) and remote_base.add_dumper(receiver, dumper) take the receiver object directly. The slot counting lives in these helpers: a hand written cg.add(receiver.register_listener(var)) emits the call but counts no slot, so it fails the same way the C++ registration does.
When a configuration counted no slot at all, a C++ registration fails the build with a message naming this fix; when the counted slots are already used, the registration is dropped and the same message is logged as an error at boot, so the listener or dumper never receives data. Check the boot log for No listener slot or No dumper slot after migrating.
Bundled protocols used from C++: request the source file
Section titled “Bundled protocols used from C++: request the source file”A component that uses one of the protocol classes bundled in remote_base directly in C++, with no dumper, trigger, binary sensor or transmit action for it in the configuration, must keep the source file in the build:
# in to_code()remote_base.request_protocol("coolix")The name is the protocol’s source file without the _protocol.cpp suffix: NECProtocol lives in nec_protocol.cpp, so it is "nec"; RCSwitchBase lives in rc_switch_protocol.cpp, so it is "rc_switch". Otherwise the protocol’s .cpp is filtered out and the link fails with an undefined reference to its encode(), decode() or dump(). Unknown names raise during code generation and list the valid ones.
A protocol that ships in your own component needs no request. Only the source files in remote_base are filtered, so registering your protocol with register_trigger, register_dumper, register_binary_sensor or register_action works as before, and request_protocol only accepts the bundled names.
The same applies to a YAML lambda that uses a protocol class, for example id(tx).transmit<remote_base::NECProtocol>(data): reference that protocol somewhere else in the configuration, with a dump entry, an on_nec trigger or a remote_transmitter.transmit_nec action.
Receiver platforms: one call for listeners and dumpers
Section titled “Receiver platforms: one call for listeners and dumpers”A platform deriving from RemoteReceiverBase that called the protected call_listeners_() and call_dumpers_() separately now calls call_listeners_dumpers_(), which already existed as the combined entry point; the two were merged so each list can compile out on its own.
Supporting Multiple ESPHome Versions
Section titled “Supporting Multiple ESPHome Versions”override on a method that is no longer virtual is a compile error, so a component that has to build on 2026.9 and 2026.10 needs a version guard around the declarations:
#include "esphome/core/version.h"
class MyProtocol : public RemoteProtocol<MyData> { public:#if ESPHOME_VERSION_CODE >= VERSION_CODE(2026, 10, 0) void encode(RemoteTransmitData *dst, const MyData &data); optional<MyData> decode(RemoteReceiveData src); void dump(const MyData &data);#else void encode(RemoteTransmitData *dst, const MyData &data) override; optional<MyData> decode(RemoteReceiveData src) override; void dump(const MyData &data) override;#endif};For listeners, remote_base.register_listener(var, config) exists on both versions, so a component whose schema uses CONF_RECEIVER_ID can move the registration to Python once and drop the C++ call with no guard. The guard below is only for a component whose receiver option has another name, since attach_receiver, add_listener, add_dumper and request_protocol only exist from 2026.10.0; keep the C++ registration for older versions:
# in to_code()try: from esphome.components.remote_base import attach_receiverexcept ImportError: # ESPHome < 2026.10.0 registers the listener from setup() receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) cg.add(var.set_receiver(receiver))else: await attach_receiver(var, config, key=CONF_REMOTE_RECEIVER_ID)void MyComponent::setup() {#if ESPHOME_VERSION_CODE < VERSION_CODE(2026, 10, 0) this->receiver_->register_listener(this);#endif}request_protocol follows the same shape for a bundled protocol; before 2026.10.0 every protocol source file is compiled, so the failing import is safe to ignore.
Timeline
Section titled “Timeline”- ESPHome 2026.10.0 (October 2026): protocol methods are no longer virtual, receiver lists are sized from codegen, unused protocol sources are not compiled. No deprecation period:
overrideon a non-virtual method cannot be kept compiling.
Finding Code That Needs Updates
Section titled “Finding Code That Needs Updates”# Protocol classes that still mark the methods overridegrep -rn 'RemoteProtocol<' your_component/grep -rnE '(encode|decode|dump)\(.*\) override' your_component/
# Listener or dumper registration from C++grep -rnE 'register_listener|register_dumper' your_component/
# Receiver platforms calling the split helpersgrep -rnE 'call_listeners_\(|call_dumpers_\(' your_component/
# Protocol classes used from C++ or lambdasgrep -rnE 'remote_base::([A-Za-z0-9]*Protocol|RCSwitchBase)' your_component/ your_configs/Questions?
Section titled “Questions?”If you have questions about migrating your external component, please ask in:
- ESPHome Discord - #devs channel
- ESPHome GitHub Discussions
Related Documentation
Section titled “Related Documentation”Copyright © 2026 ESPHome - A project from the Open Home Foundation
Comments
Feel free to leave a comment here to discuss this post with others. You can ask questions, share your experience, or suggest improvements. If you have a question about a specific feature or issue, please consider using the ESPHome Discord. Stick to English and follow ESPHome's code of conduct. These comments exist on a discussion on GitHub, so you can also comment there directly if you prefer.