/* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . BLE connectivity adapted from the ESP32 BLE Server example by Random Nerd Tutorials: https://randomnerdtutorials.com/esp32-bluetooth-low-energy-ble-arduino-ide/. Copyright (c) 2024 - 2025 Krishnanshu Mittal - krishnanshu@upsidedownlabs.tech Copyright (c) 2024 - 2025 Deepak Khatri - deepak@upsidedownlabs.tech Copyright (c) 2024 - 2025 Upside Down Labs - contact@upsidedownlabs.tech At Upside Down Labs, we create open‐source DIY neuroscience hardware and software. Our mission is to make neuroscience affordable and accessible for everyone. By supporting us with your purchase, you help spread innovation and open science. Thank you for being part of this journey with us! */ // ----- Existing Includes ----- #include #include #include #include #include #include #include #include "hal/efuse_hal.h" #include "esp_gap_ble_api.h" #include "esp_idf_version.h" #include "esp_mac.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" // ----- New low-power / BLE / ADC includes ----- #include "esp_bt.h" // release Classic BT memory #include "esp_adc/adc_continuous.h" // ADC continuous (DMA) driver #include "hal/adc_types.h" // adc_atten_t, bit width, etc. #include "soc/soc_caps.h" // SOC_ADC_DIGI_RESULT_BYTES // ----- Chip-specific Pin Definitions ----- // // Use the ESP-IDF config macros to detect the chip. #if defined(CONFIG_IDF_TARGET_ESP32C6) // Store chip revision number (for optional raw fixup if needed) uint32_t chiprev = efuse_hal_chip_revision(); #define LED_BUILTIN 7 #define PIXEL_PIN 15 #define PIXEL_COUNT 6 #elif defined(CONFIG_IDF_TARGET_ESP32C3) #define LED_BUILTIN 6 #define PIXEL_PIN 3 #define PIXEL_COUNT 4 #else #error "Unsupported board: Please target either ESP32-C6 or ESP32-C3 in your Board Manager." #endif #define PIXEL_BRIGHTNESS 7 // Brightness of Neopixel LED #define NUM_CHANNELS 4 // Number of BioAmp channels + 1 channel for battery #define SINGLE_SAMPLE_LEN 7 // Each sample: 1 counter + (3 bioAmp channels * 2 bytes) #define BLOCK_COUNT 10 // Batch size: 10 samples per notification #define NEW_PACKET_LEN (BLOCK_COUNT * SINGLE_SAMPLE_LEN) // New packet length (70 bytes) #define SAMP_RATE 500.0 // Sampling rate per channel (500 Hz) #define ADC_CONV_BYTES SOC_ADC_DIGI_RESULT_BYTES // Number of bytes per ADC conversion result in continuous mode #define BATTERY_PIN A6 // Onboard Neopixel at PIXEL_PIN Adafruit_NeoPixel pixels(PIXEL_COUNT, PIXEL_PIN, NEO_GRB + NEO_KHZ800); // Battery monitoring variables static unsigned long lastBatteryCheck = 0; static const unsigned long BATTERY_CHECK_INTERVAL = 120000; // 2 minutes in milliseconds static BLEServer *pBLEServer = nullptr; // Store server reference for disconnect // LUT for 1S LiPo (Voltage in ascending order) const float voltageLUT[] = { 3.27, 3.61, 3.69, 3.71, 3.73, 3.75, 3.77, 3.79, 3.80, 3.82, 3.84, 3.85, 3.87, 3.91, 3.95, 3.98, 4.02, 4.08, 4.11, 4.15, 4.20 }; const int percentLUT[] = { 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100 }; const int lutSize = sizeof(voltageLUT) / sizeof(voltageLUT[0]); // Linear interpolation function float interpolatePercentage(float voltage) { // Handle out-of-range voltages if (voltage <= voltageLUT[0]) return 0; if (voltage >= voltageLUT[lutSize - 1]) return 100; // Find the nearest LUT entries int i = 0; while (i < lutSize - 1 && voltage > voltageLUT[i + 1]) i++; // Interpolate float v1 = voltageLUT[i], v2 = voltageLUT[i + 1]; int p1 = percentLUT[i], p2 = percentLUT[i + 1]; return p1 + (voltage - v1) * (p2 - p1) / (v2 - v1); } // BLE UUIDs – change if desired. #define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b" #define DATA_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" // For ADC data (Notify only) #define CONTROL_CHAR_UUID "0000ff01-0000-1000-8000-00805f9b34fb" // For commands (Read/Write/Notify) // ----- Global Variables ----- uint8_t batchBuffer[NEW_PACKET_LEN] = { 0 }; // Buffer to accumulate BLOCK_COUNT samples volatile int sampleIndex = 0; // How many samples accumulated in current batch volatile bool streaming = false; // True when "START" command is received uint8_t mac[6]; // Array to store 6-byte MAC address BLECharacteristic *pDataCharacteristic; BLECharacteristic *pControlCharacteristic; // Global sample counter (each sample's packet counter) uint8_t overallCounter = 0; // Battery monitoring - stores latest ADC reading from A6 static volatile uint16_t latestBatteryRaw = 2111; // Initially set to 2111 indicating 100% battery to avoid connection issues // Rolling average buffer for battery (1000 samples = 2 seconds @ 500Hz) #define BATTERY_AVG_SAMPLES 1000 static uint16_t batteryBuffer[BATTERY_AVG_SAMPLES] = {0}; static uint16_t batteryIndex = 0; static uint32_t batterySum = 0; // Initialize with startup value static bool batteryBufferFilled = false; // ----- ADC DMA (continuous mode) globals ----- static adc_continuous_handle_t adc_handle = nullptr; static bool adc_started = false; static SemaphoreHandle_t adc_data_semaphore = nullptr; static esp_ble_adv_params_t advParams = { .adv_int_min = 0x0680, .adv_int_max = 0x0680, .adv_type = ADV_TYPE_IND, .own_addr_type = BLE_ADDR_TYPE_PUBLIC, .channel_map = ADV_CHNL_ALL, .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY }; // Helper macros to parse DMA results as TYPE2 format on C3/C6 // (channel and data fields are in adc_digi_output_data_t::type2) #define ADC_OUTPUT_TYPE ADC_DIGI_OUTPUT_FORMAT_TYPE2 #define ADC_GET_CHANNEL(p) ((p)->type2.channel) #define ADC_GET_DATA(p) ((p)->type2.data) // Forward declarations static void adc_dma_init(); static void adc_dma_start(); static void adc_dma_stop(); static void handle_adc_dma_and_notify(); static inline uint16_t fix_raw_if_needed(uint16_t raw); // ----- BLE Server Callbacks ----- class MyServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer *pServer) override { pixels.setPixelColor(0, pixels.Color(0, PIXEL_BRIGHTNESS, 0)); // Green pixels.show(); digitalWrite(LED_BUILTIN, HIGH); delay(200); digitalWrite(LED_BUILTIN, LOW); // Apply -3 dBm to the active connection (handle 0 for first/only connection) esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_CONN_HDL0, ESP_PWR_LVL_N3); esp_ble_gap_stop_advertising(); // Explicitly stop advertising } void onDisconnect(BLEServer *pServer) override { pixels.setPixelColor(0, pixels.Color(PIXEL_BRIGHTNESS, 0, 0)); // Red pixels.show(); digitalWrite(LED_BUILTIN, HIGH); delay(200); digitalWrite(LED_BUILTIN, LOW); delay(100); digitalWrite(LED_BUILTIN, HIGH); delay(200); digitalWrite(LED_BUILTIN, LOW); streaming = false; adc_dma_stop(); esp_ble_gap_start_advertising(&advParams); } }; // ----- BLE Control Characteristic Callback ----- // Handles incoming commands ("START", "STOP", "WHORU", "STATUS") class ControlCallback : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *characteristic) override { String cmd = characteristic->getValue(); cmd.trim(); cmd.toUpperCase(); if (cmd == "START") { pixels.setPixelColor(0, pixels.Color(0, 0, PIXEL_BRIGHTNESS)); // Blue pixels.show(); overallCounter = 0; sampleIndex = 0; streaming = true; adc_dma_start(); } else if (cmd == "STOP") { pixels.setPixelColor(0, pixels.Color(0, PIXEL_BRIGHTNESS, 0)); // Green pixels.show(); streaming = false; adc_dma_stop(); } else if (cmd == "WHORU") { characteristic->setValue("NPG-LITE"); characteristic->notify(); } else if (cmd == "STATUS") { characteristic->setValue(streaming ? "RUNNING" : "STOPPED"); characteristic->notify(); } else { characteristic->setValue("UNKNOWN COMMAND"); characteristic->notify(); } } }; void checkBatteryAndDisconnect() { float voltage = (latestBatteryRaw / 1000.0) * 2; // ESP32C6 v0.1 voltage = voltage - 0.02; float percentage = interpolatePercentage(voltage); if (percentage < 5.0) { // Stop streaming streaming = false; adc_dma_stop(); // Disconnect BLE client if connected if (pBLEServer != nullptr && pBLEServer->getConnectedCount() > 0) { // Get connection ID from first connected client std::map peerDevices = pBLEServer->getPeerDevices(false); for (auto const &entry : peerDevices) { uint16_t connId = entry.first; pBLEServer->disconnect(connId); // Use public disconnect method } } } } void setup() { // ----- LEDs ----- pixels.begin(); pixels.setPixelColor(0, pixels.Color(PIXEL_BRIGHTNESS, 0, 0)); // Red (power on) pixels.show(); pinMode(LED_BUILTIN, OUTPUT); digitalWrite(LED_BUILTIN, LOW); setCpuFrequencyMhz(80); // Create binary semaphore for ADC data ready signaling adc_data_semaphore = xSemaphoreCreateBinary(); if (adc_data_semaphore == nullptr) { while (1); // Halt } esp_read_mac(mac, ESP_MAC_EFUSE_FACTORY); // ----- BLE-only memory footprint (free Classic BT) ----- // Must be called before the BLE stack is initialized esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); // ----- Initialize BLE ----- char deviceName[36]; sprintf(deviceName, "NPG-%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); BLEDevice::init(deviceName); // Set BLE TX power to -3 dBm for default/advertising/scan esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_DEFAULT, ESP_PWR_LVL_N3); esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_N3); esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_SCAN, ESP_PWR_LVL_N3); // Optional larger MTU for efficiency (doesn't change packet format) BLEDevice::setMTU(500); pBLEServer = BLEDevice::createServer(); pBLEServer->setCallbacks(new MyServerCallbacks()); BLEService *pService = pBLEServer->createService(SERVICE_UUID); // Data Characteristic (Notify only) for ADC data pDataCharacteristic = pService->createCharacteristic( DATA_CHAR_UUID, BLECharacteristic::PROPERTY_NOTIFY); pDataCharacteristic->addDescriptor(new BLE2902()); // Control Characteristic (Read/Write/Notify) pControlCharacteristic = pService->createCharacteristic( CONTROL_CHAR_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY); pControlCharacteristic->setCallbacks(new ControlCallback()); pService->start(); // Configure advertising data to include device name esp_ble_adv_data_t adv_data = {}; adv_data.set_scan_rsp = false; adv_data.include_name = true; // ← KEY: include "NPG-" in advertising adv_data.include_txpower = false; adv_data.min_interval = 0x0006; adv_data.max_interval = 0x0010; adv_data.appearance = 0x00; adv_data.manufacturer_len = 0; adv_data.p_manufacturer_data = nullptr; adv_data.service_data_len = 0; adv_data.p_service_data = nullptr; adv_data.service_uuid_len = 0; adv_data.p_service_uuid = nullptr; adv_data.flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT); esp_ble_gap_config_adv_data(&adv_data); // Stop Arduino's advertising helper and start with our params BLEDevice::getAdvertising()->stop(); // if it was started elsewhere esp_ble_gap_start_advertising(&advParams); } void loop() { if (streaming) { // Battery check only when streaming (every 2 minutes) unsigned long currentMillis = millis(); if (currentMillis - lastBatteryCheck >= BATTERY_CHECK_INTERVAL) { lastBatteryCheck = currentMillis; checkBatteryAndDisconnect(); } // Block until semaphore is given by ISR (allows deep sleep) if (xSemaphoreTake(adc_data_semaphore, portMAX_DELAY) == pdTRUE) { handle_adc_dma_and_notify(); } } else { // Longer delay when idle to maximize sleep time delay(100); } } // ====== ADC DMA implementation ====== // Maps physical ADC channel id → logical index 0..NUM_CHANNELS-1 static int8_t hw2idx[10]; static const uint8_t hw_chs[NUM_CHANNELS] = { 0, 1, 2, 6 }; static void adc_dma_init() { static adc_digi_pattern_config_t pattern[NUM_CHANNELS]; // Build pattern from the single channel list for (int i = 0; i < NUM_CHANNELS; i++) { pattern[i].atten = ADC_ATTEN_DB_11; pattern[i].channel = hw_chs[i]; // ← use physical channel id pattern[i].unit = ADC_UNIT_1; // ADC1 only pattern[i].bit_width = ADC_BITWIDTH_12; } for (int i = 0; i < (int)sizeof(hw2idx); i++) hw2idx[i] = -1; for (int i = 0; i < NUM_CHANNELS; i++) hw2idx[hw_chs[i]] = i; // Create driver handle and configure continuous conversion adc_continuous_handle_cfg_t handle_cfg = { .max_store_buf_size = NUM_CHANNELS * ADC_CONV_BYTES * BLOCK_COUNT * 5, .conv_frame_size = NUM_CHANNELS * ADC_CONV_BYTES * BLOCK_COUNT, #if defined(ESP_IDF_VERSION) && (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)) .flags = { .flush_pool = 1 }, // Only for newer IDF that supports it #endif }; if (adc_handle == nullptr) { ESP_ERROR_CHECK(adc_continuous_new_handle(&handle_cfg, &adc_handle)); } adc_continuous_evt_cbs_t cbs = { .on_conv_done = [](adc_continuous_handle_t handle, const adc_continuous_evt_data_t *edata, void *user_data) -> bool { BaseType_t xHigherPriorityTaskWoken = pdFALSE; xSemaphoreGiveFromISR(adc_data_semaphore, &xHigherPriorityTaskWoken); return (xHigherPriorityTaskWoken == pdTRUE); // Yield if higher priority task woken }, }; ESP_ERROR_CHECK(adc_continuous_register_event_callbacks(adc_handle, &cbs, nullptr)); adc_continuous_config_t dig_cfg = { .pattern_num = NUM_CHANNELS, .adc_pattern = pattern, // total sample rate = per-channel * number of channels .sample_freq_hz = (uint32_t)(SAMP_RATE * NUM_CHANNELS), // 2000 SPS total .conv_mode = ADC_CONV_SINGLE_UNIT_1, .format = ADC_OUTPUT_TYPE, // TYPE2 (unit, channel, data) }; ESP_ERROR_CHECK(adc_continuous_config(adc_handle, &dig_cfg)); } static void adc_dma_start() { // Reinitialize ADC if it was deinited if (adc_handle == nullptr) { adc_dma_init(); } if (adc_handle && !adc_started) { ESP_ERROR_CHECK(adc_continuous_start(adc_handle)); adc_started = true; } } static void adc_dma_stop() { if (adc_handle && adc_started) { ESP_ERROR_CHECK(adc_continuous_stop(adc_handle)); adc_started = false; // DEINITIALIZE ADC to save power ESP_ERROR_CHECK(adc_continuous_deinit(adc_handle)); adc_handle = nullptr; } } static inline uint16_t fix_raw_if_needed(uint16_t raw) { #if defined(CONFIG_IDF_TARGET_ESP32C6) // Optional: match your prior scaling workaround for C6 rev1 if needed if (chiprev == 1) { // scale raw (0..~3249) to 0..4095 uint32_t v = (uint32_t)raw * 4095u / 3249u; if (v > 4095u) v = 4095u; return (uint16_t)v; } #endif return raw; } static void handle_adc_dma_and_notify() { // Read whatever DMA has buffered; non-blocking with short buffer uint8_t dma_buf[NUM_CHANNELS * ADC_CONV_BYTES * BLOCK_COUNT]; uint32_t ret_len = 0; esp_err_t ret = adc_continuous_read(adc_handle, dma_buf, sizeof(dma_buf), &ret_len, 0); if (ret != ESP_OK || ret_len == 0) { return; } // Assemble triplets (ch0, ch1, ch2) into your 7-byte sample packets // Maintain a small staging for latest values per channel and a mask static uint16_t last_vals[NUM_CHANNELS] = { 0 }; static uint8_t have_mask = 0; const uint8_t FULL_MASK = (1u << NUM_CHANNELS) - 1; for (uint32_t i = 0; i + ADC_CONV_BYTES <= ret_len; i += ADC_CONV_BYTES) { auto *p = (const adc_digi_output_data_t *)&dma_buf[i]; uint8_t ch_hw = ADC_GET_CHANNEL(p); // physical channel id from TYPE2 uint16_t raw = ADC_GET_DATA(p); // map physical channel → logical index (0..NUM_CHANNELS-1) int8_t idx = (ch_hw < (uint8_t)sizeof(hw2idx)) ? hw2idx[ch_hw] : -1; if (idx >= 0) { // Apply fix only to BioAmp channels (0,1,2), NOT battery (3) if (idx < 3) { last_vals[idx] = fix_raw_if_needed(raw); } else { last_vals[idx] = raw; // Battery channel: use raw value } have_mask |= (1u << idx); // Store battery reading (channel 6 = index 3) with rolling average if (idx == 3) { uint16_t newSample = last_vals[idx]; // Subtract oldest value from sum batterySum -= batteryBuffer[batteryIndex]; // Add new value to buffer and sum batteryBuffer[batteryIndex] = newSample; batterySum += newSample; // Update circular buffer index batteryIndex++; if (batteryIndex >= BATTERY_AVG_SAMPLES) { batteryIndex = 0; batteryBufferFilled = true; // Buffer is now full } // Calculate and store average (only after buffer is filled for accuracy) if (batteryBufferFilled) { latestBatteryRaw = (uint16_t)(batterySum / BATTERY_AVG_SAMPLES); } else { // Before buffer fills, use current value (or could use partial average) latestBatteryRaw = newSample; } } } // When we have all 3 channels, emit one 7-byte record DIRECTLY to batchBuffer if (have_mask == FULL_MASK) { // Calculate offset in batchBuffer uint16_t offset = sampleIndex * SINGLE_SAMPLE_LEN; // Write counter directly to batchBuffer batchBuffer[offset] = overallCounter; overallCounter = (overallCounter + 1) & 0xFF; // Big-endian packing directly to batchBuffer (no intermediate samplePacket) for (uint8_t c = 0; c < NUM_CHANNELS - 1; c++) { uint16_t v = last_vals[c]; batchBuffer[offset + 1 + c * 2] = (uint8_t)((v >> 8) & 0xFF); batchBuffer[offset + 1 + c * 2 + 1] = (uint8_t)(v & 0xFF); } sampleIndex++; // Notify every BLOCK_COUNT samples if (sampleIndex >= BLOCK_COUNT) { pDataCharacteristic->setValue(batchBuffer, NEW_PACKET_LEN); pDataCharacteristic->notify(); sampleIndex = 0; } have_mask = 0; // reset for next triplet } } }