[core] Pack entity flags into configure_entity_() and protect setters (#14564)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
J. Nick Koston
2026-03-08 15:11:15 -10:00
committed by GitHub
parent 9547a54fac
commit d0285cdc41
10 changed files with 354 additions and 74 deletions
+8 -6
View File
@@ -11,7 +11,7 @@ static const char *const TAG = "entity_base";
// Entity Name
const StringRef &EntityBase::get_name() const { return this->name_; }
void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed) {
void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) {
this->name_ = StringRef(name);
if (this->name_.empty()) {
#ifdef USE_DEVICES
@@ -44,17 +44,19 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui
this->calc_object_id_();
}
}
// Unpack entity string table indices.
// Packed: [23..16] icon | [15..8] UoM | [7..0] device_class (each 8 bits)
// Unpack entity string table indices and flags from entity_fields.
#ifdef USE_ENTITY_DEVICE_CLASS
this->device_class_idx_ = entity_strings_packed & 0xFF;
this->device_class_idx_ = (entity_fields >> ENTITY_FIELD_DC_SHIFT) & 0xFF;
#endif
#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT
this->uom_idx_ = (entity_strings_packed >> 8) & 0xFF;
this->uom_idx_ = (entity_fields >> ENTITY_FIELD_UOM_SHIFT) & 0xFF;
#endif
#ifdef USE_ENTITY_ICON
this->icon_idx_ = (entity_strings_packed >> 16) & 0xFF;
this->icon_idx_ = (entity_fields >> ENTITY_FIELD_ICON_SHIFT) & 0xFF;
#endif
this->flags_.internal = (entity_fields >> ENTITY_FIELD_INTERNAL_SHIFT) & 1;
this->flags_.disabled_by_default = (entity_fields >> ENTITY_FIELD_DISABLED_BY_DEFAULT_SHIFT) & 1;
this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3;
}
// Weak default lookup functions — overridden by generated code in main.cpp
+20 -11
View File
@@ -55,6 +55,15 @@ enum EntityCategory : uint8_t {
ENTITY_CATEGORY_DIAGNOSTIC = 2,
};
// Bit layout for entity_fields parameter in configure_entity_().
// Keep in sync with _*_SHIFT constants in esphome/core/entity_helpers.py
static constexpr uint8_t ENTITY_FIELD_DC_SHIFT = 0;
static constexpr uint8_t ENTITY_FIELD_UOM_SHIFT = 8;
static constexpr uint8_t ENTITY_FIELD_ICON_SHIFT = 16;
static constexpr uint8_t ENTITY_FIELD_INTERNAL_SHIFT = 24;
static constexpr uint8_t ENTITY_FIELD_DISABLED_BY_DEFAULT_SHIFT = 25;
static constexpr uint8_t ENTITY_FIELD_ENTITY_CATEGORY_SHIFT = 26;
// The generic Entity base class that provides an interface common to all Entities.
class EntityBase {
public:
@@ -88,21 +97,16 @@ class EntityBase {
/// Useful for building compound strings without intermediate buffer
size_t write_object_id_to(char *buf, size_t buf_size) const;
// Get/set whether this Entity should be hidden outside ESPHome
// Get whether this Entity should be hidden outside ESPHome
bool is_internal() const { return this->flags_.internal; }
void set_internal(bool internal) { this->flags_.internal = internal; }
// Check if this object is declared to be disabled by default.
// That means that when the device gets added to Home Assistant (or other clients) it should
// not be added to the default view by default, and a user action is necessary to manually add it.
bool is_disabled_by_default() const { return this->flags_.disabled_by_default; }
void set_disabled_by_default(bool disabled_by_default) { this->flags_.disabled_by_default = disabled_by_default; }
// Get/set the entity category.
// Get the entity category.
EntityCategory get_entity_category() const { return static_cast<EntityCategory>(this->flags_.entity_category); }
void set_entity_category(EntityCategory entity_category) {
this->flags_.entity_category = static_cast<uint8_t>(entity_category);
}
// Get this entity's device class into a stack buffer.
// On non-ESP8266: returns pointer to PROGMEM string directly (buffer unused).
@@ -164,14 +168,13 @@ class EntityBase {
#endif
#ifdef USE_DEVICES
// Get/set this entity's device id
// Get this entity's device id
uint32_t get_device_id() const {
if (this->device_ == nullptr) {
return 0; // No device set, return 0
}
return this->device_->get_device_id();
}
void set_device(Device *device) { this->device_ = device; }
// Get the device this entity belongs to (nullptr if main device)
Device *get_device() const { return this->device_; }
#endif
@@ -228,8 +231,14 @@ class EntityBase {
friend void ::setup();
friend void ::original_setup();
/// Combined entity setup from codegen: set name, object_id hash, and entity string indices.
void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed);
/// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags.
/// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above.
void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields);
#ifdef USE_DEVICES
// Codegen-only setter — only accessible from setup() via friend declaration.
void set_device_(Device *device) { this->device_ = device; }
#endif
/// Non-template helper for make_entity_preference() to avoid code bloat.
/// When preference hash algorithm changes, migration logic goes here.
+73 -11
View File
@@ -34,11 +34,19 @@ _KEY_ICON_IDX = "_entity_icon_idx"
_KEY_ENTITY_NAME = "_entity_name"
_KEY_OBJECT_ID_HASH = "_entity_object_id_hash"
# Bit layout for entity_strings_packed in configure_entity_() — must match C++ in entity_base.h:
# [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits)
# Bit layout for entity_fields in configure_entity_().
# Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h
_DC_SHIFT = 0
_UOM_SHIFT = 8
_ICON_SHIFT = 16
_INTERNAL_SHIFT = 24
_DISABLED_BY_DEFAULT_SHIFT = 25
_ENTITY_CATEGORY_SHIFT = 26
# Private config keys for storing flags
_KEY_INTERNAL = "_entity_internal"
_KEY_DISABLED_BY_DEFAULT = "_entity_disabled_by_default"
_KEY_ENTITY_CATEGORY = "_entity_category"
# Maximum unique strings per category (8-bit index, 0 = not set)
_MAX_DEVICE_CLASSES = 0xFF # 255
@@ -220,8 +228,39 @@ def setup_unit_of_measurement(config: ConfigType) -> None:
config[_KEY_UOM_IDX] = idx
def _sanitize_comment(text: str) -> str:
r"""Sanitize a string for safe inclusion in a C++ // line comment.
Dangerous characters:
- \n, \r: break out of line comment, next line becomes code
- \: at end of line, splices next line into comment (eats real code)
"""
return text.replace("\\", "/").replace("\n", " ").replace("\r", "")
def _describe_packed_flags(config: ConfigType, entity_category: int) -> str:
"""Build a human-readable description of packed entity flags for C++ comments."""
parts: list[str] = []
if config.get(_KEY_INTERNAL):
parts.append("internal")
if config.get(_KEY_DISABLED_BY_DEFAULT):
parts.append("disabled_by_default")
entity_cat_keys = list(cv.ENTITY_CATEGORIES)
if entity_category < len(entity_cat_keys) and (
cat_name := entity_cat_keys[entity_category]
):
parts.append(f"category:{cat_name}")
if config.get(_KEY_DC_IDX) and (dc := config.get(CONF_DEVICE_CLASS)):
parts.append(f"dc:{_sanitize_comment(dc)}")
if config.get(_KEY_UOM_IDX) and (uom := config.get(CONF_UNIT_OF_MEASUREMENT)):
parts.append(f"uom:{_sanitize_comment(uom)}")
if config.get(_KEY_ICON_IDX) and (icon := config.get(CONF_ICON)):
parts.append(f"icon:{_sanitize_comment(icon)}")
return ", ".join(parts)
def finalize_entity_strings(var: MockObj, config: ConfigType) -> None:
"""Emit a single configure_entity_() call with name, hash, and packed string indices.
"""Emit a single configure_entity_() call with name, hash, packed string indices, and flags.
Call this at the end of each component's setup function, after
setup_entity() and any register_device_class/register_unit_of_measurement calls.
@@ -231,8 +270,24 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None:
dc_idx = config.get(_KEY_DC_IDX, 0)
uom_idx = config.get(_KEY_UOM_IDX, 0)
icon_idx = config.get(_KEY_ICON_IDX, 0)
packed = (dc_idx << _DC_SHIFT) | (uom_idx << _UOM_SHIFT) | (icon_idx << _ICON_SHIFT)
add(var.configure_entity_(entity_name, object_id_hash, packed))
internal = config.get(_KEY_INTERNAL, 0)
disabled_by_default = config.get(_KEY_DISABLED_BY_DEFAULT, 0)
entity_category = config.get(_KEY_ENTITY_CATEGORY, 0)
packed = (
(dc_idx << _DC_SHIFT)
| (uom_idx << _UOM_SHIFT)
| (icon_idx << _ICON_SHIFT)
| (internal << _INTERNAL_SHIFT)
| (disabled_by_default << _DISABLED_BY_DEFAULT_SHIFT)
| (entity_category << _ENTITY_CATEGORY_SHIFT)
)
# Build inline comment describing the packed flags for readability
comment = _describe_packed_flags(config, entity_category)
expr = var.configure_entity_(entity_name, object_id_hash, packed)
if comment:
add(RawStatement(f"{expr}; // {comment}"))
else:
add(expr)
def get_base_entity_object_id(
@@ -332,7 +387,7 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) ->
# Get device info if configured
if device_id_obj := config.get(CONF_DEVICE_ID):
device: MockObj = await get_variable(device_id_obj)
add(var.set_device(device))
add(var.set_device_(device))
# Pre-compute entity name and object_id hash for configure_entity_()
# which is emitted later by finalize_entity_strings().
@@ -343,18 +398,25 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) ->
object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0
config[_KEY_ENTITY_NAME] = entity_name
config[_KEY_OBJECT_ID_HASH] = object_id_hash
# Only set disabled_by_default if True (default is False)
if config[CONF_DISABLED_BY_DEFAULT]:
add(var.set_disabled_by_default(True))
# Store flags for packing into configure_entity_()
config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT])
if CONF_INTERNAL in config:
add(var.set_internal(config[CONF_INTERNAL]))
config[_KEY_INTERNAL] = int(config[CONF_INTERNAL])
icon_idx = 0
if CONF_ICON in config:
# Add USE_ENTITY_ICON define when icons are used
cg.add_define("USE_ENTITY_ICON")
icon_idx = register_icon(config[CONF_ICON])
if CONF_ENTITY_CATEGORY in config:
add(var.set_entity_category(config[CONF_ENTITY_CATEGORY]))
# Derive integer value from key position in cv.ENTITY_CATEGORIES
# (must match C++ EntityCategory enum in entity_base.h)
entity_cat_str = str(config[CONF_ENTITY_CATEGORY])
entity_cat_keys = list(cv.ENTITY_CATEGORIES)
config[_KEY_ENTITY_CATEGORY] = (
entity_cat_keys.index(entity_cat_str)
if entity_cat_str in entity_cat_keys
else 0
)
# Store icon index for finalize_entity_strings
config[_KEY_ICON_IDX] = icon_idx
@@ -1,5 +1,7 @@
"""Tests for the binary sensor component."""
from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value
def test_binary_sensor_is_setup(generate_main):
"""
@@ -44,9 +46,9 @@ def test_binary_sensor_config_value_internal_set(generate_main):
"tests/component_tests/binary_sensor/test_binary_sensor.yaml"
)
# Then
assert "bs_1->set_internal(true);" in main_cpp
assert "bs_2->set_internal(false);" in main_cpp
# Then: bs_1 has internal: true, bs_2 has internal: false
assert extract_packed_value(main_cpp, "bs_1") & INTERNAL_BIT != 0
assert extract_packed_value(main_cpp, "bs_2") & INTERNAL_BIT == 0
def test_binary_sensor_config_value_use_raw_set(generate_main):
+5 -3
View File
@@ -1,5 +1,7 @@
"""Tests for the button component"""
from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value
def test_button_is_setup(generate_main):
"""
@@ -39,6 +41,6 @@ def test_button_config_value_internal_set(generate_main):
# When
main_cpp = generate_main("tests/component_tests/button/test_button.yaml")
# Then
assert "wol_1->set_internal(true);" in main_cpp
assert "wol_2->set_internal(false);" in main_cpp
# Then: wol_1 has internal: true, wol_2 has internal: false
assert extract_packed_value(main_cpp, "wol_1") & INTERNAL_BIT != 0
assert extract_packed_value(main_cpp, "wol_2") & INTERNAL_BIT == 0
+19
View File
@@ -0,0 +1,19 @@
"""Shared helpers for component tests."""
from __future__ import annotations
import re
INTERNAL_BIT = 1 << 24
def extract_packed_value(main_cpp: str, var_name: str) -> int:
"""Extract the third (packed) argument from a configure_entity_ call."""
pattern = (
rf"{re.escape(var_name)}->configure_entity_\("
r'"(?:\\.|[^"\\])*"'
r",\s*\w+,\s*(\d+)\)"
)
match = re.search(pattern, main_cpp)
assert match, f"configure_entity_ call not found for {var_name}"
return int(match.group(1))
+2 -10
View File
@@ -1,14 +1,6 @@
"""Tests for the sensor component."""
import re
def _extract_packed_value(main_cpp, var_name):
"""Extract the third (packed) argument from a configure_entity_ call."""
pattern = rf"{re.escape(var_name)}->configure_entity_\([^,]+,\s*\w+,\s*(\d+)\)"
match = re.search(pattern, main_cpp)
assert match, f"configure_entity_ call not found for {var_name}"
return int(match.group(1))
from tests.component_tests.helpers import extract_packed_value
def test_sensor_device_class_set(generate_main):
@@ -21,5 +13,5 @@ def test_sensor_device_class_set(generate_main):
main_cpp = generate_main("tests/component_tests/sensor/test_sensor.yaml")
# Then: device_class: voltage means packed value must be non-zero
packed = _extract_packed_value(main_cpp, "s_1")
packed = extract_packed_value(main_cpp, "s_1")
assert packed != 0
+6 -4
View File
@@ -1,4 +1,6 @@
"""Tests for the binary sensor component."""
"""Tests for the text component."""
from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value
def test_text_is_setup(generate_main):
@@ -37,9 +39,9 @@ def test_text_config_value_internal_set(generate_main):
# When
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
# Then
assert "it_2->set_internal(false);" in main_cpp
assert "it_3->set_internal(true);" in main_cpp
# Then: it_2 has internal: false, it_3 has internal: true
assert extract_packed_value(main_cpp, "it_2") & INTERNAL_BIT == 0
assert extract_packed_value(main_cpp, "it_3") & INTERNAL_BIT != 0
def test_text_config_value_mode_set(generate_main):
@@ -1,14 +1,6 @@
"""Tests for the text sensor component."""
import re
def _extract_packed_value(main_cpp, var_name):
"""Extract the third (packed) argument from a configure_entity_ call."""
pattern = rf"{re.escape(var_name)}->configure_entity_\([^,]+,\s*\w+,\s*(\d+)\)"
match = re.search(pattern, main_cpp)
assert match, f"configure_entity_ call not found for {var_name}"
return int(match.group(1))
from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value
def test_text_sensor_is_setup(generate_main):
@@ -49,9 +41,9 @@ def test_text_sensor_config_value_internal_set(generate_main):
# When
main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml")
# Then
assert "ts_2->set_internal(true);" in main_cpp
assert "ts_3->set_internal(false);" in main_cpp
# Then: ts_2 has internal: true, ts_3 has internal: false
assert extract_packed_value(main_cpp, "ts_2") & INTERNAL_BIT != 0
assert extract_packed_value(main_cpp, "ts_3") & INTERNAL_BIT == 0
def test_text_sensor_device_class_set(generate_main):
@@ -65,7 +57,7 @@ def test_text_sensor_device_class_set(generate_main):
# Then: ts_2 has device_class: timestamp, ts_3 has device_class: date
# so their packed values must be non-zero
packed_ts_2 = _extract_packed_value(main_cpp, "ts_2")
packed_ts_2 = extract_packed_value(main_cpp, "ts_2")
assert packed_ts_2 != 0
packed_ts_3 = _extract_packed_value(main_cpp, "ts_3")
packed_ts_3 = extract_packed_value(main_cpp, "ts_3")
assert packed_ts_3 != 0
+210 -12
View File
@@ -9,6 +9,7 @@ import pytest
from esphome.config_validation import Invalid
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_DEVICE_ID,
CONF_DISABLED_BY_DEFAULT,
CONF_ENTITY_CATEGORY,
@@ -16,16 +17,20 @@ from esphome.const import (
CONF_ID,
CONF_INTERNAL,
CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
)
from esphome.core import CORE, ID, entity_helpers
from esphome.core.entity_helpers import (
_register_string,
_setup_entity_impl,
entity_duplicate_validator,
finalize_entity_strings,
get_base_entity_object_id,
register_device_class,
register_icon,
setup_device_class,
setup_entity,
setup_unit_of_measurement,
)
from esphome.cpp_generator import MockObj
from esphome.helpers import sanitize, snake_case
@@ -486,8 +491,6 @@ async def test_setup_entity_disabled_by_default(
) -> None:
"""Test setup_entity sets disabled_by_default correctly."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
@@ -497,10 +500,8 @@ async def test_setup_entity_disabled_by_default(
await _setup_entity_impl(var, config, "sensor")
# Check disabled_by_default was set
assert any(
"sensor1.set_disabled_by_default(true)" in expr for expr in added_expressions
)
# disabled_by_default is now packed into config for configure_entity_()
assert config.get("_entity_disabled_by_default") == 1
def test_entity_duplicate_validator() -> None:
@@ -785,8 +786,8 @@ async def test_setup_entity_empty_name_with_device(
entity_helpers.get_variable = original_get_variable
# Check that set_device was called
assert any("sensor1.set_device" in expr for expr in added_expressions)
# Check that set_device_ was called (separate protected call, accessible via friend)
assert any("sensor1.set_device_" in expr for expr in added_expressions)
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
@@ -928,7 +929,7 @@ def test_register_device_class_max_length() -> None:
async def test_setup_entity_with_entity_category(
setup_test_environment: list[str],
) -> None:
"""Test setup_entity sets entity_category correctly."""
"""Test entity_category is packed correctly through the full setup flow."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
@@ -937,9 +938,10 @@ async def test_setup_entity_with_entity_category(
CONF_ENTITY_CATEGORY: "diagnostic",
}
await _setup_entity_impl(var, config, "sensor")
assert any(
'set_entity_category("diagnostic")' in expr for expr in added_expressions
)
finalize_entity_strings(var, config)
packed = _extract_packed_value(added_expressions)
assert packed != 0
assert "category:diagnostic" in added_expressions[0]
@pytest.mark.asyncio
@@ -988,3 +990,199 @@ async def test_setup_entity_decorator_mode(setup_test_environment: list[str]) ->
assert body_called
object_id = extract_object_id_from_expressions(added_expressions)
assert object_id == "temperature"
# Tests for finalize_entity_strings packing
#
# These tests verify that flags and string indices produce non-zero packed values
# and correct inline comments. The actual bit layout correctness (Python _*_SHIFT
# matching C++ ENTITY_FIELD_*_SHIFT) is verified end-to-end by the integration
# test test_host_mode_entity_fields, which compiles firmware and checks values
# via the native API.
def _extract_packed_value(expressions: list[str]) -> int:
"""Extract the third argument (packed value) from a configure_entity_() call."""
for expr in expressions:
if "configure_entity_" in expr:
# Match the last integer argument before the closing ");"
match = re.search(r",\s*(\d+)\s*\)", expr)
if match:
return int(match.group(1))
raise AssertionError("No configure_entity_ call found")
@pytest.mark.asyncio
async def test_finalize_no_flags(setup_test_environment: list[str]) -> None:
"""Test entity with no special flags — packed value is 0, no comment."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "Test",
CONF_DISABLED_BY_DEFAULT: False,
}
await _setup_entity_impl(var, config, "sensor")
finalize_entity_strings(var, config)
packed = _extract_packed_value(added_expressions)
assert packed == 0
assert "//" not in added_expressions[0]
@pytest.mark.asyncio
async def test_finalize_internal(setup_test_environment: list[str]) -> None:
"""Test entity with internal=True packs the internal flag."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "Test",
CONF_DISABLED_BY_DEFAULT: False,
CONF_INTERNAL: True,
}
await _setup_entity_impl(var, config, "sensor")
finalize_entity_strings(var, config)
packed = _extract_packed_value(added_expressions)
assert packed != 0
assert "// internal" in added_expressions[0]
@pytest.mark.asyncio
async def test_finalize_disabled_by_default(
setup_test_environment: list[str],
) -> None:
"""Test entity with disabled_by_default=True packs the flag."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "Test",
CONF_DISABLED_BY_DEFAULT: True,
}
await _setup_entity_impl(var, config, "sensor")
finalize_entity_strings(var, config)
packed = _extract_packed_value(added_expressions)
assert packed != 0
assert "// disabled_by_default" in added_expressions[0]
@pytest.mark.asyncio
async def test_finalize_entity_category(
setup_test_environment: list[str],
) -> None:
"""Test entity_category values are packed and described in comment."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
# Test diagnostic
config = {
CONF_NAME: "Test",
CONF_DISABLED_BY_DEFAULT: False,
CONF_ENTITY_CATEGORY: "diagnostic",
}
await _setup_entity_impl(var, config, "sensor")
finalize_entity_strings(var, config)
packed_diag = _extract_packed_value(added_expressions)
assert packed_diag != 0
assert "category:diagnostic" in added_expressions[0]
# Test config — different packed value
added_expressions.clear()
config2 = {
CONF_NAME: "Test2",
CONF_DISABLED_BY_DEFAULT: False,
CONF_ENTITY_CATEGORY: "config",
}
await _setup_entity_impl(var, config2, "sensor")
finalize_entity_strings(var, config2)
packed_cfg = _extract_packed_value(added_expressions)
assert packed_cfg != 0
assert packed_cfg != packed_diag
assert "category:config" in added_expressions[0]
@pytest.mark.asyncio
async def test_finalize_string_indices(
setup_test_environment: list[str],
) -> None:
"""Test device_class, unit_of_measurement, and icon produce non-zero packed value."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "Test",
CONF_DISABLED_BY_DEFAULT: False,
CONF_DEVICE_CLASS: "temperature",
CONF_UNIT_OF_MEASUREMENT: "°C",
CONF_ICON: "mdi:thermometer",
}
await _setup_entity_impl(var, config, "sensor")
setup_device_class(config)
setup_unit_of_measurement(config)
finalize_entity_strings(var, config)
packed = _extract_packed_value(added_expressions)
assert packed != 0
comment = added_expressions[0]
assert "dc:temperature" in comment
assert "uom:°C" in comment
assert "icon:mdi:thermometer" in comment
@pytest.mark.asyncio
async def test_finalize_all_fields(
setup_test_environment: list[str],
) -> None:
"""Test all fields set: flags, string indices, and comment."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "Test",
CONF_DISABLED_BY_DEFAULT: True,
CONF_INTERNAL: True,
CONF_ENTITY_CATEGORY: "diagnostic",
CONF_DEVICE_CLASS: "temperature",
CONF_UNIT_OF_MEASUREMENT: "°C",
CONF_ICON: "mdi:thermometer",
}
await _setup_entity_impl(var, config, "sensor")
setup_device_class(config)
setup_unit_of_measurement(config)
finalize_entity_strings(var, config)
packed = _extract_packed_value(added_expressions)
assert packed != 0
# Verify comment contains all flags with actual string values
comment_line = added_expressions[0]
assert (
"// internal, disabled_by_default, category:diagnostic,"
" dc:temperature, uom:°C, icon:mdi:thermometer" in comment_line
)
@pytest.mark.asyncio
async def test_finalize_comment_sanitization(
setup_test_environment: list[str],
) -> None:
"""Test that user strings in comments are sanitized against injection."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "Test",
CONF_DISABLED_BY_DEFAULT: False,
# Backslash at end would cause line splice eating next code line
CONF_ICON: "mdi:evil\\",
}
await _setup_entity_impl(var, config, "sensor")
finalize_entity_strings(var, config)
comment_line = added_expressions[0]
# Backslash must be replaced to prevent line splice
assert "\\" not in comment_line
assert "mdi:evil/" in comment_line
added_expressions.clear()
config2 = {
CONF_NAME: "Test2",
CONF_DISABLED_BY_DEFAULT: False,
CONF_ICON: "mdi:evil\nINJECTED_CODE();",
}
await _setup_entity_impl(var, config2, "sensor")
finalize_entity_strings(var, config2)
comment_line = added_expressions[0]
# Newline must be replaced to prevent breaking out of comment
assert "\n" not in comment_line
assert "INJECTED_CODE" in comment_line # still visible but safe in comment