mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 18:22:48 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df9b5ea2f | ||
|
|
a8ddb2535e | ||
|
|
d4922fb446 | ||
|
|
7acbe82c27 | ||
|
|
1a0b43449e | ||
|
|
860d819e33 | ||
|
|
f8f25c1ad4 | ||
|
|
22536f9338 | ||
|
|
c64a7fb678 | ||
|
|
6793dad8ed | ||
|
|
3dd9a28ed2 | ||
|
|
5a93de82bf |
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user