diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index e1cb6a9e01..efe3b190af 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -5,6 +5,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32S2, @@ -85,6 +86,16 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { 3: adc_channel_t.ADC_CHANNEL_3, 4: adc_channel_t.ADC_CHANNEL_4, }, + # ESP32-C5 ADC1 pin mapping - based on official ESP-IDF documentation + # https://docs.espressif.com/projects/esp-idf/en/latest/esp32c5/api-reference/peripherals/gpio.html + VARIANT_ESP32C5: { + 1: adc_channel_t.ADC_CHANNEL_0, + 2: adc_channel_t.ADC_CHANNEL_1, + 3: adc_channel_t.ADC_CHANNEL_2, + 4: adc_channel_t.ADC_CHANNEL_3, + 5: adc_channel_t.ADC_CHANNEL_4, + 6: adc_channel_t.ADC_CHANNEL_5, + }, # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c6/include/soc/adc_channel.h VARIANT_ESP32C6: { 0: adc_channel_t.ADC_CHANNEL_0, @@ -155,6 +166,8 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { VARIANT_ESP32C3: { 5: adc_channel_t.ADC_CHANNEL_0, }, + # ESP32-C5 has no ADC2 channels + VARIANT_ESP32C5: {}, # no ADC2 # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c6/include/soc/adc_channel.h VARIANT_ESP32C6: {}, # no ADC2 # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 19648e7269..a60272a1f7 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -104,9 +104,10 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage /// @param attenuation The desired ADC attenuation level (e.g., ADC_ATTEN_DB_0, ADC_ATTEN_DB_11). void set_attenuation(adc_atten_t attenuation) { this->attenuation_ = attenuation; } - /// Configure the ADC to use a specific channel on ADC1. + /// Configure the ADC to use a specific channel on a specific ADC unit. /// This sets the channel for single-shot or continuous ADC measurements. - /// @param channel The ADC1 channel to configure, such as ADC_CHANNEL_0, ADC_CHANNEL_3, etc. + /// @param unit The ADC unit to use (ADC_UNIT_1 or ADC_UNIT_2). + /// @param channel The ADC channel to configure, such as ADC_CHANNEL_0, ADC_CHANNEL_3, etc. void set_channel(adc_unit_t unit, adc_channel_t channel) { this->adc_unit_ = unit; this->channel_ = channel; diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index f38d339304..f3503b49c9 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -43,9 +43,10 @@ void ADCSensor::setup() { adc_oneshot_unit_init_cfg_t init_config = {}; // Zero initialize init_config.unit_id = this->adc_unit_; init_config.ulp_mode = ADC_ULP_MODE_DISABLE; -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32H2 +#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32H2 init_config.clk_src = ADC_DIGI_CLK_SRC_DEFAULT; -#endif // USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32H2 +#endif // USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || + // USE_ESP32_VARIANT_ESP32H2 esp_err_t err = adc_oneshot_new_unit(&init_config, &ADCSensor::shared_adc_handles[this->adc_unit_]); if (err != ESP_OK) { ESP_LOGE(TAG, "Error initializing %s: %d", LOG_STR_ARG(adc_unit_to_str(this->adc_unit_)), err); @@ -74,7 +75,8 @@ void ADCSensor::setup() { adc_cali_handle_t handle = nullptr; esp_err_t err; -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 +#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 // RISC-V variants and S3 use curve fitting calibration adc_cali_curve_fitting_config_t cali_config = {}; // Zero initialize first #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) @@ -111,7 +113,7 @@ void ADCSensor::setup() { ESP_LOGW(TAG, "Line fitting calibration failed with error %d, will use uncalibrated readings", err); this->setup_flags_.calibration_complete = false; } -#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C6 || ESP32S3 || ESP32H2 +#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32S3 || ESP32H2 } this->setup_flags_.init_complete = true; @@ -185,11 +187,12 @@ float ADCSensor::sample_fixed_attenuation_() { } else { ESP_LOGW(TAG, "ADC calibration conversion failed with error %d, disabling calibration", err); if (this->calibration_handle_ != nullptr) { -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 +#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else // Other ESP32 variants use line fitting calibration adc_cali_delete_scheme_line_fitting(this->calibration_handle_); -#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C6 || ESP32S3 || ESP32H2 +#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32S3 || ESP32H2 this->calibration_handle_ = nullptr; } } @@ -217,7 +220,8 @@ float ADCSensor::sample_autorange_() { // Need to recalibrate for the new attenuation if (this->calibration_handle_ != nullptr) { // Delete old calibration handle -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 +#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else adc_cali_delete_scheme_line_fitting(this->calibration_handle_); @@ -228,7 +232,8 @@ float ADCSensor::sample_autorange_() { // Create new calibration handle for this attenuation adc_cali_handle_t handle = nullptr; -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 +#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 adc_cali_curve_fitting_config_t cali_config = {}; #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) cali_config.chan = this->channel_; @@ -256,7 +261,8 @@ float ADCSensor::sample_autorange_() { if (err != ESP_OK) { ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err); if (handle != nullptr) { -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 +#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); @@ -275,7 +281,8 @@ float ADCSensor::sample_autorange_() { voltage = raw * 3.3f / 4095.0f; } // Clean up calibration handle -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 +#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b9b85d853d..2ac3303691 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -42,18 +42,37 @@ static const char *const TAG = "api.connection"; static const int CAMERA_STOP_STREAM = 5000; #endif -// Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call object +#ifdef USE_DEVICES +// Helper macro for entity command handlers - gets entity by key and device_id, returns if not found, and creates call +// object +#define ENTITY_COMMAND_MAKE_CALL(entity_type, entity_var, getter_name) \ + entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \ + if ((entity_var) == nullptr) \ + return; \ + auto call = (entity_var)->make_call(); + +// Helper macro for entity command handlers that don't use make_call() - gets entity by key and device_id and returns if +// not found +#define ENTITY_COMMAND_GET(entity_type, entity_var, getter_name) \ + entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \ + if ((entity_var) == nullptr) \ + return; +#else // No device support, use simpler macros +// Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call +// object #define ENTITY_COMMAND_MAKE_CALL(entity_type, entity_var, getter_name) \ entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \ if ((entity_var) == nullptr) \ return; \ auto call = (entity_var)->make_call(); -// Helper macro for entity command handlers that don't use make_call() - gets entity by key and returns if not found +// Helper macro for entity command handlers that don't use make_call() - gets entity by key and returns if +// not found #define ENTITY_COMMAND_GET(entity_type, entity_var, getter_name) \ entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \ if ((entity_var) == nullptr) \ return; +#endif // USE_DEVICES APIConnection::APIConnection(std::unique_ptr sock, APIServer *parent) : parent_(parent), initial_state_iterator_(this), list_entities_iterator_(this) { @@ -183,7 +202,8 @@ void APIConnection::loop() { } else if (now - this->last_traffic_ > KEEPALIVE_TIMEOUT_MS && !this->flags_.remove) { // Only send ping if we're not disconnecting ESP_LOGVV(TAG, "Sending keepalive PING"); - this->flags_.sent_ping = this->send_message(PingRequest()); + PingRequest req; + this->flags_.sent_ping = this->send_message(req, PingRequest::MESSAGE_TYPE); if (!this->flags_.sent_ping) { // If we can't send the ping request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority @@ -232,7 +252,7 @@ void APIConnection::loop() { resp.entity_id = it.entity_id; resp.attribute = it.attribute.value(); resp.once = it.once; - if (this->send_message(resp)) { + if (this->send_message(resp, SubscribeHomeAssistantStateResponse::MESSAGE_TYPE)) { state_subs_at_++; } } else { @@ -1104,9 +1124,9 @@ bool APIConnection::send_bluetooth_le_advertisement(const BluetoothLEAdvertiseme manufacturer_data.legacy_data.assign(manufacturer_data.data.begin(), manufacturer_data.data.end()); manufacturer_data.data.clear(); } - return this->send_message(resp); + return this->send_message(resp, BluetoothLEAdvertisementResponse::MESSAGE_TYPE); } - return this->send_message(msg); + return this->send_message(msg, BluetoothLEAdvertisementResponse::MESSAGE_TYPE); } void APIConnection::bluetooth_device_request(const BluetoothDeviceRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index e9692305b5..9ed18c24dc 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -111,7 +111,7 @@ class APIConnection : public APIServerConnection { void send_homeassistant_service_call(const HomeassistantServiceResponse &call) { if (!this->flags_.service_call_subscription) return; - this->send_message(call); + this->send_message(call, HomeassistantServiceResponse::MESSAGE_TYPE); } #ifdef USE_BLUETOOTH_PROXY void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; @@ -133,7 +133,7 @@ class APIConnection : public APIServerConnection { #ifdef USE_HOMEASSISTANT_TIME void send_time_request() { GetTimeRequest req; - this->send_message(req); + this->send_message(req, GetTimeRequest::MESSAGE_TYPE); } #endif diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index b96e5736a4..888dc16836 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -598,32 +598,32 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, void APIServerConnection::on_hello_request(const HelloRequest &msg) { HelloResponse ret = this->hello(msg); - if (!this->send_message(ret)) { + if (!this->send_message(ret, HelloResponse::MESSAGE_TYPE)) { this->on_fatal_error(); } } void APIServerConnection::on_connect_request(const ConnectRequest &msg) { ConnectResponse ret = this->connect(msg); - if (!this->send_message(ret)) { + if (!this->send_message(ret, ConnectResponse::MESSAGE_TYPE)) { this->on_fatal_error(); } } void APIServerConnection::on_disconnect_request(const DisconnectRequest &msg) { DisconnectResponse ret = this->disconnect(msg); - if (!this->send_message(ret)) { + if (!this->send_message(ret, DisconnectResponse::MESSAGE_TYPE)) { this->on_fatal_error(); } } void APIServerConnection::on_ping_request(const PingRequest &msg) { PingResponse ret = this->ping(msg); - if (!this->send_message(ret)) { + if (!this->send_message(ret, PingResponse::MESSAGE_TYPE)) { this->on_fatal_error(); } } void APIServerConnection::on_device_info_request(const DeviceInfoRequest &msg) { if (this->check_connection_setup_()) { DeviceInfoResponse ret = this->device_info(msg); - if (!this->send_message(ret)) { + if (!this->send_message(ret, DeviceInfoResponse::MESSAGE_TYPE)) { this->on_fatal_error(); } } @@ -657,7 +657,7 @@ void APIServerConnection::on_subscribe_home_assistant_states_request(const Subsc void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) { if (this->check_connection_setup_()) { GetTimeResponse ret = this->get_time(msg); - if (!this->send_message(ret)) { + if (!this->send_message(ret, GetTimeResponse::MESSAGE_TYPE)) { this->on_fatal_error(); } } @@ -673,7 +673,7 @@ void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest void APIServerConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) { if (this->check_authenticated_()) { NoiseEncryptionSetKeyResponse ret = this->noise_encryption_set_key(msg); - if (!this->send_message(ret)) { + if (!this->send_message(ret, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE)) { this->on_fatal_error(); } } @@ -867,7 +867,7 @@ void APIServerConnection::on_subscribe_bluetooth_connections_free_request( const SubscribeBluetoothConnectionsFreeRequest &msg) { if (this->check_authenticated_()) { BluetoothConnectionsFreeResponse ret = this->subscribe_bluetooth_connections_free(msg); - if (!this->send_message(ret)) { + if (!this->send_message(ret, BluetoothConnectionsFreeResponse::MESSAGE_TYPE)) { this->on_fatal_error(); } } @@ -899,7 +899,7 @@ void APIServerConnection::on_subscribe_voice_assistant_request(const SubscribeVo void APIServerConnection::on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) { if (this->check_authenticated_()) { VoiceAssistantConfigurationResponse ret = this->voice_assistant_get_configuration(msg); - if (!this->send_message(ret)) { + if (!this->send_message(ret, VoiceAssistantConfigurationResponse::MESSAGE_TYPE)) { this->on_fatal_error(); } } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 9c5dc244fe..f7076a28ca 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -18,11 +18,11 @@ class APIServerConnectionBase : public ProtoService { public: #endif - template bool send_message(const T &msg) { + bool send_message(const ProtoMessage &msg, uint8_t message_type) { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_send_message_(msg.message_name(), msg.dump()); #endif - return this->send_message_(msg, T::MESSAGE_TYPE); + return this->send_message_(msg, message_type); } virtual void on_hello_request(const HelloRequest &value){}; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d95cec2f23..78c04f79c2 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -428,7 +428,8 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); this->set_noise_psk(psk); for (auto &c : this->clients_) { - c->send_message(DisconnectRequest()); + DisconnectRequest req; + c->send_message(req, DisconnectRequest::MESSAGE_TYPE); } }); } @@ -461,7 +462,8 @@ void APIServer::on_shutdown() { // Send disconnect requests to all connected clients for (auto &c : this->clients_) { - if (!c->send_message(DisconnectRequest())) { + DisconnectRequest req; + if (!c->send_message(req, DisconnectRequest::MESSAGE_TYPE)) { // If we can't send the disconnect request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority c->schedule_message_front_(nullptr, &APIConnection::try_send_disconnect_request, DisconnectRequest::MESSAGE_TYPE, diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index 1fbe68117b..809c658803 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -86,7 +86,7 @@ ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(clie #ifdef USE_API_SERVICES bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { auto resp = service->encode_list_service_response(); - return this->client_->send_message(resp); + return this->client_->send_message(resp, ListEntitiesServicesResponse::MESSAGE_TYPE); } #endif diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 44d434802c..2bfccdb438 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -75,7 +75,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga resp.data.reserve(param->read.value_len); // Use bulk insert instead of individual push_backs resp.data.insert(resp.data.end(), param->read.value, param->read.value + param->read.value_len); - this->proxy_->get_api_connection()->send_message(resp); + this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTReadResponse::MESSAGE_TYPE); break; } case ESP_GATTC_WRITE_CHAR_EVT: @@ -89,7 +89,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga api::BluetoothGATTWriteResponse resp; resp.address = this->address_; resp.handle = param->write.handle; - this->proxy_->get_api_connection()->send_message(resp); + this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTWriteResponse::MESSAGE_TYPE); break; } case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { @@ -103,7 +103,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga api::BluetoothGATTNotifyResponse resp; resp.address = this->address_; resp.handle = param->unreg_for_notify.handle; - this->proxy_->get_api_connection()->send_message(resp); + this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE); break; } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { @@ -116,7 +116,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga api::BluetoothGATTNotifyResponse resp; resp.address = this->address_; resp.handle = param->reg_for_notify.handle; - this->proxy_->get_api_connection()->send_message(resp); + this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE); break; } case ESP_GATTC_NOTIFY_EVT: { @@ -128,7 +128,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga resp.data.reserve(param->notify.value_len); // Use bulk insert instead of individual push_backs resp.data.insert(resp.data.end(), param->notify.value, param->notify.value + param->notify.value_len); - this->proxy_->get_api_connection()->send_message(resp); + this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyDataResponse::MESSAGE_TYPE); break; } default: diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 1c856b8d93..fea8975060 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -39,7 +39,7 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta resp.state = static_cast(state); resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - this->api_connection_->send_message(resp); + this->api_connection_->send_message(resp, api::BluetoothScannerStateResponse::MESSAGE_TYPE); } #ifdef USE_ESP32_BLE_DEVICE @@ -111,7 +111,7 @@ void BluetoothProxy::flush_pending_advertisements() { api::BluetoothLERawAdvertisementsResponse resp; resp.advertisements.swap(batch_buffer); - this->api_connection_->send_message(resp); + this->api_connection_->send_message(resp, api::BluetoothLERawAdvertisementsResponse::MESSAGE_TYPE); } #ifdef USE_ESP32_BLE_DEVICE @@ -150,7 +150,7 @@ void BluetoothProxy::send_api_packet_(const esp32_ble_tracker::ESPBTDevice &devi manufacturer_data.data.assign(data.data.begin(), data.data.end()); } - this->api_connection_->send_message(resp); + this->api_connection_->send_message(resp, api::BluetoothLEAdvertisementResponse::MESSAGE_TYPE); } #endif // USE_ESP32_BLE_DEVICE @@ -309,7 +309,7 @@ void BluetoothProxy::loop() { service_resp.characteristics.push_back(std::move(characteristic_resp)); } resp.services.push_back(std::move(service_resp)); - this->api_connection_->send_message(resp); + this->api_connection_->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } } } @@ -460,7 +460,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest call.success = ret == ESP_OK; call.error = ret; - this->api_connection_->send_message(call); + this->api_connection_->send_message(call, api::BluetoothDeviceClearCacheResponse::MESSAGE_TYPE); break; } @@ -582,7 +582,7 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui call.connected = connected; call.mtu = mtu; call.error = error; - this->api_connection_->send_message(call); + this->api_connection_->send_message(call, api::BluetoothDeviceConnectionResponse::MESSAGE_TYPE); } void BluetoothProxy::send_connections_free() { if (this->api_connection_ == nullptr) @@ -595,7 +595,7 @@ void BluetoothProxy::send_connections_free() { call.allocated.push_back(connection->address_); } } - this->api_connection_->send_message(call); + this->api_connection_->send_message(call, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); } void BluetoothProxy::send_gatt_services_done(uint64_t address) { @@ -603,7 +603,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) { return; api::BluetoothGATTGetServicesDoneResponse call; call.address = address; - this->api_connection_->send_message(call); + this->api_connection_->send_message(call, api::BluetoothGATTGetServicesDoneResponse::MESSAGE_TYPE); } void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) { @@ -613,7 +613,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_ call.address = address; call.handle = handle; call.error = error; - this->api_connection_->send_message(call); + this->api_connection_->send_message(call, api::BluetoothGATTWriteResponse::MESSAGE_TYPE); } void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) { @@ -622,7 +622,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_ call.paired = paired; call.error = error; - this->api_connection_->send_message(call); + this->api_connection_->send_message(call, api::BluetoothDevicePairingResponse::MESSAGE_TYPE); } void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) { @@ -631,7 +631,7 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_e call.success = success; call.error = error; - this->api_connection_->send_message(call); + this->api_connection_->send_message(call, api::BluetoothDeviceUnpairingResponse::MESSAGE_TYPE); } void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 4beed57188..90a1619e4c 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, CONF_WEB_SERVER, + DEVICE_CLASS_ABSOLUTE_HUMIDITY, DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_AQI, DEVICE_CLASS_AREA, @@ -81,6 +82,7 @@ from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ + DEVICE_CLASS_ABSOLUTE_HUMIDITY, DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_AQI, DEVICE_CLASS_AREA, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index ea74361d51..bcde623df2 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -41,6 +41,7 @@ from esphome.const import ( CONF_VALUE, CONF_WEB_SERVER, CONF_WINDOW_SIZE, + DEVICE_CLASS_ABSOLUTE_HUMIDITY, DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_AQI, DEVICE_CLASS_AREA, @@ -107,6 +108,7 @@ from esphome.util import Registry CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ + DEVICE_CLASS_ABSOLUTE_HUMIDITY, DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_AQI, DEVICE_CLASS_AREA, diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 9cf7d10936..a8cb22ccc9 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -223,7 +223,8 @@ void VoiceAssistant::loop() { msg.wake_word_phrase = this->wake_word_; this->wake_word_ = ""; - if (this->api_client_ == nullptr || !this->api_client_->send_message(msg)) { + if (this->api_client_ == nullptr || + !this->api_client_->send_message(msg, api::VoiceAssistantRequest::MESSAGE_TYPE)) { ESP_LOGW(TAG, "Could not request start"); this->error_trigger_->trigger("not-connected", "Could not request start"); this->continuous_ = false; @@ -245,7 +246,7 @@ void VoiceAssistant::loop() { if (this->audio_mode_ == AUDIO_MODE_API) { api::VoiceAssistantAudio msg; msg.data.assign((char *) this->send_buffer_, read_bytes); - this->api_client_->send_message(msg); + this->api_client_->send_message(msg, api::VoiceAssistantAudio::MESSAGE_TYPE); } else { if (!this->udp_socket_running_) { if (!this->start_udp_socket_()) { @@ -331,7 +332,7 @@ void VoiceAssistant::loop() { api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + this->api_client_->send_message(msg, api::VoiceAssistantAnnounceFinished::MESSAGE_TYPE); break; } } @@ -580,7 +581,7 @@ void VoiceAssistant::signal_stop_() { ESP_LOGD(TAG, "Signaling stop"); api::VoiceAssistantRequest msg; msg.start = false; - this->api_client_->send_message(msg); + this->api_client_->send_message(msg, api::VoiceAssistantRequest::MESSAGE_TYPE); } void VoiceAssistant::start_playback_timeout_() { @@ -590,7 +591,7 @@ void VoiceAssistant::start_playback_timeout_() { api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + this->api_client_->send_message(msg, api::VoiceAssistantAnnounceFinished::MESSAGE_TYPE); }); } diff --git a/esphome/const.py b/esphome/const.py index 333e822cfa..c5876a031b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1193,6 +1193,7 @@ UNIT_WATT = "W" UNIT_WATT_HOURS = "Wh" # device classes +DEVICE_CLASS_ABSOLUTE_HUMIDITY = "absolute_humidity" DEVICE_CLASS_APPARENT_POWER = "apparent_power" DEVICE_CLASS_AQI = "aqi" DEVICE_CLASS_AREA = "area" diff --git a/esphome/core/application.h b/esphome/core/application.h index f2b5cb5c89..75b9769ca3 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -368,8 +368,19 @@ class Application { uint8_t get_app_state() const { return this->app_state_; } -// Helper macro for entity getter method declarations - reduces code duplication -// When USE_DEVICE_ID is enabled in the future, this can be conditionally compiled to add device_id parameter +// Helper macro for entity getter method declarations +#ifdef USE_DEVICES +#define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ + entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ + for (auto *obj : this->entities_member##_) { \ + if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ + (include_internal || !obj->is_internal())) \ + return obj; \ + } \ + return nullptr; \ + } + const std::vector &get_devices() { return this->devices_; } +#else #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ @@ -378,10 +389,7 @@ class Application { } \ return nullptr; \ } - -#ifdef USE_DEVICES - const std::vector &get_devices() { return this->devices_; } -#endif +#endif // USE_DEVICES #ifdef USE_AREAS const std::vector &get_areas() { return this->areas_; } #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5ad16ac76c..cc388ffb4c 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -198,9 +198,12 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Get device name if entity is on a sub-device device_name = None + device_id = "" # Empty string for main device if CONF_DEVICE_ID in config: device_id_obj = config[CONF_DEVICE_ID] device_name = device_id_obj.id + # Use the device ID string directly for uniqueness + device_id = device_id_obj.id # Calculate what object_id will actually be used # This handles empty names correctly by using device/friendly names @@ -209,11 +212,12 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy ) # Check for duplicates - unique_key = (platform, name_key) + unique_key = (device_id, platform, name_key) if unique_key in CORE.unique_ids: + device_prefix = f" on device '{device_id}'" if device_id else "" raise cv.Invalid( - f"Duplicate {platform} entity with name '{entity_name}' found. " - f"Each entity must have a unique name within its platform across all devices." + f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " + f"Each entity on a device must have a unique name within its platform." ) # Add to tracking set diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index e441d4c6e9..46976918f9 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1713,13 +1713,12 @@ static const char *const TAG = "api.service"; hpp += " public:\n" hpp += "#endif\n\n" - # Add generic send_message method - hpp += " template\n" - hpp += " bool send_message(const T &msg) {\n" + # Add non-template send_message method + hpp += " bool send_message(const ProtoMessage &msg, uint8_t message_type) {\n" hpp += "#ifdef HAS_PROTO_MESSAGE_DUMP\n" hpp += " this->log_send_message_(msg.message_name(), msg.dump());\n" hpp += "#endif\n" - hpp += " return this->send_message_(msg, T::MESSAGE_TYPE);\n" + hpp += " return this->send_message_(msg, message_type);\n" hpp += " }\n\n" # Add logging helper method implementation to cpp @@ -1805,7 +1804,9 @@ static const char *const TAG = "api.service"; handler_body = f"this->{func}(msg);\n" else: handler_body = f"{ret} ret = this->{func}(msg);\n" - handler_body += "if (!this->send_message(ret)) {\n" + handler_body += ( + f"if (!this->send_message(ret, {ret}::MESSAGE_TYPE)) {{\n" + ) handler_body += " this->on_fatal_error();\n" handler_body += "}\n" @@ -1818,7 +1819,7 @@ static const char *const TAG = "api.service"; body += f"this->{func}(msg);\n" else: body += f"{ret} ret = this->{func}(msg);\n" - body += "if (!this->send_message(ret)) {\n" + body += f"if (!this->send_message(ret, {ret}::MESSAGE_TYPE)) {{\n" body += " this->on_fatal_error();\n" body += "}\n" diff --git a/script/helpers.py b/script/helpers.py index ff63bbc5b6..9032451b4f 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -530,27 +530,26 @@ def get_components_from_integration_fixtures() -> set[str]: Returns: Set of component names used in integration test fixtures """ - import yaml + from esphome import yaml_util components: set[str] = set() fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures" for yaml_file in fixtures_dir.glob("*.yaml"): - with open(yaml_file) as f: - config: dict[str, any] | None = yaml.safe_load(f) - if not config: + config: dict[str, any] | None = yaml_util.load_yaml(str(yaml_file)) + if not config: + continue + + # Add all top-level component keys + components.update(config.keys()) + + # Add platform components (e.g., output.template) + for value in config.values(): + if not isinstance(value, list): continue - # Add all top-level component keys - components.update(config.keys()) - - # Add platform components (e.g., output.template) - for value in config.values(): - if not isinstance(value, list): - continue - - for item in value: - if isinstance(item, dict) and "platform" in item: - components.add(item["platform"]) + for item in value: + if isinstance(item, dict) and "platform" in item: + components.add(item["platform"]) return components diff --git a/tests/integration/fixtures/areas_and_devices.yaml b/tests/integration/fixtures/areas_and_devices.yaml index 6bf1519c79..12ab070e55 100644 --- a/tests/integration/fixtures/areas_and_devices.yaml +++ b/tests/integration/fixtures/areas_and_devices.yaml @@ -54,3 +54,45 @@ sensor: device_id: smart_switch_device lambda: return 4.0; update_interval: 0.1s + +# Switches with the same name on different devices to test device_id lookup +switch: + # Switch with no device_id (defaults to 0) + - platform: template + name: Test Switch + id: test_switch_main + optimistic: true + turn_on_action: + - logger.log: "Turning on Test Switch on Main Device (no device_id)" + turn_off_action: + - logger.log: "Turning off Test Switch on Main Device (no device_id)" + + - platform: template + name: Test Switch + device_id: light_controller_device + id: test_switch_light_controller + optimistic: true + turn_on_action: + - logger.log: "Turning on Test Switch on Light Controller" + turn_off_action: + - logger.log: "Turning off Test Switch on Light Controller" + + - platform: template + name: Test Switch + device_id: temp_sensor_device + id: test_switch_temp_sensor + optimistic: true + turn_on_action: + - logger.log: "Turning on Test Switch on Temperature Sensor" + turn_off_action: + - logger.log: "Turning off Test Switch on Temperature Sensor" + + - platform: template + name: Test Switch + device_id: motion_detector_device + id: test_switch_motion_detector + optimistic: true + turn_on_action: + - logger.log: "Turning on Test Switch on Motion Detector" + turn_off_action: + - logger.log: "Turning off Test Switch on Motion Detector" diff --git a/tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml similarity index 61% rename from tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml rename to tests/integration/fixtures/duplicate_entities_on_different_devices.yaml index f7d017a0ae..ecc502ad28 100644 --- a/tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml +++ b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml @@ -1,6 +1,6 @@ esphome: name: duplicate-entities-test - # Define devices to test multi-device unique name validation + # Define devices to test multi-device duplicate handling devices: - id: controller_1 name: Controller 1 @@ -13,31 +13,31 @@ host: api: # Port will be automatically injected logger: -# Test that duplicate entity names are NOT allowed on different devices +# Test that duplicate entity names are allowed on different devices -# Scenario 1: Different sensor names on different devices (allowed) +# Scenario 1: Same sensor name on different devices (allowed) sensor: - platform: template - name: Temperature Controller 1 + name: Temperature device_id: controller_1 lambda: return 21.0; update_interval: 0.1s - platform: template - name: Temperature Controller 2 + name: Temperature device_id: controller_2 lambda: return 22.0; update_interval: 0.1s - platform: template - name: Temperature Controller 3 + name: Temperature device_id: controller_3 lambda: return 23.0; update_interval: 0.1s # Main device sensor (no device_id) - platform: template - name: Temperature Main + name: Temperature lambda: return 20.0; update_interval: 0.1s @@ -47,20 +47,20 @@ sensor: lambda: return 60.0; update_interval: 0.1s -# Scenario 2: Different binary sensor names on different devices +# Scenario 2: Same binary sensor name on different devices (allowed) binary_sensor: - platform: template - name: Status Controller 1 + name: Status device_id: controller_1 lambda: return true; - platform: template - name: Status Controller 2 + name: Status device_id: controller_2 lambda: return false; - platform: template - name: Status Main + name: Status lambda: return true; # Main device # Different platform can have same name as sensor @@ -68,43 +68,43 @@ binary_sensor: name: Temperature lambda: return true; -# Scenario 3: Different text sensor names on different devices +# Scenario 3: Same text sensor name on different devices text_sensor: - platform: template - name: Device Info Controller 1 + name: Device Info device_id: controller_1 lambda: return {"Controller 1 Active"}; update_interval: 0.1s - platform: template - name: Device Info Controller 2 + name: Device Info device_id: controller_2 lambda: return {"Controller 2 Active"}; update_interval: 0.1s - platform: template - name: Device Info Main + name: Device Info lambda: return {"Main Device Active"}; update_interval: 0.1s -# Scenario 4: Different switch names on different devices +# Scenario 4: Same switch name on different devices switch: - platform: template - name: Power Controller 1 + name: Power device_id: controller_1 lambda: return false; turn_on_action: [] turn_off_action: [] - platform: template - name: Power Controller 2 + name: Power device_id: controller_2 lambda: return true; turn_on_action: [] turn_off_action: [] - platform: template - name: Power Controller 3 + name: Power device_id: controller_3 lambda: return false; turn_on_action: [] @@ -117,54 +117,26 @@ switch: turn_on_action: [] turn_off_action: [] -# Scenario 5: Buttons with unique names +# Scenario 5: Empty names on different devices (should use device name) button: - platform: template - name: "Reset Controller 1" + name: "" device_id: controller_1 on_press: [] - platform: template - name: "Reset Controller 2" + name: "" device_id: controller_2 on_press: [] - platform: template - name: "Reset Main" + name: "" on_press: [] # Main device -# Scenario 6: Empty names (should use device names) -select: - - platform: template - name: "" - device_id: controller_1 - options: - - "Option 1" - - "Option 2" - lambda: return {"Option 1"}; - set_action: [] - - - platform: template - name: "" - device_id: controller_2 - options: - - "Option 1" - - "Option 2" - lambda: return {"Option 1"}; - set_action: [] - - - platform: template - name: "" # Main device - options: - - "Option 1" - - "Option 2" - lambda: return {"Option 1"}; - set_action: [] - -# Scenario 7: Special characters in names - now with unique names +# Scenario 6: Special characters in names number: - platform: template - name: "Temperature Setpoint! Controller 1" + name: "Temperature Setpoint!" device_id: controller_1 min_value: 10.0 max_value: 30.0 @@ -173,7 +145,7 @@ number: set_action: [] - platform: template - name: "Temperature Setpoint! Controller 2" + name: "Temperature Setpoint!" device_id: controller_2 min_value: 10.0 max_value: 30.0 diff --git a/tests/integration/test_areas_and_devices.py b/tests/integration/test_areas_and_devices.py index 55c96d896d..1af16c87e8 100644 --- a/tests/integration/test_areas_and_devices.py +++ b/tests/integration/test_areas_and_devices.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from aioesphomeapi import EntityState +from aioesphomeapi import EntityState, SwitchInfo, SwitchState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -84,23 +84,45 @@ async def test_areas_and_devices( # Subscribe to states to get sensor values loop = asyncio.get_running_loop() - states: dict[int, EntityState] = {} - states_future: asyncio.Future[bool] = loop.create_future() + states: dict[tuple[int, int], EntityState] = {} + # Subscribe to all switch states + switch_state_futures: dict[ + tuple[int, int], asyncio.Future[EntityState] + ] = {} # (device_id, key) -> future + initial_states_received: set[tuple[int, int]] = set() + initial_states_future: asyncio.Future[bool] = loop.create_future() def on_state(state: EntityState) -> None: - states[state.key] = state - # Check if we have all expected sensor states - if len(states) >= 4 and not states_future.done(): - states_future.set_result(True) + state_key = (state.device_id, state.key) + states[state_key] = state + + initial_states_received.add(state_key) + # Check if we have all initial states + if ( + len(initial_states_received) >= 8 # 8 entities expected + and not initial_states_future.done() + ): + initial_states_future.set_result(True) + + if not initial_states_future.done(): + return + + # Resolve the future for this switch if it exists + if ( + state_key in switch_state_futures + and not switch_state_futures[state_key].done() + and isinstance(state, SwitchState) + ): + switch_state_futures[state_key].set_result(state) client.subscribe_states(on_state) # Wait for sensor states try: - await asyncio.wait_for(states_future, timeout=10.0) + await asyncio.wait_for(initial_states_future, timeout=10.0) except TimeoutError: pytest.fail( - f"Did not receive all sensor states within 10 seconds. " + f"Did not receive all states within 10 seconds. " f"Received {len(states)} states" ) @@ -119,3 +141,121 @@ async def test_areas_and_devices( f"{entity.name} has device_id {entity.device_id}, " f"expected {expected_device_id}" ) + + all_entities, _ = entities # Unpack the tuple + switch_entities = [e for e in all_entities if isinstance(e, SwitchInfo)] + + # Find all switches named "Test Switch" + test_switches = [e for e in switch_entities if e.name == "Test Switch"] + assert len(test_switches) == 4, ( + f"Expected 4 'Test Switch' entities, got {len(test_switches)}" + ) + + # Verify we have switches with different device_ids including one with 0 (main) + switch_device_ids = {s.device_id for s in test_switches} + assert len(switch_device_ids) == 4, ( + "All Test Switch entities should have different device_ids" + ) + assert 0 in switch_device_ids, ( + "Should have a switch with device_id 0 (main device)" + ) + + # Wait for initial states to be received for all switches + await asyncio.wait_for(initial_states_future, timeout=2.0) + + # Test controlling each switch specifically by device_id + for device_name, device in [ + ("Light Controller", light_controller), + ("Temperature Sensor", temp_sensor), + ("Motion Detector", motion_detector), + ]: + # Find the switch for this specific device + device_switch = next( + (s for s in test_switches if s.device_id == device.device_id), None + ) + assert device_switch is not None, f"No Test Switch found for {device_name}" + + # Create future for this switch's state change + state_key = (device_switch.device_id, device_switch.key) + switch_state_futures[state_key] = loop.create_future() + + # Turn on the switch with device_id + client.switch_command( + device_switch.key, True, device_id=device_switch.device_id + ) + + # Wait for state to change + await asyncio.wait_for(switch_state_futures[state_key], timeout=2.0) + + # Verify the correct switch was turned on + assert states[state_key].state is True, f"{device_name} switch should be on" + + # Create new future for turning off + switch_state_futures[state_key] = loop.create_future() + + # Turn off the switch with device_id + client.switch_command( + device_switch.key, False, device_id=device_switch.device_id + ) + + # Wait for state to change + await asyncio.wait_for(switch_state_futures[state_key], timeout=2.0) + + # Verify the correct switch was turned off + assert states[state_key].state is False, ( + f"{device_name} switch should be off" + ) + + # Test that controlling a switch with device_id doesn't affect main switch + # Find the main switch (device_id = 0) + main_switch = next((s for s in test_switches if s.device_id == 0), None) + assert main_switch is not None, "No main switch (device_id=0) found" + + # Find a switch with a device_id + device_switch = next( + (s for s in test_switches if s.device_id == light_controller.device_id), + None, + ) + assert device_switch is not None, "No device switch found" + + # Create futures for both switches + main_key = (main_switch.device_id, main_switch.key) + device_key = (device_switch.device_id, device_switch.key) + + # Turn on the main switch first + switch_state_futures[main_key] = loop.create_future() + client.switch_command(main_switch.key, True, device_id=main_switch.device_id) + await asyncio.wait_for(switch_state_futures[main_key], timeout=2.0) + assert states[main_key].state is True, "Main switch should be on" + + # Now turn on the device switch + switch_state_futures[device_key] = loop.create_future() + client.switch_command( + device_switch.key, True, device_id=device_switch.device_id + ) + await asyncio.wait_for(switch_state_futures[device_key], timeout=2.0) + + # Verify device switch is on and main switch is still on + assert states[device_key].state is True, "Device switch should be on" + assert states[main_key].state is True, ( + "Main switch should still be on after turning on device switch" + ) + + # Turn off the device switch + switch_state_futures[device_key] = loop.create_future() + client.switch_command( + device_switch.key, False, device_id=device_switch.device_id + ) + await asyncio.wait_for(switch_state_futures[device_key], timeout=2.0) + + # Verify device switch is off and main switch is still on + assert states[device_key].state is False, "Device switch should be off" + assert states[main_key].state is True, ( + "Main switch should still be on after turning off device switch" + ) + + # Clean up - turn off main switch + switch_state_futures[main_key] = loop.create_future() + client.switch_command(main_switch.key, False, device_id=main_switch.device_id) + await asyncio.wait_for(switch_state_futures[main_key], timeout=2.0) + assert states[main_key].state is False, "Main switch should be off" diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index 2c1fcba0eb..c738bb3fe0 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction @pytest.mark.asyncio -async def test_duplicate_entities_not_allowed_on_different_devices( +async def test_duplicate_entities_on_different_devices( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test that duplicate entity names are NOT allowed on different devices.""" + """Test that duplicate entity names are allowed on different devices.""" async with run_compiled(yaml_config), api_client_connected() as client: # Get device info device_info = await client.device_info() @@ -52,46 +52,42 @@ async def test_duplicate_entities_not_allowed_on_different_devices( switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"] buttons = [e for e in all_entities if e.__class__.__name__ == "ButtonInfo"] numbers = [e for e in all_entities if e.__class__.__name__ == "NumberInfo"] - selects = [e for e in all_entities if e.__class__.__name__ == "SelectInfo"] - # Scenario 1: Check that temperature sensors have unique names per device - temp_sensors = [s for s in sensors if "Temperature" in s.name] + # Scenario 1: Check sensors with same "Temperature" name on different devices + temp_sensors = [s for s in sensors if s.name == "Temperature"] assert len(temp_sensors) == 4, ( f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}" ) - # Verify each sensor has a unique name - temp_names = set() + # Verify each sensor is on a different device + temp_device_ids = set() temp_object_ids = set() for sensor in temp_sensors: - temp_names.add(sensor.name) + temp_device_ids.add(sensor.device_id) temp_object_ids.add(sensor.object_id) - # Should have 4 unique names - assert len(temp_names) == 4, ( - f"Temperature sensors should have unique names, got {temp_names}" + # All should have object_id "temperature" (no suffix) + assert sensor.object_id == "temperature", ( + f"Expected object_id 'temperature', got '{sensor.object_id}'" + ) + + # Should have 4 different device IDs (including None for main device) + assert len(temp_device_ids) == 4, ( + f"Temperature sensors should be on different devices, got {temp_device_ids}" ) - # Object IDs should also be unique - assert len(temp_object_ids) == 4, ( - f"Temperature sensors should have unique object_ids, got {temp_object_ids}" - ) - - # Scenario 2: Check binary sensors have unique names - status_binary = [b for b in binary_sensors if "Status" in b.name] + # Scenario 2: Check binary sensors "Status" on different devices + status_binary = [b for b in binary_sensors if b.name == "Status"] assert len(status_binary) == 3, ( f"Expected exactly 3 status binary sensors, got {len(status_binary)}" ) - # All should have unique object_ids - status_names = set() + # All should have object_id "status" for binary in status_binary: - status_names.add(binary.name) - - assert len(status_names) == 3, ( - f"Status binary sensors should have unique names, got {status_names}" - ) + assert binary.object_id == "status", ( + f"Expected object_id 'status', got '{binary.object_id}'" + ) # Scenario 3: Check that sensor and binary_sensor can have same name temp_binary = [b for b in binary_sensors if b.name == "Temperature"] @@ -100,86 +96,62 @@ async def test_duplicate_entities_not_allowed_on_different_devices( ) assert temp_binary[0].object_id == "temperature" - # Scenario 4: Check text sensors have unique names - info_text = [t for t in text_sensors if "Device Info" in t.name] + # Scenario 4: Check text sensors "Device Info" on different devices + info_text = [t for t in text_sensors if t.name == "Device Info"] assert len(info_text) == 3, ( f"Expected exactly 3 device info text sensors, got {len(info_text)}" ) - # All should have unique names and object_ids - info_names = set() + # All should have object_id "device_info" for text in info_text: - info_names.add(text.name) + assert text.object_id == "device_info", ( + f"Expected object_id 'device_info', got '{text.object_id}'" + ) - assert len(info_names) == 3, ( - f"Device info text sensors should have unique names, got {info_names}" + # Scenario 5: Check switches "Power" on different devices + power_switches = [s for s in switches if s.name == "Power"] + assert len(power_switches) == 3, ( + f"Expected exactly 3 power switches, got {len(power_switches)}" ) - # Scenario 5: Check switches have unique names - power_switches = [s for s in switches if "Power" in s.name] - assert len(power_switches) == 4, ( - f"Expected exactly 4 power switches, got {len(power_switches)}" - ) - - # All should have unique names - power_names = set() + # All should have object_id "power" for switch in power_switches: - power_names.add(switch.name) + assert switch.object_id == "power", ( + f"Expected object_id 'power', got '{switch.object_id}'" + ) - assert len(power_names) == 4, ( - f"Power switches should have unique names, got {power_names}" - ) - - # Scenario 6: Check reset buttons have unique names - reset_buttons = [b for b in buttons if "Reset" in b.name] - assert len(reset_buttons) == 3, ( - f"Expected exactly 3 reset buttons, got {len(reset_buttons)}" - ) - - # All should have unique names - reset_names = set() - for button in reset_buttons: - reset_names.add(button.name) - - assert len(reset_names) == 3, ( - f"Reset buttons should have unique names, got {reset_names}" - ) - - # Scenario 7: Check empty name selects (should use device names) - empty_selects = [s for s in selects if s.name == ""] - assert len(empty_selects) == 3, ( - f"Expected exactly 3 empty name selects, got {len(empty_selects)}" + # Scenario 6: Check empty name buttons (should use device name) + empty_buttons = [b for b in buttons if b.name == ""] + assert len(empty_buttons) == 3, ( + f"Expected exactly 3 empty name buttons, got {len(empty_buttons)}" ) # Group by device - c1_selects = [s for s in empty_selects if s.device_id == controller_1.device_id] - c2_selects = [s for s in empty_selects if s.device_id == controller_2.device_id] + c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id] + c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.device_id] # For main device, device_id is 0 - main_selects = [s for s in empty_selects if s.device_id == 0] + main_buttons = [b for b in empty_buttons if b.device_id == 0] - # Check object IDs for empty name entities - they should use device names - assert len(c1_selects) == 1 and c1_selects[0].object_id == "controller_1" - assert len(c2_selects) == 1 and c2_selects[0].object_id == "controller_2" + # Check object IDs for empty name entities + assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1" + assert len(c2_buttons) == 1 and c2_buttons[0].object_id == "controller_2" assert ( - len(main_selects) == 1 - and main_selects[0].object_id == "duplicate-entities-test" + len(main_buttons) == 1 + and main_buttons[0].object_id == "duplicate-entities-test" ) - # Scenario 8: Check special characters in number names - now unique - temp_numbers = [n for n in numbers if "Temperature Setpoint!" in n.name] + # Scenario 7: Check special characters in number names + temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"] assert len(temp_numbers) == 2, ( f"Expected exactly 2 temperature setpoint numbers, got {len(temp_numbers)}" ) - # Should have unique names - setpoint_names = set() + # Special characters should be sanitized to _ in object_id for number in temp_numbers: - setpoint_names.add(number.name) - - assert len(setpoint_names) == 2, ( - f"Temperature setpoint numbers should have unique names, got {setpoint_names}" - ) + assert number.object_id == "temperature_setpoint_", ( + f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'" + ) # Verify we can get states for all entities (ensures they're functional) loop = asyncio.get_running_loop() @@ -192,7 +164,6 @@ async def test_duplicate_entities_not_allowed_on_different_devices( + len(switches) + len(buttons) + len(numbers) - + len(selects) ) def on_state(state) -> None: diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index d0db08e6f7..423e2d3c30 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -986,8 +986,7 @@ def test_get_components_from_integration_fixtures() -> None: with ( patch("pathlib.Path.glob") as mock_glob, - patch("builtins.open", create=True), - patch("yaml.safe_load", return_value=yaml_content), + patch("esphome.yaml_util.load_yaml", return_value=yaml_content), ): mock_glob.return_value = [mock_yaml_file] diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 4f256ffb33..c639ad94b2 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -510,13 +510,13 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - assert ("sensor", "temperature") in CORE.unique_ids + assert ("", "sensor", "temperature") in CORE.unique_ids # Second entity with different name should pass config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - assert ("sensor", "humidity") in CORE.unique_ids + assert ("", "sensor", "humidity") in CORE.unique_ids # Duplicate entity should fail config3 = {CONF_NAME: "Temperature"} @@ -535,36 +535,24 @@ def test_entity_duplicate_validator_with_devices() -> None: device1 = ID("device1", type="Device") device2 = ID("device2", type="Device") - # First entity on device1 should pass + # Same name on different devices should pass config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("sensor", "temperature") in CORE.unique_ids + assert ("device1", "sensor", "temperature") in CORE.unique_ids - # Same name on different device should now fail config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} + validated2 = validator(config2) + assert validated2 == config2 + assert ("device2", "sensor", "temperature") in CORE.unique_ids + + # Duplicate on same device should fail + config3 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} with pytest.raises( Invalid, - match=r"Duplicate sensor entity with name 'Temperature' found. Each entity must have a unique name within its platform across all devices.", + match=r"Duplicate sensor entity with name 'Temperature' found on device 'device1'", ): - validator(config2) - - # Different name on device2 should pass - config3 = {CONF_NAME: "Humidity", CONF_DEVICE_ID: device2} - validated3 = validator(config3) - assert validated3 == config3 - assert ("sensor", "humidity") in CORE.unique_ids - - # Empty names should use device names and be allowed - config4 = {CONF_NAME: "", CONF_DEVICE_ID: device1} - validated4 = validator(config4) - assert validated4 == config4 - assert ("sensor", "device1") in CORE.unique_ids - - config5 = {CONF_NAME: "", CONF_DEVICE_ID: device2} - validated5 = validator(config5) - assert validated5 == config5 - assert ("sensor", "device2") in CORE.unique_ids + validator(config3) def test_duplicate_entity_yaml_validation( @@ -588,10 +576,10 @@ def test_duplicate_entity_with_devices_yaml_validation( ) assert result is None - # Check for the duplicate entity error message + # Check for the duplicate entity error message with device captured = capsys.readouterr() assert ( - "Duplicate sensor entity with name 'Temperature' found. Each entity must have a unique name within its platform across all devices." + "Duplicate sensor entity with name 'Temperature' found on device 'device1'" in captured.out ) @@ -616,21 +604,22 @@ def test_entity_duplicate_validator_internal_entities() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - assert ("sensor", "temperature") in CORE.unique_ids + # New format includes device_id (empty string for main device) + assert ("", "sensor", "temperature") in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} validated2 = validator(config2) assert validated2 == config2 # Internal entity should not be added to unique_ids - assert len([k for k in CORE.unique_ids if k == ("sensor", "temperature")]) == 1 + assert len([k for k in CORE.unique_ids if k == ("", "sensor", "temperature")]) == 1 # Another internal entity with same name should also pass config3 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - assert len([k for k in CORE.unique_ids if k == ("sensor", "temperature")]) == 1 + assert len([k for k in CORE.unique_ids if k == ("", "sensor", "temperature")]) == 1 # Non-internal entity with same name should fail config4 = {CONF_NAME: "Temperature"}