diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 8ae2140fe8..fbb1f684da 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1,5 +1,4 @@ from collections.abc import Callable -from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -45,8 +44,7 @@ from esphome.const import ( CONF_WAND_ID, CONF_ZERO, ) -from esphome.core import CORE, ID, coroutine, coroutine_with_priority -from esphome.coroutine import CoroPriority +from esphome.core import ID, coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType @@ -103,52 +101,22 @@ REMOTE_TRANSMITTABLE_SCHEMA = cv.Schema( # Listener and dumper lists are StaticVectors sized from these counts, so every registration # must go through add_listener / add_dumper. Every receiver's list gets the same capacity, so -# the define is the largest count any one receiver needs, not the sum over all receivers. +# the slots are keyed by receiver and the define is the largest count any one receiver needs. LISTENER_COUNT_DEFINE = "REMOTE_BASE_LISTENER_COUNT" DUMPER_COUNT_DEFINE = "REMOTE_BASE_DUMPER_COUNT" -@dataclass -class _SlotCounts: - per_receiver: dict[str, dict[str, int]] = field(default_factory=dict) - emitted: bool = False - - -def _get_slot_counts() -> _SlotCounts: - if DOMAIN not in CORE.data: - CORE.data[DOMAIN] = _SlotCounts() - return CORE.data[DOMAIN] - - -@coroutine_with_priority(CoroPriority.FINAL) -async def _emit_slot_counts() -> None: - state = _get_slot_counts() - state.emitted = True - for define, counts in state.per_receiver.items(): - cg.add_define(define, max(counts.values())) - - -def _request_slot(define: str, receiver: MockObj) -> None: - state = _get_slot_counts() - if state.emitted: - raise ValueError( - f"{define}: slot requested after the count define was emitted; " - "request slots from to_code, not from a job running after FINAL" - ) - if not state.per_receiver: - CORE.add_job(_emit_slot_counts) - counts = state.per_receiver.setdefault(define, {}) - key = str(receiver) - counts[key] = counts.get(key, 0) + 1 +_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) +_request_dumper_slot = cg.slot_counter(DUMPER_COUNT_DEFINE) def add_listener(receiver: MockObj, listener: MockObj) -> None: - _request_slot(LISTENER_COUNT_DEFINE, receiver) + _request_listener_slot(str(receiver)) cg.add(receiver.register_listener(listener)) def add_dumper(receiver: MockObj, dumper: MockObj) -> None: - _request_slot(DUMPER_COUNT_DEFINE, receiver) + _request_dumper_slot(str(receiver)) cg.add(receiver.register_dumper(dumper)) diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 7f23d27a56..67e5799bca 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -181,7 +181,7 @@ class RemoteTransmitterBase : public RemoteComponentBase { return TransmitCall(this); } template - void transmit(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { auto call = this->transmit(); Protocol().encode(call.get_data(), data); call.set_send_times(send_times); @@ -307,7 +307,7 @@ class RemoteTransmittable { protected: template - void transmit_(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + void transmit_(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { this->transmitter_->transmit(data, send_times, send_wait); } RemoteTransmitterBase *transmitter_; diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 53b59cb124..1e5f258129 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Hashable from dataclasses import dataclass, field import logging @@ -142,9 +142,10 @@ _SLOT_COUNTER_DOMAIN = "slot_counter" @dataclass class _SlotCounterState: - """Per-run slot counter state: requested counts and already-emitted defines.""" + """Per-run slot counter state: requested counts per define and key, and + already-emitted defines.""" - counts: dict[str, int] = field(default_factory=dict) + counts: dict[str, dict[Hashable, int]] = field(default_factory=dict) emitted: set[str] = field(default_factory=set) @@ -156,11 +157,13 @@ def _get_slot_counter_state() -> _SlotCounterState: def get_slot_count(define: str) -> int: - """Number of slots requested so far for `define`.""" - return _get_slot_counter_state().counts.get(define, 0) + """Value `define` would be emitted with so far: the largest count requested + under any one key, which is the plain request count when no key is used.""" + counts = _get_slot_counter_state().counts.get(define) + return max(counts.values()) if counts else 0 -def slot_counter(define: str) -> Callable[[], None]: +def slot_counter(define: str) -> Callable[..., None]: """Create a request_slot function for codegen-sized storage. The pattern behind a StaticVector listener array: a consumer's to_code @@ -169,6 +172,11 @@ def slot_counter(define: str) -> Callable[[], None]: emitted with the requested count. No requests, no define: the guarded storage and its registration method compile out entirely. + When several objects each declare the storage at the same size (one list + per receiver, per hub, ...) the caller passes the owning object as `key` + and the define becomes the largest count any one key requested, not the + total. Requests without a key share one count. + The counts live in a table under CORE.data, which clears between runs. A request arriving after the define was already emitted raises instead of silently undercounting: the define would keep the stale smaller value and @@ -179,10 +187,10 @@ def slot_counter(define: str) -> Callable[[], None]: async def emit_job() -> None: state = _get_slot_counter_state() state.emitted.add(define) - # Scheduled only by the first request, so the count is always >= 1 here. - add_define(define, state.counts[define]) + # Scheduled only by the first request, so there is at least one count here. + add_define(define, max(state.counts[define].values())) - def request_slot() -> None: + def request_slot(key: Hashable = None) -> None: state = _get_slot_counter_state() if define in state.emitted: raise ValueError( @@ -190,10 +198,11 @@ def slot_counter(define: str) -> Callable[[], None]: f"define was emitted; request slots from to_code, not from a " f"job running after FINAL emission" ) - counts = state.counts - counts[define] = (count := counts.get(define, 0) + 1) - if count == 1: + counts = state.counts.get(define) + if counts is None: + counts = state.counts[define] = {} CORE.add_job(emit_job) + counts[key] = counts.get(key, 0) + 1 return request_slot diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 1c0e0d0a93..d403e0fb6c 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -187,6 +187,19 @@ def test_slot_counter_emits_requested_count() -> None: assert _define_value("TEST_SLOT_COUNT") == "2" +def test_slot_counter_keyed_emits_largest_count() -> None: + """Keyed requests size storage every key declares at the same capacity: + the define is the busiest key's count, not the total over all keys.""" + request = ch.slot_counter("TEST_SLOT_COUNT_KEYED") + request("rx_a") + request("rx_a") + request("rx_a") + request("rx_b") + assert ch.get_slot_count("TEST_SLOT_COUNT_KEYED") == 3 + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_KEYED") == "3" + + def test_slot_counter_without_requests_emits_nothing() -> None: """No requests, no job, no define — the guarded storage compiles out.""" ch.slot_counter("TEST_SLOT_COUNT_UNUSED")