Skip to content

Porting

Run the driver on a new MCU by writing one class: the ADC interface.

The driver never calls your SDK directly. It calls seven methods, plus one optional hook, on a class you derive from ntc::AdcInterface<Self> (in inc/ntc_adc_interface.hpp). The base class uses CRTP: the calls are resolved at compile time, so there are no virtual functions, no vtable and nothing to allocate.

Layer diagram: the application calls the hardware-agnostic NTC thermistor driver, which uses the CRTP ADC interface, which calls the platform port you write, which reads the ADC input on the node of the voltage divider formed by the series resistor and the NTC thermistor.Layer diagram: the application calls the hardware-agnostic NTC thermistor driver, which uses the CRTP ADC interface, which calls the platform port you write, which reads the ADC input on the node of the voltage divider formed by the series resistor and the NTC thermistor.
Three layers, one of them yours
Figure description and sources

From top to bottom. Your application talks to one NtcThermistor object, whose public calls (listed from inc/ntc_thermistor.hpp) read the temperature, the resistance and the voltage, and set the divider, the sampling, the filter and the calibration. The core turns a node voltage into the thermistor's resistance and the resistance into a temperature with the Beta equation or a lookup table (inc/ntc_conversion.hpp). The ADC interface (AdcInterface in inc/ntc_adc_interface.hpp) is a CRTP base that forwards to your class. The platform port is the only code you write: a class that brings the ADC up and returns a channel's voltage and count. Below it is the hardware: the divider and the thermistor.

Drawn from: inc/ntc_thermistor.hpp NtcThermistor public calls; inc/ntc_adc_interface.hpp AdcInterface; figure module docs/diagrams/figures/driver-layers.py.

What you implement

Method Contract
IsInitialized() const true once the ADC is ready
EnsureInitialized() Bring the ADC up if needed; true on success. Called by Initialize()
IsChannelAvailable(channel) const true for a channel this port can read
ReadChannelV(channel, &volts) One conversion, as the voltage at the pin in volts. Used for every temperature
ReadChannelCount(channel, &count) One conversion, as the raw count. Used only by GetRawAdcValue()
GetReferenceVoltage() const The ADC's full-scale voltage. Not used by the driver today
GetResolutionBits() const The ADC's resolution. Not used by the driver today
DelayMs(ms) (optional) Wait at least ms milliseconds (an RTOS sleep). Used between averaged samples when sample_delay_ms is set; without it the samples are taken back to back

The read methods return ntc::AdcError: Success, or NotInitialized, InvalidChannel, ReadFailed, Timeout, HardwareError. The driver maps them to NtcError (API guide).

The port

my_adc.hpp
class MyAdc : public ntc::AdcInterface<MyAdc> {
public:
  static constexpr uint8_t kChannels = 7;       // ADC1 channels 0..6 on this MCU
  static constexpr uint8_t kBits = 12;
  static constexpr float kFullScaleV = 3.3F;    // input range at the chosen attenuation

  [[nodiscard]] bool IsInitialized() const { return ready_; }

  // Bring the ADC up once; later calls return true at once.
  bool EnsureInitialized() {
    if (!ready_) {
      ready_ = platform_adc_init();
    }
    return ready_;
  }

  [[nodiscard]] bool IsChannelAvailable(uint8_t channel) const { return channel < kChannels; }

  ntc::AdcError ReadChannelCount(uint8_t channel, uint32_t* count) {
    uint32_t mv = 0;
    return read(channel, count, &mv);
  }

  // The driver reads volts: return the calibrated voltage at the pin, not count * Vref / 4095.
  ntc::AdcError ReadChannelV(uint8_t channel, float* voltage_v) {
    uint32_t raw = 0;
    uint32_t mv = 0;
    const ntc::AdcError err = read(channel, &raw, &mv);
    if (err == ntc::AdcError::Success) {
      *voltage_v = static_cast<float>(mv) / 1000.0F;
    }
    return err;
  }

  [[nodiscard]] float GetReferenceVoltage() const { return kFullScaleV; }
  [[nodiscard]] uint8_t GetResolutionBits() const { return kBits; }

  // Optional: the driver waits sample_delay_ms between averaged samples through this.
  void DelayMs(uint32_t ms) { platform_delay_ms(ms); }

private:
  ntc::AdcError read(uint8_t channel, uint32_t* raw, uint32_t* mv) {
    if (!ready_) {
      return ntc::AdcError::NotInitialized;
    }
    if (!IsChannelAvailable(channel)) {
      return ntc::AdcError::InvalidChannel;
    }
    if (!platform_adc_read(channel, *raw, *mv)) {
      return ntc::AdcError::ReadFailed;
    }
    return ntc::AdcError::Success;
  }

  bool ready_ = false;
};

Rules for ReadChannelV():

  • Return calibrated volts. The driver's accuracy is the accuracy of this number. Use your SDK's ADC calibration (on ESP32, adc_cali_raw_to_voltage()), not count × V_full / 4095.
  • One conversion per call. The driver averages sample_count calls itself.
  • Block until the result is ready, or return Timeout.
  • Use an input range that covers the node. With a 3.3 V divider the node spans about 0.1 V to 3.1 V over −40 °C to +150 °C; pick the attenuation or gain that covers it.

Reference implementation

The ESP32 example's port, esp32_ntc_adc.hpp, is a complete ESP-IDF port. EnsureInitialized() creates the ADC1 unit with adc_oneshot_new_unit(), configures the channel at 12 dB attenuation (about 0 V to 3.3 V, the whole divider) with adc_oneshot_config_channel() and creates an adc_cali curve-fitting scheme (line fitting on chips without it). ReadChannelV() reads with adc_oneshot_read() and converts with adc_cali_raw_to_voltage(). DelayMs() sleeps with vTaskDelay(). Its component needs REQUIRES esp_adc.

Check your port

  1. Build it: the port on this page is compiled by the host CI from tests/docs/my_adc.hpp, with the driver instantiated over it.
  2. Replace the thermistor with a 10 kΩ 0.1 % resistor: GetVoltage() returns half the supply and ReadTemperatureCelsius() returns 25 °C within a few tenths of a degree, set by the two resistors and the ADC calibration.
  3. Short the node to ground: ConversionFailed. Open the thermistor: ConversionFailed, or TemperatureOutOfRange if ADC noise keeps the node just under the supply.

Threads

The driver is not thread-safe. Give each NtcThermistor one owner task, or guard every call on a driver object (and its ADC port) with one mutex. Several thermistors on one port need that mutex around the port too.