Just running the SPI/ADC is not that complicated. It’s simpler than the ESP8266.
I run the SPI at 2MHz:
vspi->beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0));
It’s been a couple of years since I coded this up, so my memory is a little off. I use the standard digitalWrite to lower and raise the CS pin.
For the actual SPI transaction I use a call to a modified lower level spiTransferBits that I renamed spiTransferBitsCB. There are two modifications:
The MCP3208 transaction is broken down into two SPI transactions. The first is the 5 bit write portion with start bit and channel select. The second is the 12 bit result. The reason for this is that when you run the MCP3208 ate 3.3V and 2MHz, the sample and hold charge time can be too short to fully charge the ADC sample capacitor. The cap charge time is increased by the inter-transaction delay time.
The second modification is to include the ability to specify a callback function to be invoked when the second SPI transaction initiates. This is used to do housekeeping and other sampling chores during the 6us data transfer from the ADC. It’s not necessary to use this callback to get the high transaction rate. I implement it as a lambda expression in the code as in:
// Sample Current (I) channel
digitalWrite(context.Ics, LOW);
spiTransferBitsCB(spi, context.Iport, &spiOut, [ ](void* ptr){
// This code is a callback from the low level SPI
// while the SPI is reading the ADC.
// Here we do whatever housekeeping possible asynchronously.
// It should complete before the SPI transfer finishes.
.
. // Some housekeeping code
.
return;
}, (void*)&context);
digitalWrite(context.Ics, HIGH);
context.newI = (spiOut & 4095) - job->offsetI;
The modified spiTransferBits code follows:
// spiTransferBitsCB()
//
// Reworked spiTransferBit from ESP32/Arduino core.
//
// Special purpose to perform MCP3208 transaction at high speed with 2MHz spi.
//
// When running at 2MHz 750ns ADC sample time is inadequate and produces lower quality results of +/- 3 LSB.
// By splitting into two transactions, the sample time defined by the rise of the 5th clock and fall of the 6th clock
// is greatly increased to about 2ms, producing better results.
//
// This code also allows for a callback during the second 14 bit transfer (7ms), which the sampler
// uses to record results, do housekeeping and loop control.
//
// The result is an effective ADC sample rate of about 80K SPS.
//
// Returns boolean noWait to indicate if no wait for spi completion was required after return from the
// callback, which means the callback did not complete in the alloted time. This is used from time-to-time
// as an aid to tuning the loop to balance the two spi calls and minimize sampling induced phase-shift.
//
#include "esp_attr.h"
#include "esp32-hal-spi.h"
#include "esp32-hal.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "rom/ets_sys.h"
#include "esp_attr.h"
#include "esp_intr.h"
#include "rom/gpio.h"
#include "soc/spi_reg.h"
#include "soc/spi_struct.h"
#include "soc/io_mux_reg.h"
#include "soc/gpio_sig_map.h"
#include "soc/dport_reg.h"
#include "soc/rtc.h"
struct spi_struct_t {
spi_dev_t * dev;
#if !CONFIG_DISABLE_HAL_LOCKS
xSemaphoreHandle lock;
#endif
uint8_t num;
};
bool IRAM_ATTR spiTransferBitsCB(spi_t * spi, uint32_t port, uint32_t * out, void (*cb)(void*), void* cbParm)
{
// hard coded for 19 bits split into 5 and 14 bit transactions.
// ADC sample capacitor charges between rise of 5th clock and fall of 6th clock,
// so splitting increases the sample time which produces more reliable results at 2MHz clk.
bool noWait = false;
// Setup 5 bit command transfer.
// port is combined with start and sgl/diff bits and shifted to
// left align in the little-endian LSB which stores as the big-endian MSB for spi.
spi->dev->data_buf[0] = (0b11000 | port) << 3;
spi->dev->mosi_dlen.usr_mosi_dbitlen = (5 - 1);
spi->dev->miso_dlen.usr_miso_dbitlen = (5 - 1);
spi->dev->cmd.usr = 1;
while(spi->dev->cmd.usr);
// Now start the 14 bit result transfer
spi->dev->mosi_dlen.usr_mosi_dbitlen = (14 - 1);
spi->dev->miso_dlen.usr_miso_dbitlen = (14 - 1);
spi->dev->data_buf[0] = 0;
spi->dev->cmd.usr = 1;
// Initiate the call back if requested
if(cb) (*cb)(cbParm);
// Now wait for completion (or not)
if(spi->dev->cmd.usr){
while(spi->dev->cmd.usr);
}
else {
noWait = true;
}
// The result is in the high order 14 bits of the adc buffer in big-endian format.
// This code extracts those bits and reformats to little-endian.
uint32_t _out = spi->dev->data_buf[0];
*out = (_out >> 10) | ((_out & 0xff) << 6);
return noWait;
}
The IoTaWatt sampler that is using this code runs in a mostly dedicated processor. This is accomplished during startup in the the arduino environment by switching the arduino task to core 0, and later starting a FreeRTOS sampler task on core 1. All other tasks are assigned to core 0. You may not need to do that depending on your sampling requirements, but here’s how I do it.
void setup(){
// Switch arduino framework to core 0
if(xPortGetCoreID() == 1){
xTaskCreateUniversal(loopTask, "looptask0", 6000, NULL, X_PRI_LOOP, &loopTaskHandle, 0);
vTaskDelete(nullptr);
}
Beyond that, the sampler task on core 1 receives work-order messages using XqueueReceive and sends the results to a post processor on core 0. Work-orders typically specify taking a set of samples of voltage and current for one AC cycle.