Compare commits

...
Author SHA1 Message Date
J. Nick Koston 7df9b5ea2f Merge remote-tracking branch 'origin/dev' into HEAD 2026-10-09 18:27:29 -10:00
J. Nick Koston a8ddb2535e Merge remote-tracking branch 'origin/dev' into number-traits-set-range 2026-10-07 16:45:04 -10:00
J. Nick Koston d4922fb446 Merge remote-tracking branch 'origin/dev' into number-traits-set-range 2026-10-04 18:07:01 -05:00
J. Nick Koston 7acbe82c27 Also guard the NumberRange size so an added field fails the build 2026-10-03 05:47:52 -05:00
J. Nick Koston 1a0b43449e Split the field type guards into one static_assert per field
clang-tidy flags the chained is_same_v checks as a redundant expression.
2026-10-02 21:42:36 -05:00
J. Nick Koston 860d819e33 Guard the NumberRange field types read from flash 2026-10-02 20:55:16 -05:00
J. Nick Koston f8f25c1ad4 Make the render time statement a slotted dataclass 2026-10-02 17:47:47 -05:00
J. Nick Koston 22536f9338 Collapse the range statements into one render time statement 2026-10-02 17:47:06 -05:00
J. Nick Koston c64a7fb678 Declare shared range tables in the global section
The table is a lazy global statement that renders only when the range is
shared, so it no longer depends on the first user's position in setup().
One helper builds the setter expressions for both paths.
2026-10-02 17:36:28 -05:00
J. Nick Koston 6793dad8ed Only use a range table when enough numbers share it
Below three users the table and set_range() cost more than the inline
setters, so the choice is made at render time once every number is
known. Lambda bounds (lvgl arc/bar) keep the setters. set_range reads
the table with aligned loads instead of memcpy_P.
2026-10-02 17:32:24 -05:00
J. Nick Koston 3dd9a28ed2 Emit the range table with progmem_array and a generated ID 2026-10-02 17:14:31 -05:00
J. Nick Koston 5a93de82bf [number] Set range from a shared PROGMEM table
Each number used to emit three inline trait setters in setup(). Codegen
now emits one PROGMEM NumberRange per distinct (min, max, step) and each
number makes a single out of line set_range() call with its address.
2026-10-02 17:10:41 -05:00
6 changed files with 166 additions and 5 deletions
+74 -5
View File
@@ -1,3 +1,6 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from esphome import automation
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
@@ -89,10 +92,13 @@ from esphome.core.entity_helpers import (
setup_entity,
setup_unit_of_measurement,
)
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
from esphome.cpp_generator import MockObj, MockObjClass, ProgmemAssignmentExpression
from esphome.types import ConfigType, SafeExpType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "number"
CONF_RANGE_ID = "range_id"
DEVICE_CLASSES = [
DEVICE_CLASS_ABSOLUTE_HUMIDITY,
DEVICE_CLASS_APPARENT_POWER,
@@ -160,6 +166,7 @@ IS_PLATFORM_COMPONENT = True
number_ns = cg.esphome_ns.namespace("number")
Number = number_ns.class_("Number", cg.EntityBase)
NumberPtr = Number.operator("ptr")
NumberRange = number_ns.struct("NumberRange")
# Triggers
ValueRangeTrigger = number_ns.class_(
@@ -206,6 +213,7 @@ _NUMBER_SCHEMA = (
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTNumberComponent),
cv.GenerateID(CONF_RANGE_ID): cv.declare_id(NumberRange),
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation(
{
@@ -281,13 +289,74 @@ async def _build_number_automations(var, config):
await automation.build_automation(trigger, [(float, "x")], conf)
# A shared table only pays for set_range() and the table itself once this many numbers use it.
RANGE_TABLE_MIN_USERS = 3
@dataclass
class NumberData:
# (min, max, step) -> range_id of every number using it; the first one names the table.
ranges: dict[tuple[float, float, float], list[ID]] = field(default_factory=dict)
def _get_data() -> NumberData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = NumberData()
return CORE.data[DOMAIN]
@dataclass(slots=True)
class _RenderTimeStatement(cg.Statement):
"""Text chosen when main.cpp is written, after every number has been counted."""
render: Callable[[], str]
def __str__(self) -> str:
return self.render()
def _add_range(
var: MockObj,
range_id: ID,
min_value: SafeExpType,
max_value: SafeExpType,
step: SafeExpType,
) -> None:
setters = (
var.traits.set_min_value(min_value),
var.traits.set_max_value(max_value),
var.traits.set_step(step),
)
# Lambda bounds (e.g. lvgl arc/bar) are runtime expressions and can't go in a table.
if not all(isinstance(v, (int, float)) for v in (min_value, max_value, step)):
for expr in setters:
cg.add(expr)
return
key = (float(min_value), float(max_value), float(step))
users = _get_data().ranges.setdefault(key, [])
users.append(range_id)
table = users[0]
def render_table() -> str:
if len(users) < RANGE_TABLE_MIN_USERS:
return ""
return f"{ProgmemAssignmentExpression(NumberRange, table, cg.safe_exp([key]))};"
def render_call() -> str:
if len(users) < RANGE_TABLE_MIN_USERS:
return "\n".join(str(cg.statement(expr)) for expr in setters)
return str(cg.statement(var.set_range(MockObj(table, "."))))
if len(users) == 1:
cg.add_global(_RenderTimeStatement(render_table))
cg.add(_RenderTimeStatement(render_call))
@setup_entity("number")
async def setup_number_core_(
var, config, *, min_value: float, max_value: float, step: float
):
cg.add(var.traits.set_min_value(min_value))
cg.add(var.traits.set_max_value(max_value))
cg.add(var.traits.set_step(step))
_add_range(var, config[CONF_RANGE_ID], min_value, max_value, step)
# Skip the setter when the config matches the C++ initializer (DEFAULT_MODE).
# The validated value is the enum key string, not the C++ enum expression.
+7
View File
@@ -19,6 +19,13 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o
LOG_ENTITY_DEVICE_CLASS(tag, prefix, *obj);
}
void Number::set_range(const NumberRange *range) {
// Aligned 32-bit loads are safe from PROGMEM on ESP8266 (same as pgm_read_float_aligned).
this->traits.set_min_value(range->min_value);
this->traits.set_max_value(range->max_value);
this->traits.set_step(range->step);
}
void Number::publish_state(float state) {
this->set_has_state(true);
this->state = state;
+3
View File
@@ -40,6 +40,9 @@ class Number : public EntityBase {
NumberTraits traits;
// Out of line so each generated call site loads one pointer instead of three floats.
void set_range(const NumberRange *range);
protected:
friend class NumberCall;
+13
View File
@@ -2,6 +2,7 @@
#include <cmath>
#include <cstdint>
#include <type_traits>
namespace esphome::number {
@@ -11,6 +12,18 @@ enum NumberMode : uint8_t {
NUMBER_MODE_SLIDER = 2,
};
/// Boundaries and step for a number; codegen shares one PROGMEM table between numbers with the same values.
struct NumberRange {
float min_value;
float max_value;
float step;
};
// Read straight from flash on ESP8266, so every field must stay a word.
static_assert(std::is_same_v<decltype(NumberRange::min_value), float>, "NumberRange fields must stay float");
static_assert(std::is_same_v<decltype(NumberRange::max_value), float>, "NumberRange fields must stay float");
static_assert(std::is_same_v<decltype(NumberRange::step), float>, "NumberRange fields must stay float");
static_assert(sizeof(NumberRange) == 3 * sizeof(float), "NumberRange is read from flash with word loads");
class NumberTraits {
public:
// Set/get the number value boundaries.
@@ -0,0 +1,32 @@
---
esphome:
name: test
esp32:
board: esp32dev
number:
- platform: template
id: shared_1
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: shared_2
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: shared_3
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: single
min_value: -5
max_value: 5
step: 0.5
optimistic: true
@@ -3,6 +3,10 @@
from collections.abc import Callable
from pathlib import Path
from esphome.components import number
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj, RawExpression
def test_default_mode_is_not_emitted(
generate_main: Callable[[str | Path], str],
@@ -14,3 +18,36 @@ def test_default_mode_is_not_emitted(
assert "auto_number->traits.set_mode(" not in main_cpp
assert "explicit_auto_number->traits.set_mode(" not in main_cpp
assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp
def test_range_table_only_for_shared_ranges(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A range used by enough numbers gets one PROGMEM table; others keep the setters."""
main_cpp = generate_main(component_config_path("range.yaml"))
assert (
"static constexpr number::NumberRange number_numberrange_id[] PROGMEM = "
"{{0.0f, 10.0f, 1.0f}};" in main_cpp
)
assert main_cpp.count("number::NumberRange ") == 1
for var in ("shared_1", "shared_2", "shared_3"):
assert f"{var}->set_range(number_numberrange_id);" in main_cpp
assert "single->set_range(" not in main_cpp
assert "single->traits.set_min_value(-5.0f);" in main_cpp
assert "single->traits.set_max_value(5.0f);" in main_cpp
assert "single->traits.set_step(0.5f);" in main_cpp
def test_range_with_lambda_bounds_uses_setters() -> None:
"""Runtime bounds (lvgl arc/bar lambdas) can't go in a PROGMEM table."""
var = MockObj("lambda_number", "->")
range_id = ID("lambda_range", is_declaration=True, type=number.NumberRange)
number._add_range(var, range_id, RawExpression("get_min()"), 10, 1)
main_cpp = CORE.cpp_main_section
assert "lambda_number->traits.set_min_value(get_min());" in main_cpp
assert "lambda_number->traits.set_max_value(10);" in main_cpp
assert "lambda_number->traits.set_step(1);" in main_cpp
assert "set_range(" not in main_cpp