Continuous ADC

The ContinuousAdc provides a mechanism for high-frequency, continuous, deterministic sampling of analog voltages for multiple channels (potentially across multiple ADC units, depending on the ESP32 chip used). It does this be enabling the continuous ADC DMA mode and then running its own task which 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:

    // 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:

    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.

API Reference

Header File

Macros

ESP_IDF_VERSION_VAL(major, minor, patch)
ESP_IDF_VERSION

Classes

class ContinuousAdc : public espp::BaseComponent

ContinuousAdc provides a wrapper around the ESP-IDF continuous adc subsystem, enabling high-frequency, filtered measurements of analog values. The get_mv() function will always return the most up to date value, without needing to perform additional reads (therefore it is non-blocking).

Continuous ADC Example

    // NOTE: the unit must support continuous (DMA) mode; on ESP32 / C3 / S3
    // that is only unit 1
    std::vector<espp::AdcConfig> 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) {
        auto maybe_mv = adc.get_mv(conf);
        if (maybe_mv.has_value()) {
          fmt::print("{}: {} mV\n", conf, maybe_mv.value());
        } else {
          fmt::print("{}: no value!\n", conf);
        }
        auto maybe_rate = adc.get_rate(conf);
        if (maybe_rate.has_value()) {
          fmt::print("{}: {} Hz\n", conf, maybe_rate.value());
        } else {
          fmt::print("{}: no rate!\n", conf);
        }
      }
      // NOTE: sleeping in this way allows the sleep to exit early when the
      // task is being stopped / destroyed
      {
        std::unique_lock<std::mutex> 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 = "Read ADC"},
                            .log_level = espp::Logger::Verbosity::INFO});
    task.start();

    // test stopping and starting the adc
    std::this_thread::sleep_for(3s);
    logger.info("Stopping ADC");
    adc.stop();
    std::this_thread::sleep_for(3s);
    logger.info("Starting ADC");
    adc.start();

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), 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 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:

// joystick axes on ADC1 + battery divider on ADC2, each ~1 kHz
espp::ContinuousAdc adc({.sample_rate_hz = 1000,
                         .channels = {joy_x, joy_y, vbat}});
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:
espp::ContinuousAdc adc({.sample_rate_hz = 1000,
                         .channels = {v_sense, i_sense},
                         .convert_mode = ADC_CONV_BOTH_UNIT});
With 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.

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.

Public Functions

inline explicit ContinuousAdc(const Config &config)

Initialize and start the continuous adc reader.

Parameters:

configConfig used to initialize the reader.

inline ~ContinuousAdc()

Stop, deinit, and destroy the adc reader.

inline void start()

Start the continuous adc reader.

inline void stop()

Stop the continuous adc reader.

inline std::optional<float> get_mv(const AdcConfig &config)

Get the most up to date filtered voltage (in mV) from the provided channel.

Note

If the channel could not be calibrated, the filtered raw value is returned instead.

Parameters:

config – The config used to initialize the channel (includes unit and channel)

Returns:

std::optional<float> voltage in mV for the provided channel (if it was configured and the adc is running).

inline std::optional<float> get_rate(const AdcConfig &config)

Get the most up to date sampling rate (in Hz) from the provided channel.

Parameters:

config – The config used to initialize the channel (includes unit and channel)

Returns:

std::optional<float> Rate in Hz for the provided channel (if it was configured and the adc is running).

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

struct Config

Configure the sample rate (globally applied to each channel), select the number of channels, and optionally the conversion mode.

Public Members

size_t sample_rate_hz

Samples per second to read from each channel.

std::vector<AdcConfig> channels

Channels to read from, with associated attenuations.

adc_digi_convert_mode_t convert_mode{static_cast<adc_digi_convert_mode_t>(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 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.

espp::Logger::Verbosity log_level = {espp::Logger::Verbosity::WARN}

Verbosity for the adc logger.