[core] Shared slot count factory for codegen sized listener storage (#18057)

This commit is contained in:
J. Nick Koston
2026-08-04 19:46:44 +00:00
committed by GitHub
parent 342f3d6994
commit 68640f8074
13 changed files with 223 additions and 76 deletions
+2
View File
@@ -49,11 +49,13 @@ from esphome.cpp_helpers import ( # noqa: F401
build_registry_entry, build_registry_entry,
build_registry_list, build_registry_list,
extract_registry_entry_config, extract_registry_entry_config,
get_slot_count,
gpio_pin_expression, gpio_pin_expression,
past_safe_mode, past_safe_mode,
register_component, register_component,
register_parented, register_parented,
set_setup_priority, set_setup_priority,
slot_counter,
) )
from esphome.cpp_types import ( # noqa: F401 from esphome.cpp_types import ( # noqa: F401
NAN, NAN,
+3 -18
View File
@@ -24,7 +24,6 @@ from esphome.components import libretiny
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"] DEPENDENCIES = ["bk72xx"]
@@ -46,21 +45,9 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.COMPONENT_SCHEMA) ).extend(cv.COMPONENT_SCHEMA)
KEY_SCAN_LISTENER_COUNT = "bk72xx_ble_scan_listener_count" # Once per registered scan listener; sizes the controller's StaticVector
# listener storage.
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
def request_scan_listener_slot() -> None:
"""Called from a consumer's codegen once per registered scan listener; sizes
the controller's StaticVector listener storage (heap-free, mirrors the
tracker's ble_device_base listener storage)."""
CORE.data[KEY_SCAN_LISTENER_COUNT] = CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0) + 1
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_listener_count() -> None:
# FINAL: every consumer's to_code has requested its slot by now.
if count := CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0):
cg.add_define("BK72XX_BLE_SCAN_LISTENER_COUNT", count)
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
@@ -110,5 +97,3 @@ async def to_code(config: ConfigType) -> None:
) )
cg.add_define("USE_BK72XX_BLE") cg.add_define("USE_BK72XX_BLE")
CORE.add_job(_add_listener_count)
@@ -24,7 +24,6 @@ from esphome.components import bk72xx_ble, ble_device_base, ota
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType from esphome.types import ConfigType
CONF_BK72XX_BLE_ID = "bk72xx_ble_id" CONF_BK72XX_BLE_ID = "bk72xx_ble_id"
@@ -53,16 +52,6 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.COMPONENT_SCHEMA) ).extend(cv.COMPONENT_SCHEMA)
# Runs at FINAL priority so every BLE sensor has registered through
# ble_device_base (and any tracker-owned listeners have been counted) before
# the StaticVector size is emitted. Same pattern as esp32_ble_tracker.
@coroutine_with_priority(CoroPriority.FINAL)
async def _emit_listener_count() -> None:
count = ble_device_base.get_listener_count()
if count > 0:
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count)
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)
@@ -81,5 +70,3 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
CORE.add_job(_emit_listener_count)
@@ -23,17 +23,16 @@ import esphome.codegen as cg
from esphome.components.const import CONF_WINDOW from esphome.components.const import CONF_WINDOW
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_INTERVAL from esphome.const import CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_INTERVAL
from esphome.core import CORE
from esphome.types import ConfigType from esphome.types import ConfigType
CODEOWNERS = ["@Bl00d-B0b"] CODEOWNERS = ["@Bl00d-B0b"]
CONF_BLE_HUB_ID = "ble_hub_id" CONF_BLE_HUB_ID = "ble_hub_id"
# CORE.data key: number of parsed-advertisement listeners registered in this # Number of parsed-advertisement listeners registered in this build; read via
# build. Trackers whose codegen sizes storage at compile time (esp32's # cg.get_slot_count() by esp32_ble_tracker's feature coupling.
# StaticVector count define) read it in their final coroutine. LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT"
KEY_BLE_LISTENER_COUNT = "ble_device_base_listener_count"
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
@@ -64,16 +63,14 @@ def request_irk_support() -> None:
cg.add_define("USE_BLE_DEVICE_IRK") cg.add_define("USE_BLE_DEVICE_IRK")
def get_listener_count() -> int: _request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE)
"""Number of parsed listeners registered so far (for tracker codegen)."""
return CORE.data.get(KEY_BLE_LISTENER_COUNT, 0)
async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj: async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
"""Register `var` as a parsed-advertisement listener on the configured hub.""" """Register `var` as a parsed-advertisement listener on the configured hub."""
hub = await cg.get_variable(config[CONF_BLE_HUB_ID]) hub = await cg.get_variable(config[CONF_BLE_HUB_ID])
cg.add(hub.register_listener(var)) cg.add(hub.register_listener(var))
CORE.data[KEY_BLE_LISTENER_COUNT] = CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) + 1 _request_listener_slot()
return var return var
@@ -308,12 +308,10 @@ async def _add_ble_features():
required_features = _get_required_features() required_features = _get_required_features()
# Sensors registered through the neutral ble_device_base path (BLEHub) need # Sensors registered through the neutral ble_device_base path (BLEHub) need
# the parsed-device pipeline compiled in, exactly like esp32-path listeners. # the parsed-device pipeline compiled in, exactly like esp32-path listeners.
neutral_listener_count = ble_device_base.get_listener_count() if cg.get_slot_count(ble_device_base.LISTENER_COUNT_DEFINE):
if neutral_listener_count > 0: # The neutral (BLEHub) listener count define itself is emitted by
# ble_device_base's own job; only the feature coupling lives here.
required_features.add(BLEFeatures.ESP_BT_DEVICE) required_features.add(BLEFeatures.ESP_BT_DEVICE)
# StaticVector sizing for the neutral (BLEHub) listener list — same
# pattern as the esp32-path registration counts below.
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", neutral_listener_count)
if BLEFeatures.ESP_BT_DEVICE in required_features: if BLEFeatures.ESP_BT_DEVICE in required_features:
cg.add_define("USE_ESP32_BLE_DEVICE") cg.add_define("USE_ESP32_BLE_DEVICE")
cg.add_define("USE_ESP32_BLE_UUID") cg.add_define("USE_ESP32_BLE_UUID")
+3 -18
View File
@@ -12,7 +12,6 @@ component does (LibreTiny v1.13.0+).
import esphome.codegen as cg import esphome.codegen as cg
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType from esphome.types import ConfigType
DEPENDENCIES = ["ln882x"] DEPENDENCIES = ["ln882x"]
@@ -32,21 +31,9 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.COMPONENT_SCHEMA) ).extend(cv.COMPONENT_SCHEMA)
KEY_SCAN_LISTENER_COUNT = "ln882h_ble_scan_listener_count" # Once per registered scan listener; sizes the controller's StaticVector
# listener storage.
request_scan_listener_slot = cg.slot_counter("LN882H_BLE_SCAN_LISTENER_COUNT")
def request_scan_listener_slot() -> None:
"""Called from a consumer's codegen once per registered scan listener; sizes
the controller's StaticVector listener storage (heap-free, mirrors the
tracker's ble_device_base listener storage)."""
CORE.data[KEY_SCAN_LISTENER_COUNT] = CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0) + 1
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_listener_count() -> None:
# FINAL: every consumer's to_code has requested its slot by now.
if count := CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0):
cg.add_define("LN882H_BLE_SCAN_LISTENER_COUNT", count)
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
@@ -64,5 +51,3 @@ async def to_code(config: ConfigType) -> None:
cg.add_platformio_option("custom_options.proj_config#h", ["CFG_SUPPORT_BLE=1"]) cg.add_platformio_option("custom_options.proj_config#h", ["CFG_SUPPORT_BLE=1"])
cg.add_define("USE_LN882H_BLE") cg.add_define("USE_LN882H_BLE")
CORE.add_job(_add_listener_count)
@@ -20,7 +20,6 @@ from esphome.const import (
CONF_ID, CONF_ID,
CONF_INTERVAL, CONF_INTERVAL,
) )
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType from esphome.types import ConfigType
CONF_RP2040_BLE_ID = "rp2040_ble_id" CONF_RP2040_BLE_ID = "rp2040_ble_id"
@@ -54,16 +53,6 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.COMPONENT_SCHEMA) ).extend(cv.COMPONENT_SCHEMA)
# Runs at FINAL priority so every BLE sensor has registered through
# ble_device_base (and any tracker-owned listeners have been counted) before
# the StaticVector size is emitted. Same pattern as esp32_ble_tracker.
@coroutine_with_priority(CoroPriority.FINAL)
async def _emit_listener_count() -> None:
count = ble_device_base.get_listener_count()
if count > 0:
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count)
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)
@@ -80,5 +69,3 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_active(scan[CONF_ACTIVE])) cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
CORE.add_job(_emit_listener_count)
+62
View File
@@ -1,3 +1,4 @@
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging import logging
@@ -136,6 +137,67 @@ async def _generate_component_source_table() -> None:
) )
_SLOT_COUNTER_DOMAIN = "slot_counter"
@dataclass
class _SlotCounterState:
"""Per-run slot counter state: requested counts and already-emitted defines."""
counts: dict[str, int] = field(default_factory=dict)
emitted: set[str] = field(default_factory=set)
def _get_slot_counter_state() -> _SlotCounterState:
"""Get or create the slot counter state from CORE.data."""
if _SLOT_COUNTER_DOMAIN not in CORE.data:
CORE.data[_SLOT_COUNTER_DOMAIN] = _SlotCounterState()
return CORE.data[_SLOT_COUNTER_DOMAIN]
def get_slot_count(define: str) -> int:
"""Number of slots requested so far for `define`."""
return _get_slot_counter_state().counts.get(define, 0)
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
calls the returned function once per slot it will occupy at runtime, and
at FINAL priority — after every consumer's to_code has run — `define` is
emitted with the requested count. No requests, no define: the guarded
storage and its registration method compile out entirely.
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
StaticVector::push_back would drop the extra listener at runtime.
"""
@coroutine_with_priority(CoroPriority.FINAL)
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])
def request_slot() -> None:
state = _get_slot_counter_state()
if define in state.emitted:
raise ValueError(
f"slot_counter('{define}'): slot requested after the count "
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:
CORE.add_job(emit_job)
return request_slot
async def gpio_pin_expression(conf): async def gpio_pin_expression(conf):
"""Generate an expression for the given pin option. """Generate an expression for the given pin option.
@@ -0,0 +1,7 @@
esphome:
name: slotcount-controller
bk72xx:
board: generic-bk7252
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: slotcount-tracker
bk72xx:
board: generic-bk7252
bk72xx_ble_tracker:
@@ -0,0 +1,63 @@
"""Tests for the shared slot_counter codegen factory.
The factory is exercised end to end through the real controllers: a tracker
config must emit the platform's scan listener count define, and a
controller-only config must emit nothing so the guarded StaticVector storage
compiles out.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from esphome.core import CORE
from ..helpers import get_define_value
def test_tracker_requests_one_slot(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The tracker's to_code requests a slot; the FINAL job emits the count.
The neutral listener count must stay absent from the same build: no BLE
consumer registered through register_ble_device().
"""
generate_main(component_config_path("bk72xx_tracker.yaml"))
assert get_define_value("BK72XX_BLE_SCAN_LISTENER_COUNT") == "1"
assert get_define_value("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT") is None
def test_controller_only_emits_no_count(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""No consumer, no define — the guarded listener storage compiles out."""
generate_main(component_config_path("bk72xx_controller_only.yaml"))
assert get_define_value("BK72XX_BLE_SCAN_LISTENER_COUNT") is None
def test_neutral_listener_count_emitted_when_requested() -> None:
"""Registering through register_ble_device() emits the neutral count.
No in-tree sensor registers through ble_device_base.register_ble_device()
yet (consumer migration is a follow-up), so the coroutine is driven with a
mock hub instead of a config; every tracker's #ifdef-guarded listener
storage keys on this define, and a broken emit path would compile the
storage out silently.
"""
import esphome.codegen as cg
from esphome.components import ble_device_base
from esphome.core import ID
hub_id = ID("hub", type=ble_device_base.BLEHub)
CORE.register_variable(hub_id, cg.MockObj("hub"))
CORE.add_job(
ble_device_base.register_ble_device,
cg.MockObj("listener"),
{ble_device_base.CONF_BLE_HUB_ID: hub_id},
)
CORE.flush_tasks()
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "1"
+16
View File
@@ -27,3 +27,19 @@ def extract_packed_value(main_cpp: str, var_name: str) -> int:
match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp) match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp)
assert match, f"configure call not found for {var_name}" assert match, f"configure call not found for {var_name}"
return int(match.group(1)) return int(match.group(1))
def get_define_value(name: str) -> str | None:
"""Rendered value of a CORE define, or None when absent.
Values are codegen expressions (IntLiteral); they are compared rendered.
A value-less define (e.g. USE_BK72XX_BLE) is present but renders as the
string "None", while an absent define returns the None object — easy to
conflate in assertions, so use this helper for valued defines only.
"""
from esphome.core import CORE
for define in CORE.defines:
if define.name == name:
return str(define.value)
return None
+51
View File
@@ -4,6 +4,7 @@ from unittest.mock import Mock
import pytest import pytest
from esphome import const, cpp_helpers as ch from esphome import const, cpp_helpers as ch
from esphome.core import CoroPriority, coroutine_with_priority
from esphome.cpp_helpers import ComponentSourcePool, register_component_source from esphome.cpp_helpers import ComponentSourcePool, register_component_source
@@ -167,3 +168,53 @@ def test_register_component_source_overflow_suppressed_in_testing_mode(
idx = register_component_source("overflow_component") idx = register_component_source("overflow_component")
assert idx == 0 assert idx == 0
assert "Too many unique component source names" not in caplog.text assert "Too many unique component source names" not in caplog.text
def _define_value(name: str) -> str | None:
for define in ch.CORE.defines:
if define.name == name:
# Values are codegen expressions (IntLiteral); compare rendered.
return str(define.value)
return None
def test_slot_counter_emits_requested_count() -> None:
"""Each request bumps the count; the self-scheduled FINAL job emits it."""
request = ch.slot_counter("TEST_SLOT_COUNT")
request()
request()
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT") == "2"
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")
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT_UNUSED") is None
def test_slot_counter_request_from_final_job_still_emits() -> None:
"""The FIRST request for a define may come from a FINAL job: its emit job
is scheduled mid-drain and flush_tasks() loops until the heap is empty.
Later requests do not get this guarantee — see the companion test."""
request = ch.slot_counter("TEST_SLOT_COUNT_LATE")
@coroutine_with_priority(CoroPriority.FINAL)
async def late_requester() -> None:
request()
ch.CORE.add_job(late_requester)
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT_LATE") == "1"
def test_slot_counter_request_after_emit_raises() -> None:
"""The boundary of FINAL-time requests: once the define was emitted, a
further request would silently undersize the storage, so it fails loudly."""
request = ch.slot_counter("TEST_SLOT_COUNT_TOO_LATE")
request()
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT_TOO_LATE") == "1"
with pytest.raises(ValueError, match="TEST_SLOT_COUNT_TOO_LATE"):
request()