mirror of
https://github.com/esphome/esphome.git
synced 2025-08-10 12:27:46 +00:00
Merge branch 'multi_device' into integration
This commit is contained in:
@@ -323,6 +323,7 @@ esphome/components/one_wire/* @ssieb
|
||||
esphome/components/online_image/* @clydebarrow @guillempages
|
||||
esphome/components/opentherm/* @olegtarasov
|
||||
esphome/components/openthread/* @mrene
|
||||
esphome/components/opt3001/* @ccutrer
|
||||
esphome/components/ota/* @esphome/core
|
||||
esphome/components/output/* @esphome/core
|
||||
esphome/components/packet_transport/* @clydebarrow
|
||||
|
@@ -190,7 +190,7 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id(
|
||||
|
||||
|
||||
async def setup_alarm_control_panel_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "alarm_control_panel")
|
||||
for conf in config.get(CONF_ON_STATE, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
@@ -521,7 +521,7 @@ BINARY_SENSOR_SCHEMA.add_extra(cv.deprecated_schema_constant("binary_sensor"))
|
||||
|
||||
|
||||
async def setup_binary_sensor_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "binary_sensor")
|
||||
|
||||
if (device_class := config.get(CONF_DEVICE_CLASS)) is not None:
|
||||
cg.add(var.set_device_class(device_class))
|
||||
|
@@ -87,7 +87,7 @@ BUTTON_SCHEMA.add_extra(cv.deprecated_schema_constant("button"))
|
||||
|
||||
|
||||
async def setup_button_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "button")
|
||||
|
||||
for conf in config.get(CONF_ON_PRESS, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
|
@@ -273,7 +273,7 @@ CLIMATE_SCHEMA.add_extra(cv.deprecated_schema_constant("climate"))
|
||||
|
||||
|
||||
async def setup_climate_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "climate")
|
||||
|
||||
visual = config[CONF_VISUAL]
|
||||
if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None:
|
||||
|
@@ -154,7 +154,7 @@ COVER_SCHEMA.add_extra(cv.deprecated_schema_constant("cover"))
|
||||
|
||||
|
||||
async def setup_cover_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "cover")
|
||||
|
||||
if (device_class := config.get(CONF_DEVICE_CLASS)) is not None:
|
||||
cg.add(var.set_device_class(device_class))
|
||||
|
@@ -133,7 +133,7 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema:
|
||||
|
||||
|
||||
async def setup_datetime_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "datetime")
|
||||
|
||||
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
|
@@ -284,7 +284,7 @@ SETTERS = {
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "camera")
|
||||
await cg.register_component(var, config)
|
||||
|
||||
for key, setter in SETTERS.items():
|
||||
|
@@ -88,7 +88,7 @@ EVENT_SCHEMA.add_extra(cv.deprecated_schema_constant("event"))
|
||||
|
||||
|
||||
async def setup_event_core_(var, config, *, event_types: list[str]):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "event")
|
||||
|
||||
for conf in config.get(CONF_ON_EVENT, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
|
@@ -225,7 +225,7 @@ def validate_preset_modes(value):
|
||||
|
||||
|
||||
async def setup_fan_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "fan")
|
||||
|
||||
cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE]))
|
||||
|
||||
|
@@ -207,7 +207,7 @@ def validate_color_temperature_channels(value):
|
||||
|
||||
|
||||
async def setup_light_core_(light_var, output_var, config):
|
||||
await setup_entity(light_var, config)
|
||||
await setup_entity(light_var, config, "light")
|
||||
|
||||
cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE]))
|
||||
|
||||
|
@@ -94,7 +94,7 @@ LOCK_SCHEMA.add_extra(cv.deprecated_schema_constant("lock"))
|
||||
|
||||
|
||||
async def _setup_lock_core(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "lock")
|
||||
|
||||
for conf in config.get(CONF_ON_LOCK, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
|
@@ -81,7 +81,7 @@ IsAnnouncingCondition = media_player_ns.class_(
|
||||
|
||||
|
||||
async def setup_media_player_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "media_player")
|
||||
for conf in config.get(CONF_ON_STATE, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
@@ -237,7 +237,7 @@ NUMBER_SCHEMA.add_extra(cv.deprecated_schema_constant("number"))
|
||||
async def setup_number_core_(
|
||||
var, config, *, min_value: float, max_value: float, step: float
|
||||
):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "number")
|
||||
|
||||
cg.add(var.traits.set_min_value(min_value))
|
||||
cg.add(var.traits.set_max_value(max_value))
|
||||
|
@@ -34,6 +34,7 @@ MULTI_CONF = True
|
||||
|
||||
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
|
||||
CONF_PLACEHOLDER = "placeholder"
|
||||
CONF_UPDATE = "update"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -167,6 +168,7 @@ SET_URL_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(OnlineImage),
|
||||
cv.Required(CONF_URL): cv.templatable(cv.url),
|
||||
cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -188,6 +190,9 @@ async def online_image_action_to_code(config, action_id, template_arg, args):
|
||||
if CONF_URL in config:
|
||||
template_ = await cg.templatable(config[CONF_URL], args, cg.std_string)
|
||||
cg.add(var.set_url(template_))
|
||||
if CONF_UPDATE in config:
|
||||
template_ = await cg.templatable(config[CONF_UPDATE], args, bool)
|
||||
cg.add(var.set_update(template_))
|
||||
return var
|
||||
|
||||
|
||||
|
@@ -201,9 +201,12 @@ template<typename... Ts> class OnlineImageSetUrlAction : public Action<Ts...> {
|
||||
public:
|
||||
OnlineImageSetUrlAction(OnlineImage *parent) : parent_(parent) {}
|
||||
TEMPLATABLE_VALUE(std::string, url)
|
||||
TEMPLATABLE_VALUE(bool, update)
|
||||
void play(Ts... x) override {
|
||||
this->parent_->set_url(this->url_.value(x...));
|
||||
this->parent_->update();
|
||||
if (this->update_.value(x...)) {
|
||||
this->parent_->update();
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
|
0
esphome/components/opt3001/__init__.py
Normal file
0
esphome/components/opt3001/__init__.py
Normal file
122
esphome/components/opt3001/opt3001.cpp
Normal file
122
esphome/components/opt3001/opt3001.cpp
Normal file
@@ -0,0 +1,122 @@
|
||||
#include "opt3001.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace opt3001 {
|
||||
|
||||
static const char *const TAG = "opt3001.sensor";
|
||||
|
||||
static const uint8_t OPT3001_REG_RESULT = 0x00;
|
||||
static const uint8_t OPT3001_REG_CONFIGURATION = 0x01;
|
||||
// See datasheet for full description of each bit.
|
||||
static const uint16_t OPT3001_CONFIGURATION_RANGE_FULL = 0b1100000000000000;
|
||||
static const uint16_t OPT3001_CONFIGURATION_CONVERSION_TIME_800 = 0b100000000000;
|
||||
static const uint16_t OPT3001_CONFIGURATION_CONVERSION_MODE_MASK = 0b11000000000;
|
||||
static const uint16_t OPT3001_CONFIGURATION_CONVERSION_MODE_SINGLE_SHOT = 0b01000000000;
|
||||
static const uint16_t OPT3001_CONFIGURATION_CONVERSION_MODE_SHUTDOWN = 0b00000000000;
|
||||
// tl;dr: Configure an automatic-ranged, 800ms single shot reading,
|
||||
// with INT processing disabled
|
||||
static const uint16_t OPT3001_CONFIGURATION_FULL_RANGE_ONE_SHOT = OPT3001_CONFIGURATION_RANGE_FULL |
|
||||
OPT3001_CONFIGURATION_CONVERSION_TIME_800 |
|
||||
OPT3001_CONFIGURATION_CONVERSION_MODE_SINGLE_SHOT;
|
||||
static const uint16_t OPT3001_CONVERSION_TIME_800 = 825; // give it 25 extra ms; it seems to not be ready quite often
|
||||
|
||||
/*
|
||||
opt3001 properties:
|
||||
|
||||
- e (exponent) = high 4 bits of result register
|
||||
- m (mantissa) = low 12 bits of result register
|
||||
- formula: (0.01 * 2^e) * m lx
|
||||
|
||||
*/
|
||||
|
||||
void OPT3001Sensor::read_result_(const std::function<void(float)> &f) {
|
||||
// ensure the single shot flag is clear, indicating it's done
|
||||
uint16_t raw_value;
|
||||
if (this->read(reinterpret_cast<uint8_t *>(&raw_value), 2) != i2c::ERROR_OK) {
|
||||
ESP_LOGW(TAG, "Reading configuration register failed");
|
||||
f(NAN);
|
||||
return;
|
||||
}
|
||||
raw_value = i2c::i2ctohs(raw_value);
|
||||
|
||||
if ((raw_value & OPT3001_CONFIGURATION_CONVERSION_MODE_MASK) != OPT3001_CONFIGURATION_CONVERSION_MODE_SHUTDOWN) {
|
||||
// not ready; wait 10ms and try again
|
||||
ESP_LOGW(TAG, "Data not ready; waiting 10ms");
|
||||
this->set_timeout("opt3001_wait", 10, [this, f]() { read_result_(f); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->read_register(OPT3001_REG_RESULT, reinterpret_cast<uint8_t *>(&raw_value), 2) != i2c::ERROR_OK) {
|
||||
ESP_LOGW(TAG, "Reading result register failed");
|
||||
f(NAN);
|
||||
return;
|
||||
}
|
||||
raw_value = i2c::i2ctohs(raw_value);
|
||||
|
||||
uint8_t exponent = raw_value >> 12;
|
||||
uint16_t mantissa = raw_value & 0b111111111111;
|
||||
|
||||
double lx = 0.01 * pow(2.0, double(exponent)) * double(mantissa);
|
||||
f(float(lx));
|
||||
}
|
||||
|
||||
void OPT3001Sensor::read_lx_(const std::function<void(float)> &f) {
|
||||
// turn on (after one-shot sensor automatically powers down)
|
||||
uint16_t start_measurement = i2c::htoi2cs(OPT3001_CONFIGURATION_FULL_RANGE_ONE_SHOT);
|
||||
if (this->write_register(OPT3001_REG_CONFIGURATION, reinterpret_cast<uint8_t *>(&start_measurement), 2) !=
|
||||
i2c::ERROR_OK) {
|
||||
ESP_LOGW(TAG, "Triggering one shot measurement failed");
|
||||
f(NAN);
|
||||
return;
|
||||
}
|
||||
|
||||
this->set_timeout("read", OPT3001_CONVERSION_TIME_800, [this, f]() {
|
||||
if (this->write(&OPT3001_REG_CONFIGURATION, 1, true) != i2c::ERROR_OK) {
|
||||
ESP_LOGW(TAG, "Starting configuration register read failed");
|
||||
f(NAN);
|
||||
return;
|
||||
}
|
||||
|
||||
this->read_result_(f);
|
||||
});
|
||||
}
|
||||
|
||||
void OPT3001Sensor::dump_config() {
|
||||
LOG_SENSOR("", "OPT3001", this);
|
||||
LOG_I2C_DEVICE(this);
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
}
|
||||
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
}
|
||||
|
||||
void OPT3001Sensor::update() {
|
||||
// Set a flag and skip just in case the sensor isn't responding,
|
||||
// and we just keep waiting for it in read_result_.
|
||||
// This way we don't end up with potentially boundless "threads"
|
||||
// using up memory and eventually crashing the device
|
||||
if (this->updating_) {
|
||||
return;
|
||||
}
|
||||
this->updating_ = true;
|
||||
|
||||
this->read_lx_([this](float val) {
|
||||
this->updating_ = false;
|
||||
|
||||
if (std::isnan(val)) {
|
||||
this->status_set_warning();
|
||||
this->publish_state(NAN);
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), val);
|
||||
this->status_clear_warning();
|
||||
this->publish_state(val);
|
||||
});
|
||||
}
|
||||
|
||||
float OPT3001Sensor::get_setup_priority() const { return setup_priority::DATA; }
|
||||
|
||||
} // namespace opt3001
|
||||
} // namespace esphome
|
27
esphome/components/opt3001/opt3001.h
Normal file
27
esphome/components/opt3001/opt3001.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace opt3001 {
|
||||
|
||||
/// This class implements support for the i2c-based OPT3001 ambient light sensor.
|
||||
class OPT3001Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice {
|
||||
public:
|
||||
void dump_config() override;
|
||||
void update() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
protected:
|
||||
// checks if one-shot is complete before reading the result and returning it
|
||||
void read_result_(const std::function<void(float)> &f);
|
||||
// begins a one-shot measurement
|
||||
void read_lx_(const std::function<void(float)> &f);
|
||||
|
||||
bool updating_{false};
|
||||
};
|
||||
|
||||
} // namespace opt3001
|
||||
} // namespace esphome
|
35
esphome/components/opt3001/sensor.py
Normal file
35
esphome/components/opt3001/sensor.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.components import i2c, sensor
|
||||
from esphome.const import (
|
||||
DEVICE_CLASS_ILLUMINANCE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_LUX,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
CODEOWNERS = ["@ccutrer"]
|
||||
|
||||
opt3001_ns = cg.esphome_ns.namespace("opt3001")
|
||||
|
||||
OPT3001Sensor = opt3001_ns.class_(
|
||||
"OPT3001Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
sensor.sensor_schema(
|
||||
OPT3001Sensor,
|
||||
unit_of_measurement=UNIT_LUX,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_ILLUMINANCE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
.extend(i2c.i2c_device_schema(0x44))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
@@ -89,7 +89,7 @@ SELECT_SCHEMA.add_extra(cv.deprecated_schema_constant("select"))
|
||||
|
||||
|
||||
async def setup_select_core_(var, config, *, options: list[str]):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "select")
|
||||
|
||||
cg.add(var.traits.set_options(options))
|
||||
|
||||
|
@@ -787,7 +787,7 @@ async def build_filters(config):
|
||||
|
||||
|
||||
async def setup_sensor_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "sensor")
|
||||
|
||||
if (device_class := config.get(CONF_DEVICE_CLASS)) is not None:
|
||||
cg.add(var.set_device_class(device_class))
|
||||
|
@@ -131,7 +131,7 @@ SWITCH_SCHEMA.add_extra(cv.deprecated_schema_constant("switch"))
|
||||
|
||||
|
||||
async def setup_switch_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "switch")
|
||||
|
||||
if (inverted := config.get(CONF_INVERTED)) is not None:
|
||||
cg.add(var.set_inverted(inverted))
|
||||
|
@@ -94,7 +94,7 @@ async def setup_text_core_(
|
||||
max_length: int | None,
|
||||
pattern: str | None,
|
||||
):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "text")
|
||||
|
||||
cg.add(var.traits.set_min_length(min_length))
|
||||
cg.add(var.traits.set_max_length(max_length))
|
||||
|
@@ -186,7 +186,7 @@ async def build_filters(config):
|
||||
|
||||
|
||||
async def setup_text_sensor_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "text_sensor")
|
||||
|
||||
if (device_class := config.get(CONF_DEVICE_CLASS)) is not None:
|
||||
cg.add(var.set_device_class(device_class))
|
||||
|
@@ -87,7 +87,7 @@ UPDATE_SCHEMA.add_extra(cv.deprecated_schema_constant("update"))
|
||||
|
||||
|
||||
async def setup_update_core_(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "update")
|
||||
|
||||
if device_class_config := config.get(CONF_DEVICE_CLASS):
|
||||
cg.add(var.set_device_class(device_class_config))
|
||||
|
@@ -132,7 +132,7 @@ VALVE_SCHEMA.add_extra(cv.deprecated_schema_constant("valve"))
|
||||
|
||||
|
||||
async def _setup_valve_core(var, config):
|
||||
await setup_entity(var, config)
|
||||
await setup_entity(var, config, "valve")
|
||||
|
||||
if device_class_config := config.get(CONF_DEVICE_CLASS):
|
||||
cg.add(var.set_device_class(device_class_config))
|
||||
|
@@ -522,6 +522,9 @@ class EsphomeCore:
|
||||
# Dict to track platform entity counts for pre-allocation
|
||||
# Key: platform name (e.g. "sensor", "binary_sensor"), Value: count
|
||||
self.platform_counts: defaultdict[str, int] = defaultdict(int)
|
||||
# Track entity unique IDs to handle duplicates
|
||||
# Key: (device_id, platform, object_id), Value: count of duplicates
|
||||
self.unique_ids: dict[tuple[int, str, str], int] = {}
|
||||
# Whether ESPHome was started in verbose mode
|
||||
self.verbose = False
|
||||
# Whether ESPHome was started in quiet mode
|
||||
@@ -553,6 +556,7 @@ class EsphomeCore:
|
||||
self.loaded_integrations = set()
|
||||
self.component_ids = set()
|
||||
self.platform_counts = defaultdict(int)
|
||||
self.unique_ids = {}
|
||||
PIN_SCHEMA_REGISTRY.reset()
|
||||
|
||||
@property
|
||||
|
@@ -110,7 +110,6 @@ class Application {
|
||||
this->name_ = name;
|
||||
this->friendly_name_ = friendly_name;
|
||||
}
|
||||
// area is now handled through the areas system
|
||||
this->comment_ = comment;
|
||||
this->compilation_time_ = compilation_time;
|
||||
}
|
||||
|
@@ -11,7 +11,14 @@ const StringRef &EntityBase::get_name() const { return this->name_; }
|
||||
void EntityBase::set_name(const char *name) {
|
||||
this->name_ = StringRef(name);
|
||||
if (this->name_.empty()) {
|
||||
this->name_ = StringRef(App.get_friendly_name());
|
||||
#ifdef USE_DEVICES
|
||||
if (this->device_ != nullptr) {
|
||||
this->name_ = StringRef(this->device_->get_name());
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
this->name_ = StringRef(App.get_friendly_name());
|
||||
}
|
||||
this->flags_.has_own_name = false;
|
||||
} else {
|
||||
this->flags_.has_own_name = true;
|
||||
@@ -47,19 +54,7 @@ void EntityBase::set_object_id(const char *object_id) {
|
||||
}
|
||||
|
||||
// Calculate Object ID Hash from Entity Name
|
||||
void EntityBase::calc_object_id_() {
|
||||
// Check if `App.get_friendly_name()` is constant or dynamic.
|
||||
if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) {
|
||||
// `App.get_friendly_name()` is dynamic.
|
||||
const auto object_id = str_sanitize(str_snake_case(App.get_friendly_name()));
|
||||
// FNV-1 hash
|
||||
this->object_id_hash_ = fnv1_hash(object_id);
|
||||
} else {
|
||||
// `App.get_friendly_name()` is constant.
|
||||
// FNV-1 hash
|
||||
this->object_id_hash_ = fnv1_hash(this->object_id_c_str_);
|
||||
}
|
||||
}
|
||||
void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash(this->get_object_id()); }
|
||||
|
||||
uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; }
|
||||
|
||||
|
@@ -6,6 +6,10 @@
|
||||
#include "helpers.h"
|
||||
#include "log.h"
|
||||
|
||||
#ifdef USE_DEVICES
|
||||
#include "device.h"
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
enum EntityCategory : uint8_t {
|
||||
@@ -53,8 +57,13 @@ class EntityBase {
|
||||
|
||||
#ifdef USE_DEVICES
|
||||
// Get/set this entity's device id
|
||||
uint32_t get_device_id() const { return this->device_id_; }
|
||||
void set_device_id(const uint32_t device_id) { this->device_id_ = 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; }
|
||||
#endif
|
||||
|
||||
// Check if this entity has state
|
||||
@@ -74,7 +83,7 @@ class EntityBase {
|
||||
const char *icon_c_str_{nullptr};
|
||||
uint32_t object_id_hash_{};
|
||||
#ifdef USE_DEVICES
|
||||
uint32_t device_id_{};
|
||||
Device *device_{};
|
||||
#endif
|
||||
|
||||
// Bit-packed flags to save memory (1 byte instead of 5)
|
||||
|
@@ -1,12 +1,6 @@
|
||||
import logging
|
||||
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DISABLED_BY_DEFAULT,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_ICON,
|
||||
CONF_INTERNAL,
|
||||
CONF_NAME,
|
||||
CONF_SAFE_MODE,
|
||||
CONF_SETUP_PRIORITY,
|
||||
CONF_TYPE_ID,
|
||||
@@ -17,7 +11,9 @@ from esphome.core import CORE, ID, coroutine
|
||||
from esphome.coroutine import FakeAwaitable
|
||||
from esphome.cpp_generator import add, get_variable
|
||||
from esphome.cpp_types import App
|
||||
from esphome.helpers import fnv1a_32bit_hash, sanitize, snake_case
|
||||
from esphome.entity import ( # noqa: F401 # pylint: disable=unused-import
|
||||
setup_entity, # Import for backward compatibility
|
||||
)
|
||||
from esphome.types import ConfigFragmentType, ConfigType
|
||||
from esphome.util import Registry, RegistryEntry
|
||||
|
||||
@@ -97,25 +93,6 @@ async def register_parented(var, value):
|
||||
add(var.set_parent(paren))
|
||||
|
||||
|
||||
async def setup_entity(var, config):
|
||||
"""Set up generic properties of an Entity"""
|
||||
add(var.set_name(config[CONF_NAME]))
|
||||
if not config[CONF_NAME]:
|
||||
add(var.set_object_id(sanitize(snake_case(CORE.friendly_name))))
|
||||
else:
|
||||
add(var.set_object_id(sanitize(snake_case(config[CONF_NAME]))))
|
||||
add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT]))
|
||||
if CONF_INTERNAL in config:
|
||||
add(var.set_internal(config[CONF_INTERNAL]))
|
||||
if CONF_ICON in config:
|
||||
add(var.set_icon(config[CONF_ICON]))
|
||||
if CONF_ENTITY_CATEGORY in config:
|
||||
add(var.set_entity_category(config[CONF_ENTITY_CATEGORY]))
|
||||
if CONF_DEVICE_ID in config:
|
||||
device_id: ID = config[CONF_DEVICE_ID]
|
||||
add(var.set_device_id(fnv1a_32bit_hash(device_id.id)))
|
||||
|
||||
|
||||
def extract_registry_entry_config(
|
||||
registry: Registry,
|
||||
full_config: ConfigType,
|
||||
|
134
esphome/entity.py
Normal file
134
esphome/entity.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Entity-related helper functions."""
|
||||
|
||||
import logging
|
||||
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DISABLED_BY_DEFAULT,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_ICON,
|
||||
CONF_INTERNAL,
|
||||
CONF_NAME,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj, add, get_variable
|
||||
from esphome.helpers import fnv1a_32bit_hash, sanitize, snake_case
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_base_entity_object_id(
|
||||
name: str, friendly_name: str | None, device_name: str | None = None
|
||||
) -> str:
|
||||
"""Calculate the base object ID for an entity that will be set via set_object_id().
|
||||
|
||||
This function calculates what object_id_c_str_ should be set to in C++.
|
||||
|
||||
The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as:
|
||||
- If !has_own_name && is_name_add_mac_suffix_enabled():
|
||||
return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic
|
||||
- Else:
|
||||
return object_id_c_str_ ?? "" // What we set via set_object_id()
|
||||
|
||||
Since we're calculating what to pass to set_object_id(), we always need to
|
||||
generate the object_id the same way, regardless of name_add_mac_suffix setting.
|
||||
|
||||
Args:
|
||||
name: The entity name (empty string if no name)
|
||||
friendly_name: The friendly name from CORE.friendly_name
|
||||
device_name: The device name if entity is on a sub-device
|
||||
|
||||
Returns:
|
||||
The base object ID to use for duplicate checking and to pass to set_object_id()
|
||||
"""
|
||||
|
||||
if name:
|
||||
# Entity has its own name (has_own_name will be true)
|
||||
base_str = name
|
||||
elif device_name:
|
||||
# Entity has empty name and is on a sub-device
|
||||
# C++ EntityBase::set_name() uses device->get_name() when device is set
|
||||
base_str = device_name
|
||||
elif friendly_name:
|
||||
# Entity has empty name (has_own_name will be false)
|
||||
# C++ uses App.get_friendly_name() which returns friendly_name or device name
|
||||
base_str = friendly_name
|
||||
else:
|
||||
# Fallback to device name
|
||||
base_str = CORE.name
|
||||
|
||||
return sanitize(snake_case(base_str))
|
||||
|
||||
|
||||
async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None:
|
||||
"""Set up generic properties of an Entity.
|
||||
|
||||
This function handles duplicate entity names by automatically appending
|
||||
a suffix (_2, _3, etc.) when multiple entities have the same object_id
|
||||
within the same platform and device combination.
|
||||
|
||||
Args:
|
||||
var: The entity variable to set up
|
||||
config: Configuration dictionary containing entity settings
|
||||
platform: The platform name (e.g., "sensor", "binary_sensor")
|
||||
"""
|
||||
# Get device info
|
||||
device_id: int = 0
|
||||
device_name: str | None = None
|
||||
if CONF_DEVICE_ID in config:
|
||||
device_id_obj: ID = config[CONF_DEVICE_ID]
|
||||
device: MockObj = await get_variable(device_id_obj)
|
||||
add(var.set_device(device))
|
||||
# Use the device's ID hash as device_id
|
||||
|
||||
device_id = fnv1a_32bit_hash(device_id_obj.id)
|
||||
# Get device name for object ID calculation
|
||||
device_name = device_id_obj.id
|
||||
|
||||
add(var.set_name(config[CONF_NAME]))
|
||||
|
||||
# Calculate base object_id using the same logic as C++
|
||||
# This must match the C++ behavior in esphome/core/entity_base.cpp
|
||||
base_object_id = get_base_entity_object_id(
|
||||
config[CONF_NAME], CORE.friendly_name, device_name
|
||||
)
|
||||
|
||||
if not config[CONF_NAME]:
|
||||
_LOGGER.debug(
|
||||
"Entity has empty name, using '%s' as object_id base", base_object_id
|
||||
)
|
||||
|
||||
# Handle duplicates
|
||||
# Check for duplicates
|
||||
unique_key: tuple[int, str, str] = (device_id, platform, base_object_id)
|
||||
if unique_key in CORE.unique_ids:
|
||||
# Found duplicate, add suffix
|
||||
count = CORE.unique_ids[unique_key] + 1
|
||||
CORE.unique_ids[unique_key] = count
|
||||
object_id = f"{base_object_id}_{count}"
|
||||
_LOGGER.info(
|
||||
"Duplicate %s entity '%s' found. Renaming to '%s'",
|
||||
platform,
|
||||
config[CONF_NAME],
|
||||
object_id,
|
||||
)
|
||||
else:
|
||||
# First occurrence
|
||||
CORE.unique_ids[unique_key] = 1
|
||||
object_id = base_object_id
|
||||
|
||||
add(var.set_object_id(object_id))
|
||||
_LOGGER.debug(
|
||||
"Setting object_id '%s' for entity '%s' on platform '%s'",
|
||||
object_id,
|
||||
config[CONF_NAME],
|
||||
platform,
|
||||
)
|
||||
add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT]))
|
||||
if CONF_INTERNAL in config:
|
||||
add(var.set_internal(config[CONF_INTERNAL]))
|
||||
if CONF_ICON in config:
|
||||
add(var.set_icon(config[CONF_ICON]))
|
||||
if CONF_ENTITY_CATEGORY in config:
|
||||
add(var.set_entity_category(config[CONF_ENTITY_CATEGORY]))
|
@@ -13,7 +13,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile
|
||||
esptool==4.9.0
|
||||
click==8.1.7
|
||||
esphome-dashboard==20250514.0
|
||||
aioesphomeapi==33.1.0
|
||||
aioesphomeapi==33.1.1
|
||||
zeroconf==0.147.0
|
||||
puremagic==1.29
|
||||
ruamel.yaml==0.18.14 # dashboard_import
|
||||
|
10
tests/components/opt3001/common.yaml
Normal file
10
tests/components/opt3001/common.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
i2c:
|
||||
- id: i2c_opt3001
|
||||
scl: ${scl_pin}
|
||||
sda: ${sda_pin}
|
||||
|
||||
sensor:
|
||||
- platform: opt3001
|
||||
name: Living Room Brightness
|
||||
address: 0x44
|
||||
update_interval: 30s
|
5
tests/components/opt3001/test.esp32-ard.yaml
Normal file
5
tests/components/opt3001/test.esp32-ard.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
scl_pin: GPIO16
|
||||
sda_pin: GPIO17
|
||||
|
||||
<<: !include common.yaml
|
5
tests/components/opt3001/test.esp32-c3-ard.yaml
Normal file
5
tests/components/opt3001/test.esp32-c3-ard.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
scl_pin: GPIO5
|
||||
sda_pin: GPIO4
|
||||
|
||||
<<: !include common.yaml
|
5
tests/components/opt3001/test.esp32-c3-idf.yaml
Normal file
5
tests/components/opt3001/test.esp32-c3-idf.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
scl_pin: GPIO5
|
||||
sda_pin: GPIO4
|
||||
|
||||
<<: !include common.yaml
|
5
tests/components/opt3001/test.esp32-idf.yaml
Normal file
5
tests/components/opt3001/test.esp32-idf.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
scl_pin: GPIO16
|
||||
sda_pin: GPIO17
|
||||
|
||||
<<: !include common.yaml
|
5
tests/components/opt3001/test.esp8266-ard.yaml
Normal file
5
tests/components/opt3001/test.esp8266-ard.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
scl_pin: GPIO5
|
||||
sda_pin: GPIO4
|
||||
|
||||
<<: !include common.yaml
|
5
tests/components/opt3001/test.rp2040-ard.yaml
Normal file
5
tests/components/opt3001/test.rp2040-ard.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
scl_pin: GPIO5
|
||||
sda_pin: GPIO4
|
||||
|
||||
<<: !include common.yaml
|
211
tests/integration/fixtures/duplicate_entities.yaml
Normal file
211
tests/integration/fixtures/duplicate_entities.yaml
Normal file
@@ -0,0 +1,211 @@
|
||||
esphome:
|
||||
name: duplicate-entities-test
|
||||
# Define devices to test multi-device duplicate handling
|
||||
devices:
|
||||
- id: controller_1
|
||||
name: Controller 1
|
||||
- id: controller_2
|
||||
name: Controller 2
|
||||
|
||||
host:
|
||||
api: # Port will be automatically injected
|
||||
logger:
|
||||
|
||||
# Create duplicate entities across different scenarios
|
||||
|
||||
# Scenario 1: Multiple sensors with same name on same device (should get _2, _3, _4)
|
||||
sensor:
|
||||
- platform: template
|
||||
name: Temperature
|
||||
lambda: return 1.0;
|
||||
update_interval: 0.1s
|
||||
|
||||
- platform: template
|
||||
name: Temperature
|
||||
lambda: return 2.0;
|
||||
update_interval: 0.1s
|
||||
|
||||
- platform: template
|
||||
name: Temperature
|
||||
lambda: return 3.0;
|
||||
update_interval: 0.1s
|
||||
|
||||
- platform: template
|
||||
name: Temperature
|
||||
lambda: return 4.0;
|
||||
update_interval: 0.1s
|
||||
|
||||
# Scenario 2: Device-specific duplicates using device_id configuration
|
||||
- platform: template
|
||||
name: Device Temperature
|
||||
device_id: controller_1
|
||||
lambda: return 10.0;
|
||||
update_interval: 0.1s
|
||||
|
||||
- platform: template
|
||||
name: Device Temperature
|
||||
device_id: controller_1
|
||||
lambda: return 11.0;
|
||||
update_interval: 0.1s
|
||||
|
||||
- platform: template
|
||||
name: Device Temperature
|
||||
device_id: controller_1
|
||||
lambda: return 12.0;
|
||||
update_interval: 0.1s
|
||||
|
||||
# Different device, same name - should not conflict
|
||||
- platform: template
|
||||
name: Device Temperature
|
||||
device_id: controller_2
|
||||
lambda: return 20.0;
|
||||
update_interval: 0.1s
|
||||
|
||||
# Scenario 3: Binary sensors (different platform, same name)
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: Temperature
|
||||
lambda: return true;
|
||||
|
||||
- platform: template
|
||||
name: Temperature
|
||||
lambda: return false;
|
||||
|
||||
- platform: template
|
||||
name: Temperature
|
||||
lambda: return true;
|
||||
|
||||
# Scenario 5: Binary sensors on devices
|
||||
- platform: template
|
||||
name: Device Temperature
|
||||
device_id: controller_1
|
||||
lambda: return true;
|
||||
|
||||
- platform: template
|
||||
name: Device Temperature
|
||||
device_id: controller_2
|
||||
lambda: return false;
|
||||
|
||||
# Issue #6953: Empty names on binary sensors
|
||||
- platform: template
|
||||
name: ""
|
||||
lambda: return true;
|
||||
- platform: template
|
||||
name: ""
|
||||
lambda: return false;
|
||||
|
||||
- platform: template
|
||||
name: ""
|
||||
lambda: return true;
|
||||
|
||||
- platform: template
|
||||
name: ""
|
||||
lambda: return false;
|
||||
|
||||
# Scenario 6: Test with special characters that need sanitization
|
||||
text_sensor:
|
||||
- platform: template
|
||||
name: "Status Message!"
|
||||
lambda: return {"status1"};
|
||||
update_interval: 0.1s
|
||||
|
||||
- platform: template
|
||||
name: "Status Message!"
|
||||
lambda: return {"status2"};
|
||||
update_interval: 0.1s
|
||||
|
||||
- platform: template
|
||||
name: "Status Message!"
|
||||
lambda: return {"status3"};
|
||||
update_interval: 0.1s
|
||||
|
||||
# Scenario 7: More switch duplicates
|
||||
switch:
|
||||
- platform: template
|
||||
name: "Power Switch"
|
||||
lambda: return false;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
- platform: template
|
||||
name: "Power Switch"
|
||||
lambda: return true;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
# Scenario 8: Issue #6953 - Multiple entities with empty names
|
||||
# Empty names on main device - should use device name with suffixes
|
||||
- platform: template
|
||||
name: ""
|
||||
lambda: return false;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
- platform: template
|
||||
name: ""
|
||||
lambda: return true;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
- platform: template
|
||||
name: ""
|
||||
lambda: return false;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
# Scenario 9: Issue #6953 - Empty names on sub-devices
|
||||
# Empty names on sub-device - should use sub-device name with suffixes
|
||||
- platform: template
|
||||
name: ""
|
||||
device_id: controller_1
|
||||
lambda: return false;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
- platform: template
|
||||
name: ""
|
||||
device_id: controller_1
|
||||
lambda: return true;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
- platform: template
|
||||
name: ""
|
||||
device_id: controller_1
|
||||
lambda: return false;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
# Empty names on different sub-device
|
||||
- platform: template
|
||||
name: ""
|
||||
device_id: controller_2
|
||||
lambda: return false;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
- platform: template
|
||||
name: ""
|
||||
device_id: controller_2
|
||||
lambda: return true;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
# Scenario 10: Issue #6953 - Duplicate "xyz" names
|
||||
- platform: template
|
||||
name: "xyz"
|
||||
lambda: return false;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
- platform: template
|
||||
name: "xyz"
|
||||
lambda: return true;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
||||
|
||||
- platform: template
|
||||
name: "xyz"
|
||||
lambda: return false;
|
||||
turn_on_action: []
|
||||
turn_off_action: []
|
265
tests/integration/test_duplicate_entities.py
Normal file
265
tests/integration/test_duplicate_entities.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Integration test for duplicate entity handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import EntityInfo
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_entities(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that duplicate entity names are automatically suffixed with _2, _3, _4."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
# Get device info
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
|
||||
# Get devices
|
||||
devices = device_info.devices
|
||||
assert len(devices) >= 2, f"Expected at least 2 devices, got {len(devices)}"
|
||||
|
||||
# Find our test devices
|
||||
controller_1 = next((d for d in devices if d.name == "Controller 1"), None)
|
||||
controller_2 = next((d for d in devices if d.name == "Controller 2"), None)
|
||||
|
||||
assert controller_1 is not None, "Controller 1 device not found"
|
||||
assert controller_2 is not None, "Controller 2 device not found"
|
||||
|
||||
# Get entity list
|
||||
entities = await client.list_entities_services()
|
||||
all_entities: list[EntityInfo] = []
|
||||
for entity_list in entities[0]:
|
||||
all_entities.append(entity_list)
|
||||
|
||||
# Group entities by type for easier testing
|
||||
sensors = [e for e in all_entities if e.__class__.__name__ == "SensorInfo"]
|
||||
binary_sensors = [
|
||||
e for e in all_entities if e.__class__.__name__ == "BinarySensorInfo"
|
||||
]
|
||||
text_sensors = [
|
||||
e for e in all_entities if e.__class__.__name__ == "TextSensorInfo"
|
||||
]
|
||||
switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"]
|
||||
|
||||
# Scenario 1: Check sensors with duplicate "Temperature" names
|
||||
temp_sensors = [s for s in sensors if s.name == "Temperature"]
|
||||
temp_object_ids = sorted([s.object_id for s in temp_sensors])
|
||||
|
||||
# Should have temperature, temperature_2, temperature_3, temperature_4
|
||||
assert len(temp_object_ids) >= 4, (
|
||||
f"Expected at least 4 temperature sensors, got {len(temp_object_ids)}"
|
||||
)
|
||||
assert "temperature" in temp_object_ids, (
|
||||
"First temperature sensor should not have suffix"
|
||||
)
|
||||
assert "temperature_2" in temp_object_ids, (
|
||||
"Second temperature sensor should be temperature_2"
|
||||
)
|
||||
assert "temperature_3" in temp_object_ids, (
|
||||
"Third temperature sensor should be temperature_3"
|
||||
)
|
||||
assert "temperature_4" in temp_object_ids, (
|
||||
"Fourth temperature sensor should be temperature_4"
|
||||
)
|
||||
|
||||
# Scenario 2: Check device-specific sensors don't conflict
|
||||
device_temp_sensors = [s for s in sensors if s.name == "Device Temperature"]
|
||||
|
||||
# Group by device
|
||||
controller_1_temps = [
|
||||
s
|
||||
for s in device_temp_sensors
|
||||
if getattr(s, "device_id", None) == controller_1.device_id
|
||||
]
|
||||
controller_2_temps = [
|
||||
s
|
||||
for s in device_temp_sensors
|
||||
if getattr(s, "device_id", None) == controller_2.device_id
|
||||
]
|
||||
|
||||
# Controller 1 should have device_temperature, device_temperature_2, device_temperature_3
|
||||
c1_object_ids = sorted([s.object_id for s in controller_1_temps])
|
||||
assert len(c1_object_ids) >= 3, (
|
||||
f"Expected at least 3 sensors on controller_1, got {len(c1_object_ids)}"
|
||||
)
|
||||
assert "device_temperature" in c1_object_ids, (
|
||||
"First device sensor should not have suffix"
|
||||
)
|
||||
assert "device_temperature_2" in c1_object_ids, (
|
||||
"Second device sensor should be device_temperature_2"
|
||||
)
|
||||
assert "device_temperature_3" in c1_object_ids, (
|
||||
"Third device sensor should be device_temperature_3"
|
||||
)
|
||||
|
||||
# Controller 2 should have only device_temperature (no suffix)
|
||||
c2_object_ids = [s.object_id for s in controller_2_temps]
|
||||
assert len(c2_object_ids) >= 1, (
|
||||
f"Expected at least 1 sensor on controller_2, got {len(c2_object_ids)}"
|
||||
)
|
||||
assert "device_temperature" in c2_object_ids, (
|
||||
"Controller 2 sensor should not have suffix"
|
||||
)
|
||||
|
||||
# Scenario 3: Check binary sensors (different platform, same name)
|
||||
temp_binary = [b for b in binary_sensors if b.name == "Temperature"]
|
||||
binary_object_ids = sorted([b.object_id for b in temp_binary])
|
||||
|
||||
# Should have temperature, temperature_2, temperature_3 (no conflict with sensor platform)
|
||||
assert len(binary_object_ids) >= 3, (
|
||||
f"Expected at least 3 binary sensors, got {len(binary_object_ids)}"
|
||||
)
|
||||
assert "temperature" in binary_object_ids, (
|
||||
"First binary sensor should not have suffix"
|
||||
)
|
||||
assert "temperature_2" in binary_object_ids, (
|
||||
"Second binary sensor should be temperature_2"
|
||||
)
|
||||
assert "temperature_3" in binary_object_ids, (
|
||||
"Third binary sensor should be temperature_3"
|
||||
)
|
||||
|
||||
# Scenario 4: Check text sensors with special characters
|
||||
status_sensors = [t for t in text_sensors if t.name == "Status Message!"]
|
||||
status_object_ids = sorted([t.object_id for t in status_sensors])
|
||||
|
||||
# Special characters should be sanitized to _
|
||||
assert len(status_object_ids) >= 3, (
|
||||
f"Expected at least 3 status sensors, got {len(status_object_ids)}"
|
||||
)
|
||||
assert "status_message_" in status_object_ids, (
|
||||
"First status sensor should be status_message_"
|
||||
)
|
||||
assert "status_message__2" in status_object_ids, (
|
||||
"Second status sensor should be status_message__2"
|
||||
)
|
||||
assert "status_message__3" in status_object_ids, (
|
||||
"Third status sensor should be status_message__3"
|
||||
)
|
||||
|
||||
# Scenario 5: Check switches with duplicate names
|
||||
power_switches = [s for s in switches if s.name == "Power Switch"]
|
||||
power_object_ids = sorted([s.object_id for s in power_switches])
|
||||
|
||||
# Should have power_switch, power_switch_2
|
||||
assert len(power_object_ids) >= 2, (
|
||||
f"Expected at least 2 power switches, got {len(power_object_ids)}"
|
||||
)
|
||||
assert "power_switch" in power_object_ids, (
|
||||
"First power switch should be power_switch"
|
||||
)
|
||||
assert "power_switch_2" in power_object_ids, (
|
||||
"Second power switch should be power_switch_2"
|
||||
)
|
||||
|
||||
# Scenario 6: Check empty names on main device (Issue #6953)
|
||||
empty_binary = [b for b in binary_sensors if b.name == ""]
|
||||
empty_binary_ids = sorted([b.object_id for b in empty_binary])
|
||||
|
||||
# Should use device name "duplicate-entities-test" (sanitized, not snake_case)
|
||||
assert len(empty_binary_ids) >= 4, (
|
||||
f"Expected at least 4 empty name binary sensors, got {len(empty_binary_ids)}"
|
||||
)
|
||||
assert "duplicate-entities-test" in empty_binary_ids, (
|
||||
"First empty binary sensor should use device name"
|
||||
)
|
||||
assert "duplicate-entities-test_2" in empty_binary_ids, (
|
||||
"Second empty binary sensor should be duplicate-entities-test_2"
|
||||
)
|
||||
assert "duplicate-entities-test_3" in empty_binary_ids, (
|
||||
"Third empty binary sensor should be duplicate-entities-test_3"
|
||||
)
|
||||
assert "duplicate-entities-test_4" in empty_binary_ids, (
|
||||
"Fourth empty binary sensor should be duplicate-entities-test_4"
|
||||
)
|
||||
|
||||
# Scenario 7: Check empty names on sub-devices (Issue #6953)
|
||||
empty_switches = [s for s in switches if s.name == ""]
|
||||
|
||||
# Group by device
|
||||
c1_empty_switches = [
|
||||
s
|
||||
for s in empty_switches
|
||||
if getattr(s, "device_id", None) == controller_1.device_id
|
||||
]
|
||||
c2_empty_switches = [
|
||||
s
|
||||
for s in empty_switches
|
||||
if getattr(s, "device_id", None) == controller_2.device_id
|
||||
]
|
||||
main_empty_switches = [
|
||||
s
|
||||
for s in empty_switches
|
||||
if getattr(s, "device_id", None)
|
||||
not in [controller_1.device_id, controller_2.device_id]
|
||||
]
|
||||
|
||||
# Controller 1 empty switches should use "controller_1"
|
||||
c1_empty_ids = sorted([s.object_id for s in c1_empty_switches])
|
||||
assert len(c1_empty_ids) >= 3, (
|
||||
f"Expected at least 3 empty switches on controller_1, got {len(c1_empty_ids)}"
|
||||
)
|
||||
assert "controller_1" in c1_empty_ids, "First should be controller_1"
|
||||
assert "controller_1_2" in c1_empty_ids, "Second should be controller_1_2"
|
||||
assert "controller_1_3" in c1_empty_ids, "Third should be controller_1_3"
|
||||
|
||||
# Controller 2 empty switches
|
||||
c2_empty_ids = sorted([s.object_id for s in c2_empty_switches])
|
||||
assert len(c2_empty_ids) >= 2, (
|
||||
f"Expected at least 2 empty switches on controller_2, got {len(c2_empty_ids)}"
|
||||
)
|
||||
assert "controller_2" in c2_empty_ids, "First should be controller_2"
|
||||
assert "controller_2_2" in c2_empty_ids, "Second should be controller_2_2"
|
||||
|
||||
# Main device empty switches
|
||||
main_empty_ids = sorted([s.object_id for s in main_empty_switches])
|
||||
assert len(main_empty_ids) >= 3, (
|
||||
f"Expected at least 3 empty switches on main device, got {len(main_empty_ids)}"
|
||||
)
|
||||
assert "duplicate-entities-test" in main_empty_ids
|
||||
assert "duplicate-entities-test_2" in main_empty_ids
|
||||
assert "duplicate-entities-test_3" in main_empty_ids
|
||||
|
||||
# Scenario 8: Check "xyz" duplicates (Issue #6953)
|
||||
xyz_switches = [s for s in switches if s.name == "xyz"]
|
||||
xyz_ids = sorted([s.object_id for s in xyz_switches])
|
||||
|
||||
assert len(xyz_ids) >= 3, (
|
||||
f"Expected at least 3 xyz switches, got {len(xyz_ids)}"
|
||||
)
|
||||
assert "xyz" in xyz_ids, "First xyz switch should be xyz"
|
||||
assert "xyz_2" in xyz_ids, "Second xyz switch should be xyz_2"
|
||||
assert "xyz_3" in xyz_ids, "Third xyz switch should be xyz_3"
|
||||
|
||||
# Verify we can get states for all entities (ensures they're functional)
|
||||
loop = asyncio.get_running_loop()
|
||||
states_future: asyncio.Future[None] = loop.create_future()
|
||||
state_count = 0
|
||||
expected_count = (
|
||||
len(sensors) + len(binary_sensors) + len(text_sensors) + len(switches)
|
||||
)
|
||||
|
||||
def on_state(state) -> None:
|
||||
nonlocal state_count
|
||||
state_count += 1
|
||||
if state_count >= expected_count and not states_future.done():
|
||||
states_future.set_result(None)
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Wait for all entity states
|
||||
try:
|
||||
await asyncio.wait_for(states_future, timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail(
|
||||
f"Did not receive all entity states within 10 seconds. "
|
||||
f"Expected {expected_count}, received {state_count}"
|
||||
)
|
@@ -14,6 +14,8 @@ import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
here = Path(__file__).parent
|
||||
|
||||
# Configure location of package root
|
||||
@@ -21,6 +23,13 @@ package_root = here.parent.parent
|
||||
sys.path.insert(0, package_root.as_posix())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_core():
|
||||
"""Reset CORE after each test."""
|
||||
yield
|
||||
CORE.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture_path() -> Path:
|
||||
"""
|
||||
|
@@ -28,13 +28,6 @@ def yaml_file(tmp_path: Path) -> Callable[[str], str]:
|
||||
return _yaml_file
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_core():
|
||||
"""Reset CORE after each test."""
|
||||
yield
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def load_config_from_yaml(
|
||||
yaml_file: Callable[[str], str], yaml_content: str
|
||||
) -> Config | None:
|
||||
@@ -61,7 +54,7 @@ def load_config_from_fixture(
|
||||
|
||||
def test_validate_area_config_with_string() -> None:
|
||||
"""Test that string area config is converted to structured format."""
|
||||
result: dict[str, Any] = validate_area_config("Living Room")
|
||||
result = validate_area_config("Living Room")
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "id" in result
|
||||
@@ -80,7 +73,7 @@ def test_validate_area_config_with_dict() -> None:
|
||||
"name": "Test Area",
|
||||
}
|
||||
|
||||
result: dict[str, Any] = validate_area_config(input_config)
|
||||
result = validate_area_config(input_config)
|
||||
|
||||
assert result == input_config
|
||||
assert result["id"] == area_id
|
||||
@@ -205,7 +198,6 @@ def test_device_with_invalid_area_id(
|
||||
|
||||
# Check for the specific error message in stdout
|
||||
captured = capsys.readouterr()
|
||||
print(captured.out)
|
||||
assert (
|
||||
"Couldn't find ID 'nonexistent_area'. Please check you have defined an ID with that name in your configuration."
|
||||
in captured.out
|
||||
|
596
tests/unit_tests/test_entity.py
Normal file
596
tests/unit_tests/test_entity.py
Normal file
@@ -0,0 +1,596 @@
|
||||
"""Test get_base_entity_object_id function matches C++ behavior."""
|
||||
|
||||
from collections.abc import Generator
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import entity
|
||||
from esphome.const import CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, CONF_NAME
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.entity import get_base_entity_object_id, setup_entity
|
||||
from esphome.helpers import sanitize, snake_case
|
||||
|
||||
# Pre-compiled regex pattern for extracting object IDs from expressions
|
||||
OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)')
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_core_state() -> Generator[None, None, None]:
|
||||
"""Save and restore CORE state for tests."""
|
||||
original_name = CORE.name
|
||||
original_friendly_name = CORE.friendly_name
|
||||
yield
|
||||
CORE.name = original_name
|
||||
CORE.friendly_name = original_friendly_name
|
||||
|
||||
|
||||
def test_with_entity_name() -> None:
|
||||
"""Test when entity has its own name - should use entity name."""
|
||||
# Simple name
|
||||
assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor"
|
||||
assert (
|
||||
get_base_entity_object_id("Temperature Sensor", "Device Name")
|
||||
== "temperature_sensor"
|
||||
)
|
||||
# Even with device name, entity name takes precedence
|
||||
assert (
|
||||
get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device")
|
||||
== "temperature_sensor"
|
||||
)
|
||||
|
||||
# Name with special characters
|
||||
assert (
|
||||
get_base_entity_object_id("Temp!@#$%^&*()Sensor", None)
|
||||
== "temp__________sensor"
|
||||
)
|
||||
assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123"
|
||||
|
||||
# Already snake_case
|
||||
assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor"
|
||||
|
||||
# Mixed case
|
||||
assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor"
|
||||
assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor"
|
||||
|
||||
|
||||
def test_empty_name_with_device_name() -> None:
|
||||
"""Test when entity has empty name and is on a sub-device - should use device name."""
|
||||
# C++ behavior: when has_own_name is false and device is set, uses device->get_name()
|
||||
assert (
|
||||
get_base_entity_object_id("", "Friendly Device", "Sub Device 1")
|
||||
== "sub_device_1"
|
||||
)
|
||||
assert (
|
||||
get_base_entity_object_id("", "Kitchen Controller", "controller_1")
|
||||
== "controller_1"
|
||||
)
|
||||
assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123"
|
||||
|
||||
|
||||
def test_empty_name_with_friendly_name() -> None:
|
||||
"""Test when entity has empty name and no device - should use friendly name."""
|
||||
# C++ behavior: when has_own_name is false, uses App.get_friendly_name()
|
||||
assert get_base_entity_object_id("", "Friendly Device") == "friendly_device"
|
||||
assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller"
|
||||
assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123"
|
||||
|
||||
# Special characters in friendly name
|
||||
assert get_base_entity_object_id("", "Device!@#$%") == "device_____"
|
||||
|
||||
|
||||
def test_empty_name_no_friendly_name() -> None:
|
||||
"""Test when entity has empty name and no friendly name - should use device name."""
|
||||
# Test with CORE.name set
|
||||
CORE.name = "device-name"
|
||||
assert get_base_entity_object_id("", None) == "device-name"
|
||||
|
||||
CORE.name = "Test Device"
|
||||
assert get_base_entity_object_id("", None) == "test_device"
|
||||
|
||||
|
||||
def test_edge_cases() -> None:
|
||||
"""Test edge cases."""
|
||||
# Only spaces
|
||||
assert get_base_entity_object_id(" ", None) == "___"
|
||||
|
||||
# Unicode characters (should be replaced)
|
||||
assert get_base_entity_object_id("Température", None) == "temp_rature"
|
||||
assert get_base_entity_object_id("测试", None) == "__"
|
||||
|
||||
# Empty string with empty friendly name (empty friendly name is treated as None)
|
||||
# Falls back to CORE.name
|
||||
CORE.name = "device"
|
||||
assert get_base_entity_object_id("", "") == "device"
|
||||
|
||||
# Very long name (should work fine)
|
||||
long_name = "a" * 100 + " " + "b" * 100
|
||||
expected = "a" * 100 + "_" + "b" * 100
|
||||
assert get_base_entity_object_id(long_name, None) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("Temperature Sensor", "temperature_sensor"),
|
||||
("Living Room Light", "living_room_light"),
|
||||
("Test-Device_123", "test-device_123"),
|
||||
("Special!@#Chars", "special___chars"),
|
||||
("UPPERCASE NAME", "uppercase_name"),
|
||||
("lowercase name", "lowercase_name"),
|
||||
("Mixed Case Name", "mixed_case_name"),
|
||||
(" Spaces ", "___spaces___"),
|
||||
],
|
||||
)
|
||||
def test_matches_cpp_helpers(name: str, expected: str) -> None:
|
||||
"""Test that the logic matches using snake_case and sanitize directly."""
|
||||
# For non-empty names, verify our function produces same result as direct snake_case + sanitize
|
||||
assert get_base_entity_object_id(name, None) == sanitize(snake_case(name))
|
||||
assert get_base_entity_object_id(name, None) == expected
|
||||
|
||||
|
||||
def test_empty_name_fallback() -> None:
|
||||
"""Test empty name handling which falls back to friendly_name or CORE.name."""
|
||||
# Empty name is handled specially - it doesn't just use sanitize(snake_case(""))
|
||||
# Instead it falls back to friendly_name or CORE.name
|
||||
assert sanitize(snake_case("")) == "" # Direct conversion gives empty string
|
||||
# But our function returns a fallback
|
||||
CORE.name = "device"
|
||||
assert get_base_entity_object_id("", None) == "device" # Uses device name
|
||||
|
||||
|
||||
def test_name_add_mac_suffix_behavior() -> None:
|
||||
"""Test behavior related to name_add_mac_suffix.
|
||||
|
||||
In C++, when name_add_mac_suffix is enabled and entity has no name,
|
||||
get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name()))
|
||||
dynamically. Our function always returns the same result since we're
|
||||
calculating the base for duplicate tracking.
|
||||
"""
|
||||
# The function should always return the same result regardless of
|
||||
# name_add_mac_suffix setting, as we're calculating the base object_id
|
||||
assert get_base_entity_object_id("", "Test Device") == "test_device"
|
||||
assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name"
|
||||
|
||||
|
||||
def test_priority_order() -> None:
|
||||
"""Test the priority order: entity name > device name > friendly name > CORE.name."""
|
||||
CORE.name = "core-device"
|
||||
|
||||
# 1. Entity name has highest priority
|
||||
assert (
|
||||
get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name")
|
||||
== "entity_name"
|
||||
)
|
||||
|
||||
# 2. Device name is next priority (when entity name is empty)
|
||||
assert (
|
||||
get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name"
|
||||
)
|
||||
|
||||
# 3. Friendly name is next (when entity and device names are empty)
|
||||
assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name"
|
||||
|
||||
# 4. CORE.name is last resort
|
||||
assert get_base_entity_object_id("", None, None) == "core-device"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "friendly_name", "device_name", "expected"),
|
||||
[
|
||||
# name, friendly_name, device_name, expected
|
||||
("Living Room Light", None, None, "living_room_light"),
|
||||
("", "Kitchen Controller", None, "kitchen_controller"),
|
||||
(
|
||||
"",
|
||||
"ESP32 Device",
|
||||
"controller_1",
|
||||
"controller_1",
|
||||
), # Device name takes precedence
|
||||
("GPIO2 Button", None, None, "gpio2_button"),
|
||||
("WiFi Signal", "My Device", None, "wifi_signal"),
|
||||
("", None, "esp32_node", "esp32_node"),
|
||||
("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"),
|
||||
],
|
||||
)
|
||||
def test_real_world_examples(
|
||||
name: str, friendly_name: str | None, device_name: str | None, expected: str
|
||||
) -> None:
|
||||
"""Test real-world entity naming scenarios."""
|
||||
result = get_base_entity_object_id(name, friendly_name, device_name)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_issue_6953_scenarios() -> None:
|
||||
"""Test specific scenarios from issue #6953."""
|
||||
# Scenario 1: Multiple empty names on main device with name_add_mac_suffix
|
||||
# The Python code calculates the base, C++ might append MAC suffix dynamically
|
||||
CORE.name = "device-name"
|
||||
CORE.friendly_name = "Friendly Device"
|
||||
|
||||
# All empty names should resolve to same base
|
||||
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
|
||||
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
|
||||
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
|
||||
|
||||
# Scenario 2: Empty names on sub-devices
|
||||
assert (
|
||||
get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1"
|
||||
)
|
||||
assert (
|
||||
get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2"
|
||||
)
|
||||
|
||||
# Scenario 3: xyz duplicates
|
||||
assert get_base_entity_object_id("xyz", None) == "xyz"
|
||||
assert get_base_entity_object_id("xyz", "Device") == "xyz"
|
||||
|
||||
|
||||
# Tests for setup_entity function
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_test_environment() -> Generator[list[str], None, None]:
|
||||
"""Set up test environment for setup_entity tests."""
|
||||
# Set CORE state for tests
|
||||
CORE.name = "test-device"
|
||||
CORE.friendly_name = "Test Device"
|
||||
# Store original add function
|
||||
|
||||
original_add = entity.add
|
||||
# Track what gets added
|
||||
added_expressions: list[str] = []
|
||||
|
||||
def mock_add(expression: Any) -> Any:
|
||||
added_expressions.append(str(expression))
|
||||
return original_add(expression)
|
||||
|
||||
# Patch add function in entity module
|
||||
entity.add = mock_add
|
||||
yield added_expressions
|
||||
# Clean up
|
||||
entity.add = original_add
|
||||
|
||||
|
||||
def extract_object_id_from_expressions(expressions: list[str]) -> str | None:
|
||||
"""Extract the object ID that was set from the generated expressions."""
|
||||
for expr in expressions:
|
||||
# Look for set_object_id calls with regex to handle various formats
|
||||
# Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2')
|
||||
if match := OBJECT_ID_PATTERN.search(expr):
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> None:
|
||||
"""Test setup_entity with unique names."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
# Create mock entities
|
||||
var1 = MockObj("sensor1")
|
||||
var2 = MockObj("sensor2")
|
||||
|
||||
# Set up first entity
|
||||
config1 = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
await setup_entity(var1, config1, "sensor")
|
||||
|
||||
# Get object ID from first entity
|
||||
object_id1 = extract_object_id_from_expressions(added_expressions)
|
||||
assert object_id1 == "temperature"
|
||||
|
||||
# Clear for next entity
|
||||
added_expressions.clear()
|
||||
|
||||
# Set up second entity with different name
|
||||
config2 = {
|
||||
CONF_NAME: "Humidity",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
await setup_entity(var2, config2, "sensor")
|
||||
|
||||
# Get object ID from second entity
|
||||
object_id2 = extract_object_id_from_expressions(added_expressions)
|
||||
assert object_id2 == "humidity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_with_duplicates(setup_test_environment: list[str]) -> None:
|
||||
"""Test setup_entity with duplicate names."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
# Create mock entities
|
||||
entities = [MockObj(f"sensor{i}") for i in range(4)]
|
||||
|
||||
# Set up entities with same name
|
||||
config = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
object_ids: list[str] = []
|
||||
for var in entities:
|
||||
added_expressions.clear()
|
||||
await setup_entity(var, config, "sensor")
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
object_ids.append(object_id)
|
||||
|
||||
# Check that object IDs were set with proper suffixes
|
||||
assert object_ids[0] == "temperature"
|
||||
assert object_ids[1] == "temperature_2"
|
||||
assert object_ids[2] == "temperature_3"
|
||||
assert object_ids[3] == "temperature_4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_different_platforms(
|
||||
setup_test_environment: list[str],
|
||||
) -> None:
|
||||
"""Test that same name on different platforms doesn't conflict."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
# Create mock entities
|
||||
sensor = MockObj("sensor1")
|
||||
binary_sensor = MockObj("binary_sensor1")
|
||||
text_sensor = MockObj("text_sensor1")
|
||||
|
||||
config = {
|
||||
CONF_NAME: "Status",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
# Set up entities on different platforms
|
||||
platforms = [
|
||||
(sensor, "sensor"),
|
||||
(binary_sensor, "binary_sensor"),
|
||||
(text_sensor, "text_sensor"),
|
||||
]
|
||||
|
||||
object_ids: list[str] = []
|
||||
for var, platform in platforms:
|
||||
added_expressions.clear()
|
||||
await setup_entity(var, config, platform)
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
object_ids.append(object_id)
|
||||
|
||||
# All should get base object ID without suffix
|
||||
assert all(obj_id == "status" for obj_id in object_ids)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_variable() -> Generator[dict[ID, MockObj], None, None]:
|
||||
"""Mock get_variable to return test devices."""
|
||||
devices = {}
|
||||
original_get_variable = entity.get_variable
|
||||
|
||||
async def _mock_get_variable(device_id: ID) -> MockObj:
|
||||
if device_id in devices:
|
||||
return devices[device_id]
|
||||
return await original_get_variable(device_id)
|
||||
|
||||
entity.get_variable = _mock_get_variable
|
||||
yield devices
|
||||
# Clean up
|
||||
entity.get_variable = original_get_variable
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_with_devices(
|
||||
setup_test_environment: list[str], mock_get_variable: dict[ID, MockObj]
|
||||
) -> None:
|
||||
"""Test that same name on different devices doesn't conflict."""
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
# Create mock devices
|
||||
device1_id = ID("device1", type="Device")
|
||||
device2_id = ID("device2", type="Device")
|
||||
device1 = MockObj("device1_obj")
|
||||
device2 = MockObj("device2_obj")
|
||||
|
||||
# Register devices with the mock
|
||||
mock_get_variable[device1_id] = device1
|
||||
mock_get_variable[device2_id] = device2
|
||||
|
||||
# Create sensors with same name on different devices
|
||||
sensor1 = MockObj("sensor1")
|
||||
sensor2 = MockObj("sensor2")
|
||||
|
||||
config1 = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DEVICE_ID: device1_id,
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
config2 = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DEVICE_ID: device2_id,
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
# Get object IDs
|
||||
object_ids: list[str] = []
|
||||
for var, config in [(sensor1, config1), (sensor2, config2)]:
|
||||
added_expressions.clear()
|
||||
await setup_entity(var, config, "sensor")
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
object_ids.append(object_id)
|
||||
|
||||
# Both should get base object ID without suffix (different devices)
|
||||
assert object_ids[0] == "temperature"
|
||||
assert object_ids[1] == "temperature"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> None:
|
||||
"""Test setup_entity with empty entity name."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
var = MockObj("sensor1")
|
||||
|
||||
config = {
|
||||
CONF_NAME: "",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
await setup_entity(var, config, "sensor")
|
||||
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
# Should use friendly name
|
||||
assert object_id == "test_device"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_empty_name_duplicates(
|
||||
setup_test_environment: list[str],
|
||||
) -> None:
|
||||
"""Test setup_entity with multiple empty names."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
entities = [MockObj(f"sensor{i}") for i in range(3)]
|
||||
|
||||
config = {
|
||||
CONF_NAME: "",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
object_ids: list[str] = []
|
||||
for var in entities:
|
||||
added_expressions.clear()
|
||||
await setup_entity(var, config, "sensor")
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
object_ids.append(object_id)
|
||||
|
||||
# Should use device name with suffixes
|
||||
assert object_ids[0] == "test_device"
|
||||
assert object_ids[1] == "test_device_2"
|
||||
assert object_ids[2] == "test_device_3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_special_characters(
|
||||
setup_test_environment: list[str],
|
||||
) -> None:
|
||||
"""Test setup_entity with names containing special characters."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
entities = [MockObj(f"sensor{i}") for i in range(3)]
|
||||
|
||||
config = {
|
||||
CONF_NAME: "Temperature Sensor!",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
object_ids: list[str] = []
|
||||
for var in entities:
|
||||
added_expressions.clear()
|
||||
await setup_entity(var, config, "sensor")
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
object_ids.append(object_id)
|
||||
|
||||
# Special characters should be sanitized
|
||||
assert object_ids[0] == "temperature_sensor_"
|
||||
assert object_ids[1] == "temperature_sensor__2"
|
||||
assert object_ids[2] == "temperature_sensor__3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None:
|
||||
"""Test setup_entity sets icon correctly."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
var = MockObj("sensor1")
|
||||
|
||||
config = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
CONF_ICON: "mdi:thermometer",
|
||||
}
|
||||
|
||||
await setup_entity(var, config, "sensor")
|
||||
|
||||
# Check icon was set
|
||||
assert any(
|
||||
'sensor1.set_icon("mdi:thermometer")' in expr for expr in added_expressions
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_disabled_by_default(
|
||||
setup_test_environment: list[str],
|
||||
) -> None:
|
||||
"""Test setup_entity sets disabled_by_default correctly."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
var = MockObj("sensor1")
|
||||
|
||||
config = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DISABLED_BY_DEFAULT: True,
|
||||
}
|
||||
|
||||
await setup_entity(var, config, "sensor")
|
||||
|
||||
# Check disabled_by_default was set
|
||||
assert any(
|
||||
"sensor1.set_disabled_by_default(true)" in expr for expr in added_expressions
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_mixed_duplicates(setup_test_environment: list[str]) -> None:
|
||||
"""Test complex duplicate scenario with multiple platforms and devices."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
# Track results
|
||||
results: list[tuple[str, str]] = []
|
||||
|
||||
# 3 sensors named "Status"
|
||||
for i in range(3):
|
||||
added_expressions.clear()
|
||||
var = MockObj(f"sensor_status_{i}")
|
||||
await setup_entity(
|
||||
var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "sensor"
|
||||
)
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
results.append(("sensor", object_id))
|
||||
|
||||
# 2 binary_sensors named "Status"
|
||||
for i in range(2):
|
||||
added_expressions.clear()
|
||||
var = MockObj(f"binary_sensor_status_{i}")
|
||||
await setup_entity(
|
||||
var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "binary_sensor"
|
||||
)
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
results.append(("binary_sensor", object_id))
|
||||
|
||||
# 1 text_sensor named "Status"
|
||||
added_expressions.clear()
|
||||
var = MockObj("text_sensor_status")
|
||||
await setup_entity(
|
||||
var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "text_sensor"
|
||||
)
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
results.append(("text_sensor", object_id))
|
||||
|
||||
# Check results - each platform has its own namespace
|
||||
assert results[0] == ("sensor", "status") # sensor
|
||||
assert results[1] == ("sensor", "status_2") # sensor
|
||||
assert results[2] == ("sensor", "status_3") # sensor
|
||||
assert results[3] == ("binary_sensor", "status") # binary_sensor (new namespace)
|
||||
assert results[4] == ("binary_sensor", "status_2") # binary_sensor
|
||||
assert results[5] == ("text_sensor", "status") # text_sensor (new namespace)
|
Reference in New Issue
Block a user