diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c9662794d..496dd420d 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,12 @@ jobs: test: - path: 'components/adc/example' target: esp32 + - path: 'components/adc/example' + target: esp32s3 + - path: 'components/adc/example' + target: esp32p4 + - path: 'components/adc/example' + target: esp32c6 - path: 'components/adrc/example' target: esp32 - path: 'components/ads1x15/example' diff --git a/components/adc/example/README.md b/components/adc/example/README.md index 6ac5a1329..0a23f9f4d 100644 --- a/components/adc/example/README.md +++ b/components/adc/example/README.md @@ -13,9 +13,34 @@ ADC hardware, supporting DMA and filtering as well. ## How to use example You can build and run this example on any esp dev board, such as the QtPy ESP32 -series, the TinyPICO / TinyS3 boards, the WROVER-DevKit, and the ESP32-S3-BOX. +series, the TinyPICO / TinyS3 boards, the WROVER-DevKit, and the M5Stack Tab5. Make sure to set the idf target when building the example. +### Configure + +Select which ADC unit / channels the example reads via the `ADC Example +Configuration` menu: + +``` +idf.py menuconfig +``` + +The `Hardware` option has selections for specific boards which set the ADC +unit and channels to pins that are usable (exposed) on that board: + +| Hardware | Unit | Channels | Pins | +|----------|------|----------|------| +| Adafruit QtPy ESP32 Pico | 1 | 4, 5 | GPIO32 (`TX`), GPIO33 (`SCL`) | +| Adafruit QtPy ESP32-S3 | 1 | 7, 8 | GPIO8 (`A3`), GPIO9 (`A2`) | +| M5Stack Tab5 (ESP32-P4) | 1 | 0, 1 | GPIO16 / GPIO17 (expansion header) | +| Custom | any | any | your choice | + +With `Custom`, set the ADC unit and the two channels yourself. Note that the +continuous (DMA) portion of the example requires a DMA-capable unit; on +ESP32 / ESP32-C3 / ESP32-S3 that is only unit 1, and on ESP32-P4 ADC2 +continuous conversions currently produce all-zero samples with esp-idf +(oneshot on ADC2, e.g. the Tab5's Grove pins GPIO53/54, works fine). + ### Build and Flash Build the project and flash it to the board, then run monitor tool to view serial output: diff --git a/components/adc/example/main/Kconfig.projbuild b/components/adc/example/main/Kconfig.projbuild new file mode 100644 index 000000000..7474fcc99 --- /dev/null +++ b/components/adc/example/main/Kconfig.projbuild @@ -0,0 +1,114 @@ +menu "ADC Example Configuration" + + choice EXAMPLE_HARDWARE + prompt "Hardware" + default EXAMPLE_HARDWARE_QTPY_ESP32 if IDF_TARGET_ESP32 + default EXAMPLE_HARDWARE_QTPY_ESP32S3 if IDF_TARGET_ESP32S3 + default EXAMPLE_HARDWARE_M5STACK_TAB5 if IDF_TARGET_ESP32P4 + default EXAMPLE_HARDWARE_CUSTOM + help + Which hardware to run the example on. The board selections + configure the ADC unit and channels to pins which are usable + (exposed) on that board; select custom to choose the ADC unit and + channels yourself. + + config EXAMPLE_HARDWARE_QTPY_ESP32 + depends on IDF_TARGET_ESP32 + bool "Adafruit QtPy ESP32 Pico" + help + Uses ADC unit 1, channels 4 and 5: the pins labeled 'TX' + (GPIO32) and 'SCL' (GPIO33), the only ADC1-capable pins + exposed on the board. + + config EXAMPLE_HARDWARE_QTPY_ESP32S3 + depends on IDF_TARGET_ESP32S3 + bool "Adafruit QtPy ESP32-S3" + help + Uses ADC unit 1, channels 7 and 8: the pins labeled 'A3' + (GPIO8) and 'A2' (GPIO9). + + config EXAMPLE_HARDWARE_M5STACK_TAB5 + depends on IDF_TARGET_ESP32P4 + bool "M5Stack Tab5" + help + Uses ADC unit 1, channels 0 and 1: GPIO16 and GPIO17, which + are exposed on the Tab5's expansion header. + + NOTE: the Grove connector's signal pins (GPIO53/54) are ADC + unit 2 channels 4/5 and work with the oneshot ADC, but ADC2 + continuous (DMA) conversions on the ESP32-P4 currently + produce all-zero samples with esp-idf, so this example uses + ADC1 pins instead. + + config EXAMPLE_HARDWARE_CUSTOM + bool "Custom" + help + Select the ADC unit and channels yourself. + endchoice + + config EXAMPLE_ADC_UNIT + int "ADC unit (1 or 2)" if EXAMPLE_HARDWARE_CUSTOM + range 1 2 + default 1 + help + The ADC unit both channels are read from. + + NOTE: the continuous (DMA) part of the example requires a + DMA-capable unit; on ESP32, ESP32-C3, and ESP32-S3 that is only + unit 1. On ESP32-P4, ADC2 continuous conversions currently + produce all-zero samples with esp-idf, so prefer unit 1 there as + well. + + config EXAMPLE_ADC_CHANNEL_1 + int "First ADC channel" if EXAMPLE_HARDWARE_CUSTOM + range 0 9 + default 4 if EXAMPLE_HARDWARE_QTPY_ESP32 + default 7 if EXAMPLE_HARDWARE_QTPY_ESP32S3 + default 0 if EXAMPLE_HARDWARE_M5STACK_TAB5 + default 2 + help + The first ADC channel to read. Which GPIO a channel maps to is + chip specific; see the ADC section of your chip's technical + reference manual (e.g. ADC1 channels 0-7 are GPIO 36-39/32-35 on + ESP32, GPIO 1-10 on ESP32-S3, GPIO 16-23 on ESP32-P4). + + config EXAMPLE_ADC_CHANNEL_2 + int "Second ADC channel" if EXAMPLE_HARDWARE_CUSTOM + range 0 9 + default 5 if EXAMPLE_HARDWARE_QTPY_ESP32 + default 8 if EXAMPLE_HARDWARE_QTPY_ESP32S3 + default 1 if EXAMPLE_HARDWARE_M5STACK_TAB5 + default 3 + help + The second ADC channel to read. See EXAMPLE_ADC_CHANNEL_1 for the + channel-to-GPIO mapping. + + config EXAMPLE_ADC_SAMPLE_RATE_HZ + int "Continuous ADC Sample Rate (Hz)" + range 0 1000000 + default 20000 if EXAMPLE_HARDWARE_QTPY_ESP32 + default 20000 if EXAMPLE_HARDWARE_QTPY_ESP32S3 + default 10000 if EXAMPLE_HARDWARE_M5STACK_TAB5 + default 20000 + help + The sample rate for the continuous ADC readings. The example + uses a DMA buffer to read samples from the ADC. The example prints the average of each + channel's samples every second, so if you set the sample rate to + 1000 Hz, it will print the average of 1000 samples per channel + every second. + + config EXAMPLE_ADC_WINDOW_SIZE_BYTES + int "Continuous ADC Window Size (Bytes)" + range 0 65536 + default 1024 if EXAMPLE_HARDWARE_QTPY_ESP32 + default 1024 if EXAMPLE_HARDWARE_QTPY_ESP32S3 + default 256 if EXAMPLE_HARDWARE_M5STACK_TAB5 + default 1024 + help + The size of the DMA buffer used for continuous ADC readings. The + example prints the average of each channel's samples every second, + so if you set the sample rate to 1000 Hz and the window size to + 1000 bytes, it will print the average of 1000 samples per channel + every second. + +endmenu diff --git a/components/adc/example/main/adc_example.cpp b/components/adc/example/main/adc_example.cpp index 12b184968..b1eda33db 100644 --- a/components/adc/example/main/adc_example.cpp +++ b/components/adc/example/main/adc_example.cpp @@ -7,72 +7,48 @@ using namespace std::chrono_literals; +// The ADC unit and channels come from the "ADC Example Configuration" menu +// (menuconfig), which has selections for specific boards (setting them to +// pins which are usable on that board) as well as a custom option. Which GPIO +// a channel maps to is chip specific. +static constexpr adc_unit_t EXAMPLE_ADC_UNIT = + (CONFIG_EXAMPLE_ADC_UNIT == 1) ? ADC_UNIT_1 : ADC_UNIT_2; +static constexpr adc_channel_t EXAMPLE_ADC_CHANNEL_1 = + static_cast(CONFIG_EXAMPLE_ADC_CHANNEL_1); +static constexpr adc_channel_t EXAMPLE_ADC_CHANNEL_2 = + static_cast(CONFIG_EXAMPLE_ADC_CHANNEL_2); + extern "C" void app_main(void) { espp::Logger logger({.tag = "Adc Example", .level = espp::Logger::Verbosity::INFO}); size_t num_seconds_to_run = 5; - { - logger.info("Reading oneshot adc for {} seconds", num_seconds_to_run); - //! [oneshot adc example] - std::vector channels{ - {.unit = ADC_UNIT_1, .channel = ADC_CHANNEL_6, .attenuation = ADC_ATTEN_DB_12}, - {.unit = ADC_UNIT_1, .channel = ADC_CHANNEL_7, .attenuation = ADC_ATTEN_DB_12}}; - espp::OneshotAdc adc({ - .unit = ADC_UNIT_1, - .channels = channels, - }); - auto task_fn = [&adc, &channels](std::mutex &m, std::condition_variable &cv) { - static bool use_individual_functions = false; - if (use_individual_functions) { - // this iteration, we'll use the read_mv function for each channel - for (auto &conf : channels) { - auto maybe_mv = adc.read_mv(conf); - if (maybe_mv.has_value()) { - fmt::print("{}: {} mV\n", conf, maybe_mv.value()); - } else { - fmt::print("{}: no value!\n", conf); - } - } - } else { - // this iteration, we'll use the read_all_mv function to read all - // configured channels - auto voltages = adc.read_all_mv(); - for (const auto &mv : voltages) { - fmt::print("{} mV\n", mv); - } - } - use_individual_functions = !use_individual_functions; - // NOTE: sleeping in this way allows the sleep to exit early when the - // task is being stopped / destroyed - { - std::unique_lock lk(m); - cv.wait_for(lk, 500ms); - } - // don't want to stop the task - return false; - }; - auto task = espp::Task({.callback = task_fn, - .task_config = {.name = "Oneshot ADC"}, - .log_level = espp::Logger::Verbosity::INFO}); - task.start(); - //! [oneshot adc example] - std::this_thread::sleep_for(num_seconds_to_run * 1s); - } + logger.info("Using ADC unit {} channels {} and {}", CONFIG_EXAMPLE_ADC_UNIT, + (int)EXAMPLE_ADC_CHANNEL_1, (int)EXAMPLE_ADC_CHANNEL_2); + // NOTE: the continuous section runs first: on ESP32-P4, initializing and + // then deleting the oneshot ADC driver before using the continuous (DMA) + // driver currently causes the continuous conversions to produce all-zero + // samples (esp-idf issue; verified on hardware with IDF v6.0.1) { logger.info("Reading continuous adc for {} seconds", num_seconds_to_run); //! [continuous adc example] - std::vector channels{ - {.unit = ADC_UNIT_1, .channel = ADC_CHANNEL_6, .attenuation = ADC_ATTEN_DB_12}, - {.unit = ADC_UNIT_1, .channel = ADC_CHANNEL_7, .attenuation = ADC_ATTEN_DB_12}}; - // this initailizes the DMA and filter task for the continuous adc - espp::ContinuousAdc adc( - {.sample_rate_hz = 20 * 1000, - .channels = channels, - .convert_mode = - ADC_CONV_SINGLE_UNIT_1, // or BOTH_UNIT, ALTER_UNIT, SINGLE_UNIT_1, SINGLE_UNIT_2 - .window_size_bytes = 1024, - .log_level = espp::Logger::Verbosity::WARN}); + // NOTE: the unit must support continuous (DMA) mode; on ESP32 / C3 / S3 + // that is only unit 1 + std::vector channels{{.unit = EXAMPLE_ADC_UNIT, + .channel = EXAMPLE_ADC_CHANNEL_1, + .attenuation = ADC_ATTEN_DB_12}, + {.unit = EXAMPLE_ADC_UNIT, + .channel = EXAMPLE_ADC_CHANNEL_2, + .attenuation = ADC_ATTEN_DB_12}}; + // this initializes the DMA and filter task for the continuous adc. The + // conversion mode is derived automatically from the channel units; you + // can also set it explicitly with e.g. .convert_mode = + // ADC_CONV_SINGLE_UNIT_1 (chip-dependent: BOTH_UNIT, ALTER_UNIT, + // SINGLE_UNIT_1, SINGLE_UNIT_2) + espp::ContinuousAdc adc({.sample_rate_hz = CONFIG_EXAMPLE_ADC_SAMPLE_RATE_HZ, + .channels = channels, + .window_size_bytes = CONFIG_EXAMPLE_ADC_WINDOW_SIZE_BYTES, + .log_level = espp::Logger::Verbosity::WARN}); adc.start(); auto task_fn = [&adc, &channels](std::mutex &m, std::condition_variable &cv) { for (auto &conf : channels) { @@ -115,6 +91,57 @@ extern "C" void app_main(void) { std::this_thread::sleep_for(num_seconds_to_run * 1s); } + { + logger.info("Reading oneshot adc for {} seconds", num_seconds_to_run); + //! [oneshot adc example] + std::vector channels{{.unit = EXAMPLE_ADC_UNIT, + .channel = EXAMPLE_ADC_CHANNEL_1, + .attenuation = ADC_ATTEN_DB_12}, + {.unit = EXAMPLE_ADC_UNIT, + .channel = EXAMPLE_ADC_CHANNEL_2, + .attenuation = ADC_ATTEN_DB_12}}; + espp::OneshotAdc adc({ + .unit = EXAMPLE_ADC_UNIT, + .channels = channels, + }); + auto task_fn = [&adc, &channels](std::mutex &m, std::condition_variable &cv) { + static bool use_individual_functions = false; + if (use_individual_functions) { + // this iteration, we'll use the read_mv function for each channel + for (auto &conf : channels) { + auto maybe_mv = adc.read_mv(conf); + if (maybe_mv.has_value()) { + fmt::print("{}: {} mV\n", conf, maybe_mv.value()); + } else { + fmt::print("{}: no value!\n", conf); + } + } + } else { + // this iteration, we'll use the read_all_mv function to read all + // configured channels + auto voltages = adc.read_all_mv(); + for (const auto &mv : voltages) { + fmt::print("{} mV\n", mv); + } + } + use_individual_functions = !use_individual_functions; + // NOTE: sleeping in this way allows the sleep to exit early when the + // task is being stopped / destroyed + { + std::unique_lock lk(m); + cv.wait_for(lk, 500ms); + } + // don't want to stop the task + return false; + }; + auto task = espp::Task({.callback = task_fn, + .task_config = {.name = "Oneshot ADC"}, + .log_level = espp::Logger::Verbosity::INFO}); + task.start(); + //! [oneshot adc example] + std::this_thread::sleep_for(num_seconds_to_run * 1s); + } + logger.info("complete!"); while (true) { diff --git a/components/adc/include/adc_types.hpp b/components/adc/include/adc_types.hpp index a69d902ba..0c2239923 100644 --- a/components/adc/include/adc_types.hpp +++ b/components/adc/include/adc_types.hpp @@ -15,11 +15,13 @@ struct AdcConfig { attenuation; /**< The attenuation associated with this channel, e.g. ADC_ATTEN_DB_12. */ }; -static bool operator!=(const AdcConfig &lhs, const AdcConfig &rhs) { +[[maybe_unused]] static bool operator!=(const AdcConfig &lhs, const AdcConfig &rhs) { return lhs.unit != rhs.unit || lhs.channel != rhs.channel || lhs.attenuation != rhs.attenuation; } -static bool operator==(const AdcConfig &lhs, const AdcConfig &rhs) { return !(lhs != rhs); } +[[maybe_unused]] static bool operator==(const AdcConfig &lhs, const AdcConfig &rhs) { + return !(lhs != rhs); +} } // namespace espp // for libfmt printing of adc_unit_t diff --git a/components/adc/include/continuous_adc.hpp b/components/adc/include/continuous_adc.hpp index 342a4f540..3531bb9b3 100644 --- a/components/adc/include/continuous_adc.hpp +++ b/components/adc/include/continuous_adc.hpp @@ -1,14 +1,23 @@ #pragma once +#include +#include #include #include -#include #include #include "esp_adc/adc_cali.h" #include "esp_adc/adc_cali_scheme.h" #include "esp_adc/adc_continuous.h" +#include "esp_idf_version.h" +#ifndef ESP_IDF_VERSION_VAL +#define ESP_IDF_VERSION_VAL(major, minor, patch) (((major) << 16) | ((minor) << 8) | (patch)) +#endif +#ifndef ESP_IDF_VERSION +#define ESP_IDF_VERSION ESP_IDF_VERSION_VAL(0, 0, 0) +#endif + #include "adc_types.hpp" #include "base_component.hpp" #include "task.hpp" @@ -21,8 +30,62 @@ namespace espp { * date value, without needing to perform additional reads (therefore * it is non-blocking). * - * @note The available modes, frequencies, and throughput is dependent on - * whether you run on ESP32, ESP32s2, or ESP32s3. + * @note The available conversion modes, sample frequency range, and + * throughput depend on the chip. The aggregate sample frequency + * (sample_rate_hz * number of channels) must be within + * [SOC_ADC_SAMPLE_FREQ_THRES_LOW, SOC_ADC_SAMPLE_FREQ_THRES_HIGH] for + * the chip (e.g. 20 kHz - 2 MHz on ESP32; 611 Hz - 83.3 kHz on + * ESP32-S3 / C3 / C6 / P4 and other newer chips). Some chips (ESP32, + * ESP32-C3, ESP32-S3) only support continuous (DMA) mode on ADC unit + * 1, and on ESP32-P4 ADC2 continuous conversions currently produce + * all-zero samples with esp-idf (oneshot on ADC2 works fine), so + * prefer ADC1 there as well. + * + * @note If a channel could not be calibrated (e.g. the calibration eFuse is + * not burnt), \c get_mv() returns the filtered raw value for that + * channel instead of millivolts (matching OneshotAdc's behavior). + * + * @note This class is thread-safe: start(), stop(), get_mv(), and get_rate() + * may be called concurrently from multiple tasks (though, as with any + * object, destruction must not race other calls). + * + * @note When the configured channels span both ADC units (on chips whose + * digital controller supports both units in DMA mode, e.g. ESP32-S2 / + * ESP32-P4), the conversion mode - if not explicitly provided - is + * derived as ADC_CONV_ALTER_UNIT: the controller performs one + * conversion per trigger, alternating between the units. This keeps + * every channel sampled evenly at approximately \c sample_rate_hz + * regardless of how the channels are split across the units, and is + * the right choice for general multi-channel monitoring - e.g. several + * independent sensors that happen to be wired to pins on different + * units: + * @code + * // joystick axes on ADC1 + battery divider on ADC2, each ~1 kHz + * espp::ContinuousAdc adc({.sample_rate_hz = 1000, + * .channels = {joy_x, joy_y, vbat}}); + * @endcode + * The alternative, ADC_CONV_BOTH_UNIT, makes both units convert + * simultaneously (in lockstep) on every trigger. Choose it explicitly + * when the relative timing of two signals matters - e.g. sampling a + * voltage (on ADC1) and a current (on ADC2) at the same instant to + * compute instantaneous power, or capturing phase-matched sensor + * pairs: + * @code + * espp::ContinuousAdc adc({.sample_rate_hz = 1000, + * .channels = {v_sense, i_sense}, + * .convert_mode = ADC_CONV_BOTH_UNIT}); + * @endcode + * With BOTH_UNIT each unit converts on every trigger, so the effective + * per-channel rate is higher than \c sample_rate_hz (twice, for a + * one-channel-per-unit configuration); get_rate() reports the actual + * rate. + * + * @warning On ESP32-P4 (verified on hardware with esp-idf v6.0.1), + * initializing and then deleting the oneshot ADC driver (e.g. a + * destructed espp::OneshotAdc) before starting continuous mode + * causes all continuous conversions to produce all-zero samples, + * on either unit. If you need both drivers on the P4, create the + * ContinuousAdc first, or keep the OneshotAdc alive. * * \section adc_continuous_ex1 Continuous ADC Example * \snippet adc_example.cpp continuous adc example @@ -31,13 +94,16 @@ class ContinuousAdc : public espp::BaseComponent { public: /** * @brief Configure the sample rate (globally applied to each channel), - * select the number of channels, and the conversion mode (for S2 S3) + * select the number of channels, and optionally the conversion mode. */ struct Config { size_t sample_rate_hz; /**< Samples per second to read from each channel. */ std::vector channels; /**< Channels to read from, with associated attenuations. */ - adc_digi_convert_mode_t convert_mode; /**< Conversion mode (unit 1, unit 2, alternating, or - both). May depend on ESP32 chip. */ + adc_digi_convert_mode_t convert_mode{ + static_cast(0)}; /**< Conversion mode (unit 1, unit 2, + alternating, or both). Support depends on the chip. Leave at the + default (0) to derive it automatically from the units used by \c + channels. */ size_t task_priority{5}; /**< Priority to run the adc data reading / filtering task. */ size_t window_size_bytes{256}; /**< Amount of bytes to allocate for the DMA buffer when reading results. Larger values lead to more filtering. */ @@ -58,8 +124,13 @@ class ContinuousAdc : public espp::BaseComponent { , result_data_(window_size_bytes_, 0xcc) { // set the rate limit for the logger logger_.set_rate_limit(std::chrono::milliseconds(100)); - // initialize the adc continuous subsystem - init(config.channels); + // initialize the adc continuous subsystem; on validation failure the + // object is left in a safe (but non-functional) state: no driver handle, + // no reader task, and start() / get_mv() / get_rate() are no-ops + if (!init(config.channels)) { + logger_.error("Initialization failed; the continuous ADC will be non-functional"); + return; + } // and start the task using namespace std::placeholders; task_ = espp::Task::make_unique( @@ -70,6 +141,7 @@ class ContinuousAdc : public espp::BaseComponent { .priority = config.task_priority, }, .log_level = espp::Logger::Verbosity::WARN}); + previous_timestamp_ = std::chrono::high_resolution_clock::now(); task_->start(); } @@ -77,20 +149,32 @@ class ContinuousAdc : public espp::BaseComponent { * @brief Stop, deinit, and destroy the adc reader. */ ~ContinuousAdc() { - // to force the task to return from the wait - BaseType_t mustYield = pdFALSE; - vTaskNotifyGiveFromISR(task_handle_, &mustYield); - // then stop the task - task_->stop(); + // stop the hardware first, so the conversion-done ISR (which references + // the task we are about to stop) can no longer fire stop(); - ESP_ERROR_CHECK(adc_continuous_deinit(handle_)); + // wake the task if it is blocked waiting for a conversion notification + // (its wait is bounded, so this just speeds up the shutdown), then stop + // it + TaskHandle_t task_handle = task_handle_.load(); + if (task_handle) { + xTaskNotifyGive(task_handle); + } + if (task_) { + task_->stop(); + } + // the handle is only created if init() passed validation + if (handle_ != nullptr) { + ESP_ERROR_CHECK(adc_continuous_deinit(handle_)); + } // clean up the calibration data - for (const auto &config : configs_) { - [[maybe_unused]] auto id = get_id(config); + for (auto cali_handle : cali_handles_) { + if (cali_handle == nullptr) { + continue; + } #if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED - ESP_ERROR_CHECK(adc_cali_delete_scheme_curve_fitting(cali_handles_[id])); + ESP_ERROR_CHECK(adc_cali_delete_scheme_curve_fitting(cali_handle)); #elif ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED - ESP_ERROR_CHECK(adc_cali_delete_scheme_line_fitting(cali_handles_[id])); + ESP_ERROR_CHECK(adc_cali_delete_scheme_line_fitting(cali_handle)); #endif } } @@ -99,6 +183,13 @@ class ContinuousAdc : public espp::BaseComponent { * @brief Start the continuous adc reader. */ void start() { + if (handle_ == nullptr) { + logger_.error("Cannot start: initialization failed"); + return; + } + // serialize start() / stop() so concurrent callers cannot double-start + // or double-stop the driver (which would return an error and abort) + std::lock_guard lock(control_mutex_); if (running_) { return; } @@ -110,6 +201,8 @@ class ContinuousAdc : public espp::BaseComponent { * @brief Stop the continuous adc reader. */ void stop() { + // serialize start() / stop(); see start() + std::lock_guard lock(control_mutex_); if (!running_) { return; } @@ -124,18 +217,19 @@ class ContinuousAdc : public espp::BaseComponent { * channel) * @return std::optional voltage in mV for the provided channel (if * it was configured and the adc is running). + * @note If the channel could not be calibrated, the filtered raw value is + * returned instead. */ std::optional get_mv(const AdcConfig &config) { if (!running_) { return {}; } - std::lock_guard lock{data_mutex_}; - int id = get_id(config); - if (values_.find(id) != values_.end()) { - return values_[id]; - } else { + auto index = get_index(config); + if (index == INVALID_INDEX) { return {}; } + std::lock_guard lock{data_mutex_}; + return values_[index]; } /** @@ -150,37 +244,60 @@ class ContinuousAdc : public espp::BaseComponent { if (!running_) { return {}; } - std::lock_guard lock{data_mutex_}; - int id = get_id(config); - if (actual_rates_.find(id) != actual_rates_.end()) { - return actual_rates_[id]; - } else { + auto index = get_index(config); + if (index == INVALID_INDEX) { return {}; } + std::lock_guard lock{data_mutex_}; + return actual_rates_[index]; } protected: - int get_id(const AdcConfig &config) { return get_id(config.unit, config.channel); } - - int get_id(adc_unit_t unit, adc_channel_t channel) { - // We want to make sure that the id is unique for each channel, so we - // multiply the unit by 32 (which is currently larger than the number of - // channels on any ESP32 chip), and then add the channel number to get a - // unique id. + // Unique id for a (unit, channel) pair. 32 is larger than the number of + // channels on any ESP32 chip, so the id is unique across units. The id + // math (and the SOC_ADC_* capability macro lookups, and the comparison + // against the unit field of the DMA output data) all assume the adc_unit_t + // enum values are 0-based indices, which esp-idf guarantees: + static_assert(ADC_UNIT_1 == 0 && ADC_UNIT_2 == 1, + "adc_unit_t values are assumed to be 0-based unit indices"); + static constexpr int MAX_IDS = SOC_ADC_PERIPH_NUM * 32; + static constexpr uint8_t INVALID_INDEX = 0xFF; + + static constexpr int get_id(adc_unit_t unit, adc_channel_t channel) { return static_cast(unit) * 32 + static_cast(channel); } + // Get the dense index (0 .. num_channels_ - 1) for a (unit, channel) pair, + // or INVALID_INDEX if it was not configured. + uint8_t get_index(adc_unit_t unit, adc_channel_t channel) const { + int id = get_id(unit, channel); + if (id < 0 || id >= MAX_IDS) { + return INVALID_INDEX; + } + return id_to_index_[id]; + } + + uint8_t get_index(const AdcConfig &config) const { + return get_index(config.unit, config.channel); + } + bool update_task(std::mutex &, std::condition_variable &, bool &) { task_handle_ = xTaskGetCurrentTaskHandle(); - static auto previous_timestamp = std::chrono::high_resolution_clock::now(); - // wait until conversion is ready (will be notified by the registered - ulTaskNotifyTake(pdTRUE, portMAX_DELAY); - auto current_timestamp = std::chrono::high_resolution_clock::now(); - for (const auto &config : configs_) { - auto id = get_id(config); - sums_[id] = 0; - num_samples_[id] = 0; + // wait (bounded, so that stopping the task never blocks forever) until + // conversion results are ready; we will be notified by the registered + // conversion-done callback + if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)) == 0) { + // timed out; no data ready + return false; // don't want to stop the task } + if (!running_) { + // stopped (or being destroyed) while we were waiting; don't read from + // the stopped driver + return false; // don't want to stop the task + } + auto current_timestamp = std::chrono::high_resolution_clock::now(); + std::fill(sums_.begin(), sums_.end(), 0); + std::fill(num_samples_.begin(), num_samples_.end(), 0); esp_err_t ret; uint32_t ret_num = 0; ret = adc_continuous_read(handle_, result_data_.data(), window_size_bytes_, &ret_num, 0); @@ -193,61 +310,87 @@ class ContinuousAdc : public espp::BaseComponent { logger_.error("Could not read adc data: {} - '{}'", ret, esp_err_to_name(ret)); return false; } - for (int i = 0; i < ret_num; i += SOC_ADC_DIGI_RESULT_BYTES) { + for (uint32_t i = 0; i < ret_num; i += SOC_ADC_DIGI_RESULT_BYTES) { adc_digi_output_data_t *p = reinterpret_cast(&result_data_[i]); + uint8_t index = INVALID_INDEX; + int parsed_unit = -1; + int parsed_channel = -1; + uint32_t data = 0; #if CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2 if (output_format_ == ADC_DIGI_OUTPUT_FORMAT_TYPE1) { - auto unit = (conv_mode_ == ADC_CONV_SINGLE_UNIT_1) ? ADC_UNIT_1 : ADC_UNIT_2; - auto channel = (adc_channel_t)p->type1.channel; - auto id = get_id(unit, channel); - auto data = p->type1.data; - sums_[id] += data; - num_samples_[id]++; + // type1 data has no unit field; the unit is implied by the (single + // unit) conversion mode + parsed_unit = (conv_mode_ == ADC_CONV_SINGLE_UNIT_2) ? 1 : 0; + parsed_channel = p->type1.channel; + data = p->type1.data; } #endif #if !CONFIG_IDF_TARGET_ESP32 if (output_format_ == ADC_DIGI_OUTPUT_FORMAT_TYPE2) { if (check_valid_data(p)) { - auto unit = (adc_unit_t)p->type2.unit; - auto channel = (adc_channel_t)p->type2.channel; - auto data = p->type2.data; - auto id = get_id(unit, channel); - sums_[id] += data; - num_samples_[id]++; +#if SOC_ADC_PERIPH_NUM > 1 + parsed_unit = p->type2.unit; +#else + // single-ADC chips (C2, C6, H2, ...) have no unit field in the + // output data + parsed_unit = 0; +#endif + parsed_channel = p->type2.channel; + data = p->type2.data; } else { - // we have invalid data? how?! - logger_.error("invalid data!"); + logger_.warn_rate_limited("invalid data (channel {})!", (int)p->type2.channel); + continue; } } #endif + if (parsed_unit >= 0) { + index = get_index((adc_unit_t)parsed_unit, (adc_channel_t)parsed_channel); + } + if (index == INVALID_INDEX) { + // Not a channel we were configured with. The pattern table only + // contains the configured channels, so getting here usually means the + // chip encodes the output (unit / channel fields) differently than + // expected - log it to aid debugging. + logger_.warn_rate_limited("dropping sample with unexpected unit {} / channel {} (raw {})", + parsed_unit, parsed_channel, data); + continue; + } + sums_[index] += data; + num_samples_[index]++; } // for() // measure elapsed time float elapsed_seconds = - std::chrono::duration(current_timestamp - previous_timestamp).count(); + std::chrono::duration(current_timestamp - previous_timestamp_).count(); if (elapsed_seconds == 0) { // prevent divide by 0 later elapsed_seconds = 0.001f; } - previous_timestamp = current_timestamp; + previous_timestamp_ = current_timestamp; // filter / update the measurements { std::lock_guard lk(data_mutex_); - for (auto &config : configs_) { - auto id = get_id(config); - float num_samples = num_samples_[id]; + for (size_t index = 0; index < num_channels_; index++) { + float num_samples = num_samples_[index]; if (num_samples > 0) { - float sum = sums_[id]; - // note: the data collected above is uncalibrated (raw) values so we need to convert - // to millivolts + float sum = sums_[index]; + // note: the data collected above is uncalibrated (raw) values // TODO: use ESP32 hardware-accelerated filter // simple filter - int millivolts = 0; - adc_cali_raw_to_voltage(cali_handles_[id], sum / num_samples, &millivolts); - values_[id] = float(millivolts); - actual_rates_[id] = num_samples / elapsed_seconds; + float average = sum / num_samples; + if (cali_handles_[index] != nullptr) { + // convert to millivolts + int millivolts = 0; + adc_cali_raw_to_voltage(cali_handles_[index], static_cast(average), &millivolts); + values_[index] = float(millivolts); + } else { + // no calibration available for this channel; report the raw value + values_[index] = average; + } + actual_rates_[index] = num_samples / elapsed_seconds; } - logger_.debug_rate_limited("CH{} - {}, {}, {:.2f}, {:.2f}", (int)config.channel, sums_[id], - num_samples_[id], values_[id], actual_rates_[id]); + logger_.debug_rate_limited("CH {} - {}, {}, {:.2f}, {:.2f}", (int)configs_[index].channel, + sums_[index], num_samples_[index], values_[index], + actual_rates_[index]); } } @@ -257,24 +400,108 @@ class ContinuousAdc : public espp::BaseComponent { #if !CONFIG_IDF_TARGET_ESP32 static bool check_valid_data(const adc_digi_output_data_t *data) { +#if SOC_ADC_PERIPH_NUM > 1 const unsigned int unit = data->type2.unit; - if (unit > 2) + if (unit >= SOC_ADC_PERIPH_NUM) return false; +#else + // single-ADC chips (C2, C6, H2, ...) have no unit field in the output + // data + const unsigned int unit = 0; +#endif if (data->type2.channel >= SOC_ADC_CHANNEL_NUM(unit)) return false; return true; } #endif - void init(const std::vector &channels) { + // Initialize the continuous adc driver. Returns false (without touching + // the driver) if the configuration fails validation, so failures are + // deterministic and reported with their root cause rather than aborting in + // a later driver call. + bool init(const std::vector &channels) { + id_to_index_.fill(INVALID_INDEX); + if (channels.empty()) { + logger_.error("No channels provided!"); + return false; + } + + // validate the channels against the chip's capabilities + bool valid = true; + bool has_unit_1 = false; + bool has_unit_2 = false; + for (const auto &conf : channels) { + int unit = static_cast(conf.unit); + if (unit >= SOC_ADC_PERIPH_NUM) { + logger_.error("{} invalid: this chip only has {} ADC unit(s)", conf, SOC_ADC_PERIPH_NUM); + valid = false; + } else if (static_cast(conf.channel) >= SOC_ADC_CHANNEL_NUM(unit)) { + logger_.error("{} invalid: the unit only has {} channels", conf, SOC_ADC_CHANNEL_NUM(unit)); + valid = false; + } +#ifdef SOC_ADC_DIG_SUPPORTED_UNIT + if (!SOC_ADC_DIG_SUPPORTED_UNIT(unit)) { + logger_.error("{} invalid: this chip does not support continuous (DMA) mode on this unit", + conf); + valid = false; + } +#endif + has_unit_1 |= conf.unit == ADC_UNIT_1; + has_unit_2 |= conf.unit == ADC_UNIT_2; + } + +#if CONFIG_IDF_TARGET_ESP32P4 + if (has_unit_2) { + // empirically (esp-idf v6.0, M5Stack Tab5): the conversion frames + // arrive at the configured rate but every sample is all-zero + logger_.warn("ADC2 continuous (DMA) conversions on ESP32-P4 currently produce all-zero " + "samples with esp-idf (oneshot on ADC2 works fine); prefer ADC1 channels"); + } +#endif + + // derive the conversion mode from the channels if it was not explicitly + // provided + if (static_cast(conv_mode_) == 0) { + if (has_unit_1 && has_unit_2) { + // ALTER (one conversion per trigger, alternating units) rather than + // BOTH (both units simultaneously per trigger): ALTER keeps the + // per-channel sample rate math correct for any mix of channels and + // spaces samples evenly, while BOTH is for deliberately synchronized + // sampling of the two units (pass it explicitly if that is desired) + conv_mode_ = ADC_CONV_ALTER_UNIT; + } else if (has_unit_2) { + conv_mode_ = ADC_CONV_SINGLE_UNIT_2; + } else { + conv_mode_ = ADC_CONV_SINGLE_UNIT_1; + } + logger_.info("Derived conversion mode {} from the channel units", (int)conv_mode_); + } + + // validate the aggregate sample frequency against the chip's supported + // range + size_t sample_freq_hz = sample_rate_hz_ * num_channels_; + if (sample_freq_hz < SOC_ADC_SAMPLE_FREQ_THRES_LOW || + sample_freq_hz > SOC_ADC_SAMPLE_FREQ_THRES_HIGH) { + logger_.error("Aggregate sample frequency {} Hz (sample_rate_hz * number of channels) is " + "outside this chip's supported range [{}, {}] Hz", + sample_freq_hz, SOC_ADC_SAMPLE_FREQ_THRES_LOW, SOC_ADC_SAMPLE_FREQ_THRES_HIGH); + valid = false; + } + + if (!valid) { + return false; + } + adc_continuous_handle_cfg_t adc_config; memset(&adc_config, 0, sizeof(adc_config)); - adc_config.max_store_buf_size = 1024; + adc_config.max_store_buf_size = std::max(1024, window_size_bytes_ * 4); adc_config.conv_frame_size = window_size_bytes_; ESP_ERROR_CHECK(adc_continuous_new_handle(&adc_config, &handle_)); + // Select the output format we will parse for this chip / mode (mirrors + // the logic inside the esp_adc adc_continuous driver) #if CONFIG_IDF_TARGET_ESP32 - // ESP32 only supports ADC1 DMA mode + // ESP32 only supports ADC1 DMA mode with type1 output output_format_ = ADC_DIGI_OUTPUT_FORMAT_TYPE1; if (conv_mode_ != ADC_CONV_SINGLE_UNIT_1) { logger_.error("Configured for ESP32, which only supports ADC_CONV_SINGLE_UNIT_1, " @@ -286,21 +513,35 @@ class ContinuousAdc : public espp::BaseComponent { output_format_ = (conv_mode_ == ADC_CONV_ALTER_UNIT || conv_mode_ == ADC_CONV_BOTH_UNIT) ? ADC_DIGI_OUTPUT_FORMAT_TYPE2 : ADC_DIGI_OUTPUT_FORMAT_TYPE1; -#elif CONFIG_IDF_TARGET_ESP32S3 - // S3 only supports type 2 +#else + // all other chips (S3, C2, C3, C6, H2, P4, ...) only support type 2 output_format_ = ADC_DIGI_OUTPUT_FORMAT_TYPE2; #endif adc_continuous_config_t dig_cfg; memset(&dig_cfg, 0, sizeof(dig_cfg)); - dig_cfg.sample_freq_hz = sample_rate_hz_ * num_channels_; + dig_cfg.sample_freq_hz = sample_freq_hz; dig_cfg.conv_mode = conv_mode_; +#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0) + // Older IDF versions require the output format in the config; from IDF + // 6.0 the field is deprecated and the driver derives the format itself + // (with the same logic as output_format_ above, which we still need for + // parsing the results). dig_cfg.format = output_format_; +#endif adc_digi_pattern_config_t adc_pattern[SOC_ADC_PATT_LEN_MAX]; memset(&adc_pattern[0], 0, sizeof(adc_digi_pattern_config_t) * SOC_ADC_PATT_LEN_MAX); dig_cfg.pattern_num = num_channels_; - for (int i = 0; i < num_channels_; i++) { + // allocate the (dense) per-channel data, indexed by the position of the + // channel in the configured channel list + cali_handles_.resize(num_channels_, nullptr); + sums_.resize(num_channels_, 0); + num_samples_.resize(num_channels_, 0); + values_.resize(num_channels_, 0); + actual_rates_.resize(num_channels_, 0); + + for (size_t i = 0; i < num_channels_; i++) { auto conf = channels[i]; adc_pattern[i].atten = conf.attenuation; adc_pattern[i].channel = conf.channel; @@ -311,22 +552,19 @@ class ContinuousAdc : public espp::BaseComponent { logger_.info("adc_pattern[{}].channel is 0x{:02x}", i, (int)adc_pattern[i].channel); logger_.info("adc_pattern[{}].unit is 0x{:02x}", i, (int)adc_pattern[i].unit); - // get the id for this config - auto id = get_id(conf); + // map the (unit, channel) id to this dense index + int id = get_id(conf.unit, conf.channel); + if (id >= 0 && id < MAX_IDS) { + id_to_index_[id] = i; + } // create the calibration data - auto calibrated = - init_calibration(conf.unit, conf.attenuation, (adc_bitwidth_t)adc_pattern[i].bit_width, - &cali_handles_[id]); + auto calibrated = init_calibration( + conf.unit, conf.attenuation, (adc_bitwidth_t)adc_pattern[i].bit_width, &cali_handles_[i]); logger_.info("adc_pattern[{}].cali_handle is calibrated: {}", i, calibrated); // save the channel configs_.push_back(conf); - // update our dictionaries - sums_[id] = 0; - num_samples_[id] = 0; - values_[id] = 0; - actual_rates_[id] = 0; } dig_cfg.adc_pattern = adc_pattern; ESP_ERROR_CHECK(adc_continuous_config(handle_, &dig_cfg)); @@ -335,6 +573,7 @@ class ContinuousAdc : public espp::BaseComponent { memset(&cbs, 0, sizeof(cbs)); cbs.on_conv_done = s_conv_done_cb; ESP_ERROR_CHECK(adc_continuous_register_event_callbacks(handle_, &cbs, &task_handle_)); + return true; } bool init_calibration(adc_unit_t unit, adc_atten_t atten, adc_bitwidth_t bitwidth, @@ -389,9 +628,10 @@ class ContinuousAdc : public espp::BaseComponent { const adc_continuous_evt_data_t *edata, void *user_data) { BaseType_t mustYield = pdFALSE; // Notify that ADC continuous driver has done enough number of conversions - auto s_task_handle = (TaskHandle_t *)user_data; - if (*s_task_handle) { - vTaskNotifyGiveFromISR(*s_task_handle, &mustYield); + auto *s_task_handle = static_cast *>(user_data); + TaskHandle_t task_handle = s_task_handle->load(); + if (task_handle) { + vTaskNotifyGiveFromISR(task_handle, &mustYield); } return (mustYield == pdTRUE); } @@ -399,22 +639,31 @@ class ContinuousAdc : public espp::BaseComponent { size_t sample_rate_hz_; size_t window_size_bytes_; size_t num_channels_; - adc_continuous_handle_t handle_; + adc_continuous_handle_t handle_{nullptr}; adc_digi_convert_mode_t conv_mode_; adc_digi_output_format_t output_format_; std::vector result_data_; std::unique_ptr task_; - TaskHandle_t task_handle_{NULL}; + // written by the reader task, read by the conversion-done ISR and the + // destructor + std::atomic task_handle_{nullptr}; + // serializes start() / stop() + std::mutex control_mutex_; std::mutex data_mutex_; + std::chrono::high_resolution_clock::time_point previous_timestamp_; std::atomic running_{false}; std::vector configs_; - // these maps are indexed by get_id(config.unit, config.channel) - std::unordered_map cali_handles_; - std::unordered_map sums_; - std::unordered_map num_samples_; - std::unordered_map values_; - std::unordered_map actual_rates_; + // maps (unit, channel) ids to indices into the dense per-channel vectors + // below (INVALID_INDEX if the channel was not configured) + std::array id_to_index_; + // these vectors are indexed by the position of the channel in the + // configured channel list + std::vector cali_handles_; + std::vector sums_; + std::vector num_samples_; + std::vector values_; + std::vector actual_rates_; }; } // namespace espp diff --git a/components/adc/include/oneshot_adc.hpp b/components/adc/include/oneshot_adc.hpp index 23fb94ceb..94da19f1a 100644 --- a/components/adc/include/oneshot_adc.hpp +++ b/components/adc/include/oneshot_adc.hpp @@ -1,10 +1,10 @@ #pragma once #include -#include +#include #include #include -#include +#include #include "esp_adc/adc_cali.h" #include "esp_adc/adc_cali_scheme.h" @@ -20,6 +20,16 @@ namespace espp { * analog values. The \c read_mv() function will always take a new * measurement (therefore it is blocking). * + * @note This class is thread-safe: the read functions may be called + * concurrently from multiple tasks (though, as with any object, + * destruction must not race other calls). + * + * @warning On ESP32-P4 (verified on hardware with esp-idf v6.0.1), destroying + * a OneshotAdc before starting the continuous (DMA) ADC driver + * causes the continuous conversions to produce all-zero samples. If + * you need both drivers on the P4, create the ContinuousAdc first, + * or keep the OneshotAdc alive. + * * \section adc_oneshot_ex1 Oneshot ADC Example * \snippet adc_example.cpp oneshot adc example */ @@ -30,7 +40,7 @@ class OneshotAdc : public BaseComponent { * channels. * * @note The \c unit must be the same as what is provided in each element - * of \p channels. + * of \p channels; channels for a different unit are not configured. */ struct Config { adc_unit_t unit; /**< Unit this oneshot reader will be associated with. @note all channels must @@ -53,26 +63,57 @@ class OneshotAdc : public BaseComponent { * @brief Delete and destroy the adc reader. */ ~OneshotAdc() { - ESP_ERROR_CHECK(adc_oneshot_del_unit(adc_handle_)); - if (calibrated_) { + // the handle is only created if init() got far enough (e.g. it returns + // early when no channels were provided) + if (adc_handle_ != nullptr) { + ESP_ERROR_CHECK(adc_oneshot_del_unit(adc_handle_)); + } + for (auto cali_handle : cali_handles_) { + if (cali_handle == nullptr) { + continue; + } #if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED logger_.info("deregister Curve Fitting calibration scheme"); - ESP_ERROR_CHECK(adc_cali_delete_scheme_curve_fitting(adc_cali_handle_)); - + ESP_ERROR_CHECK(adc_cali_delete_scheme_curve_fitting(cali_handle)); #elif ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED logger_.info("deregister Line Fitting calibration scheme"); - ESP_ERROR_CHECK(adc_cali_delete_scheme_line_fitting(adc_cali_handle_)); + ESP_ERROR_CHECK(adc_cali_delete_scheme_line_fitting(cali_handle)); #endif } } + /** + * @brief Take a new ADC reading for the provided \p config. + * @param config The channel configuration to take a reading from. + * @return std::optional raw value for the provided channel config (if + * it was configured). + * @note This function is provided for convenience; it is equivalent to \c + * read_raw(). This function is provided to match the API of \c + * ContinuousAdc, which has a \c get_raw() function. + */ + std::optional get_raw(const AdcConfig &config) { return read_raw(config); } + + /** + * @brief Take a new ADC reading for the provided \p config and convert it to + * voltage (mV) if the channel's attenuation was properly calibrated. + * If it was not properly calibrated, then it will return the same + * value as \c read_raw(). + * @param config The channel configuration to take a reading from. + * @return std::optional Voltage in mV for the provided channel config + * (if it was configured). + * @note This function is provided for convenience; it is equivalent to \c + * read_mv(). This function is provided to match the API of \c + * ContinuousAdc, which has a \c get_mv() function. + */ + std::optional get_mv(const AdcConfig &config) { return read_mv(config); } + /** * @brief Take a new ADC reading for all configured channels and convert it to - * voltage (mV) if the unit was properly calibrated. If it was not - * properly calibrated, then it will return the same value as \c - * read_raw(). - * @return std::vector Voltage in mV for all configured channels (if - * they were configured). + * voltage (mV) if the channel's attenuation was properly calibrated. + * If it was not properly calibrated, then it will return the same + * value as \c read_raw(). + * @return std::vector Voltage in mV for all configured channels (in + * the order they were configured). */ std::vector read_all_mv() { std::vector values; @@ -85,7 +126,7 @@ class OneshotAdc : public BaseComponent { logger_.error("Couldn't read oneshot: {} - '{}'", err, esp_err_to_name(err)); values.push_back(0); } else { - values.push_back(raw_to_mv(raw)); + values.push_back(raw_to_mv(raw, config.attenuation)); } } return values; @@ -94,7 +135,7 @@ class OneshotAdc : public BaseComponent { /** * @brief Take a new ADC reading for the provided \p config. * @param config The channel configuration to take a reading from. - * @return std::optional raw value for the provided channel config (if + * @return std::optional raw value for the provided channel config (if * it was configured). */ std::optional read_raw(const AdcConfig &config) { @@ -115,37 +156,51 @@ class OneshotAdc : public BaseComponent { /** * @brief Take a new ADC reading for the provided \p config and convert it to - * voltage (mV) if the unit was properly calibrated. If it was not - * properly calibrated, then it will return the same value as \c - * read_raw(). + * voltage (mV) if the channel's attenuation was properly calibrated. + * If it was not properly calibrated, then it will return the same + * value as \c read_raw(). * @param config The channel configuration to take a reading from. - * @return std::optional Voltage in mV for the provided channel config + * @return std::optional Voltage in mV for the provided channel config * (if it was configured). */ std::optional read_mv(const AdcConfig &config) { std::lock_guard lock(mutex_); auto maybe_raw = read_raw(config); if (maybe_raw.has_value()) { - return raw_to_mv(maybe_raw.value()); + return raw_to_mv(maybe_raw.value(), config.attenuation); } return {}; } protected: - int raw_to_mv(int raw) { + // adc_atten_t values are 0 (ADC_ATTEN_DB_0) through 3 (ADC_ATTEN_DB_12), so + // we keep one calibration handle per attenuation (calibration curves are + // specific to the (unit, attenuation) pair). + static constexpr size_t NUM_ATTENUATIONS = 4; + + int raw_to_mv(int raw, adc_atten_t attenuation) { + auto atten_index = static_cast(attenuation); + if (atten_index >= NUM_ATTENUATIONS || cali_handles_[atten_index] == nullptr) { + logger_.warn_rate_limited( + "attenuation {} not calibrated, cannot convert raw to mv - returning raw value!", + attenuation); + return raw; + } int mv = raw; - if (calibrated_) { - auto err = adc_cali_raw_to_voltage(adc_cali_handle_, raw, &mv); - if (err != ESP_OK) { - logger_.error("Could not convert raw to voltage: {} - '{}'", err, esp_err_to_name(err)); - } - } else { - logger_.warn("not calibrated, cannot convert raw to mv - returning raw value!"); + auto err = adc_cali_raw_to_voltage(cali_handles_[atten_index], raw, &mv); + if (err != ESP_OK) { + logger_.error("Could not convert raw to voltage: {} - '{}'", err, esp_err_to_name(err)); } return mv; } void init(const Config &config) { + // set the rate limit for the logger + logger_.set_rate_limit(std::chrono::milliseconds(100)); + if (config.channels.empty()) { + logger_.error("No channels provided!"); + return; + } adc_oneshot_unit_init_cfg_t init_config; memset(&init_config, 0, sizeof(init_config)); init_config.unit_id = config.unit; @@ -155,8 +210,9 @@ class OneshotAdc : public BaseComponent { auto attenuation = conf.attenuation; auto channel_unit = conf.unit; if (config.unit != channel_unit) { - logger_.warn("Channel configuration invalid, main unit ({}) != channel unit ({})", - (int)config.unit, (int)channel_unit); + logger_.error("{} invalid, main unit ({}) != channel unit ({}); not configuring it", conf, + (int)config.unit, (int)channel_unit); + continue; } adc_oneshot_chan_cfg_t oneshot_config; memset(&oneshot_config, 0, sizeof(oneshot_config)); @@ -164,45 +220,53 @@ class OneshotAdc : public BaseComponent { oneshot_config.atten = attenuation; ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle_, channel, &oneshot_config)); configs_.push_back(conf); + // create the calibration data for this channel's attenuation if we + // don't have it yet + auto atten_index = static_cast(attenuation); + if (atten_index < NUM_ATTENUATIONS && cali_handles_[atten_index] == nullptr) { + calibration_init(config.unit, attenuation, &cali_handles_[atten_index]); + } } - calibration_init(config.unit, configs_[0].attenuation); } - void calibration_init(adc_unit_t unit, adc_atten_t attenuation) { + void calibration_init(adc_unit_t unit, adc_atten_t attenuation, adc_cali_handle_t *out_handle) { esp_err_t ret = ESP_FAIL; + bool calibrated = false; + adc_cali_handle_t handle = nullptr; #if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED - if (!calibrated_) { - logger_.info("calibration scheme version is Curve Fitting"); - adc_cali_curve_fitting_config_t cali_config; - memset(&cali_config, 0, sizeof(cali_config)); - cali_config.unit_id = unit; - cali_config.atten = attenuation; - cali_config.bitwidth = ADC_BITWIDTH_DEFAULT; - ret = adc_cali_create_scheme_curve_fitting(&cali_config, &adc_cali_handle_); - if (ret == ESP_OK) { - calibrated_ = true; - } + logger_.info("calibration scheme version is Curve Fitting"); + adc_cali_curve_fitting_config_t cali_config; + memset(&cali_config, 0, sizeof(cali_config)); + cali_config.unit_id = unit; + cali_config.atten = attenuation; + cali_config.bitwidth = ADC_BITWIDTH_DEFAULT; + ret = adc_cali_create_scheme_curve_fitting(&cali_config, &handle); + if (ret == ESP_OK) { + calibrated = true; } #endif #if ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED - if (!calibrated_) { + // cppcheck-suppress knownConditionTrueFalse + if (!calibrated) { logger_.info("calibration scheme version is Line Fitting"); adc_cali_line_fitting_config_t cali_config; memset(&cali_config, 0, sizeof(cali_config)); cali_config.unit_id = unit; cali_config.atten = attenuation; cali_config.bitwidth = ADC_BITWIDTH_DEFAULT; - ret = adc_cali_create_scheme_line_fitting(&cali_config, &adc_cali_handle_); + ret = adc_cali_create_scheme_line_fitting(&cali_config, &handle); if (ret == ESP_OK) { - calibrated_ = true; + calibrated = true; } } #endif + *out_handle = handle; if (ret == ESP_OK) { logger_.info("Calibration Success"); - } else if (ret == ESP_ERR_NOT_SUPPORTED || !calibrated_) { + // cppcheck-suppress knownConditionTrueFalse + } else if (ret == ESP_ERR_NOT_SUPPORTED || !calibrated) { logger_.warn("eFuse not burnt, skip software calibration"); } else { logger_.error("Invalid arg or no memory"); @@ -211,8 +275,9 @@ class OneshotAdc : public BaseComponent { std::recursive_mutex mutex_; std::vector configs_; - adc_oneshot_unit_handle_t adc_handle_; - adc_cali_handle_t adc_cali_handle_; - std::atomic calibrated_{false}; + adc_oneshot_unit_handle_t adc_handle_{nullptr}; + // one calibration handle per attenuation level (nullptr = not calibrated), + // indexed by adc_atten_t + std::array cali_handles_{}; }; } // namespace espp diff --git a/doc/en/adc/continuous_adc.rst b/doc/en/adc/continuous_adc.rst index 75a77fdeb..1072ea0ed 100644 --- a/doc/en/adc/continuous_adc.rst +++ b/doc/en/adc/continuous_adc.rst @@ -9,6 +9,44 @@ retrieves the data and filters it. When the user calls `get_mv(adc_channel_t)`, it simply returns the most recent filtered value for that channel, if it was configured. +Conversion Modes (multiple units) +--------------------------------- + +On chips whose digital controller supports both ADC units in DMA mode (e.g. +ESP32-S2 / ESP32-P4), channels from both units can be sampled by one +`ContinuousAdc`. Two conversion modes control how the hardware schedules them: + +* ``ADC_CONV_ALTER_UNIT`` (the derived default when the configured channels + span both units): the controller performs one conversion per trigger, + alternating between the units. Every channel is sampled evenly at + approximately ``sample_rate_hz`` regardless of how the channels are split + across the units. Use this for general multi-channel monitoring - e.g. + several independent sensors that happen to be wired to pins on different + units: + + .. code-block:: c++ + + // joystick axes on ADC1 + battery divider on ADC2, each sampled ~1 kHz + espp::ContinuousAdc adc({.sample_rate_hz = 1000, + .channels = {joy_x, joy_y, vbat}}); + +* ``ADC_CONV_BOTH_UNIT``: both units convert simultaneously (in lockstep) on + every trigger. Choose it explicitly when the *relative timing* of two + signals matters - e.g. sampling a voltage (on ADC1) and a current (on ADC2) + at the same instant to compute instantaneous power, or capturing + phase-matched sensor pairs: + + .. code-block:: c++ + + espp::ContinuousAdc adc({.sample_rate_hz = 1000, + .channels = {v_sense, i_sense}, + .convert_mode = ADC_CONV_BOTH_UNIT}); + + Note that with ``ADC_CONV_BOTH_UNIT`` each unit converts on every trigger, + so the effective per-channel rate is higher than ``sample_rate_hz`` (twice, + for a one-channel-per-unit configuration); ``get_rate()`` reports the + actual rate. + .. ------------------------------- Example ------------------------------------- .. toctree::