diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 5dd779effb..30cf982649 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -f84518ea4140c194b21cc516aae05aaa0cf876ab866f89e22e91842df46333ed +6af8b429b94191fe8e239fcb3b73f7982d0266cb5b05ffbc81edaeac1bc8c273 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3f290c43f..992918a035 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,7 +281,7 @@ jobs: pio_cache_key: tidyesp32-idf - id: clang-tidy name: Run script/clang-tidy for ZEPHYR - options: --environment nrf52-tidy --grep USE_ZEPHYR + options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 pio_cache_key: tidy-zephyr ignore_errors: false diff --git a/CODEOWNERS b/CODEOWNERS index 244e204ab6..e40be9a737 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -155,6 +155,7 @@ esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core esphome/components/esp_ldo/* @clydebarrow +esphome/components/espnow/* @jesserockz esphome/components/ethernet_info/* @gtjadsonsantos esphome/components/event/* @nohat esphome/components/event_emitter/* @Rapsssito diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 1232d9677f..f260e13242 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -267,6 +267,11 @@ def validate_adc_pin(value): {CONF_ANALOG: True, CONF_INPUT: True}, internal=True )(value) + if CORE.is_nrf52: + return pins.gpio_pin_schema( + {CONF_ANALOG: True, CONF_INPUT: True}, internal=True + )(value) + raise NotImplementedError @@ -283,5 +288,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "adc_sensor_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 00a703191e..526dd57fd5 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -13,6 +13,10 @@ #include "hal/adc_types.h" // This defines ADC_CHANNEL_MAX #endif // USE_ESP32 +#ifdef USE_ZEPHYR +#include +#endif + namespace esphome { namespace adc { @@ -38,15 +42,15 @@ enum class SamplingMode : uint8_t { const LogString *sampling_mode_to_str(SamplingMode mode); -class Aggregator { +template class Aggregator { public: Aggregator(SamplingMode mode); - void add_sample(uint32_t value); - uint32_t aggregate(); + void add_sample(T value); + T aggregate(); protected: - uint32_t aggr_{0}; - uint32_t samples_{0}; + T aggr_{0}; + uint8_t samples_{0}; SamplingMode mode_{SamplingMode::AVG}; }; @@ -69,6 +73,11 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage /// @return A float representing the setup priority. float get_setup_priority() const override; +#ifdef USE_ZEPHYR + /// Set the ADC channel to be used by the ADC sensor. + /// @param channel Pointer to an adc_dt_spec structure representing the ADC channel. + void set_adc_channel(const adc_dt_spec *channel) { this->channel_ = channel; } +#endif /// Set the GPIO pin to be used by the ADC sensor. /// @param pin Pointer to an InternalGPIOPin representing the ADC input pin. void set_pin(InternalGPIOPin *pin) { this->pin_ = pin; } @@ -151,6 +160,10 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage #ifdef USE_RP2040 bool is_temperature_{false}; #endif // USE_RP2040 + +#ifdef USE_ZEPHYR + const struct adc_dt_spec *channel_ = nullptr; +#endif }; } // namespace adc diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 797ab75045..748c8634b7 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -18,15 +18,15 @@ const LogString *sampling_mode_to_str(SamplingMode mode) { return LOG_STR("unknown"); } -Aggregator::Aggregator(SamplingMode mode) { +template Aggregator::Aggregator(SamplingMode mode) { this->mode_ = mode; // set to max uint if mode is "min" if (mode == SamplingMode::MIN) { - this->aggr_ = UINT32_MAX; + this->aggr_ = std::numeric_limits::max(); } } -void Aggregator::add_sample(uint32_t value) { +template void Aggregator::add_sample(T value) { this->samples_ += 1; switch (this->mode_) { @@ -47,7 +47,7 @@ void Aggregator::add_sample(uint32_t value) { } } -uint32_t Aggregator::aggregate() { +template T Aggregator::aggregate() { if (this->mode_ == SamplingMode::AVG) { if (this->samples_ == 0) { return this->aggr_; @@ -59,6 +59,12 @@ uint32_t Aggregator::aggregate() { return this->aggr_; } +#ifdef USE_ZEPHYR +template class Aggregator; +#else +template class Aggregator; +#endif + void ADCSensor::update() { float value_v = this->sample(); ESP_LOGV(TAG, "'%s': Voltage=%.4fV", this->get_name().c_str(), value_v); diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index 9905475b1e..87d4ddd35f 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -152,7 +152,7 @@ float ADCSensor::sample() { } float ADCSensor::sample_fixed_attenuation_() { - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { int raw; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index 1b4b314570..be14b252d4 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -37,7 +37,7 @@ void ADCSensor::dump_config() { } float ADCSensor::sample() { - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { uint32_t raw = 0; diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index e4fd4e5d4d..0b1393c2e7 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -30,7 +30,7 @@ void ADCSensor::dump_config() { float ADCSensor::sample() { uint32_t raw = 0; - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); if (this->output_raw_) { for (uint8_t sample = 0; sample < this->sample_count_; sample++) { diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 90c640a0b1..8496e0f41e 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -41,7 +41,7 @@ void ADCSensor::dump_config() { float ADCSensor::sample() { uint32_t raw = 0; - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp new file mode 100644 index 0000000000..2fb9d4b0e5 --- /dev/null +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -0,0 +1,207 @@ + +#include "adc_sensor.h" +#ifdef USE_ZEPHYR +#include "esphome/core/log.h" + +#include "hal/nrf_saadc.h" + +namespace esphome { +namespace adc { + +static const char *const TAG = "adc.zephyr"; + +void ADCSensor::setup() { + if (!adc_is_ready_dt(this->channel_)) { + ESP_LOGE(TAG, "ADC controller device %s not ready", this->channel_->dev->name); + return; + } + + auto err = adc_channel_setup_dt(this->channel_); + if (err < 0) { + ESP_LOGE(TAG, "Could not setup channel %s (%d)", this->channel_->dev->name, err); + return; + } +} + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE +static const LogString *gain_to_str(enum adc_gain gain) { + switch (gain) { + case ADC_GAIN_1_6: + return LOG_STR("1/6"); + case ADC_GAIN_1_5: + return LOG_STR("1/5"); + case ADC_GAIN_1_4: + return LOG_STR("1/4"); + case ADC_GAIN_1_3: + return LOG_STR("1/3"); + case ADC_GAIN_2_5: + return LOG_STR("2/5"); + case ADC_GAIN_1_2: + return LOG_STR("1/2"); + case ADC_GAIN_2_3: + return LOG_STR("2/3"); + case ADC_GAIN_4_5: + return LOG_STR("4/5"); + case ADC_GAIN_1: + return LOG_STR("1"); + case ADC_GAIN_2: + return LOG_STR("2"); + case ADC_GAIN_3: + return LOG_STR("3"); + case ADC_GAIN_4: + return LOG_STR("4"); + case ADC_GAIN_6: + return LOG_STR("6"); + case ADC_GAIN_8: + return LOG_STR("8"); + case ADC_GAIN_12: + return LOG_STR("12"); + case ADC_GAIN_16: + return LOG_STR("16"); + case ADC_GAIN_24: + return LOG_STR("24"); + case ADC_GAIN_32: + return LOG_STR("32"); + case ADC_GAIN_64: + return LOG_STR("64"); + case ADC_GAIN_128: + return LOG_STR("128"); + } + return LOG_STR("undefined gain"); +} + +static const LogString *reference_to_str(enum adc_reference reference) { + switch (reference) { + case ADC_REF_VDD_1: + return LOG_STR("VDD"); + case ADC_REF_VDD_1_2: + return LOG_STR("VDD/2"); + case ADC_REF_VDD_1_3: + return LOG_STR("VDD/3"); + case ADC_REF_VDD_1_4: + return LOG_STR("VDD/4"); + case ADC_REF_INTERNAL: + return LOG_STR("INTERNAL"); + case ADC_REF_EXTERNAL0: + return LOG_STR("External, input 0"); + case ADC_REF_EXTERNAL1: + return LOG_STR("External, input 1"); + } + return LOG_STR("undefined reference"); +} + +static const LogString *input_to_str(uint8_t input) { + switch (input) { + case NRF_SAADC_INPUT_AIN0: + return LOG_STR("AIN0"); + case NRF_SAADC_INPUT_AIN1: + return LOG_STR("AIN1"); + case NRF_SAADC_INPUT_AIN2: + return LOG_STR("AIN2"); + case NRF_SAADC_INPUT_AIN3: + return LOG_STR("AIN3"); + case NRF_SAADC_INPUT_AIN4: + return LOG_STR("AIN4"); + case NRF_SAADC_INPUT_AIN5: + return LOG_STR("AIN5"); + case NRF_SAADC_INPUT_AIN6: + return LOG_STR("AIN6"); + case NRF_SAADC_INPUT_AIN7: + return LOG_STR("AIN7"); + case NRF_SAADC_INPUT_VDD: + return LOG_STR("VDD"); + case NRF_SAADC_INPUT_VDDHDIV5: + return LOG_STR("VDDHDIV5"); + } + return LOG_STR("undefined input"); +} +#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + +void ADCSensor::dump_config() { + LOG_SENSOR("", "ADC Sensor", this); + LOG_PIN(" Pin: ", this->pin_); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, + " Name: %s\n" + " Channel: %d\n" + " vref_mv: %d\n" + " Resolution %d\n" + " Oversampling %d", + this->channel_->dev->name, this->channel_->channel_id, this->channel_->vref_mv, this->channel_->resolution, + this->channel_->oversampling); + + ESP_LOGV(TAG, + " Gain: %s\n" + " reference: %s\n" + " acquisition_time: %d\n" + " differential %s", + LOG_STR_ARG(gain_to_str(this->channel_->channel_cfg.gain)), + LOG_STR_ARG(reference_to_str(this->channel_->channel_cfg.reference)), + this->channel_->channel_cfg.acquisition_time, YESNO(this->channel_->channel_cfg.differential)); + if (this->channel_->channel_cfg.differential) { + ESP_LOGV(TAG, + " Positive: %s\n" + " Negative: %s", + LOG_STR_ARG(input_to_str(this->channel_->channel_cfg.input_positive)), + LOG_STR_ARG(input_to_str(this->channel_->channel_cfg.input_negative))); + } else { + ESP_LOGV(TAG, " Positive: %s", LOG_STR_ARG(input_to_str(this->channel_->channel_cfg.input_positive))); + } +#endif + + LOG_UPDATE_INTERVAL(this); +} + +float ADCSensor::sample() { + auto aggr = Aggregator(this->sampling_mode_); + int err; + for (uint8_t sample = 0; sample < this->sample_count_; sample++) { + int16_t buf = 0; + struct adc_sequence sequence = { + .buffer = &buf, + /* buffer size in bytes, not number of samples */ + .buffer_size = sizeof(buf), + }; + int32_t val_raw; + + err = adc_sequence_init_dt(this->channel_, &sequence); + if (err < 0) { + ESP_LOGE(TAG, "Could sequence init %s (%d)", this->channel_->dev->name, err); + return 0.0; + } + + err = adc_read(this->channel_->dev, &sequence); + if (err < 0) { + ESP_LOGE(TAG, "Could not read %s (%d)", this->channel_->dev->name, err); + return 0.0; + } + + val_raw = (int32_t) buf; + if (!this->channel_->channel_cfg.differential) { + // https://github.com/adafruit/Adafruit_nRF52_Arduino/blob/0ed4d9ffc674ae407be7cacf5696a02f5e789861/cores/nRF5/wiring_analog_nRF52.c#L222 + if (val_raw < 0) { + val_raw = 0; + } + } + aggr.add_sample(val_raw); + } + + int32_t val_mv = aggr.aggregate(); + + if (this->output_raw_) { + return val_mv; + } + + err = adc_raw_to_millivolts_dt(this->channel_, &val_mv); + /* conversion to mV may not be supported, skip if not */ + if (err < 0) { + ESP_LOGE(TAG, "Value in mV not available %s (%d)", this->channel_->dev->name, err); + return 0.0; + } + + return val_mv / 1000.0f; +} + +} // namespace adc +} // namespace esphome +#endif diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 01bbaeda15..49970c5e3d 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -3,6 +3,12 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler from esphome.components.esp32 import get_esp32_variant +from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC +from esphome.components.zephyr import ( + zephyr_add_overlay, + zephyr_add_prj_conf, + zephyr_add_user, +) import esphome.config_validation as cv from esphome.const import ( CONF_ATTENUATION, @@ -11,6 +17,7 @@ from esphome.const import ( CONF_PIN, CONF_RAW, DEVICE_CLASS_VOLTAGE, + PLATFORM_NRF52, STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) @@ -60,6 +67,10 @@ ADCSensor = adc_ns.class_( "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) +CONF_NRF_SAADC = "nrf_saadc" + +adc_dt_spec = cg.global_ns.class_("adc_dt_spec") + CONFIG_SCHEMA = cv.All( sensor.sensor_schema( ADCSensor, @@ -75,6 +86,7 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All( cv.only_on_esp32, _attenuation ), + cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec), cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255), cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode, } @@ -83,6 +95,8 @@ CONFIG_SCHEMA = cv.All( validate_config, ) +CONF_ADC_CHANNEL_ID = "adc_channel_id" + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -93,7 +107,7 @@ async def to_code(config): cg.add_define("USE_ADC_SENSOR_VCC") elif config[CONF_PIN] == "TEMPERATURE": cg.add(var.set_is_temperature()) - else: + elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) @@ -122,3 +136,41 @@ async def to_code(config): ): chan = ESP32_VARIANT_ADC2_PIN_TO_CHANNEL[variant][pin_num] cg.add(var.set_channel(adc_unit_t.ADC_UNIT_2, chan)) + + elif CORE.is_nrf52: + CORE.data.setdefault(CONF_ADC_CHANNEL_ID, 0) + channel_id = CORE.data[CONF_ADC_CHANNEL_ID] + CORE.data[CONF_ADC_CHANNEL_ID] = channel_id + 1 + zephyr_add_prj_conf("ADC", True) + nrf_saadc = config[CONF_NRF_SAADC] + rhs = cg.RawExpression( + f"ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), {channel_id})" + ) + adc = cg.new_Pvariable(nrf_saadc, rhs) + cg.add(var.set_adc_channel(adc)) + gain = "ADC_GAIN_1_6" + pin_number = config[CONF_PIN][CONF_NUMBER] + if pin_number == "VDDHDIV5": + gain = "ADC_GAIN_1_2" + if isinstance(pin_number, int): + GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()} + pin_number = GPIO_TO_AIN[pin_number] + zephyr_add_user("io-channels", f"<&adc {channel_id}>") + zephyr_add_overlay( + f""" +&adc {{ + #address-cells = <1>; + #size-cells = <0>; + + channel@{channel_id} {{ + reg = <{channel_id}>; + zephyr,gain = "{gain}"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,input-positive = ; + zephyr,resolution = <14>; + zephyr,oversampling = <8>; + }}; +}}; +""" + ) diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index f73b8ef08f..c4ac7adb23 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -34,17 +34,20 @@ SetFrameAction = animation_ns.class_( "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) ) -CONFIG_SCHEMA = espImage.IMAGE_SCHEMA.extend( - { - cv.Required(CONF_ID): cv.declare_id(Animation_), - cv.Optional(CONF_LOOP): cv.All( - { - cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, - cv.Optional(CONF_END_FRAME): cv.positive_int, - cv.Optional(CONF_REPEAT): cv.positive_int, - } - ), - }, +CONFIG_SCHEMA = cv.All( + espImage.IMAGE_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(Animation_), + cv.Optional(CONF_LOOP): cv.All( + { + cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, + cv.Optional(CONF_END_FRAME): cv.positive_int, + cv.Optional(CONF_REPEAT): cv.positive_int, + } + ), + }, + ), + espImage.validate_settings, ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4aa5cc4be0..e0b2c19a21 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1621,7 +1621,10 @@ message BluetoothConnectionsFreeResponse { uint32 free = 1; uint32 limit = 2; - repeated uint64 allocated = 3; + repeated uint64 allocated = 3 [ + (fixed_array_size_define) = "BLUETOOTH_PROXY_MAX_CONNECTIONS", + (fixed_array_skip_zero) = true + ]; } message BluetoothGATTErrorResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 8ac6c3b71e..a6c037d2c2 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1105,10 +1105,8 @@ void APIConnection::bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) bool APIConnection::send_subscribe_bluetooth_connections_free_response( const SubscribeBluetoothConnectionsFreeRequest &msg) { - BluetoothConnectionsFreeResponse resp; - resp.free = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_connections_free(); - resp.limit = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_connections_limit(); - return this->send_message(resp, BluetoothConnectionsFreeResponse::MESSAGE_TYPE); + bluetooth_proxy::global_bluetooth_proxy->send_connections_free(); + return true; } void APIConnection::bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index d4b5700024..ed0e0d7455 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -29,6 +29,7 @@ extend google.protobuf.FieldOptions { optional uint32 fixed_array_size = 50007; optional bool no_zero_copy = 50008 [default=false]; optional bool fixed_array_skip_zero = 50009 [default=false]; + optional string fixed_array_size_define = 50010; // container_pointer: Zero-copy optimization for repeated fields. // diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 29d0f2842c..8c14153155 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2073,15 +2073,17 @@ void BluetoothGATTNotifyDataResponse::calculate_size(ProtoSize &size) const { void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->free); buffer.encode_uint32(2, this->limit); - for (auto &it : this->allocated) { - buffer.encode_uint64(3, it, true); + for (const auto &it : this->allocated) { + if (it != 0) { + buffer.encode_uint64(3, it, true); + } } } void BluetoothConnectionsFreeResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->free); size.add_uint32(1, this->limit); - if (!this->allocated.empty()) { - for (const auto &it : this->allocated) { + for (const auto &it : this->allocated) { + if (it != 0) { size.add_uint64_force(1, it); } } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 524674e6ef..0bc75ef00b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2076,13 +2076,13 @@ class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { class BluetoothConnectionsFreeResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 81; - static constexpr uint8_t ESTIMATED_SIZE = 16; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_connections_free_response"; } #endif uint32_t free{0}; uint32_t limit{0}; - std::vector allocated{}; + std::array allocated{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index a1e9d464df..ec1df6a06c 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -87,6 +87,10 @@ async def to_code(config): cg.add(var.set_active(config[CONF_ACTIVE])) await esp32_ble_tracker.register_raw_ble_device(var, config) + # Define max connections for protobuf fixed array + connection_count = len(config.get(CONF_CONNECTIONS, [])) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) + for connection_conf in config.get(CONF_CONNECTIONS, []): connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) await cg.register_component(connection_var, connection_conf) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index c19eb6aa6f..9c651ed04b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -46,6 +46,18 @@ static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4 static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic // Helper to estimate service size before fetching all data +/** + * Estimate the size of a Bluetooth service based on the number of characteristics and UUID format. + * + * @param char_count The number of characteristics in the service. + * @param use_efficient_uuids Whether to use efficient UUIDs (16-bit or 32-bit) for newer APIVersions. + * @return The estimated size of the service in bytes. + * + * This function calculates the size of a Bluetooth service by considering: + * - A service overhead, which depends on whether efficient UUIDs are used. + * - The size of each characteristic, assuming 128-bit UUIDs for safety. + * - The size of descriptors, assuming one 128-bit descriptor per characteristic. + */ static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; // Always assume 128-bit UUIDs for characteristics to be safe @@ -66,6 +78,30 @@ void BluetoothConnection::dump_config() { BLEClientBase::dump_config(); } +void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { + auto &allocated = this->proxy_->connections_free_response_.allocated; + auto it = std::find(allocated.begin(), allocated.end(), find_value); + if (it != allocated.end()) { + *it = set_value; + } +} + +void BluetoothConnection::set_address(uint64_t address) { + // If we're clearing an address (disconnecting), update the pre-allocated message + if (address == 0 && this->address_ != 0) { + this->proxy_->connections_free_response_.free++; + this->update_allocated_slot_(this->address_, 0); + } + // If we're setting a new address (connecting), update the pre-allocated message + else if (address != 0 && this->address_ == 0) { + this->proxy_->connections_free_response_.free--; + this->update_allocated_slot_(0, address); + } + + // Call parent implementation to actually set the address + BLEClientBase::set_address(address); +} + void BluetoothConnection::loop() { BLEClientBase::loop(); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 622d257bf8..042868e7a4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -24,12 +24,15 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { esp_err_t notify_characteristic(uint16_t handle, bool enable); + void set_address(uint64_t address) override; + protected: friend class BluetoothProxy; bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); + void update_allocated_slot_(uint64_t find_value, uint64_t set_value); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index de5508c777..a9a68e25c5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -35,6 +35,9 @@ void BluetoothProxy::setup() { // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool + this->connections_free_response_.limit = this->connections_.size(); + this->connections_free_response_.free = this->connections_.size(); + this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { this->send_bluetooth_scanner_state_(state); @@ -134,20 +137,6 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connections_.size()); } -int BluetoothProxy::get_bluetooth_connections_free() { - int free = 0; - for (auto *connection : this->connections_) { - if (connection->address_ == 0) { - free++; - ESP_LOGV(TAG, "[%d] Free connection", connection->get_connection_index()); - } else { - ESP_LOGV(TAG, "[%d] Used connection by [%s]", connection->get_connection_index(), - connection->address_str().c_str()); - } - } - return free; -} - void BluetoothProxy::loop() { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { for (auto *connection : this->connections_) { @@ -441,15 +430,9 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui void BluetoothProxy::send_connections_free() { if (this->api_connection_ == nullptr) return; - api::BluetoothConnectionsFreeResponse call; - call.free = this->get_bluetooth_connections_free(); - call.limit = this->get_bluetooth_connections_limit(); - for (auto *connection : this->connections_) { - if (connection->address_ != 0) { - call.allocated.push_back(connection->address_); - } - } - this->api_connection_->send_message(call, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); + + this->api_connection_->send_message(this->connections_free_response_, + api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); } void BluetoothProxy::send_gatt_services_done(uint64_t address) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d249515fdf..8e7462c660 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -49,6 +49,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { + friend class BluetoothConnection; // Allow connection to call free_connection_ public: BluetoothProxy(); #ifdef USE_ESP32_BLE_DEVICE @@ -74,9 +75,6 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg); void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg); - int get_bluetooth_connections_free(); - int get_bluetooth_connections_limit() { return this->connections_.size(); } - void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } @@ -149,6 +147,9 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 3: 4-byte types uint32_t last_advertisement_flush_time_{0}; + // Pre-allocated response message - always ready to send + api::BluetoothConnectionsFreeResponse connections_free_response_; + // Group 4: 1-byte types grouped together bool active_; uint8_t advertisement_count_{0}; diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index 500dfac1fe..b8dabc3374 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -10,6 +11,7 @@ from esphome.const import ( CONF_LOOP_TIME, PlatformFramework, ) +from esphome.core import CORE CODEOWNERS = ["@OttoWinter"] DEPENDENCIES = ["logger"] @@ -44,6 +46,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + if CORE.using_zephyr: + zephyr_add_prj_conf("HWINFO", True) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -62,5 +66,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "debug_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp new file mode 100644 index 0000000000..9a361b158f --- /dev/null +++ b/esphome/components/debug/debug_zephyr.cpp @@ -0,0 +1,281 @@ +#include "debug_component.h" +#ifdef USE_ZEPHYR +#include +#include "esphome/core/log.h" +#include +#include +#include + +#define BOOTLOADER_VERSION_REGISTER NRF_TIMER2->CC[0] + +namespace esphome { +namespace debug { + +static const char *const TAG = "debug"; +constexpr std::uintptr_t MBR_PARAM_PAGE_ADDR = 0xFFC; +constexpr std::uintptr_t MBR_BOOTLOADER_ADDR = 0xFF8; + +static void show_reset_reason(std::string &reset_reason, bool set, const char *reason) { + if (!set) { + return; + } + if (!reset_reason.empty()) { + reset_reason += ", "; + } + reset_reason += reason; +} + +inline uint32_t read_mem_u32(uintptr_t addr) { + return *reinterpret_cast(addr); // NOLINT(performance-no-int-to-ptr) +} + +std::string DebugComponent::get_reset_reason_() { + uint32_t cause; + auto ret = hwinfo_get_reset_cause(&cause); + if (ret) { + ESP_LOGE(TAG, "Unable to get reset cause: %d", ret); + return ""; + } + std::string reset_reason; + + show_reset_reason(reset_reason, cause & RESET_PIN, "External pin"); + show_reset_reason(reset_reason, cause & RESET_SOFTWARE, "Software reset"); + show_reset_reason(reset_reason, cause & RESET_BROWNOUT, "Brownout (drop in voltage)"); + show_reset_reason(reset_reason, cause & RESET_POR, "Power-on reset (POR)"); + show_reset_reason(reset_reason, cause & RESET_WATCHDOG, "Watchdog timer expiration"); + show_reset_reason(reset_reason, cause & RESET_DEBUG, "Debug event"); + show_reset_reason(reset_reason, cause & RESET_SECURITY, "Security violation"); + show_reset_reason(reset_reason, cause & RESET_LOW_POWER_WAKE, "Waking up from low power mode"); + show_reset_reason(reset_reason, cause & RESET_CPU_LOCKUP, "CPU lock-up detected"); + show_reset_reason(reset_reason, cause & RESET_PARITY, "Parity error"); + show_reset_reason(reset_reason, cause & RESET_PLL, "PLL error"); + show_reset_reason(reset_reason, cause & RESET_CLOCK, "Clock error"); + show_reset_reason(reset_reason, cause & RESET_HARDWARE, "Hardware reset"); + show_reset_reason(reset_reason, cause & RESET_USER, "User reset"); + show_reset_reason(reset_reason, cause & RESET_TEMPERATURE, "Temperature reset"); + + ESP_LOGD(TAG, "Reset Reason: %s", reset_reason.c_str()); + return reset_reason; +} + +uint32_t DebugComponent::get_free_heap_() { return INT_MAX; } + +void DebugComponent::get_device_info_(std::string &device_info) { + std::string supply = "Main supply status: "; + if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) { + supply += "Normal voltage."; + } else { + supply += "High voltage."; + } + ESP_LOGD(TAG, "%s", supply.c_str()); + device_info += "|" + supply; + + std::string reg0 = "Regulator stage 0: "; + if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { + reg0 += nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO"; + reg0 += ", "; + switch (NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) { + case (UICR_REGOUT0_VOUT_DEFAULT << UICR_REGOUT0_VOUT_Pos): + reg0 += "1.8V (default)"; + break; + case (UICR_REGOUT0_VOUT_1V8 << UICR_REGOUT0_VOUT_Pos): + reg0 += "1.8V"; + break; + case (UICR_REGOUT0_VOUT_2V1 << UICR_REGOUT0_VOUT_Pos): + reg0 += "2.1V"; + break; + case (UICR_REGOUT0_VOUT_2V4 << UICR_REGOUT0_VOUT_Pos): + reg0 += "2.4V"; + break; + case (UICR_REGOUT0_VOUT_2V7 << UICR_REGOUT0_VOUT_Pos): + reg0 += "2.7V"; + break; + case (UICR_REGOUT0_VOUT_3V0 << UICR_REGOUT0_VOUT_Pos): + reg0 += "3.0V"; + break; + case (UICR_REGOUT0_VOUT_3V3 << UICR_REGOUT0_VOUT_Pos): + reg0 += "3.3V"; + break; + default: + reg0 += "???V"; + } + } else { + reg0 += "disabled"; + } + ESP_LOGD(TAG, "%s", reg0.c_str()); + device_info += "|" + reg0; + + std::string reg1 = "Regulator stage 1: "; + reg1 += nrf_power_dcdcen_get(NRF_POWER) ? "DC/DC" : "LDO"; + ESP_LOGD(TAG, "%s", reg1.c_str()); + device_info += "|" + reg1; + + std::string usb_power = "USB power state: "; + if (nrf_power_usbregstatus_vbusdet_get(NRF_POWER)) { + if (nrf_power_usbregstatus_outrdy_get(NRF_POWER)) { + /**< From the power viewpoint, USB is ready for working. */ + usb_power += "ready"; + } else { + /**< The USB power is detected, but USB power regulator is not ready. */ + usb_power += "connected (regulator is not ready)"; + } + } else { + /**< No power on USB lines detected. */ + usb_power += "disconected"; + } + ESP_LOGD(TAG, "%s", usb_power.c_str()); + device_info += "|" + usb_power; + + bool enabled; + nrf_power_pof_thr_t pof_thr; + + pof_thr = nrf_power_pofcon_get(NRF_POWER, &enabled); + std::string pof = "Power-fail comparator: "; + if (enabled) { + switch (pof_thr) { + case POWER_POFCON_THRESHOLD_V17: + pof += "1.7V"; + break; + case POWER_POFCON_THRESHOLD_V18: + pof += "1.8V"; + break; + case POWER_POFCON_THRESHOLD_V19: + pof += "1.9V"; + break; + case POWER_POFCON_THRESHOLD_V20: + pof += "2.0V"; + break; + case POWER_POFCON_THRESHOLD_V21: + pof += "2.1V"; + break; + case POWER_POFCON_THRESHOLD_V22: + pof += "2.2V"; + break; + case POWER_POFCON_THRESHOLD_V23: + pof += "2.3V"; + break; + case POWER_POFCON_THRESHOLD_V24: + pof += "2.4V"; + break; + case POWER_POFCON_THRESHOLD_V25: + pof += "2.5V"; + break; + case POWER_POFCON_THRESHOLD_V26: + pof += "2.6V"; + break; + case POWER_POFCON_THRESHOLD_V27: + pof += "2.7V"; + break; + case POWER_POFCON_THRESHOLD_V28: + pof += "2.8V"; + break; + } + + if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { + pof += ", VDDH: "; + switch (nrf_power_pofcon_vddh_get(NRF_POWER)) { + case NRF_POWER_POFTHRVDDH_V27: + pof += "2.7V"; + break; + case NRF_POWER_POFTHRVDDH_V28: + pof += "2.8V"; + break; + case NRF_POWER_POFTHRVDDH_V29: + pof += "2.9V"; + break; + case NRF_POWER_POFTHRVDDH_V30: + pof += "3.0V"; + break; + case NRF_POWER_POFTHRVDDH_V31: + pof += "3.1V"; + break; + case NRF_POWER_POFTHRVDDH_V32: + pof += "3.2V"; + break; + case NRF_POWER_POFTHRVDDH_V33: + pof += "3.3V"; + break; + case NRF_POWER_POFTHRVDDH_V34: + pof += "3.4V"; + break; + case NRF_POWER_POFTHRVDDH_V35: + pof += "3.5V"; + break; + case NRF_POWER_POFTHRVDDH_V36: + pof += "3.6V"; + break; + case NRF_POWER_POFTHRVDDH_V37: + pof += "3.7V"; + break; + case NRF_POWER_POFTHRVDDH_V38: + pof += "3.8V"; + break; + case NRF_POWER_POFTHRVDDH_V39: + pof += "3.9V"; + break; + case NRF_POWER_POFTHRVDDH_V40: + pof += "4.0V"; + break; + case NRF_POWER_POFTHRVDDH_V41: + pof += "4.1V"; + break; + case NRF_POWER_POFTHRVDDH_V42: + pof += "4.2V"; + break; + } + } + } else { + pof += "disabled"; + } + ESP_LOGD(TAG, "%s", pof.c_str()); + device_info += "|" + pof; + + auto package = [](uint32_t value) { + switch (value) { + case 0x2004: + return "QIxx - 7x7 73-pin aQFN"; + case 0x2000: + return "QFxx - 6x6 48-pin QFN"; + case 0x2005: + return "CKxx - 3.544 x 3.607 WLCSP"; + } + return "Unspecified"; + }; + + ESP_LOGD(TAG, "Code page size: %u, code size: %u, device id: 0x%08x%08x", NRF_FICR->CODEPAGESIZE, NRF_FICR->CODESIZE, + NRF_FICR->DEVICEID[1], NRF_FICR->DEVICEID[0]); + ESP_LOGD(TAG, "Encryption root: 0x%08x%08x%08x%08x, Identity Root: 0x%08x%08x%08x%08x", NRF_FICR->ER[0], + NRF_FICR->ER[1], NRF_FICR->ER[2], NRF_FICR->ER[3], NRF_FICR->IR[0], NRF_FICR->IR[1], NRF_FICR->IR[2], + NRF_FICR->IR[3]); + ESP_LOGD(TAG, "Device address type: %s, address: %s", (NRF_FICR->DEVICEADDRTYPE & 0x1 ? "Random" : "Public"), + get_mac_address_pretty().c_str()); + ESP_LOGD(TAG, "Part code: nRF%x, version: %c%c%c%c, package: %s", NRF_FICR->INFO.PART, + NRF_FICR->INFO.VARIANT >> 24 & 0xFF, NRF_FICR->INFO.VARIANT >> 16 & 0xFF, NRF_FICR->INFO.VARIANT >> 8 & 0xFF, + NRF_FICR->INFO.VARIANT & 0xFF, package(NRF_FICR->INFO.PACKAGE)); + ESP_LOGD(TAG, "RAM: %ukB, Flash: %ukB, production test: %sdone", NRF_FICR->INFO.RAM, NRF_FICR->INFO.FLASH, + (NRF_FICR->PRODTEST[0] == 0xBB42319F ? "" : "not ")); + ESP_LOGD( + TAG, "GPIO as NFC pins: %s, GPIO as nRESET pin: %s", + YESNO((NRF_UICR->NFCPINS & UICR_NFCPINS_PROTECT_Msk) == (UICR_NFCPINS_PROTECT_NFC << UICR_NFCPINS_PROTECT_Pos)), + YESNO(((NRF_UICR->PSELRESET[0] & UICR_PSELRESET_CONNECT_Msk) != + (UICR_PSELRESET_CONNECT_Connected << UICR_PSELRESET_CONNECT_Pos)) || + ((NRF_UICR->PSELRESET[1] & UICR_PSELRESET_CONNECT_Msk) != + (UICR_PSELRESET_CONNECT_Connected << UICR_PSELRESET_CONNECT_Pos)))); + +#ifdef USE_BOOTLOADER_MCUBOOT + ESP_LOGD(TAG, "bootloader: mcuboot"); +#else + ESP_LOGD(TAG, "bootloader: Adafruit, version %u.%u.%u", (BOOTLOADER_VERSION_REGISTER >> 16) & 0xFF, + (BOOTLOADER_VERSION_REGISTER >> 8) & 0xFF, BOOTLOADER_VERSION_REGISTER & 0xFF); + ESP_LOGD(TAG, "MBR bootloader addr 0x%08x, UICR bootloader addr 0x%08x", read_mem_u32(MBR_BOOTLOADER_ADDR), + NRF_UICR->NRFFW[0]); + ESP_LOGD(TAG, "MBR param page addr 0x%08x, UICR param page addr 0x%08x", read_mem_u32(MBR_PARAM_PAGE_ADDR), + NRF_UICR->NRFFW[1]); +#endif +} + +void DebugComponent::update_platform_() {} + +} // namespace debug +} // namespace esphome +#endif diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 4669095d5d..4484f15935 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.esp32 import CONF_CPU_FREQUENCY +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BLOCK, @@ -54,7 +55,7 @@ CONFIG_SCHEMA = { ), cv.Optional(CONF_PSRAM): cv.All( cv.only_on_esp32, - cv.requires_component("psram"), + cv.requires_component(PSRAM_DOMAIN), sensor.sensor_schema( unit_of_measurement=UNIT_BYTES, icon=ICON_COUNTER, diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 649b0c5564..05a79553a4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -76,6 +76,7 @@ CONF_ASSERTION_LEVEL = "assertion_level" CONF_COMPILER_OPTIMIZATION = "compiler_optimization" CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" +CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_RELEASE = "release" ASSERTION_LEVELS = { @@ -313,7 +314,7 @@ def _format_framework_espidf_version( RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 2, 1) # The platform-espressif32 version to use for arduino frameworks # - https://github.com/pioarduino/platform-espressif32/releases -ARDUINO_PLATFORM_VERSION = cv.Version(54, 3, 21, "1") +ARDUINO_PLATFORM_VERSION = cv.Version(54, 3, 21, "2") # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases @@ -322,7 +323,7 @@ RECOMMENDED_ESP_IDF_FRAMEWORK_VERSION = cv.Version(5, 4, 2) # The platformio/espressif32 version to use for esp-idf frameworks # - https://github.com/platformio/platform-espressif32/releases # - https://api.registry.platformio.org/v3/packages/platformio/platform/espressif32 -ESP_IDF_PLATFORM_VERSION = cv.Version(54, 3, 21, "1") +ESP_IDF_PLATFORM_VERSION = cv.Version(54, 3, 21, "2") # List based on https://registry.platformio.org/tools/platformio/framework-espidf/versions SUPPORTED_PLATFORMIO_ESP_IDF_5X = [ @@ -519,32 +520,59 @@ def _detect_variant(value): def final_validate(config): - if not ( - pio_options := fv.full_config.get()[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS) - ): - # Not specified or empty - return config - - pio_flash_size_key = "board_upload.flash_size" - pio_partitions_key = "board_build.partitions" - if CONF_PARTITIONS in config and pio_partitions_key in pio_options: - raise cv.Invalid( - f"Do not specify '{pio_partitions_key}' in '{CONF_PLATFORMIO_OPTIONS}' with '{CONF_PARTITIONS}' in esp32" - ) - - if pio_flash_size_key in pio_options: - raise cv.Invalid( - f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" - ) + # Imported locally to avoid circular import issues + from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + errs = [] + full_config = fv.full_config.get() + if pio_options := full_config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS): + pio_flash_size_key = "board_upload.flash_size" + pio_partitions_key = "board_build.partitions" + if CONF_PARTITIONS in config and pio_partitions_key in pio_options: + errs.append( + cv.Invalid( + f"Do not specify '{pio_partitions_key}' in '{CONF_PLATFORMIO_OPTIONS}' with '{CONF_PARTITIONS}' in esp32" + ) + ) + if pio_flash_size_key in pio_options: + errs.append( + cv.Invalid( + f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" + ) + ) if ( config[CONF_VARIANT] != VARIANT_ESP32 and CONF_ADVANCED in (conf_fw := config[CONF_FRAMEWORK]) and CONF_IGNORE_EFUSE_MAC_CRC in conf_fw[CONF_ADVANCED] ): - raise cv.Invalid( - f"{CONF_IGNORE_EFUSE_MAC_CRC} is not supported on {config[CONF_VARIANT]}" + errs.append( + cv.Invalid( + f"'{CONF_IGNORE_EFUSE_MAC_CRC}' is not supported on {config[CONF_VARIANT]}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_IGNORE_EFUSE_MAC_CRC], + ) ) + if ( + config.get(CONF_FRAMEWORK, {}) + .get(CONF_ADVANCED, {}) + .get(CONF_EXECUTE_FROM_PSRAM) + ): + if config[CONF_VARIANT] != VARIANT_ESP32S3: + errs.append( + cv.Invalid( + f"'{CONF_EXECUTE_FROM_PSRAM}' is only supported on {VARIANT_ESP32S3} variant", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], + ) + ) + if PSRAM_DOMAIN not in full_config: + errs.append( + cv.Invalid( + f"'{CONF_EXECUTE_FROM_PSRAM}' requires PSRAM to be configured", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], + ) + ) + + if errs: + raise cv.MultipleInvalid(errs) return config @@ -627,6 +655,7 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( cv.Optional( CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True ): cv.boolean, + cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, } ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( @@ -792,6 +821,9 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) + if advanced.get(CONF_EXECUTE_FROM_PSRAM, False): + add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) # Apply LWIP core locking for better socket performance # This is already enabled by default in Arduino framework, where it provides diff --git a/esphome/components/esp32/gpio_esp32_h2.py b/esphome/components/esp32/gpio_esp32_h2.py index 7c3a658b17..f37297764b 100644 --- a/esphome/components/esp32/gpio_esp32_h2.py +++ b/esphome/components/esp32/gpio_esp32_h2.py @@ -2,6 +2,7 @@ import logging import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin _ESP32H2_SPI_FLASH_PINS = {6, 7, 15, 16, 17, 18, 19, 20, 21} @@ -15,13 +16,6 @@ _LOGGER = logging.getLogger(__name__) def esp32_h2_validate_gpio_pin(value): if value < 0 or value > 27: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-27)") - if value in _ESP32H2_STRAPPING_PINS: - _LOGGER.warning( - "GPIO%d is a Strapping PIN and should be avoided.\n" - "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" - "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", - value, - ) if value in _ESP32H2_SPI_FLASH_PINS: _LOGGER.warning( "GPIO%d is reserved for SPI Flash communication on some ESP32-H2 chip variants.\n" @@ -49,4 +43,5 @@ def esp32_h2_validate_supports(value): if is_input: # All ESP32 pins support input mode pass + check_strapping_pin(value, _ESP32H2_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 650d06e108..34d1b3139d 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -2,6 +2,7 @@ import logging import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin _ESP32P4_USB_JTAG_PINS = {24, 25} @@ -13,13 +14,6 @@ _LOGGER = logging.getLogger(__name__) def esp32_p4_validate_gpio_pin(value): if value < 0 or value > 54: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") - if value in _ESP32P4_STRAPPING_PINS: - _LOGGER.warning( - "GPIO%d is a Strapping PIN and should be avoided.\n" - "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" - "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", - value, - ) if value in _ESP32P4_USB_JTAG_PINS: _LOGGER.warning( "GPIO%d is reserved for the USB-Serial-JTAG interface.\n" @@ -40,4 +34,5 @@ def esp32_p4_validate_supports(value): if is_input: # All ESP32 pins support input mode pass + check_strapping_pin(value, _ESP32P4_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 457a88ec1d..0a2fda4476 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -48,7 +48,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; } - void set_address(uint64_t address) { + virtual void set_address(uint64_t address) { this->address_ = address; this->remote_bda_[0] = (address >> 40) & 0xFF; this->remote_bda_[1] = (address >> 32) & 0xFF; diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py new file mode 100644 index 0000000000..d15817cf92 --- /dev/null +++ b/esphome/components/espnow/__init__.py @@ -0,0 +1,320 @@ +from esphome import automation, core +import esphome.codegen as cg +from esphome.components import wifi +from esphome.components.udp import CONF_ON_RECEIVE +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_CHANNEL, + CONF_DATA, + CONF_ENABLE_ON_BOOT, + CONF_ID, + CONF_ON_ERROR, + CONF_TRIGGER_ID, + CONF_WIFI, +) +from esphome.core import CORE, HexInt +from esphome.types import ConfigType + +CODEOWNERS = ["@jesserockz"] + +byte_vector = cg.std_vector.template(cg.uint8) +peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) + +espnow_ns = cg.esphome_ns.namespace("espnow") +ESPNowComponent = espnow_ns.class_("ESPNowComponent", cg.Component) + +# Handler interfaces that other components can use to register callbacks +ESPNowReceivedPacketHandler = espnow_ns.class_("ESPNowReceivedPacketHandler") +ESPNowUnknownPeerHandler = espnow_ns.class_("ESPNowUnknownPeerHandler") +ESPNowBroadcastedHandler = espnow_ns.class_("ESPNowBroadcastedHandler") + +ESPNowRecvInfo = espnow_ns.class_("ESPNowRecvInfo") +ESPNowRecvInfoConstRef = ESPNowRecvInfo.operator("const").operator("ref") + +SendAction = espnow_ns.class_("SendAction", automation.Action) +SetChannelAction = espnow_ns.class_("SetChannelAction", automation.Action) +AddPeerAction = espnow_ns.class_("AddPeerAction", automation.Action) +DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) + +ESPNowHandlerTrigger = automation.Trigger.template( + ESPNowRecvInfoConstRef, + cg.uint8.operator("const").operator("ptr"), + cg.uint8, +) + +OnUnknownPeerTrigger = espnow_ns.class_( + "OnUnknownPeerTrigger", ESPNowHandlerTrigger, ESPNowUnknownPeerHandler +) +OnReceiveTrigger = espnow_ns.class_( + "OnReceiveTrigger", ESPNowHandlerTrigger, ESPNowReceivedPacketHandler +) +OnBroadcastedTrigger = espnow_ns.class_( + "OnBroadcastedTrigger", ESPNowHandlerTrigger, ESPNowBroadcastedHandler +) + + +CONF_AUTO_ADD_PEER = "auto_add_peer" +CONF_PEERS = "peers" +CONF_ON_SENT = "on_sent" +CONF_ON_UNKNOWN_PEER = "on_unknown_peer" +CONF_ON_BROADCAST = "on_broadcast" +CONF_CONTINUE_ON_ERROR = "continue_on_error" +CONF_WAIT_FOR_SENT = "wait_for_sent" + +MAX_ESPNOW_PACKET_SIZE = 250 # Maximum size of the payload in bytes + + +def _validate_unknown_peer(config): + if config[CONF_AUTO_ADD_PEER] and config.get(CONF_ON_UNKNOWN_PEER): + raise cv.Invalid( + f"'{CONF_ON_UNKNOWN_PEER}' cannot be used when '{CONF_AUTO_ADD_PEER}' is enabled.", + path=[CONF_ON_UNKNOWN_PEER], + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ESPNowComponent), + cv.OnlyWithout(CONF_CHANNEL, CONF_WIFI): wifi.validate_channel, + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_AUTO_ADD_PEER, default=False): cv.boolean, + cv.Optional(CONF_PEERS): cv.ensure_list(cv.mac_address), + cv.Optional(CONF_ON_UNKNOWN_PEER): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnUnknownPeerTrigger), + }, + single=True, + ), + cv.Optional(CONF_ON_RECEIVE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnReceiveTrigger), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), + cv.Optional(CONF_ON_BROADCAST): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastedTrigger), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), + }, + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + _validate_unknown_peer, +) + + +async def _trigger_to_code(config): + if address := config.get(CONF_ADDRESS): + address = address.parts + trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], address) + await automation.build_automation( + trigger, + [ + (ESPNowRecvInfoConstRef, "info"), + (cg.uint8.operator("const").operator("ptr"), "data"), + (cg.uint8, "size"), + ], + config, + ) + return trigger + + +async def to_code(config): + print(config) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if CORE.using_arduino: + cg.add_library("WiFi", None) + + cg.add_define("USE_ESPNOW") + if wifi_channel := config.get(CONF_CHANNEL): + cg.add(var.set_wifi_channel(wifi_channel)) + + cg.add(var.set_auto_add_peer(config[CONF_AUTO_ADD_PEER])) + + for peer in config.get(CONF_PEERS, []): + cg.add(var.add_peer(peer.parts)) + + if on_receive := config.get(CONF_ON_UNKNOWN_PEER): + trigger = await _trigger_to_code(on_receive) + cg.add(var.register_unknown_peer_handler(trigger)) + + for on_receive in config.get(CONF_ON_RECEIVE, []): + trigger = await _trigger_to_code(on_receive) + cg.add(var.register_received_handler(trigger)) + + for on_receive in config.get(CONF_ON_BROADCAST, []): + trigger = await _trigger_to_code(on_receive) + cg.add(var.register_broadcasted_handler(trigger)) + + +# ========================================== A C T I O N S ================================================ + + +def validate_peer(value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + return cv.mac_address(value) + + +def _validate_raw_data(value): + if isinstance(value, str): + if len(value) >= MAX_ESPNOW_PACKET_SIZE: + raise cv.Invalid( + f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} characters long, got {len(value)}" + ) + return value + if isinstance(value, list): + if len(value) > MAX_ESPNOW_PACKET_SIZE: + raise cv.Invalid( + f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} bytes long, got {len(value)}" + ) + return cv.Schema([cv.hex_uint8_t])(value) + raise cv.Invalid( + f"'{CONF_DATA}' must either be a string wrapped in quotes or a list of bytes" + ) + + +async def register_peer(var, config, args): + peer = config[CONF_ADDRESS] + if isinstance(peer, core.MACAddress): + peer = [HexInt(p) for p in peer.parts] + + template_ = await cg.templatable(peer, args, peer_address_t, peer_address_t) + cg.add(var.set_address(template_)) + + +PEER_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(ESPNowComponent), + cv.Required(CONF_ADDRESS): cv.templatable(cv.mac_address), + } +) + +SEND_SCHEMA = PEER_SCHEMA.extend( + { + cv.Required(CONF_DATA): cv.templatable(_validate_raw_data), + cv.Optional(CONF_ON_SENT): automation.validate_action_list, + cv.Optional(CONF_ON_ERROR): automation.validate_action_list, + cv.Optional(CONF_WAIT_FOR_SENT, default=True): cv.boolean, + cv.Optional(CONF_CONTINUE_ON_ERROR, default=True): cv.boolean, + } +) + + +def _validate_send_action(config): + if not config[CONF_WAIT_FOR_SENT] and not config[CONF_CONTINUE_ON_ERROR]: + raise cv.Invalid( + f"'{CONF_CONTINUE_ON_ERROR}' cannot be false if '{CONF_WAIT_FOR_SENT}' is false as the automation will not wait for the failed result.", + path=[CONF_CONTINUE_ON_ERROR], + ) + return config + + +SEND_SCHEMA.add_extra(_validate_send_action) + + +@automation.register_action( + "espnow.send", + SendAction, + SEND_SCHEMA, +) +@automation.register_action( + "espnow.broadcast", + SendAction, + cv.maybe_simple_value( + SEND_SCHEMA.extend( + { + cv.Optional(CONF_ADDRESS, default="FF:FF:FF:FF:FF:FF"): cv.mac_address, + } + ), + key=CONF_DATA, + ), +) +async def send_action( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + await register_peer(var, config, args) + + data = config.get(CONF_DATA, []) + if isinstance(data, str): + data = [cg.RawExpression(f"'{c}'") for c in data] + templ = await cg.templatable(data, args, byte_vector, byte_vector) + cg.add(var.set_data(templ)) + + cg.add(var.set_wait_for_sent(config[CONF_WAIT_FOR_SENT])) + cg.add(var.set_continue_on_error(config[CONF_CONTINUE_ON_ERROR])) + + if on_sent_config := config.get(CONF_ON_SENT): + actions = await automation.build_action_list(on_sent_config, template_arg, args) + cg.add(var.add_on_sent(actions)) + if on_error_config := config.get(CONF_ON_ERROR): + actions = await automation.build_action_list( + on_error_config, template_arg, args + ) + cg.add(var.add_on_error(actions)) + return var + + +@automation.register_action( + "espnow.peer.add", + AddPeerAction, + cv.maybe_simple_value( + PEER_SCHEMA, + key=CONF_ADDRESS, + ), +) +@automation.register_action( + "espnow.peer.delete", + DeletePeerAction, + cv.maybe_simple_value( + PEER_SCHEMA, + key=CONF_ADDRESS, + ), +) +async def peer_action( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + await register_peer(var, config, args) + + return var + + +@automation.register_action( + "espnow.set_channel", + SetChannelAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(ESPNowComponent), + cv.Required(CONF_CHANNEL): cv.templatable(wifi.validate_channel), + }, + key=CONF_CHANNEL, + ), +) +async def channel_action( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) + cg.add(var.set_channel(template_)) + return var diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h new file mode 100644 index 0000000000..ad534b279a --- /dev/null +++ b/esphome/components/espnow/automation.h @@ -0,0 +1,175 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "espnow_component.h" + +#include "esphome/core/automation.h" +#include "esphome/core/base_automation.h" + +namespace esphome::espnow { + +template class SendAction : public Action, public Parented { + TEMPLATABLE_VALUE(peer_address_t, address); + TEMPLATABLE_VALUE(std::vector, data); + + public: + void add_on_sent(const std::vector *> &actions) { + this->sent_.add_actions(actions); + if (this->flags_.wait_for_sent) { + this->sent_.add_action(new LambdaAction([this](Ts... x) { this->play_next_(x...); })); + } + } + void add_on_error(const std::vector *> &actions) { + this->error_.add_actions(actions); + if (this->flags_.wait_for_sent) { + this->error_.add_action(new LambdaAction([this](Ts... x) { + if (this->flags_.continue_on_error) { + this->play_next_(x...); + } else { + this->stop_complex(); + } + })); + } + } + + void set_wait_for_sent(bool wait_for_sent) { this->flags_.wait_for_sent = wait_for_sent; } + void set_continue_on_error(bool continue_on_error) { this->flags_.continue_on_error = continue_on_error; } + + void play_complex(Ts... x) override { + this->num_running_++; + send_callback_t send_callback = [this, x...](esp_err_t status) { + if (status == ESP_OK) { + if (this->sent_.empty() && this->flags_.wait_for_sent) { + this->play_next_(x...); + } else if (!this->sent_.empty()) { + this->sent_.play(x...); + } + } else { + if (this->error_.empty() && this->flags_.wait_for_sent) { + if (this->flags_.continue_on_error) { + this->play_next_(x...); + } else { + this->stop_complex(); + } + } else if (!this->error_.empty()) { + this->error_.play(x...); + } + } + }; + peer_address_t address = this->address_.value(x...); + std::vector data = this->data_.value(x...); + esp_err_t err = this->parent_->send(address.data(), data, send_callback); + if (err != ESP_OK) { + send_callback(err); + } else if (!this->flags_.wait_for_sent) { + this->play_next_(x...); + } + } + + void play(Ts... x) override { /* ignore - see play_complex */ + } + + void stop() override { + this->sent_.stop(); + this->error_.stop(); + } + + protected: + ActionList sent_; + ActionList error_; + + struct { + uint8_t wait_for_sent : 1; // Wait for the send operation to complete before continuing automation + uint8_t continue_on_error : 1; // Continue automation even if the send operation fails + uint8_t reserved : 6; // Reserved for future use + } flags_{0}; +}; + +template class AddPeerAction : public Action, public Parented { + TEMPLATABLE_VALUE(peer_address_t, address); + + public: + void play(Ts... x) override { + peer_address_t address = this->address_.value(x...); + this->parent_->add_peer(address.data()); + } +}; + +template class DeletePeerAction : public Action, public Parented { + TEMPLATABLE_VALUE(peer_address_t, address); + + public: + void play(Ts... x) override { + peer_address_t address = this->address_.value(x...); + this->parent_->del_peer(address.data()); + } +}; + +template class SetChannelAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, channel) + void play(Ts... x) override { + if (this->parent_->is_wifi_enabled()) { + return; + } + this->parent_->set_wifi_channel(this->channel_.value(x...)); + this->parent_->apply_wifi_channel(); + } +}; + +class OnReceiveTrigger : public Trigger, + public ESPNowReceivedPacketHandler { + public: + explicit OnReceiveTrigger(std::array address) : has_address_(true) { + memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); + } + + explicit OnReceiveTrigger() : has_address_(false) {} + + bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); + if (!match) + return false; + + this->trigger(info, data, size); + return false; // Return false to continue processing other internal handlers + } + + protected: + bool has_address_{false}; + const uint8_t *address_[ESP_NOW_ETH_ALEN]; +}; +class OnUnknownPeerTrigger : public Trigger, + public ESPNowUnknownPeerHandler { + public: + bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + this->trigger(info, data, size); + return false; // Return false to continue processing other internal handlers + } +}; +class OnBroadcastedTrigger : public Trigger, + public ESPNowBroadcastedHandler { + public: + explicit OnBroadcastedTrigger(std::array address) : has_address_(true) { + memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); + } + explicit OnBroadcastedTrigger() : has_address_(false) {} + + bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); + if (!match) + return false; + + this->trigger(info, data, size); + return false; // Return false to continue processing other internal handlers + } + + protected: + bool has_address_{false}; + const uint8_t *address_[ESP_NOW_ETH_ALEN]; +}; + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp new file mode 100644 index 0000000000..dab8e2b726 --- /dev/null +++ b/esphome/components/espnow/espnow_component.cpp @@ -0,0 +1,468 @@ +#include "espnow_component.h" + +#ifdef USE_ESP32 + +#include "espnow_err.h" + +#include "esphome/core/defines.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif + +namespace esphome::espnow { + +static constexpr const char *TAG = "espnow"; + +static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50; +static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100; + +ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static const LogString *espnow_error_to_str(esp_err_t error) { + switch (error) { + case ESP_ERR_ESPNOW_FAILED: + return LOG_STR("ESPNow is in fail mode"); + case ESP_ERR_ESPNOW_OWN_ADDRESS: + return LOG_STR("Message to your self"); + case ESP_ERR_ESPNOW_DATA_SIZE: + return LOG_STR("Data size to large"); + case ESP_ERR_ESPNOW_PEER_NOT_SET: + return LOG_STR("Peer address not set"); + case ESP_ERR_ESPNOW_PEER_NOT_PAIRED: + return LOG_STR("Peer address not paired"); + case ESP_ERR_ESPNOW_NOT_INIT: + return LOG_STR("Not init"); + case ESP_ERR_ESPNOW_ARG: + return LOG_STR("Invalid argument"); + case ESP_ERR_ESPNOW_INTERNAL: + return LOG_STR("Internal Error"); + case ESP_ERR_ESPNOW_NO_MEM: + return LOG_STR("Our of memory"); + case ESP_ERR_ESPNOW_NOT_FOUND: + return LOG_STR("Peer not found"); + case ESP_ERR_ESPNOW_IF: + return LOG_STR("Interface does not match"); + case ESP_OK: + return LOG_STR("OK"); + case ESP_NOW_SEND_FAIL: + return LOG_STR("Failed"); + default: + return LOG_STR("Unknown Error"); + } +} + +std::string peer_str(uint8_t *peer) { + if (peer == nullptr || peer[0] == 0) { + return "[Not Set]"; + } else if (memcmp(peer, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + return "[Broadcast]"; + } else if (memcmp(peer, ESPNOW_MULTICAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + return "[Multicast]"; + } else { + return format_mac_address_pretty(peer); + } +} + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) +#else +void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) +#endif +{ + // Allocate an event from the pool + ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + if (packet == nullptr) { + // No events available - queue is full or we're out of memory + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + +// Load new packet data (replaces previous packet) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + packet->load_sent_data(info->des_addr, status); +#else + packet->load_sent_data(mac_addr, status); +#endif + + // Push the packet to the queue + global_esp_now->receive_packet_queue_.push(packet); + // Push always because we're the only producer and the pool ensures we never exceed queue size +} + +void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + // Allocate an event from the pool + ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + if (packet == nullptr) { + // No events available - queue is full or we're out of memory + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + + // Load new packet data (replaces previous packet) + packet->load_received_data(info, data, size); + + // Push the packet to the queue + global_esp_now->receive_packet_queue_.push(packet); + // Push always because we're the only producer and the pool ensures we never exceed queue size +} + +ESPNowComponent::ESPNowComponent() { global_esp_now = this; } + +void ESPNowComponent::dump_config() { + uint32_t version = 0; + esp_now_get_version(&version); + + ESP_LOGCONFIG(TAG, "espnow:"); + if (this->is_disabled()) { + ESP_LOGCONFIG(TAG, " Disabled"); + return; + } + ESP_LOGCONFIG(TAG, + " Own address: %s\n" + " Version: v%" PRIu32 "\n" + " Wi-Fi channel: %d", + format_mac_address_pretty(this->own_address_).c_str(), version, this->wifi_channel_); +#ifdef USE_WIFI + ESP_LOGCONFIG(TAG, " Wi-Fi enabled: %s", YESNO(this->is_wifi_enabled())); +#endif +} + +bool ESPNowComponent::is_wifi_enabled() { +#ifdef USE_WIFI + return wifi::global_wifi_component != nullptr && !wifi::global_wifi_component->is_disabled(); +#else + return false; +#endif +} + +void ESPNowComponent::setup() { + if (this->enable_on_boot_) { + this->enable_(); + } else { + this->state_ = ESPNOW_STATE_DISABLED; + } +} + +void ESPNowComponent::enable() { + if (this->state_ != ESPNOW_STATE_ENABLED) + return; + + ESP_LOGD(TAG, "Enabling"); + this->state_ = ESPNOW_STATE_OFF; + + this->enable_(); +} + +void ESPNowComponent::enable_() { + if (!this->is_wifi_enabled()) { + esp_event_loop_create_default(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_ERROR_CHECK(esp_wifi_disconnect()); + + this->apply_wifi_channel(); + } +#ifdef USE_WIFI + else { + this->wifi_channel_ = wifi::global_wifi_component->get_wifi_channel(); + } +#endif + + esp_err_t err = esp_now_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_init failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + err = esp_now_register_recv_cb(on_data_received); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + err = esp_now_register_send_cb(on_send_report); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); + +#ifdef USE_DEEP_SLEEP + esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW); + esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); +#endif + + for (auto peer : this->peers_) { + this->add_peer(peer.address); + } + this->state_ = ESPNOW_STATE_ENABLED; +} + +void ESPNowComponent::disable() { + if (this->state_ == ESPNOW_STATE_DISABLED) + return; + + ESP_LOGD(TAG, "Disabling"); + this->state_ = ESPNOW_STATE_DISABLED; + + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + + for (auto peer : this->peers_) { + this->del_peer(peer.address); + } + + esp_err_t err = esp_now_deinit(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_deinit failed! 0x%x", err); + } +} + +void ESPNowComponent::apply_wifi_channel() { + if (this->state_ == ESPNOW_STATE_DISABLED) { + ESP_LOGE(TAG, "Cannot set channel when ESPNOW disabled"); + this->mark_failed(); + return; + } + + if (this->is_wifi_enabled()) { + ESP_LOGE(TAG, "Cannot set channel when Wi-Fi enabled"); + this->mark_failed(); + return; + } + + ESP_LOGI(TAG, "Channel set to %d.", this->wifi_channel_); + esp_wifi_set_promiscuous(true); + esp_wifi_set_channel(this->wifi_channel_, WIFI_SECOND_CHAN_NONE); + esp_wifi_set_promiscuous(false); +} + +void ESPNowComponent::loop() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) { + int32_t new_channel = wifi::global_wifi_component->get_wifi_channel(); + if (new_channel != this->wifi_channel_) { + ESP_LOGI(TAG, "Wifi Channel is changed from %d to %d.", this->wifi_channel_, new_channel); + this->wifi_channel_ = new_channel; + } + } +#endif + + // Process received packets + ESPNowPacket *packet = this->receive_packet_queue_.pop(); + while (packet != nullptr) { + switch (packet->type_) { + case ESPNowPacket::RECEIVED: { + const ESPNowRecvInfo info = packet->get_receive_info(); + if (!esp_now_is_peer_exist(info.src_addr)) { + if (this->auto_add_peer_) { + this->add_peer(info.src_addr); + } else { + for (auto *handler : this->unknown_peer_handlers_) { + if (handler->on_unknown_peer(info, packet->packet_.receive.data, packet->packet_.receive.size)) + break; // If a handler returns true, stop processing further handlers + } + } + } + // Intentionally left as if instead of else in case the peer is added above + if (esp_now_is_peer_exist(info.src_addr)) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, "<<< [%s -> %s] %s", format_mac_address_pretty(info.src_addr).c_str(), + format_mac_address_pretty(info.des_addr).c_str(), + format_hex_pretty(packet->packet_.receive.data, packet->packet_.receive.size).c_str()); +#endif + if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + for (auto *handler : this->broadcasted_handlers_) { + if (handler->on_broadcasted(info, packet->packet_.receive.data, packet->packet_.receive.size)) + break; // If a handler returns true, stop processing further handlers + } + } else { + for (auto *handler : this->received_handlers_) { + if (handler->on_received(info, packet->packet_.receive.data, packet->packet_.receive.size)) + break; // If a handler returns true, stop processing further handlers + } + } + } + break; + } + case ESPNowPacket::SENT: { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, ">>> [%s] %s", format_mac_address_pretty(packet->packet_.sent.address).c_str(), + LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status))); +#endif + if (this->current_send_packet_ != nullptr) { + this->current_send_packet_->callback_(packet->packet_.sent.status); + this->send_packet_pool_.release(this->current_send_packet_); + this->current_send_packet_ = nullptr; // Reset current packet after sending + } + break; + } + default: + break; + } + // Return the packet to the pool + this->receive_packet_pool_.release(packet); + packet = this->receive_packet_queue_.pop(); + } + + // Process sending packet queue + if (this->current_send_packet_ == nullptr) { + this->send_(); + } + + // Log dropped received packets periodically + uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count(); + if (received_dropped > 0) { + ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped); + } + + // Log dropped send packets periodically + uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count(); + if (send_dropped > 0) { + ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped); + } +} + +esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payload, size_t size, + const send_callback_t &callback) { + if (this->state_ != ESPNOW_STATE_ENABLED) { + return ESP_ERR_ESPNOW_NOT_INIT; + } else if (this->is_failed()) { + return ESP_ERR_ESPNOW_FAILED; + } else if (peer_address == 0ULL) { + return ESP_ERR_ESPNOW_PEER_NOT_SET; + } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { + return ESP_ERR_ESPNOW_OWN_ADDRESS; + } else if (size > ESP_NOW_MAX_DATA_LEN) { + return ESP_ERR_ESPNOW_DATA_SIZE; + } else if (!esp_now_is_peer_exist(peer_address)) { + if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { + esp_err_t err = this->add_peer(peer_address); + if (err != ESP_OK) { + return err; + } + } else { + return ESP_ERR_ESPNOW_PEER_NOT_PAIRED; + } + } + // Allocate a packet from the pool + ESPNowSendPacket *packet = this->send_packet_pool_.allocate(); + if (packet == nullptr) { + this->send_packet_queue_.increment_dropped_count(); + ESP_LOGE(TAG, "Failed to allocate send packet from pool"); + this->status_momentary_warning("send-packet-pool-full"); + return ESP_ERR_ESPNOW_NO_MEM; + } + // Load the packet data + packet->load_data(peer_address, payload, size, callback); + // Push the packet to the send queue + this->send_packet_queue_.push(packet); + return ESP_OK; +} + +void ESPNowComponent::send_() { + ESPNowSendPacket *packet = this->send_packet_queue_.pop(); + if (packet == nullptr) { + return; // No packets to send + } + + this->current_send_packet_ = packet; + esp_err_t err = esp_now_send(packet->address_, packet->data_, packet->size_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to send packet to %s - %s", format_mac_address_pretty(packet->address_).c_str(), + LOG_STR_ARG(espnow_error_to_str(err))); + if (packet->callback_ != nullptr) { + packet->callback_(err); + } + this->status_momentary_warning("send-failed"); + this->send_packet_pool_.release(packet); + this->current_send_packet_ = nullptr; // Reset current packet + return; + } +} + +esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { + if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { + return ESP_ERR_ESPNOW_NOT_INIT; + } + + if (memcmp(peer, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { + this->mark_failed(); + return ESP_ERR_INVALID_MAC; + } + + if (!esp_now_is_peer_exist(peer)) { + esp_now_peer_info_t peer_info = {}; + memset(&peer_info, 0, sizeof(esp_now_peer_info_t)); + peer_info.ifidx = WIFI_IF_STA; + memcpy(peer_info.peer_addr, peer, ESP_NOW_ETH_ALEN); + esp_err_t err = esp_now_add_peer(&peer_info); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to add peer %s - %s", format_mac_address_pretty(peer).c_str(), + LOG_STR_ARG(espnow_error_to_str(err))); + this->status_momentary_warning("peer-add-failed"); + return err; + } + } + bool found = false; + for (auto &it : this->peers_) { + if (it == peer) { + found = true; + break; + } + } + if (!found) { + ESPNowPeer new_peer; + memcpy(new_peer.address, peer, ESP_NOW_ETH_ALEN); + this->peers_.push_back(new_peer); + } + + return ESP_OK; +} + +esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { + if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { + return ESP_ERR_ESPNOW_NOT_INIT; + } + if (esp_now_is_peer_exist(peer)) { + esp_err_t err = esp_now_del_peer(peer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to delete peer %s - %s", format_mac_address_pretty(peer).c_str(), + LOG_STR_ARG(espnow_error_to_str(err))); + this->status_momentary_warning("peer-del-failed"); + return err; + } + } + for (auto it = this->peers_.begin(); it != this->peers_.end(); ++it) { + if (*it == peer) { + this->peers_.erase(it); + break; + } + } + return ESP_OK; +} + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h new file mode 100644 index 0000000000..3a523d1f7e --- /dev/null +++ b/esphome/components/espnow/espnow_component.h @@ -0,0 +1,182 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +#ifdef USE_ESP32 + +#include "esphome/core/event_pool.h" +#include "esphome/core/lock_free_queue.h" +#include "espnow_packet.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace esphome::espnow { + +// Maximum size of the ESPNow event queue - must be power of 2 for lock-free queue +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; + +using peer_address_t = std::array; + +enum class ESPNowTriggers : uint8_t { + TRIGGER_NONE = 0, + ON_NEW_PEER = 1, + ON_RECEIVED = 2, + ON_BROADCASTED = 3, + ON_SUCCEED = 10, + ON_FAILED = 11, +}; + +enum ESPNowState : uint8_t { + /** Nothing has been initialized yet. */ + ESPNOW_STATE_OFF = 0, + /** ESPNOW is disabled. */ + ESPNOW_STATE_DISABLED, + /** ESPNOW is enabled. */ + ESPNOW_STATE_ENABLED, +}; + +struct ESPNowPeer { + uint8_t address[ESP_NOW_ETH_ALEN]; // MAC address of the peer + + bool operator==(const ESPNowPeer &other) const { return memcmp(this->address, other.address, ESP_NOW_ETH_ALEN) == 0; } + bool operator==(const uint8_t *other) const { return memcmp(this->address, other, ESP_NOW_ETH_ALEN) == 0; } +}; + +/// Handler interface for receiving ESPNow packets from unknown peers +/// Components should inherit from this class to handle incoming ESPNow data +class ESPNowUnknownPeerHandler { + public: + /// Called when an ESPNow packet is received from an unknown peer + /// @param info Information about the received packet (sender MAC, etc.) + /// @param data Pointer to the received data payload + /// @param size Size of the received data in bytes + /// @return true if the packet was handled, false otherwise + virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; +}; + +/// Handler interface for receiving ESPNow packets +/// Components should inherit from this class to handle incoming ESPNow data +class ESPNowReceivedPacketHandler { + public: + /// Called when an ESPNow packet is received + /// @param info Information about the received packet (sender MAC, etc.) + /// @param data Pointer to the received data payload + /// @param size Size of the received data in bytes + /// @return true if the packet was handled, false otherwise + virtual bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; +}; +/// Handler interface for receiving broadcasted ESPNow packets +/// Components should inherit from this class to handle incoming ESPNow data +class ESPNowBroadcastedHandler { + public: + /// Called when a broadcasted ESPNow packet is received + /// @param info Information about the received packet (sender MAC, etc.) + /// @param data Pointer to the received data payload + /// @param size Size of the received data in bytes + /// @return true if the packet was handled, false otherwise + virtual bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; +}; + +class ESPNowComponent : public Component { + public: + ESPNowComponent(); + void setup() override; + void loop() override; + void dump_config() override; + + float get_setup_priority() const override { return setup_priority::LATE; } + + // Add a peer to the internal list of peers + void add_peer(peer_address_t address) { + ESPNowPeer peer; + memcpy(peer.address, address.data(), ESP_NOW_ETH_ALEN); + this->peers_.push_back(peer); + } + // Add a peer with the esp_now api and add to the internal list if doesnt exist already + esp_err_t add_peer(const uint8_t *peer); + // Remove a peer with the esp_now api and remove from the internal list if exists + esp_err_t del_peer(const uint8_t *peer); + + void set_wifi_channel(uint8_t channel) { this->wifi_channel_ = channel; } + void apply_wifi_channel(); + + void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } + + void enable(); + void disable(); + bool is_disabled() const { return this->state_ == ESPNOW_STATE_DISABLED; }; + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + bool is_wifi_enabled(); + + /// @brief Queue a packet to be sent to a specific peer address. + /// This method will add the packet to the internal queue and + /// call the callback when the packet is sent. + /// Only one packet will be sent at any given time and the next one will not be sent until + /// the previous one has been acknowledged or failed. + /// @param peer_address MAC address of the peer to send the packet to + /// @param payload Data payload to send + /// @param callback Callback to call when the send operation is complete + /// @return ESP_OK on success, or an error code on failure + esp_err_t send(const uint8_t *peer_address, const std::vector &payload, + const send_callback_t &callback = nullptr) { + return this->send(peer_address, payload.data(), payload.size(), callback); + } + esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, + const send_callback_t &callback = nullptr); + + void register_received_handler(ESPNowReceivedPacketHandler *handler) { this->received_handlers_.push_back(handler); } + void register_unknown_peer_handler(ESPNowUnknownPeerHandler *handler) { + this->unknown_peer_handlers_.push_back(handler); + } + void register_broadcasted_handler(ESPNowBroadcastedHandler *handler) { + this->broadcasted_handlers_.push_back(handler); + } + + protected: + friend void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + friend void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status); +#else + friend void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status); +#endif + + void enable_(); + void send_(); + + std::vector unknown_peer_handlers_; + std::vector received_handlers_; + std::vector broadcasted_handlers_; + + std::vector peers_{}; + + uint8_t own_address_[ESP_NOW_ETH_ALEN]{0}; + LockFreeQueue receive_packet_queue_{}; + EventPool receive_packet_pool_{}; + + LockFreeQueue send_packet_queue_{}; + EventPool send_packet_pool_{}; + ESPNowSendPacket *current_send_packet_{nullptr}; // Currently sending packet, nullptr if none + + uint8_t wifi_channel_{0}; + ESPNowState state_{ESPNOW_STATE_OFF}; + + bool auto_add_peer_{false}; + bool enable_on_boot_{true}; +}; + +extern ESPNowComponent *global_esp_now; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_err.h b/esphome/components/espnow/espnow_err.h new file mode 100644 index 0000000000..ceda1b7683 --- /dev/null +++ b/esphome/components/espnow/espnow_err.h @@ -0,0 +1,19 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include + +namespace esphome::espnow { + +static const esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); +static const esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); +static const esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); +static const esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); +static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); +static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h new file mode 100644 index 0000000000..d39f7d2c24 --- /dev/null +++ b/esphome/components/espnow/espnow_packet.h @@ -0,0 +1,166 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "espnow_err.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace esphome::espnow { + +static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; +static const uint8_t ESPNOW_MULTICAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE}; + +struct WifiPacketRxControl { + int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm + uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or + // light sleep is not enabled +}; + +struct ESPNowRecvInfo { + uint8_t src_addr[ESP_NOW_ETH_ALEN]; /**< Source address of ESPNOW packet */ + uint8_t des_addr[ESP_NOW_ETH_ALEN]; /**< Destination address of ESPNOW packet */ + wifi_pkt_rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ +}; + +using send_callback_t = std::function; + +class ESPNowPacket { + public: + // NOLINTNEXTLINE(readability-identifier-naming) + enum esp_now_packet_type_t : uint8_t { + RECEIVED, + SENT, + }; + + // Constructor for received data + ESPNowPacket(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + this->init_received_data_(info, data, size); + }; + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // Constructor for sent data + ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { + this->init_sent_data(info->src_addr, status); + } +#else + // Constructor for sent data + ESPNowPacket(const uint8_t *mac_addr, esp_now_send_status_t status) { this->init_sent_data_(mac_addr, status); } +#endif + + // Default constructor for pre-allocation in pool + ESPNowPacket() {} + + void release() {} + + void load_received_data(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + this->type_ = RECEIVED; + this->init_received_data_(info, data, size); + } + + void load_sent_data(const uint8_t *mac_addr, esp_now_send_status_t status) { + this->type_ = SENT; + this->init_sent_data_(mac_addr, status); + } + + // Disable copy to prevent double-delete + ESPNowPacket(const ESPNowPacket &) = delete; + ESPNowPacket &operator=(const ESPNowPacket &) = delete; + + union { + // NOLINTNEXTLINE(readability-identifier-naming) + struct received_data { + ESPNowRecvInfo info; // Information about the received packet + uint8_t data[ESP_NOW_MAX_DATA_LEN]; // Data received in the packet + uint8_t size; // Size of the received data + WifiPacketRxControl rx_ctrl; // Status of the received packet + } receive; + + // NOLINTNEXTLINE(readability-identifier-naming) + struct sent_data { + uint8_t address[ESP_NOW_ETH_ALEN]; + esp_now_send_status_t status; + } sent; + } packet_; + + esp_now_packet_type_t type_; + + esp_now_packet_type_t type() const { return this->type_; } + const ESPNowRecvInfo &get_receive_info() const { return this->packet_.receive.info; } + + private: + void init_received_data_(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + memcpy(this->packet_.receive.info.src_addr, info->src_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.info.des_addr, info->des_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.data, data, size); + this->packet_.receive.size = size; + + this->packet_.receive.rx_ctrl.rssi = info->rx_ctrl->rssi; + this->packet_.receive.rx_ctrl.timestamp = info->rx_ctrl->timestamp; + + this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); + } + + void init_sent_data_(const uint8_t *mac_addr, esp_now_send_status_t status) { + memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); + this->packet_.sent.status = status; + } +}; + +class ESPNowSendPacket { + public: + ESPNowSendPacket(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &&callback) + : callback_(callback) { + this->init_data_(peer_address, payload, size); + } + ESPNowSendPacket(const uint8_t *peer_address, const uint8_t *payload, size_t size) { + this->init_data_(peer_address, payload, size); + } + + // Default constructor for pre-allocation in pool + ESPNowSendPacket() {} + + void release() {} + + // Disable copy to prevent double-delete + ESPNowSendPacket(const ESPNowSendPacket &) = delete; + ESPNowSendPacket &operator=(const ESPNowSendPacket &) = delete; + + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback) { + this->init_data_(peer_address, payload, size); + this->callback_ = callback; + } + + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size) { + this->init_data_(peer_address, payload, size); + this->callback_ = nullptr; // Reset callback + } + + uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to + uint8_t data_[ESP_NOW_MAX_DATA_LEN]{0}; // Data to send + uint8_t size_{0}; // Size of the data to send, must be <= ESP_NOW_MAX_DATA_LEN + send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete + + private: + void init_data_(const uint8_t *peer_address, const uint8_t *payload, size_t size) { + memcpy(this->address_, peer_address, ESP_NOW_ETH_ALEN); + if (size > ESP_NOW_MAX_DATA_LEN) { + this->size_ = 0; + return; + } + this->size_ = size; + memcpy(this->data_, payload, this->size_); + } +}; + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 99646c9f7e..f880b5f736 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -108,6 +108,24 @@ class ImageEncoder: :return: """ + @classmethod + def is_endian(cls) -> bool: + """ + Check if the image encoder supports endianness configuration + """ + return getattr(cls, "set_big_endian", None) is not None + + @classmethod + def get_options(cls) -> list[str]: + """ + Get the available options for this image encoder + """ + options = [*OPTIONS] + if not cls.is_endian(): + options.remove(CONF_BYTE_ORDER) + options.append(CONF_RAW_DATA_ID) + return options + def is_alpha_only(image: Image): """ @@ -446,13 +464,14 @@ def validate_type(image_types): return validate -def validate_settings(value): +def validate_settings(value, path=()): """ Validate the settings for a single image configuration. """ conf_type = value[CONF_TYPE] type_class = IMAGE_TYPE[conf_type] - transparency = value[CONF_TRANSPARENCY].lower() + + transparency = value.get(CONF_TRANSPARENCY, CONF_OPAQUE).lower() if transparency not in type_class.allow_config: raise cv.Invalid( f"Image format '{conf_type}' cannot have transparency: {transparency}" @@ -464,11 +483,10 @@ def validate_settings(value): and CONF_INVERT_ALPHA not in type_class.allow_config ): raise cv.Invalid("No alpha channel to invert") - if value.get(CONF_BYTE_ORDER) is not None and not callable( - getattr(type_class, "set_big_endian", None) - ): + if value.get(CONF_BYTE_ORDER) is not None and not type_class.is_endian(): raise cv.Invalid( - f"Image format '{conf_type}' does not support byte order configuration" + f"Image format '{conf_type}' does not support byte order configuration", + path=path, ) if file := value.get(CONF_FILE): file = Path(file) @@ -479,7 +497,7 @@ def validate_settings(value): Image.open(file) except UnidentifiedImageError as exc: raise cv.Invalid( - f"File can't be opened as image: {file.absolute()}" + f"File can't be opened as image: {file.absolute()}", path=path ) from exc return value @@ -499,6 +517,10 @@ OPTIONS_SCHEMA = { cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), +} + +DEFAULTS_SCHEMA = { + **OPTIONS_SCHEMA, cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE), } @@ -510,47 +532,61 @@ IMAGE_SCHEMA_NO_DEFAULTS = { **{cv.Optional(key): OPTIONS_SCHEMA[key] for key in OPTIONS}, } -BASE_SCHEMA = cv.Schema( +IMAGE_SCHEMA = cv.Schema( { **IMAGE_ID_SCHEMA, **OPTIONS_SCHEMA, - } -).add_extra(validate_settings) - -IMAGE_SCHEMA = BASE_SCHEMA.extend( - { cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), } ) +def apply_defaults(image, defaults, path): + """ + Apply defaults to an image configuration + """ + type = image.get(CONF_TYPE, defaults.get(CONF_TYPE)) + if type is None: + raise cv.Invalid( + "Type is required either in the image config or in the defaults", path=path + ) + type_class = IMAGE_TYPE[type] + config = { + **{key: image.get(key, defaults.get(key)) for key in type_class.get_options()}, + **{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA}, + CONF_TYPE: image.get(CONF_TYPE, defaults.get(CONF_TYPE)), + } + validate_settings(config, path) + return config + + def validate_defaults(value): """ - Validate the options for images with defaults + Apply defaults to the images in the configuration and flatten to a single list. """ defaults = value[CONF_DEFAULTS] result = [] - for index, image in enumerate(value[CONF_IMAGES]): - type = image.get(CONF_TYPE, defaults.get(CONF_TYPE)) - if type is None: - raise cv.Invalid( - "Type is required either in the image config or in the defaults", - path=[CONF_IMAGES, index], - ) - type_class = IMAGE_TYPE[type] - # A default byte order should be simply ignored if the type does not support it - available_options = [*OPTIONS] - if ( - not callable(getattr(type_class, "set_big_endian", None)) - and CONF_BYTE_ORDER not in image - ): - available_options.remove(CONF_BYTE_ORDER) - config = { - **{key: image.get(key, defaults.get(key)) for key in available_options}, - **{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA}, - } - validate_settings(config) - result.append(config) + # Apply defaults to the images: list and add the list entries to the result + for index, image in enumerate(value.get(CONF_IMAGES, [])): + result.append(apply_defaults(image, defaults, [CONF_IMAGES, index])) + + # Apply defaults to images under the type keys and add them to the result + for image_type, type_config in value.items(): + type_upper = image_type.upper() + if type_upper not in IMAGE_TYPE: + continue + type_class = IMAGE_TYPE[type_upper] + if isinstance(type_config, list): + # If the type is a list, apply defaults to each entry + for index, image in enumerate(type_config): + result.append(apply_defaults(image, defaults, [image_type, index])) + else: + # Handle transparency options for the type + for trans_type in set(type_class.allow_config).intersection(type_config): + for index, image in enumerate(type_config[trans_type]): + result.append( + apply_defaults(image, defaults, [image_type, trans_type, index]) + ) return result @@ -562,16 +598,20 @@ def typed_image_schema(image_type): cv.Schema( { cv.Optional(t.lower()): cv.ensure_list( - BASE_SCHEMA.extend( - { - cv.Optional( - CONF_TRANSPARENCY, default=t - ): validate_transparency((t,)), - cv.Optional(CONF_TYPE, default=image_type): validate_type( - (image_type,) - ), - } - ) + { + **IMAGE_ID_SCHEMA, + **{ + cv.Optional(key): OPTIONS_SCHEMA[key] + for key in OPTIONS + if key != CONF_TRANSPARENCY + }, + cv.Optional( + CONF_TRANSPARENCY, default=t + ): validate_transparency((t,)), + cv.Optional(CONF_TYPE, default=image_type): validate_type( + (image_type,) + ), + } ) for t in IMAGE_TYPE[image_type].allow_config.intersection( TRANSPARENCY_TYPES @@ -580,46 +620,44 @@ def typed_image_schema(image_type): ), # Allow a default configuration with no transparency preselected cv.ensure_list( - BASE_SCHEMA.extend( - { - cv.Optional( - CONF_TRANSPARENCY, default=CONF_OPAQUE - ): validate_transparency(), - cv.Optional(CONF_TYPE, default=image_type): validate_type( - (image_type,) - ), - } - ) + { + **IMAGE_SCHEMA_NO_DEFAULTS, + cv.Optional(CONF_TYPE, default=image_type): validate_type( + (image_type,) + ), + } ), ) # The config schema can be a (possibly empty) single list of images, -# or a dictionary of image types each with a list of images -# or a dictionary with keys `defaults:` and `images:` +# or a dictionary with optional keys `defaults:`, `images:` and the image types -def _config_schema(config): - if isinstance(config, list): - return cv.Schema([IMAGE_SCHEMA])(config) - if not isinstance(config, dict): +def _config_schema(value): + if isinstance(value, list) or ( + isinstance(value, dict) and (CONF_ID in value or CONF_FILE in value) + ): + return cv.ensure_list(cv.All(IMAGE_SCHEMA, validate_settings))(value) + if not isinstance(value, dict): raise cv.Invalid( - "Badly formed image configuration, expected a list or a dictionary" + "Badly formed image configuration, expected a list or a dictionary", ) - if CONF_DEFAULTS in config or CONF_IMAGES in config: - return validate_defaults( - cv.Schema( - { - cv.Required(CONF_DEFAULTS): OPTIONS_SCHEMA, - cv.Required(CONF_IMAGES): cv.ensure_list(IMAGE_SCHEMA_NO_DEFAULTS), - } - )(config) - ) - if CONF_ID in config or CONF_FILE in config: - return cv.ensure_list(IMAGE_SCHEMA)([config]) - return cv.Schema( - {cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE} - )(config) + return cv.All( + cv.Schema( + { + cv.Optional(CONF_DEFAULTS, default={}): DEFAULTS_SCHEMA, + cv.Optional(CONF_IMAGES, default=[]): cv.ensure_list( + { + **IMAGE_SCHEMA_NO_DEFAULTS, + cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE), + } + ), + **{cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE}, + } + ), + validate_defaults, + )(value) CONFIG_SCHEMA = _config_schema @@ -668,7 +706,7 @@ async def write_image(config, all_frames=False): else Image.Dither.FLOYDSTEINBERG ) type = config[CONF_TYPE] - transparency = config[CONF_TRANSPARENCY] + transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) invert_alpha = config[CONF_INVERT_ALPHA] frame_count = 1 if all_frames: @@ -699,14 +737,9 @@ async def write_image(config, all_frames=False): async def to_code(config): - if isinstance(config, list): - for entry in config: - await to_code(entry) - elif CONF_ID not in config: - for entry in config.values(): - await to_code(entry) - else: - prog_arr, width, height, image_type, trans_value, _ = await write_image(config) + # By now the config should be a simple list. + for entry in config: + prog_arr, width, height, image_type, trans_value, _ = await write_image(entry) cg.new_Pvariable( - config[CONF_ID], prog_arr, width, height, image_type, trans_value + entry[CONF_ID], prog_arr, width, height, image_type, trans_value ) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 0cd65d298f..a37f4570f3 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -4,6 +4,7 @@ from esphome.automation import build_automation, register_action, validate_autom import esphome.codegen as cg from esphome.components.const import CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING from esphome.components.display import Display +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, @@ -219,7 +220,7 @@ def final_validation(configs): draw_rounding, config[CONF_DRAW_ROUNDING] ) buffer_frac = config[CONF_BUFFER_SIZE] - if CORE.is_esp32 and buffer_frac > 0.5 and "psram" not in global_config: + if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: LOGGER.warning("buffer_size: may need to be reduced without PSRAM") for image_id in lv_images_used: path = global_config.get_path_for_id(image_id)[:-1] diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index eba8fc87f7..a16aed2e72 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -54,15 +54,15 @@ class IrFollowMeData : public IrData { void set_fahrenheit(bool val) { this->set_mask_(2, val, 32); } protected: - static const uint8_t MIN_TEMP_C = 0; - static const uint8_t MAX_TEMP_C = 37; + inline static constexpr uint8_t MIN_TEMP_C = 0; + inline static constexpr uint8_t MAX_TEMP_C = 37; // see // https://github.com/crankyoldgit/IRremoteESP8266/blob/9bdf8abcb465268c5409db99dc83a26df64c7445/src/ir_Midea.h#L116 - static const uint8_t MIN_TEMP_F = 32; + inline static constexpr uint8_t MIN_TEMP_F = 32; // see // https://github.com/crankyoldgit/IRremoteESP8266/blob/9bdf8abcb465268c5409db99dc83a26df64c7445/src/ir_Midea.h#L117 - static const uint8_t MAX_TEMP_F = 99; + inline static constexpr uint8_t MAX_TEMP_F = 99; }; class IrSpecialData : public IrData { diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index cb2de6c3d7..e891e2daad 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -25,6 +25,7 @@ from esphome.components.mipi import ( power_of_two, requires_buffer, ) +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA @@ -292,7 +293,7 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True - if "psram" not in global_config and CONF_BUFFER_SIZE not in config: + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: if not requires_buffer(config): return config # No buffer needed, so no need to set a buffer size # If PSRAM is not enabled, choose a small buffer size by default diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 17807b9e2b..908a855f70 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -124,7 +124,9 @@ async def to_code(config: ConfigType) -> None: ], ) - if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: + if config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: + cg.add_define("USE_BOOTLOADER_MCUBOOT") + else: # make sure that firmware.zip is created # for Adafruit_nRF52_Bootloader cg.add_platformio_option("board_upload.protocol", "nrfutil") diff --git a/esphome/components/nrf52/const.py b/esphome/components/nrf52/const.py index d827e5fb22..715d527a66 100644 --- a/esphome/components/nrf52/const.py +++ b/esphome/components/nrf52/const.py @@ -2,3 +2,17 @@ BOOTLOADER_ADAFRUIT = "adafruit" BOOTLOADER_ADAFRUIT_NRF52_SD132 = "adafruit_nrf52_sd132" BOOTLOADER_ADAFRUIT_NRF52_SD140_V6 = "adafruit_nrf52_sd140_v6" BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 = "adafruit_nrf52_sd140_v7" +EXTRA_ADC = [ + "VDD", + "VDDHDIV5", +] +AIN_TO_GPIO = { + "AIN0": 2, + "AIN1": 3, + "AIN2": 4, + "AIN3": 5, + "AIN4": 28, + "AIN5": 29, + "AIN6": 30, + "AIN7": 31, +} diff --git a/esphome/components/nrf52/gpio.py b/esphome/components/nrf52/gpio.py index 85230c1f57..260114f90e 100644 --- a/esphome/components/nrf52/gpio.py +++ b/esphome/components/nrf52/gpio.py @@ -2,12 +2,23 @@ from esphome import pins import esphome.codegen as cg from esphome.components.zephyr.const import zephyr_ns import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER, PLATFORM_NRF52 +from esphome.const import ( + CONF_ANALOG, + CONF_ID, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + PLATFORM_NRF52, +) + +from .const import AIN_TO_GPIO, EXTRA_ADC ZephyrGPIOPin = zephyr_ns.class_("ZephyrGPIOPin", cg.InternalGPIOPin) def _translate_pin(value): + if value in AIN_TO_GPIO: + return AIN_TO_GPIO[value] if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -28,18 +39,33 @@ def _translate_pin(value): def validate_gpio_pin(value): + if value in EXTRA_ADC: + return value value = _translate_pin(value) if value < 0 or value > (32 + 16): raise cv.Invalid(f"NRF52: Invalid pin number: {value}") return value +def validate_supports(value): + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_analog = mode[CONF_ANALOG] + if is_analog: + if num in EXTRA_ADC: + return value + if num not in AIN_TO_GPIO.values(): + raise cv.Invalid(f"Cannot use {num} as analog pin") + return value + + NRF52_PIN_SCHEMA = cv.All( pins.gpio_base_schema( ZephyrGPIOPin, validate_gpio_pin, - modes=pins.GPIO_STANDARD_MODES, + modes=pins.GPIO_STANDARD_MODES + (CONF_ANALOG,), ), + validate_supports, ) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 9299cdcd0e..fd7e70a055 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -28,12 +28,13 @@ from esphome.core import CORE import esphome.final_validate as fv CODEOWNERS = ["@esphome/core"] +DOMAIN = "psram" DEPENDENCIES = [PLATFORM_ESP32] _LOGGER = logging.getLogger(__name__) -psram_ns = cg.esphome_ns.namespace("psram") +psram_ns = cg.esphome_ns.namespace(DOMAIN) PsramComponent = psram_ns.class_("PsramComponent", cg.Component) TYPE_QUAD = "quad" diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5d70785389..23e6ad0f2c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -599,7 +599,9 @@ async def throttle_filter_to_code(config, filter_id): TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( { cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds, - cv.Optional(CONF_VALUE, default="nan"): cv.ensure_list(cv.float_), + cv.Optional(CONF_VALUE, default="nan"): cv.Any( + cv.templatable(cv.float_), [cv.templatable(cv.float_)] + ), }, key=CONF_TIMEOUT, ) @@ -611,6 +613,8 @@ TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( TIMEOUT_WITH_PRIORITY_SCHEMA, ) async def throttle_with_priority_filter_to_code(config, filter_id): + if not isinstance(config[CONF_VALUE], list): + config[CONF_VALUE] = [config[CONF_VALUE]] template_ = [await cg.templatable(x, [], float) for x in config[CONF_VALUE]] return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 39b507f960..f077ad2416 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -225,7 +225,7 @@ optional SlidingWindowMovingAverageFilter::new_value(float value) { // ExponentialMovingAverageFilter ExponentialMovingAverageFilter::ExponentialMovingAverageFilter(float alpha, size_t send_every, size_t send_first_at) - : send_every_(send_every), send_at_(send_every - send_first_at), alpha_(alpha) {} + : alpha_(alpha), send_every_(send_every), send_at_(send_every - send_first_at) {} optional ExponentialMovingAverageFilter::new_value(float value) { if (!std::isnan(value)) { if (this->first_value_) { @@ -325,7 +325,7 @@ optional FilterOutValueFilter::new_value(float value) { // ThrottleFilter ThrottleFilter::ThrottleFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {} optional ThrottleFilter::new_value(float value) { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); if (this->last_input_ == 0 || now - this->last_input_ >= min_time_between_inputs_) { this->last_input_ = now; return value; @@ -369,19 +369,17 @@ optional ThrottleWithPriorityFilter::new_value(float value) { // DeltaFilter DeltaFilter::DeltaFilter(float delta, bool percentage_mode) - : delta_(delta), current_delta_(delta), percentage_mode_(percentage_mode), last_value_(NAN) {} + : delta_(delta), current_delta_(delta), last_value_(NAN), percentage_mode_(percentage_mode) {} optional DeltaFilter::new_value(float value) { if (std::isnan(value)) { if (std::isnan(this->last_value_)) { return {}; } else { - if (this->percentage_mode_) { - this->current_delta_ = fabsf(value * this->delta_); - } return this->last_value_ = value; } } - if (std::isnan(this->last_value_) || fabsf(value - this->last_value_) >= this->current_delta_) { + float diff = fabsf(value - this->last_value_); + if (std::isnan(this->last_value_) || (diff > 0.0f && diff >= this->current_delta_)) { if (this->percentage_mode_) { this->current_delta_ = fabsf(value * this->delta_); } diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 8e2c6fef08..5765c9a081 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -221,11 +221,11 @@ class ExponentialMovingAverageFilter : public Filter { void set_alpha(float alpha); protected: - bool first_value_{true}; float accumulator_{NAN}; + float alpha_; size_t send_every_; size_t send_at_; - float alpha_; + bool first_value_{true}; }; /** Simple throttle average filter. @@ -243,9 +243,9 @@ class ThrottleAverageFilter : public Filter, public Component { float get_setup_priority() const override; protected: - uint32_t time_period_; float sum_{0.0f}; unsigned int n_{0}; + uint32_t time_period_; bool have_nan_{false}; }; @@ -378,8 +378,8 @@ class DeltaFilter : public Filter { protected: float delta_; float current_delta_; - bool percentage_mode_; float last_value_{NAN}; + bool percentage_mode_; }; class OrFilter : public Filter { @@ -401,8 +401,8 @@ class OrFilter : public Filter { }; std::vector filters_; - bool has_value_{false}; PhiNode phi_; + bool has_value_{false}; }; class CalibrateLinearFilter : public Filter { diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 2b542404a5..c698122030 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,5 +1,5 @@ import os -from typing import Final, TypedDict +from typing import TypedDict import esphome.codegen as cg from esphome.const import CONF_BOARD @@ -8,18 +8,19 @@ from esphome.helpers import copy_file_if_changed, write_file_if_changed from .const import ( BOOTLOADER_MCUBOOT, + KEY_BOARD, KEY_BOOTLOADER, KEY_EXTRA_BUILD_FILES, KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, + KEY_USER, KEY_ZEPHYR, zephyr_ns, ) CODEOWNERS = ["@tomaszduda23"] AUTO_LOAD = ["preferences"] -KEY_BOARD: Final = "board" PrjConfValueType = bool | str | int @@ -49,6 +50,7 @@ class ZephyrData(TypedDict): overlay: str extra_build_files: dict[str, str] pm_static: list[Section] + user: dict[str, list[str]] def zephyr_set_core_data(config): @@ -59,6 +61,7 @@ def zephyr_set_core_data(config): overlay="", extra_build_files={}, pm_static=[], + user={}, ) return config @@ -178,7 +181,25 @@ def zephyr_add_pm_static(section: Section): CORE.data[KEY_ZEPHYR][KEY_PM_STATIC].extend(section) +def zephyr_add_user(key, value): + user = zephyr_data()[KEY_USER] + if key not in user: + user[key] = [] + user[key] += [value] + + def copy_files(): + user = zephyr_data()[KEY_USER] + if user: + zephyr_add_overlay( + f""" +/ {{ + zephyr,user {{ + {[f"{key} = {', '.join(value)};" for key, value in user.items()][0]} +}}; +}};""" + ) + want_opts = zephyr_data()[KEY_PRJ_CONF] prj_conf = ( diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f14a326344..06a4fc42bc 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -10,5 +10,7 @@ KEY_OVERLAY: Final = "overlay" KEY_PM_STATIC: Final = "pm_static" KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" +KEY_BOARD: Final = "board" +KEY_USER: Final = "user" zephyr_ns = cg.esphome_ns.namespace("zephyr") diff --git a/esphome/config.py b/esphome/config.py index 670cbe7233..cf7a232d8e 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -329,6 +329,28 @@ class ConfigValidationStep(abc.ABC): def run(self, result: Config) -> None: ... # noqa: E704 +class LoadTargetPlatformValidationStep(ConfigValidationStep): + """Load target platform step.""" + + def __init__(self, domain: str, conf: ConfigType): + self.domain = domain + self.conf = conf + + def run(self, result: Config) -> None: + if self.conf is None: + result[self.domain] = self.conf = {} + result.add_output_path([self.domain], self.domain) + component = get_component(self.domain) + + result[self.domain] = self.conf + path = [self.domain] + CORE.loaded_integrations.add(self.domain) + + result.add_validation_step( + SchemaValidationStep(self.domain, path, self.conf, component) + ) + + class LoadValidationStep(ConfigValidationStep): """Load step, this step is called once for each domain config fragment. @@ -582,16 +604,18 @@ class MetadataValidationStep(ConfigValidationStep): ) return for i, part_conf in enumerate(self.conf): + path = self.path + [i] result.add_validation_step( - SchemaValidationStep( - self.domain, self.path + [i], part_conf, self.comp - ) + SchemaValidationStep(self.domain, path, part_conf, self.comp) ) + result.add_validation_step(FinalValidateValidationStep(path, self.comp)) + return result.add_validation_step( SchemaValidationStep(self.domain, self.path, self.conf, self.comp) ) + result.add_validation_step(FinalValidateValidationStep(self.path, self.comp)) class SchemaValidationStep(ConfigValidationStep): @@ -628,7 +652,6 @@ class SchemaValidationStep(ConfigValidationStep): result.set_by_path(self.path, validated) path_context.reset(token) - result.add_validation_step(FinalValidateValidationStep(self.path, self.comp)) class IDPassValidationStep(ConfigValidationStep): @@ -909,7 +932,7 @@ def validate_config( # First run platform validation steps result.add_validation_step( - LoadValidationStep(target_platform, config[target_platform]) + LoadTargetPlatformValidationStep(target_platform, config[target_platform]) ) result.run_validation_steps() diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e226f748a8..55652e443e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -147,6 +147,7 @@ #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_BLUETOOTH_PROXY +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #define USE_CAPTIVE_PORTAL #define USE_ESP32_BLE #define USE_ESP32_BLE_CLIENT diff --git a/platformio.ini b/platformio.ini index ab0774b29f..d9f2f879ec 100644 --- a/platformio.ini +++ b/platformio.ini @@ -125,7 +125,7 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.2.1/esp32-3.2.1.zip @@ -161,7 +161,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.4.2/esp-idf-v5.4.2.zip diff --git a/requirements.txt b/requirements.txt index b7d259f0c5..bfdb08323e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,10 +9,10 @@ tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile -esptool==4.9.0 +esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.1 +aioesphomeapi==37.2.3 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f21d344527..fc7c22bf6d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -342,6 +342,11 @@ def create_field_type_info( # Check if this repeated field has fixed_array_size option if (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None: return FixedArrayRepeatedType(field, fixed_size) + # Check if this repeated field has fixed_array_size_define option + if ( + size_define := get_field_opt(field, pb.fixed_array_size_define) + ) is not None: + return FixedArrayRepeatedType(field, size_define) return RepeatedTypeInfo(field) # Check for fixed_array_size option on bytes fields @@ -1065,9 +1070,10 @@ class FixedArrayRepeatedType(TypeInfo): control how many items we receive when decoding. """ - def __init__(self, field: descriptor.FieldDescriptorProto, size: int) -> None: + def __init__(self, field: descriptor.FieldDescriptorProto, size: int | str) -> None: super().__init__(field) self.array_size = size + self.is_define = isinstance(size, str) # Check if we should skip encoding when all elements are zero # Use getattr to handle older versions of api_options_pb2 self.skip_zero = get_field_opt( @@ -1124,6 +1130,14 @@ class FixedArrayRepeatedType(TypeInfo): # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: + if self.is_define: + # When using a define, we need to use a loop-based approach + o = f"for (const auto &it : this->{self.field_name}) {{\n" + o += " if (it != 0) {\n" + o += f" {encode_element('it')}\n" + o += " }\n" + o += "}" + return o # Build the condition to check if at least one element is non-zero non_zero_checks = " || ".join( [f"this->{self.field_name}[{i}] != 0" for i in range(self.array_size)] @@ -1134,6 +1148,13 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(encode_lines) + "\n}" + # When using a define, always use loop-based approach + if self.is_define: + o = f"for (const auto &it : this->{self.field_name}) {{\n" + o += f" {encode_element('it')}\n" + o += "}" + return o + # Unroll small arrays for efficiency if self.array_size == 1: return encode_element(f"this->{self.field_name}[0]") @@ -1164,6 +1185,14 @@ class FixedArrayRepeatedType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # If skip_zero is enabled, wrap size calculation in a zero check if self.skip_zero: + if self.is_define: + # When using a define, we need to use a loop-based approach + o = f"for (const auto &it : {name}) {{\n" + o += " if (it != 0) {\n" + o += f" {self._ti.get_size_calculation('it', True)}\n" + o += " }\n" + o += "}" + return o # Build the condition to check if at least one element is non-zero non_zero_checks = " || ".join( [f"{name}[{i}] != 0" for i in range(self.array_size)] @@ -1174,6 +1203,13 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(size_lines) + "\n}" + # When using a define, always use loop-based approach + if self.is_define: + o = f"for (const auto &it : {name}) {{\n" + o += f" {self._ti.get_size_calculation('it', True)}\n" + o += "}" + return o + # For fixed arrays, we always encode all elements # Special case for single-element arrays - no loop needed @@ -1197,6 +1233,11 @@ class FixedArrayRepeatedType(TypeInfo): def get_estimated_size(self) -> int: # For fixed arrays, estimate underlying type size * array size underlying_size = self._ti.get_estimated_size() + if self.is_define: + # When using a define, we don't know the actual size so just guess 3 + # This is only used for documentation and never actually used since + # fixed arrays are only for SOURCE_SERVER (encode-only) messages + return underlying_size * 3 return underlying_size * self.array_size diff --git a/script/clang-tidy b/script/clang-tidy index 9576b8da8b..2c4a2e36ac 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -205,7 +205,12 @@ def main(): parser.add_argument( "-c", "--changed", action="store_true", help="only run on changed files" ) - parser.add_argument("-g", "--grep", help="only run on files containing value") + parser.add_argument( + "-g", + "--grep", + action="append", + help="only run on files containing value", + ) parser.add_argument( "--split-num", type=int, help="split the files into X jobs.", default=None ) diff --git a/script/helpers.py b/script/helpers.py index 4903521e2d..b346f3a461 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -338,12 +338,12 @@ def filter_changed(files: list[str]) -> list[str]: return files -def filter_grep(files: list[str], value: str) -> list[str]: +def filter_grep(files: list[str], value: list[str]) -> list[str]: matched = [] for file in files: with open(file, encoding="utf-8") as handle: contents = handle.read() - if value in contents: + if any(v in contents for v in value): matched.append(file) return matched diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 09a0850cbf..922f1171b4 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -25,6 +25,7 @@ int main() { return 0;} Path(zephyr_dir / "prj.conf").write_text( """ CONFIG_NEWLIB_LIBC=y +CONFIG_ADC=y """, encoding="utf-8", ) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index b269e23cd6..2045b03502 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -65,6 +65,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, + full_config: dict[str, ConfigType] | None = None, ) -> None: platform, framework = platform_framework.value @@ -83,7 +84,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: CORE.data[platform.value] = platform_data config.path_context.set([]) - final_validate.full_config.set(Config()) + final_validate.full_config.set(full_config or Config()) yield setter diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index fe031c653f..91e96f24d6 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -8,10 +8,13 @@ import pytest from esphome.components.esp32 import VARIANTS import esphome.config_validation as cv -from esphome.const import PlatformFramework +from esphome.const import CONF_ESPHOME, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable -def test_esp32_config(set_core_config) -> None: +def test_esp32_config( + set_core_config: SetCoreConfigCallable, +) -> None: set_core_config(PlatformFramework.ESP32_IDF) from esphome.components.esp32 import CONFIG_SCHEMA @@ -60,14 +63,49 @@ def test_esp32_config(set_core_config) -> None: r"Option 'variant' does not match selected board. @ data\['variant'\]", id="mismatched_board_variant_config", ), + pytest.param( + { + "variant": "esp32s2", + "framework": { + "type": "esp-idf", + "advanced": {"execute_from_psram": True}, + }, + }, + r"'execute_from_psram' is only supported on ESP32S3 variant @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", + id="execute_from_psram_invalid_for_variant_config", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"execute_from_psram": True}, + }, + }, + r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", + id="execute_from_psram_requires_psram_config", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"ignore_efuse_mac_crc": True}, + }, + }, + r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]", + id="ignore_efuse_mac_crc_only_on_esp32", + ), ], ) def test_esp32_configuration_errors( config: Any, error_match: str, + set_core_config: SetCoreConfigCallable, ) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={CONF_ESPHOME: {}}) """Test detection of invalid configuration.""" - from esphome.components.esp32 import CONFIG_SCHEMA + from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA with pytest.raises(cv.Invalid, match=error_match): - CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) diff --git a/tests/component_tests/image/config/image_test.yaml b/tests/component_tests/image/config/image_test.yaml index 3ff1260bd0..c34e0993a5 100644 --- a/tests/component_tests/image/config/image_test.yaml +++ b/tests/component_tests/image/config/image_test.yaml @@ -5,10 +5,12 @@ esp32: board: esp32s3box image: - - file: image.png - byte_order: little_endian - id: cat_img + defaults: type: rgb565 + byte_order: little_endian + images: + - file: image.png + id: cat_img spi: mosi_pin: 6 diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index d8a883d32f..f0b132cef8 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -9,7 +9,8 @@ from typing import Any import pytest from esphome import config_validation as cv -from esphome.components.image import CONFIG_SCHEMA +from esphome.components.image import CONF_TRANSPARENCY, CONFIG_SCHEMA +from esphome.const import CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE @pytest.mark.parametrize( @@ -22,12 +23,12 @@ from esphome.components.image import CONFIG_SCHEMA ), pytest.param( {"id": "image_id", "type": "rgb565"}, - r"required key not provided @ data\[0\]\['file'\]", + r"required key not provided @ data\['file'\]", id="missing_file", ), pytest.param( {"file": "image.png", "type": "rgb565"}, - r"required key not provided @ data\[0\]\['id'\]", + r"required key not provided @ data\['id'\]", id="missing_id", ), pytest.param( @@ -160,13 +161,66 @@ def test_image_configuration_errors( }, id="type_based_organization", ), + pytest.param( + { + "defaults": { + "type": "binary", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + }, + "rgb565": { + "alpha_channel": [ + { + "id": "image_id", + "file": "image.png", + "transparency": "alpha_channel", + "dither": "none", + } + ] + }, + "binary": [ + { + "id": "image_id", + "file": "image.png", + "transparency": "opaque", + } + ], + }, + id="type_based_with_defaults", + ), + pytest.param( + { + "defaults": { + "type": "rgb565", + "transparency": "alpha_channel", + }, + "binary": { + "opaque": [ + { + "id": "image_id", + "file": "image.png", + } + ], + }, + }, + id="binary_with_defaults", + ), ], ) def test_image_configuration_success( config: dict[str, Any] | list[dict[str, Any]], ) -> None: """Test successful configuration validation.""" - CONFIG_SCHEMA(config) + result = CONFIG_SCHEMA(config) + # All valid configurations should return a list of images + assert isinstance(result, list) + for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): + assert all(key in x for x in result), ( + f"Missing key {key} in image configuration" + ) def test_image_generation( diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py new file mode 100644 index 0000000000..c3070c7965 --- /dev/null +++ b/tests/component_tests/mipi_spi/conftest.py @@ -0,0 +1,43 @@ +"""Tests for mpip_spi configuration validation.""" + +from collections.abc import Callable, Generator + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS +from esphome.components.esp32.gpio import validate_gpio_pin +from esphome.const import CONF_INPUT, CONF_OUTPUT +from esphome.core import CORE +from esphome.pins import gpio_pin_schema + + +@pytest.fixture +def choose_variant_with_pins() -> Generator[Callable[[list], None]]: + """ + Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms + do not have variants. + """ + + def chooser(pins: list) -> None: + for v in VARIANTS: + try: + CORE.data[KEY_ESP32][KEY_VARIANT] = v + for pin in pins: + if pin is not None: + pin = gpio_pin_schema( + { + CONF_INPUT: True, + CONF_OUTPUT: True, + }, + internal=True, + )(pin) + validate_gpio_pin(pin) + return + except cv.Invalid: + continue + raise cv.Invalid( + f"No compatible variant found for pins: {', '.join(map(str, pins))}" + ) + + yield chooser diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index c4c93866ca..fbb3222812 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -9,13 +9,10 @@ import pytest from esphome import config_validation as cv from esphome.components.esp32 import ( KEY_BOARD, - KEY_ESP32, KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32S3, - VARIANTS, ) -from esphome.components.esp32.gpio import validate_gpio_pin from esphome.components.mipi import CONF_NATIVE_HEIGHT from esphome.components.mipi_spi.display import ( CONF_BUS_MODE, @@ -32,8 +29,6 @@ from esphome.const import ( CONF_WIDTH, PlatformFramework, ) -from esphome.core import CORE -from esphome.pins import internal_gpio_pin_number from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -43,28 +38,6 @@ def run_schema_validation(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) -@pytest.fixture -def choose_variant_with_pins() -> Callable[..., None]: - """ - Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms - do not have variants. - """ - - def chooser(*pins: int | str | None) -> None: - for v in VARIANTS: - try: - CORE.data[KEY_ESP32][KEY_VARIANT] = v - for pin in pins: - if pin is not None: - pin = internal_gpio_pin_number(pin) - validate_gpio_pin(pin) - return - except cv.Invalid: - continue - - return chooser - - @pytest.mark.parametrize( ("config", "error_match"), [ @@ -315,7 +288,7 @@ def test_custom_model_with_all_options( def test_all_predefined_models( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], - choose_variant_with_pins: Callable[..., None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test all predefined display models validate successfully with appropriate defaults.""" set_core_config( diff --git a/tests/component_tests/types.py b/tests/component_tests/types.py index 72b8be4503..ee9d317339 100644 --- a/tests/component_tests/types.py +++ b/tests/component_tests/types.py @@ -18,4 +18,5 @@ class SetCoreConfigCallable(Protocol): *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, + full_config: dict[str, ConfigType] | None = None, ) -> None: ... diff --git a/tests/components/adc/test.nrf52-adafruit.yaml b/tests/components/adc/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..4be5b4b5c2 --- /dev/null +++ b/tests/components/adc/test.nrf52-adafruit.yaml @@ -0,0 +1,23 @@ +sensor: + - platform: adc + pin: VDDHDIV5 + name: "VDDH Voltage" + update_interval: 5sec + filters: + - multiply: 5 + - platform: adc + pin: VDD + name: "VDD Voltage" + update_interval: 5sec + - platform: adc + pin: AIN0 + name: "AIN0 Voltage" + update_interval: 5sec + - platform: adc + pin: P0.03 + name: "AIN1 Voltage" + update_interval: 5sec + - platform: adc + name: "AIN2 Voltage" + update_interval: 5sec + pin: 4 diff --git a/tests/components/adc/test.nrf52-mcumgr.yaml b/tests/components/adc/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..4be5b4b5c2 --- /dev/null +++ b/tests/components/adc/test.nrf52-mcumgr.yaml @@ -0,0 +1,23 @@ +sensor: + - platform: adc + pin: VDDHDIV5 + name: "VDDH Voltage" + update_interval: 5sec + filters: + - multiply: 5 + - platform: adc + pin: VDD + name: "VDD Voltage" + update_interval: 5sec + - platform: adc + pin: AIN0 + name: "AIN0 Voltage" + update_interval: 5sec + - platform: adc + pin: P0.03 + name: "AIN1 Voltage" + update_interval: 5sec + - platform: adc + name: "AIN2 Voltage" + update_interval: 5sec + pin: 4 diff --git a/tests/components/debug/test.nrf52-adafruit.yaml b/tests/components/debug/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/debug/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/debug/test.nrf52-mcumgr.yaml b/tests/components/debug/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/debug/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..1d5a5e52a4 --- /dev/null +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -0,0 +1,12 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + execute_from_psram: true + +psram: + mode: octal + speed: 80MHz + +logger: diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml new file mode 100644 index 0000000000..abb31c12b8 --- /dev/null +++ b/tests/components/espnow/common.yaml @@ -0,0 +1,52 @@ +espnow: + auto_add_peer: false + channel: 1 + peers: + - 11:22:33:44:55:66 + on_receive: + - logger.log: + format: "Received from: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi + - espnow.send: + address: 11:22:33:44:55:66 + data: "Hello from ESPHome" + on_sent: + - logger.log: "ESPNow message sent successfully" + on_error: + - logger.log: "ESPNow message failed to send" + wait_for_sent: true + continue_on_error: true + + - espnow.send: + address: 11:22:33:44:55:66 + data: [0x01, 0x02, 0x03, 0x04, 0x05] + - espnow.send: + address: 11:22:33:44:55:66 + data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' + - espnow.broadcast: + data: "Hello, World!" + - espnow.broadcast: + data: [0x01, 0x02, 0x03, 0x04, 0x05] + - espnow.broadcast: + data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' + - espnow.peer.add: + address: 11:22:33:44:55:66 + - espnow.peer.delete: + address: 11:22:33:44:55:66 + on_broadcast: + - logger.log: + format: "Broadcast from: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi + on_unknown_peer: + - logger.log: + format: "Unknown peer: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi diff --git a/tests/components/espnow/test.esp32-idf.yaml b/tests/components/espnow/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/espnow/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/gpio/test.nrf52-adafruit.yaml b/tests/components/gpio/test.nrf52-adafruit.yaml index 3ca285117d..912b9537c4 100644 --- a/tests/components/gpio/test.nrf52-adafruit.yaml +++ b/tests/components/gpio/test.nrf52-adafruit.yaml @@ -5,10 +5,10 @@ binary_sensor: output: - platform: gpio - pin: 3 + pin: P0.3 id: gpio_output switch: - platform: gpio - pin: 4 + pin: P1.2 id: gpio_switch diff --git a/tests/components/gpio/test.nrf52-mcumgr.yaml b/tests/components/gpio/test.nrf52-mcumgr.yaml index 3ca285117d..912b9537c4 100644 --- a/tests/components/gpio/test.nrf52-mcumgr.yaml +++ b/tests/components/gpio/test.nrf52-mcumgr.yaml @@ -5,10 +5,10 @@ binary_sensor: output: - platform: gpio - pin: 3 + pin: P0.3 id: gpio_output switch: - platform: gpio - pin: 4 + pin: P1.2 id: gpio_switch diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index e185e01c5e..6b7c7ddea1 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -135,10 +135,17 @@ sensor: - throttle: 1s - throttle_average: 2s - throttle_with_priority: 5s + - throttle_with_priority: + timeout: 3s + value: 42.0 + - throttle_with_priority: + timeout: 3s + value: !lambda return 1.0f / 2.0f; - throttle_with_priority: timeout: 3s value: - 42.0 + - !lambda return 2.0f / 2.0f; - nan - timeout: timeout: 10s