Size the receiver lists with a keyed slot_counter

cg.slot_counter() now takes an optional key per request and emits the largest count under any one key, so remote_base no longer needs its own counter to give every receiver the same capacity. Add typename to the two dependent ProtocolData parameters.
This commit is contained in:
J. Nick Koston
2026-09-11 09:52:27 -05:00
parent 03409b0818
commit a2159aee86
4 changed files with 42 additions and 52 deletions
+6 -38
View File
@@ -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))
+2 -2
View File
@@ -181,7 +181,7 @@ class RemoteTransmitterBase : public RemoteComponentBase {
return TransmitCall(this);
}
template<RemoteProtocolEncoder Protocol>
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<RemoteProtocolEncoder Protocol>
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<Protocol>(data, send_times, send_wait);
}
RemoteTransmitterBase *transmitter_;
+21 -12
View File
@@ -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
+13
View File
@@ -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")