Skip to content

CODE

Warning

The following code should be based on the code in the release code, which may have been updated.

offline_sensing.h

/**
 * @file offline_sensing.h
 * @author SHUAIWEN CUI (SHUAIWEN001@e.ntu.edu.sg)
 * @brief Offline sensing module - High-frequency batch sensor data acquisition and storage
 * @version 1.0
 * @date 2025-12-17
 * @copyright Copyright (c) 2025
 *
 * @details
 * This module provides offline sensor data acquisition with configurable:
 * - Sampling frequency (default: 100 Hz, higher than online sensing)
 * - Sampling duration (default: 10 seconds)
 * - Memory buffer storage (enabled/disabled)
 * - SD card storage (enabled/disabled)
 * - MQTT report after sampling (enabled/disabled)
 *
 * Features:
 * - High-precision timer-based sampling (ESP timer)
 * - Data storage in memory buffer and/or SD card
 * - Automatic MQTT report after sampling completion
 * - Thread-safe operation
 */

#pragma once

#include <stdint.h>
#include <stdbool.h>
#include "esp_err.h"
#include "node_acc_adxl355.h"
#include "tiny_measurement_config.h"

#ifdef __cplusplus
extern "C"
{
#endif

/* ============================================================================
 * CONFIGURATION STRUCTURE
 * ============================================================================ */

/**
 * @brief Offline sensing configuration
 */
typedef struct
{
    float sampling_frequency_hz;      ///< Sampling frequency in Hz (default: 100.0, higher than online)
    float sampling_duration_sec;      ///< Sampling duration in seconds (default: 10.0)
    bool enable_memory;               ///< Enable memory buffer storage (default: true)
    bool enable_sd;                   ///< Enable SD card storage (default: true)
    bool enable_mqtt_report;         ///< Enable MQTT report after sampling (default: true)
    const char *sd_file_path;         ///< SD card file path (default: NULL uses auto-generated path)
    const char *mqtt_report_topic;    ///< MQTT topic for report (default: NULL uses default topic)
} offline_sensing_config_t;

/**
 * @brief Offline sensing sample data structure
 */
typedef struct
{
    float x;        ///< X-axis acceleration (g)
    float y;        ///< Y-axis acceleration (g)
    float z;        ///< Z-axis acceleration (g)
    float temp;     ///< Temperature (°C)
    uint64_t timestamp_us;  ///< Timestamp in microseconds
} offline_sensing_sample_t;

/**
 * @brief Offline sensing session report
 */
typedef struct
{
    uint32_t total_samples;          ///< Total number of samples collected
    float actual_frequency_hz;       ///< Actual sampling frequency achieved
    float duration_sec;              ///< Actual duration
    bool memory_storage_success;      ///< Memory storage success status
    bool sd_storage_success;         ///< SD card storage success status
    char sd_file_path[256];          ///< SD card file path (if saved)
    uint64_t start_timestamp_us;     ///< Start timestamp
    uint64_t end_timestamp_us;       ///< End timestamp
} offline_sensing_report_t;

/* ============================================================================
 * DEFAULT CONFIGURATION
 * ============================================================================ */

/**
 * @brief Default configuration values (from tiny_measurement_config.h macros)
 */
#define OFFLINE_SENSING_DEFAULT_FREQ_HZ TINY_MEASUREMENT_OFFLINE_SENSING_DEFAULT_FREQ_HZ
#define OFFLINE_SENSING_DEFAULT_DURATION_SEC TINY_MEASUREMENT_OFFLINE_SENSING_DEFAULT_DURATION_SEC
#define OFFLINE_SENSING_DEFAULT_ENABLE_MEMORY TINY_MEASUREMENT_OFFLINE_SENSING_DEFAULT_ENABLE_MEMORY
#define OFFLINE_SENSING_DEFAULT_ENABLE_SD TINY_MEASUREMENT_OFFLINE_SENSING_DEFAULT_ENABLE_SD
#define OFFLINE_SENSING_DEFAULT_ENABLE_MQTT_REPORT TINY_MEASUREMENT_OFFLINE_SENSING_DEFAULT_ENABLE_MQTT_REPORT

/* ============================================================================
 * FUNCTION DECLARATIONS
 * ============================================================================ */

/**
 * @brief Initialize offline sensing module
 * @param config Configuration structure (NULL for default configuration)
 * @return ESP_OK on success, error code on failure
 */
esp_err_t offline_sensing_init(const offline_sensing_config_t *config);

/**
 * @brief Set sensor handle for offline sensing
 * @param handle ADXL355 sensor handle
 * @return ESP_OK on success, error code on failure
 */
esp_err_t offline_sensing_set_sensor_handle(adxl355_handle_t *handle);

/**
 * @brief Start offline sensing session
 * @return ESP_OK on success, error code on failure
 * @note This function blocks until sampling is complete
 */
esp_err_t offline_sensing_start(void);

/**
 * @brief Stop offline sensing session (if running)
 * @return ESP_OK on success, error code on failure
 */
esp_err_t offline_sensing_stop(void);

/**
 * @brief Check if offline sensing is currently running
 * @param is_running Output parameter, true if running
 * @return ESP_OK on success, error code on failure
 */
esp_err_t offline_sensing_is_running(bool *is_running);

/**
 * @brief Get the last sampling report
 * @param report Output parameter for the report
 * @return ESP_OK on success, error code on failure
 */
esp_err_t offline_sensing_get_report(offline_sensing_report_t *report);

/**
 * @brief Get memory buffer data (if enabled)
 * @param samples Output buffer for samples
 * @param max_samples Maximum number of samples to retrieve
 * @param actual_samples Output parameter for actual number of samples retrieved
 * @return ESP_OK on success, error code on failure
 */
esp_err_t offline_sensing_get_memory_data(offline_sensing_sample_t *samples, uint32_t max_samples, uint32_t *actual_samples);

/**
 * @brief Clear memory buffer
 * @return ESP_OK on success, error code on failure
 */
esp_err_t offline_sensing_clear_memory(void);

/**
 * @brief Deinitialize offline sensing module
 * @return ESP_OK on success, error code on failure
 */
esp_err_t offline_sensing_deinit(void);

#ifdef __cplusplus
}
#endif

offline_sensing.c

/**
 * @file offline_sensing.c
 * @author SHUAIWEN CUI (SHUAIWEN001@e.ntu.edu.sg)
 * @brief Offline sensing module implementation
 * @version 1.0
 * @date 2025-12-17
 * @copyright Copyright (c) 2025
 *
 */

#include "offline_sensing.h"
#include "node_mqtt.h"
#include "node_sdcard.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_heap_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include <string.h>
#include <stdio.h>
#include <time.h>
#include <inttypes.h>
#include <errno.h>

static const char *TAG = "OfflineSensing";

/* ============================================================================
 * PRIVATE VARIABLES
 * ============================================================================ */

static offline_sensing_config_t s_config;
static adxl355_handle_t *s_adxl355_handle = NULL;
static bool s_is_running = false;
static bool s_is_initialized = false;

// Memory buffer
static offline_sensing_sample_t *s_memory_buffer = NULL;
static uint32_t s_memory_buffer_size = 0;
static uint32_t s_memory_buffer_index = 0;
static SemaphoreHandle_t s_memory_mutex = NULL;

// Timer
static esp_timer_handle_t s_sampling_timer = NULL;

// Sampling state
static uint32_t s_total_samples = 0;
static uint32_t s_expected_samples = 0;  // Expected number of samples (freq * duration + 1)
static uint64_t s_start_timestamp = 0;  // Microseconds since boot (from esp_timer_get_time)
static time_t s_start_calendar_time = 0; // Calendar time at sampling start (from time(NULL))
static uint64_t s_end_timestamp = 0;
static offline_sensing_report_t s_last_report;

// MQTT client (from node_mqtt.h)
extern esp_mqtt_client_handle_t s_mqtt_client;
extern bool s_is_mqtt_connected;

/* ============================================================================
 * PRIVATE FUNCTION DECLARATIONS
 * ============================================================================ */

static void sampling_timer_callback(void *arg);
static esp_err_t save_to_sd_card(const offline_sensing_sample_t *samples, uint32_t count);
static esp_err_t send_mqtt_report(const offline_sensing_report_t *report);

/* ============================================================================
 * TIMER CALLBACK
 * ============================================================================ */

static void sampling_timer_callback(void *arg)
{
    if (!s_is_running || s_adxl355_handle == NULL)
    {
        return;
    }

    adxl355_accelerations_t accel;
    float temperature;
    esp_err_t accel_ret = adxl355_read_accelerations(s_adxl355_handle, &accel);
    esp_err_t temp_ret = adxl355_read_temperature(s_adxl355_handle, &temperature);

    if (accel_ret == ESP_OK && temp_ret == ESP_OK)
    {
        uint64_t timestamp = esp_timer_get_time();

        // Store in memory buffer if enabled
        if (s_config.enable_memory && s_memory_buffer != NULL)
        {
            if (xSemaphoreTake(s_memory_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
            {
                if (s_memory_buffer_index < s_memory_buffer_size)
                {
                    s_memory_buffer[s_memory_buffer_index].x = accel.x;
                    s_memory_buffer[s_memory_buffer_index].y = accel.y;
                    s_memory_buffer[s_memory_buffer_index].z = accel.z;
                    s_memory_buffer[s_memory_buffer_index].temp = temperature;
                    s_memory_buffer[s_memory_buffer_index].timestamp_us = timestamp;
                    s_memory_buffer_index++;
                }
                xSemaphoreGive(s_memory_mutex);
            }
        }

        s_total_samples++;

        // Auto-stop when expected number of samples is reached
        // Expected samples = (freq * duration) + 1 (for t=0 sample)
        if (s_expected_samples > 0 && s_total_samples >= s_expected_samples)
        {
            s_is_running = false; // Stop further sampling
        }
    }
}

/* ============================================================================
 * SD CARD STORAGE
 * ============================================================================ */

static esp_err_t save_to_sd_card(const offline_sensing_sample_t *samples, uint32_t count)
{
    if (samples == NULL || count == 0)
    {
        return ESP_ERR_INVALID_ARG;
    }

    // Generate file path with sampling start time, frequency, and duration
    char file_path[256];
    if (s_config.sd_file_path != NULL)
    {
        snprintf(file_path, sizeof(file_path), "%s/%s", MOUNT_POINT, s_config.sd_file_path);
    }
    else
    {
        // Auto-generate filename with sampling start time, frequency, and duration
        // Format: offline_YYYYMMDD_HHMMSS_FreqXXXHz_DurXXs.csv
        struct tm timeinfo;
        bool use_relative_time = false;

        if (s_start_calendar_time != 0)
        {
            localtime_r(&s_start_calendar_time, &timeinfo);
            // Check if time is valid (not 1970-01-01, which indicates unset time)
            if (timeinfo.tm_year < 70)  // tm_year is years since 1900, so 70 means 1970
            {
                ESP_LOGW(TAG, "System time appears unset (1970), using relative timestamp");
                use_relative_time = true;
            }
        }
        else
        {
            // Fallback to current time if calendar time not set
            time_t now = time(NULL);
            localtime_r(&now, &timeinfo);
            if (timeinfo.tm_year < 70)
            {
                ESP_LOGW(TAG, "System time appears unset (1970), using relative timestamp");
                use_relative_time = true;
            }
        }

        // Format frequency and duration for filename (4 digits each, zero-padded)
        int freq_int = (int)(s_config.sampling_frequency_hz + 0.5f);
        int dur_int = (int)(s_config.sampling_duration_sec + 0.5f);

        if (use_relative_time)
        {
            // Use relative timestamp (seconds since boot) if system time is not set
            // Format: BootXXXXX_FXXXX_DXXXX.csv
            uint64_t boot_time_sec = s_start_timestamp / 1000000ULL;
            snprintf(file_path, sizeof(file_path), "%s/Boot%05llu_F%04d_D%04d.csv",
                     MOUNT_POINT,
                     (unsigned long long)boot_time_sec % 100000,  // Last 5 digits of boot time
                     freq_int,                                   // Frequency (4 digits, zero-padded)
                     dur_int);                                   // Duration (4 digits, zero-padded)
        }
        else
        {
            // Format: YYYYMMDDHHMMSS_FXXXX_DXXXX.csv
            // Includes full date/time (year, month, day, hour, minute, second)
            // and sampling parameters (frequency and duration, both 4 digits zero-padded)
            snprintf(file_path, sizeof(file_path), "%s/%04d%02d%02d%02d%02d%02d_F%04d_D%04d.csv",
                     MOUNT_POINT,
                     timeinfo.tm_year + 1900,  // YYYY (4 digits)
                     timeinfo.tm_mon + 1,       // MM (2 digits)
                     timeinfo.tm_mday,          // DD (2 digits)
                     timeinfo.tm_hour,          // HH (2 digits)
                     timeinfo.tm_min,           // MM (2 digits)
                     timeinfo.tm_sec,           // SS (2 digits)
                     freq_int,                 // Frequency (4 digits, zero-padded)
                     dur_int);                 // Duration (4 digits, zero-padded)
        }
    }

    // Open file for writing
    FILE *f = fopen(file_path, "w");
    if (f == NULL)
    {
        ESP_LOGE(TAG, "Failed to open file for writing: %s", file_path);
        ESP_LOGE(TAG, "Error: %s (errno: %d)", strerror(errno), errno);

        // Try to check if directory exists and is writable
        FILE *test_f = fopen(MOUNT_POINT "/.test_write", "w");
        if (test_f == NULL)
        {
            ESP_LOGE(TAG, "SD card directory may not be writable. Check SD card mount status.");
        }
        else
        {
            fclose(test_f);
            remove(MOUNT_POINT "/.test_write");
            ESP_LOGE(TAG, "Directory is writable, but file creation failed. Check filename length or invalid characters.");
        }

        return ESP_FAIL;
    }

    // Write CSV header
    fprintf(f, "timestamp_us,x,y,z,temp\n");

    // Write samples
    for (uint32_t i = 0; i < count; i++)
    {
        fprintf(f, "%llu,%.6f,%.6f,%.6f,%.2f\n",
                (unsigned long long)samples[i].timestamp_us,
                samples[i].x, samples[i].y, samples[i].z, samples[i].temp);
    }

    fclose(f);
    ESP_LOGI(TAG, "SD: %u samples -> %s", count, file_path);

    // Update report with file path
    strncpy(s_last_report.sd_file_path, file_path, sizeof(s_last_report.sd_file_path) - 1);
    s_last_report.sd_file_path[sizeof(s_last_report.sd_file_path) - 1] = '\0';

    return ESP_OK;
}

/* ============================================================================
 * MQTT REPORT
 * ============================================================================ */

static esp_err_t send_mqtt_report(const offline_sensing_report_t *report)
{
    if (report == NULL)
    {
        return ESP_ERR_INVALID_ARG;
    }

    if (!s_is_mqtt_connected || s_mqtt_client == NULL)
    {
        ESP_LOGW(TAG, "MQTT not connected, skipping report");
        return ESP_ERR_INVALID_STATE;
    }

    // Format JSON report
    char report_buff[512];
    snprintf(report_buff, sizeof(report_buff),
             "{\"samples\":%" PRIu32 ",\"freq_hz\":%.2f,\"duration_sec\":%.2f,"
             "\"memory_ok\":%s,\"sd_ok\":%s,\"sd_path\":\"%s\","
             "\"start_us\":%" PRIu64 ",\"end_us\":%" PRIu64 "}",
             report->total_samples,
             report->actual_frequency_hz,
             report->duration_sec,
             report->memory_storage_success ? "true" : "false",
             report->sd_storage_success ? "true" : "false",
             report->sd_file_path[0] != '\0' ? report->sd_file_path : "",
             report->start_timestamp_us,
             report->end_timestamp_us);

    const char *topic = s_config.mqtt_report_topic ? s_config.mqtt_report_topic : "/offline_sensing/report";
    int mqtt_ret = esp_mqtt_client_publish(s_mqtt_client, topic, report_buff, strlen(report_buff), 1, 0);

    if (mqtt_ret < 0)
    {
        ESP_LOGE(TAG, "MQTT publish failed: %d", mqtt_ret);
        return ESP_FAIL;
    }

    ESP_LOGD(TAG, "MQTT report sent");
    return ESP_OK;
}

/* ============================================================================
 * PUBLIC FUNCTIONS
 * ============================================================================ */

esp_err_t offline_sensing_init(const offline_sensing_config_t *config)
{
    if (s_is_initialized)
    {
        ESP_LOGW(TAG, "Offline sensing already initialized");
        return ESP_ERR_INVALID_STATE;
    }

    // Apply configuration
    if (config != NULL)
    {
        memcpy(&s_config, config, sizeof(offline_sensing_config_t));
    }
    else
    {
        // Use default configuration
        s_config.sampling_frequency_hz = OFFLINE_SENSING_DEFAULT_FREQ_HZ;
        s_config.sampling_duration_sec = OFFLINE_SENSING_DEFAULT_DURATION_SEC;
        s_config.enable_memory = OFFLINE_SENSING_DEFAULT_ENABLE_MEMORY;
        s_config.enable_sd = OFFLINE_SENSING_DEFAULT_ENABLE_SD;
        s_config.enable_mqtt_report = OFFLINE_SENSING_DEFAULT_ENABLE_MQTT_REPORT;
        s_config.sd_file_path = NULL;
        s_config.mqtt_report_topic = NULL;
    }

    // Validate configuration
    // Note: Maximum frequency of 4000 Hz is limited by ADXL355 sensor's maximum output data rate (ODR)
    // ADXL355 supports up to 4000 Hz ODR, which is the hardware limit for this sensor
    if (s_config.sampling_frequency_hz < 1.0f || s_config.sampling_frequency_hz > 4000.0f)
    {
        ESP_LOGE(TAG, "Invalid sampling frequency: %.2f Hz (valid range: 1 - 4000 Hz, max limited by ADXL355 ODR)",
                 s_config.sampling_frequency_hz);
        return ESP_ERR_INVALID_ARG;
    }

    if (s_config.sampling_duration_sec < 0.1f || s_config.sampling_duration_sec > 3600.0f)
    {
        ESP_LOGE(TAG, "Invalid sampling duration: %.2f sec (valid range: 0.1 - 3600 sec)",
                 s_config.sampling_duration_sec);
        return ESP_ERR_INVALID_ARG;
    }

    // Allocate memory buffer if enabled
    // Use PSRAM (external RAM) to support larger buffer sizes for high-frequency/long-duration sampling
    if (s_config.enable_memory)
    {
        uint32_t buffer_size = (uint32_t)(s_config.sampling_frequency_hz * s_config.sampling_duration_sec) + 100; // Add margin
        size_t required_bytes = buffer_size * sizeof(offline_sensing_sample_t);

        // Check PSRAM availability
        size_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
        if (psram_free < required_bytes)
        {
            ESP_LOGE(TAG, "PSRAM insufficient: available %zu bytes, need %zu bytes",
                     psram_free, required_bytes);
            return ESP_ERR_NO_MEM;
        }

        // Allocate from PSRAM explicitly to utilize external memory (typically 8MB)
        s_memory_buffer = (offline_sensing_sample_t *)heap_caps_malloc(
            required_bytes,
            MALLOC_CAP_SPIRAM  // Use PSRAM for large buffer
        );
        if (s_memory_buffer == NULL)
        {
            ESP_LOGE(TAG, "Failed to allocate memory buffer from PSRAM");
            ESP_LOGE(TAG, "Available PSRAM: %zu bytes", heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
            return ESP_ERR_NO_MEM;
        }
        s_memory_buffer_size = buffer_size;
        s_memory_buffer_index = 0;
        ESP_LOGD(TAG, "Allocated memory buffer for %u samples", buffer_size);
    }

    // Create mutex for memory buffer
    s_memory_mutex = xSemaphoreCreateMutex();
    if (s_memory_mutex == NULL)
    {
        ESP_LOGE(TAG, "Failed to create mutex");
        if (s_memory_buffer != NULL)
        {
            heap_caps_free(s_memory_buffer);
            s_memory_buffer = NULL;
        }
        return ESP_ERR_NO_MEM;
    }

    // Initialize report
    memset(&s_last_report, 0, sizeof(offline_sensing_report_t));
    s_last_report.sd_file_path[0] = '\0';

    s_is_initialized = true;

    return ESP_OK;
}

esp_err_t offline_sensing_set_sensor_handle(adxl355_handle_t *handle)
{
    if (handle == NULL)
    {
        return ESP_ERR_INVALID_ARG;
    }
    s_adxl355_handle = handle;
    return ESP_OK;
}

esp_err_t offline_sensing_start(void)
{
    if (!s_is_initialized)
    {
        ESP_LOGE(TAG, "Offline sensing not initialized");
        return ESP_ERR_INVALID_STATE;
    }

    if (s_is_running)
    {
        ESP_LOGW(TAG, "Offline sensing already running");
        return ESP_ERR_INVALID_STATE;
    }

    if (s_adxl355_handle == NULL)
    {
        ESP_LOGE(TAG, "Sensor handle not set");
        return ESP_ERR_INVALID_STATE;
    }

    // Reset state
    s_total_samples = 0;
    s_memory_buffer_index = 0;
    // Calculate expected number of samples: (freq * duration) + 1 (for t=0 sample)
    s_expected_samples = (uint32_t)(s_config.sampling_frequency_hz * s_config.sampling_duration_sec) + 1;
    s_start_timestamp = esp_timer_get_time();  // Microseconds since boot
    s_start_calendar_time = time(NULL);        // Calendar time for filename generation

    // Calculate timer period in microseconds
    uint64_t period_us = (uint64_t)(1000000.0f / s_config.sampling_frequency_hz);

    // Create timer
    esp_timer_create_args_t timer_args = {
        .callback = sampling_timer_callback,
        .arg = NULL,
        .name = "offline_sampling_timer"
    };

    esp_err_t ret = esp_timer_create(&timer_args, &s_sampling_timer);
    if (ret != ESP_OK)
    {
        ESP_LOGE(TAG, "Failed to create timer: %s", esp_err_to_name(ret));
        return ret;
    }

    // Set running flag before starting timer (so callback can execute)
    s_is_running = true;

    // Perform immediate first sample (at t=0) before starting periodic timer
    // This ensures we get exactly N samples for N seconds at N Hz
    sampling_timer_callback(NULL);

    // Start periodic timer (first periodic sample will be at t=period_us)
    ret = esp_timer_start_periodic(s_sampling_timer, period_us);
    if (ret != ESP_OK)
    {
        ESP_LOGE(TAG, "Failed to start timer: %s", esp_err_to_name(ret));
        esp_timer_delete(s_sampling_timer);
        s_sampling_timer = NULL;
        s_is_running = false;
        return ret;
    }

    ESP_LOGI(TAG, "Offline sensing started: %.2f Hz for %.2f sec (expected samples: %u)", 
             s_config.sampling_frequency_hz, s_config.sampling_duration_sec, s_expected_samples);

    // Wait for sampling duration
    // The timer callback will auto-stop when expected_samples is reached
    uint32_t duration_ms = (uint32_t)(s_config.sampling_duration_sec * 1000.0f);
    // Add small margin to ensure all samples are collected
    vTaskDelay(pdMS_TO_TICKS(duration_ms + 100));

    // Ensure sampling is stopped
    s_is_running = false;

    // Small delay to ensure any in-flight callback completes
    vTaskDelay(pdMS_TO_TICKS(10));

    // Stop sampling
    offline_sensing_stop();

    // Process and save data
    s_end_timestamp = esp_timer_get_time();
    float actual_duration = (s_end_timestamp - s_start_timestamp) / 1000000.0f;
    float actual_frequency = s_total_samples / actual_duration;

    // Prepare report
    s_last_report.total_samples = s_total_samples;
    s_last_report.actual_frequency_hz = actual_frequency;
    s_last_report.duration_sec = actual_duration;
    s_last_report.start_timestamp_us = s_start_timestamp;
    s_last_report.end_timestamp_us = s_end_timestamp;

    // Save to SD card if enabled
    if (s_config.enable_sd && s_total_samples > 0)
    {
        if (s_config.enable_memory && s_memory_buffer != NULL)
        {
            s_last_report.sd_storage_success = (save_to_sd_card(s_memory_buffer, s_memory_buffer_index) == ESP_OK);
        }
        else
        {
            // Would need to re-read from sensor or use a different approach
            ESP_LOGW(TAG, "SD card storage requires memory buffer");
            s_last_report.sd_storage_success = false;
        }
    }
    else
    {
        s_last_report.sd_storage_success = false;
    }

    s_last_report.memory_storage_success = (s_config.enable_memory && s_memory_buffer != NULL);

    // Send MQTT report if enabled
    if (s_config.enable_mqtt_report)
    {
        send_mqtt_report(&s_last_report);
    }

    ESP_LOGI(TAG, "Completed: %u samples, %.2f Hz, %.2f sec", 
             s_total_samples, s_config.sampling_frequency_hz, s_config.sampling_duration_sec);

    return ESP_OK;
}

esp_err_t offline_sensing_stop(void)
{
    if (!s_is_running)
    {
        return ESP_OK;
    }

    if (s_sampling_timer != NULL)
    {
        esp_timer_stop(s_sampling_timer);
        esp_timer_delete(s_sampling_timer);
        s_sampling_timer = NULL;
    }

    s_is_running = false;
    return ESP_OK;
}

esp_err_t offline_sensing_is_running(bool *is_running)
{
    if (is_running == NULL)
    {
        return ESP_ERR_INVALID_ARG;
    }
    *is_running = s_is_running;
    return ESP_OK;
}

esp_err_t offline_sensing_get_report(offline_sensing_report_t *report)
{
    if (report == NULL)
    {
        return ESP_ERR_INVALID_ARG;
    }
    memcpy(report, &s_last_report, sizeof(offline_sensing_report_t));
    return ESP_OK;
}

esp_err_t offline_sensing_get_memory_data(offline_sensing_sample_t *samples, uint32_t max_samples, uint32_t *actual_samples)
{
    if (samples == NULL || actual_samples == NULL)
    {
        return ESP_ERR_INVALID_ARG;
    }

    if (!s_config.enable_memory || s_memory_buffer == NULL)
    {
        *actual_samples = 0;
        return ESP_ERR_INVALID_STATE;
    }

    if (xSemaphoreTake(s_memory_mutex, pdMS_TO_TICKS(100)) == pdTRUE)
    {
        uint32_t copy_count = (s_memory_buffer_index < max_samples) ? s_memory_buffer_index : max_samples;
        memcpy(samples, s_memory_buffer, copy_count * sizeof(offline_sensing_sample_t));
        *actual_samples = copy_count;
        xSemaphoreGive(s_memory_mutex);
        return ESP_OK;
    }

    return ESP_ERR_TIMEOUT;
}

esp_err_t offline_sensing_clear_memory(void)
{
    if (s_memory_buffer == NULL)
    {
        return ESP_ERR_INVALID_STATE;
    }

    if (xSemaphoreTake(s_memory_mutex, pdMS_TO_TICKS(100)) == pdTRUE)
    {
        s_memory_buffer_index = 0;
        xSemaphoreGive(s_memory_mutex);
        return ESP_OK;
    }

    return ESP_ERR_TIMEOUT;
}

esp_err_t offline_sensing_deinit(void)
{
    if (!s_is_initialized)
    {
        return ESP_OK;
    }

    // Stop if running
    offline_sensing_stop();

    // Free memory buffer
    if (s_memory_buffer != NULL)
    {
        heap_caps_free(s_memory_buffer);
        s_memory_buffer = NULL;
    }

    // Delete mutex
    if (s_memory_mutex != NULL)
    {
        vSemaphoreDelete(s_memory_mutex);
        s_memory_mutex = NULL;
    }

    // Clear report file path
    s_last_report.sd_file_path[0] = '\0';

    s_is_initialized = false;
    return ESP_OK;
}

KEY IMPLEMENTATIONS

Sampling Frequency Limit

The 4000 Hz maximum sampling frequency is determined by the ADXL355 sensor's hardware capabilities (maximum ODR of 4000 Hz). The offline sensing module architecture itself is sensor-agnostic and can theoretically support higher frequencies if a sensor with higher ODR capabilities is used. The ESP timer system and memory buffer design are capable of handling frequencies beyond 4000 Hz.

Timer Callback

static void sampling_timer_callback(void *arg)
{
    if (!s_is_running || s_adxl355_handle == NULL)
    {
        return;
    }

    adxl355_accelerations_t accel;
    float temperature;
    esp_err_t accel_ret = adxl355_read_accelerations(s_adxl355_handle, &accel);
    esp_err_t temp_ret = adxl355_read_temperature(s_adxl355_handle, &temperature);

    if (accel_ret == ESP_OK && temp_ret == ESP_OK)
    {
        uint64_t timestamp = esp_timer_get_time();

        // Store in memory buffer if enabled
        if (s_config.enable_memory && s_memory_buffer != NULL)
        {
            if (xSemaphoreTake(s_memory_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
            {
                if (s_memory_buffer_index < s_memory_buffer_size)
                {
                    s_memory_buffer[s_memory_buffer_index].x = accel.x;
                    s_memory_buffer[s_memory_buffer_index].y = accel.y;
                    s_memory_buffer[s_memory_buffer_index].z = accel.z;
                    s_memory_buffer[s_memory_buffer_index].temp = temperature;
                    s_memory_buffer[s_memory_buffer_index].timestamp_us = timestamp;
                    s_memory_buffer_index++;
                }
                xSemaphoreGive(s_memory_mutex);
            }
        }

        s_total_samples++;

        // Auto-stop when expected number of samples is reached
        // Expected samples = (freq * duration) + 1 (for t=0 sample)
        if (s_expected_samples > 0 && s_total_samples >= s_expected_samples)
        {
            s_is_running = false; // Stop further sampling
        }
    }
}

SD Card Storage

static esp_err_t save_to_sd_card(const offline_sensing_sample_t *samples, uint32_t count)
{
    // Generate file path with timestamp and parameters
    char file_path[256];
    if (s_config.sd_file_path != NULL)
    {
        snprintf(file_path, sizeof(file_path), "%s/%s", MOUNT_POINT, s_config.sd_file_path);
    }
    else
    {
        // Auto-generate filename with sampling start time, frequency, and duration
        // Format: offline_YYYYMMDD_HHMMSS_FreqXXXHz_DurXXs.csv
        struct tm timeinfo;
        bool use_relative_time = false;

        if (s_start_calendar_time != 0)
        {
            localtime_r(&s_start_calendar_time, &timeinfo);
            // Check if time is valid (not 1970-01-01, which indicates unset time)
            if (timeinfo.tm_year < 70)  // tm_year is years since 1900, so 70 means 1970
            {
                ESP_LOGW(TAG, "System time appears unset (1970), using relative timestamp");
                use_relative_time = true;
            }
        }
        else
        {
            // Fallback to current time if calendar time not set
            time_t now = time(NULL);
            localtime_r(&now, &timeinfo);
            if (timeinfo.tm_year < 70)
            {
                ESP_LOGW(TAG, "System time appears unset (1970), using relative timestamp");
                use_relative_time = true;
            }
        }

        // Format frequency and duration for filename (4 digits each, zero-padded)
        int freq_int = (int)(s_config.sampling_frequency_hz + 0.5f);
        int dur_int = (int)(s_config.sampling_duration_sec + 0.5f);

        if (use_relative_time)
        {
            // Use relative timestamp (seconds since boot) if system time is not set
            // Format: BootXXXXX_FXXXX_DXXXX.csv
            uint64_t boot_time_sec = s_start_timestamp / 1000000ULL;
            snprintf(file_path, sizeof(file_path), "%s/Boot%05llu_F%04d_D%04d.csv",
                     MOUNT_POINT,
                     (unsigned long long)boot_time_sec % 100000,  // Last 5 digits of boot time
                     freq_int,                                   // Frequency (4 digits, zero-padded)
                     dur_int);                                   // Duration (4 digits, zero-padded)
        }
        else
        {
            // Format: YYYYMMDDHHMMSS_FXXXX_DXXXX.csv
            // Includes full date/time (year, month, day, hour, minute, second)
            // and sampling parameters (frequency and duration, both 4 digits zero-padded)
            snprintf(file_path, sizeof(file_path), "%s/%04d%02d%02d%02d%02d%02d_F%04d_D%04d.csv",
                     MOUNT_POINT,
                     timeinfo.tm_year + 1900,  // YYYY (4 digits)
                     timeinfo.tm_mon + 1,       // MM (2 digits)
                     timeinfo.tm_mday,          // DD (2 digits)
                     timeinfo.tm_hour,          // HH (2 digits)
                     timeinfo.tm_min,           // MM (2 digits)
                     timeinfo.tm_sec,           // SS (2 digits)
                     freq_int,                 // Frequency (4 digits, zero-padded)
                     dur_int);                 // Duration (4 digits, zero-padded)
        }
    }

    // Open file for writing
    FILE *f = fopen(file_path, "w");
    if (f == NULL)
    {
        ESP_LOGE(TAG, "Failed to open file for writing: %s", file_path);
        ESP_LOGE(TAG, "Error: %s (errno: %d)", strerror(errno), errno);

        // Try to check if directory exists and is writable
        FILE *test_f = fopen(MOUNT_POINT "/.test_write", "w");
        if (test_f == NULL)
        {
            ESP_LOGE(TAG, "SD card directory may not be writable. Check SD card mount status.");
        }
        else
        {
            fclose(test_f);
            remove(MOUNT_POINT "/.test_write");
            ESP_LOGE(TAG, "Directory is writable, but file creation failed. Check filename length or invalid characters.");
        }

        return ESP_FAIL;
    }

    // Write CSV header
    fprintf(f, "timestamp_us,x,y,z,temp\n");

    // Write samples
    for (uint32_t i = 0; i < count; i++)
    {
        fprintf(f, "%llu,%.6f,%.6f,%.6f,%.2f\n",
                (unsigned long long)samples[i].timestamp_us,
                samples[i].x, samples[i].y, samples[i].z, samples[i].temp);
    }

    fclose(f);
    ESP_LOGI(TAG, "SD: %u samples -> %s", count, file_path);

    // Update report with file path
    strncpy(s_last_report.sd_file_path, file_path, sizeof(s_last_report.sd_file_path) - 1);
    s_last_report.sd_file_path[sizeof(s_last_report.sd_file_path) - 1] = '\0';

    return ESP_OK;
}

Initialization Function

esp_err_t offline_sensing_init(const offline_sensing_config_t *config)
{
    if (s_is_initialized)
    {
        ESP_LOGW(TAG, "Offline sensing already initialized");
        return ESP_ERR_INVALID_STATE;
    }

    // Apply configuration
    if (config != NULL)
    {
        memcpy(&s_config, config, sizeof(offline_sensing_config_t));
    }
    else
    {
        // Use default configuration
        s_config.sampling_frequency_hz = OFFLINE_SENSING_DEFAULT_FREQ_HZ;
        s_config.sampling_duration_sec = OFFLINE_SENSING_DEFAULT_DURATION_SEC;
        s_config.enable_memory = OFFLINE_SENSING_DEFAULT_ENABLE_MEMORY;
        s_config.enable_sd = OFFLINE_SENSING_DEFAULT_ENABLE_SD;
        s_config.enable_mqtt_report = OFFLINE_SENSING_DEFAULT_ENABLE_MQTT_REPORT;
        s_config.sd_file_path = NULL;
        s_config.mqtt_report_topic = NULL;
    }

    // Validate configuration
    // Note: Maximum frequency of 4000 Hz is limited by ADXL355 sensor's maximum output data rate (ODR)
    // ADXL355 supports up to 4000 Hz ODR, which is the hardware limit for this sensor
    if (s_config.sampling_frequency_hz < 1.0f || s_config.sampling_frequency_hz > 4000.0f)
    {
        ESP_LOGE(TAG, "Invalid sampling frequency: %.2f Hz (valid range: 1 - 4000 Hz, max limited by ADXL355 ODR)",
                 s_config.sampling_frequency_hz);
        return ESP_ERR_INVALID_ARG;
    }

    if (s_config.sampling_duration_sec < 0.1f || s_config.sampling_duration_sec > 3600.0f)
    {
        ESP_LOGE(TAG, "Invalid sampling duration: %.2f sec (valid range: 0.1 - 3600 sec)",
                 s_config.sampling_duration_sec);
        return ESP_ERR_INVALID_ARG;
    }

    // Allocate memory buffer if enabled
    // Use PSRAM (external RAM) to support larger buffer sizes for high-frequency/long-duration sampling
    if (s_config.enable_memory)
    {
        uint32_t buffer_size = (uint32_t)(s_config.sampling_frequency_hz * s_config.sampling_duration_sec) + 100; // Add margin
        size_t required_bytes = buffer_size * sizeof(offline_sensing_sample_t);

        // Check PSRAM availability
        size_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
        if (psram_free < required_bytes)
        {
            ESP_LOGE(TAG, "PSRAM insufficient: available %zu bytes, need %zu bytes",
                     psram_free, required_bytes);
            return ESP_ERR_NO_MEM;
        }

        // Allocate from PSRAM explicitly to utilize external memory (typically 8MB)
        s_memory_buffer = (offline_sensing_sample_t *)heap_caps_malloc(
            required_bytes,
            MALLOC_CAP_SPIRAM  // Use PSRAM for large buffer
        );
        if (s_memory_buffer == NULL)
        {
            ESP_LOGE(TAG, "Failed to allocate memory buffer from PSRAM");
            ESP_LOGE(TAG, "Available PSRAM: %zu bytes", heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
            return ESP_ERR_NO_MEM;
        }
        s_memory_buffer_size = buffer_size;
        s_memory_buffer_index = 0;
        ESP_LOGD(TAG, "Allocated memory buffer for %u samples", buffer_size);
    }

    // Create mutex for memory buffer
    s_memory_mutex = xSemaphoreCreateMutex();
    if (s_memory_mutex == NULL)
    {
        ESP_LOGE(TAG, "Failed to create mutex");
        if (s_memory_buffer != NULL)
        {
            heap_caps_free(s_memory_buffer);
            s_memory_buffer = NULL;
        }
        return ESP_ERR_NO_MEM;
    }

    // Initialize report
    memset(&s_last_report, 0, sizeof(offline_sensing_report_t));
    s_last_report.sd_file_path[0] = '\0';

    s_is_initialized = true;

    return ESP_OK;
}

MQTT Report Function

static esp_err_t send_mqtt_report(const offline_sensing_report_t *report)
{
    if (report == NULL)
    {
        return ESP_ERR_INVALID_ARG;
    }

    if (!s_is_mqtt_connected || s_mqtt_client == NULL)
    {
        ESP_LOGW(TAG, "MQTT not connected, skipping report");
        return ESP_ERR_INVALID_STATE;
    }

    // Format JSON report
    char report_buff[512];
    snprintf(report_buff, sizeof(report_buff),
             "{\"samples\":%" PRIu32 ",\"freq_hz\":%.2f,\"duration_sec\":%.2f,"
             "\"memory_ok\":%s,\"sd_ok\":%s,\"sd_path\":\"%s\","
             "\"start_us\":%" PRIu64 ",\"end_us\":%" PRIu64 "}",
             report->total_samples,
             report->actual_frequency_hz,
             report->duration_sec,
             report->memory_storage_success ? "true" : "false",
             report->sd_storage_success ? "true" : "false",
             report->sd_file_path[0] != '\0' ? report->sd_file_path : "",
             report->start_timestamp_us,
             report->end_timestamp_us);

    const char *topic = s_config.mqtt_report_topic ? s_config.mqtt_report_topic : "/offline_sensing/report";
    int mqtt_ret = esp_mqtt_client_publish(s_mqtt_client, topic, report_buff, strlen(report_buff), 1, 0);

    if (mqtt_ret < 0)
    {
        ESP_LOGE(TAG, "MQTT publish failed: %d", mqtt_ret);
        return ESP_FAIL;
    }

    ESP_LOGD(TAG, "MQTT report sent");
    return ESP_OK;
}

Initialization Function

esp_err_t offline_sensing_start(void)
{
    if (!s_is_initialized)
    {
        ESP_LOGE(TAG, "Offline sensing not initialized");
        return ESP_ERR_INVALID_STATE;
    }

    if (s_is_running)
    {
        ESP_LOGW(TAG, "Offline sensing already running");
        return ESP_ERR_INVALID_STATE;
    }

    if (s_adxl355_handle == NULL)
    {
        ESP_LOGE(TAG, "Sensor handle not set");
        return ESP_ERR_INVALID_STATE;
    }

    // Reset state
    s_total_samples = 0;
    s_memory_buffer_index = 0;
    // Calculate expected number of samples: (freq * duration) + 1 (for t=0 sample)
    s_expected_samples = (uint32_t)(s_config.sampling_frequency_hz * s_config.sampling_duration_sec) + 1;
    s_start_timestamp = esp_timer_get_time();  // Microseconds since boot
    s_start_calendar_time = time(NULL);        // Calendar time for filename generation

    // Calculate timer period in microseconds
    uint64_t period_us = (uint64_t)(1000000.0f / s_config.sampling_frequency_hz);

    // Create timer
    esp_timer_create_args_t timer_args = {
        .callback = sampling_timer_callback,
        .arg = NULL,
        .name = "offline_sampling_timer"
    };

    esp_err_t ret = esp_timer_create(&timer_args, &s_sampling_timer);
    if (ret != ESP_OK)
    {
        ESP_LOGE(TAG, "Failed to create timer: %s", esp_err_to_name(ret));
        return ret;
    }

    // Set running flag before starting timer (so callback can execute)
    s_is_running = true;

    // Perform immediate first sample (at t=0) before starting periodic timer
    // This ensures we get exactly N samples for N seconds at N Hz
    sampling_timer_callback(NULL);

    // Start periodic timer (first periodic sample will be at t=period_us)
    ret = esp_timer_start_periodic(s_sampling_timer, period_us);
    if (ret != ESP_OK)
    {
        ESP_LOGE(TAG, "Failed to start timer: %s", esp_err_to_name(ret));
        esp_timer_delete(s_sampling_timer);
        s_sampling_timer = NULL;
        s_is_running = false;
        return ret;
    }

    ESP_LOGI(TAG, "Offline sensing started: %.2f Hz for %.2f sec (expected samples: %u)", 
             s_config.sampling_frequency_hz, s_config.sampling_duration_sec, s_expected_samples);

    // Wait for sampling duration
    // The timer callback will auto-stop when expected_samples is reached
    uint32_t duration_ms = (uint32_t)(s_config.sampling_duration_sec * 1000.0f);
    // Add small margin to ensure all samples are collected
    vTaskDelay(pdMS_TO_TICKS(duration_ms + 100));

    // Ensure sampling is stopped
    s_is_running = false;

    // Small delay to ensure any in-flight callback completes
    vTaskDelay(pdMS_TO_TICKS(10));

    // Stop sampling
    offline_sensing_stop();

    // Process and save data
    s_end_timestamp = esp_timer_get_time();
    float actual_duration = (s_end_timestamp - s_start_timestamp) / 1000000.0f;
    float actual_frequency = s_total_samples / actual_duration;

    // Prepare report
    s_last_report.total_samples = s_total_samples;
    s_last_report.actual_frequency_hz = actual_frequency;
    s_last_report.duration_sec = actual_duration;
    s_last_report.start_timestamp_us = s_start_timestamp;
    s_last_report.end_timestamp_us = s_end_timestamp;

    // Save to SD card if enabled
    if (s_config.enable_sd && s_total_samples > 0)
    {
        if (s_config.enable_memory && s_memory_buffer != NULL)
        {
            s_last_report.sd_storage_success = (save_to_sd_card(s_memory_buffer, s_memory_buffer_index) == ESP_OK);
        }
        else
        {
            // Would need to re-read from sensor or use a different approach
            ESP_LOGW(TAG, "SD card storage requires memory buffer");
            s_last_report.sd_storage_success = false;
        }
    }
    else
    {
        s_last_report.sd_storage_success = false;
    }

    s_last_report.memory_storage_success = (s_config.enable_memory && s_memory_buffer != NULL);

    // Send MQTT report if enabled
    if (s_config.enable_mqtt_report)
    {
        send_mqtt_report(&s_last_report);
    }

    ESP_LOGI(TAG, "Completed: %u samples, %.2f Hz, %.2f sec", 
             s_total_samples, s_config.sampling_frequency_hz, s_config.sampling_duration_sec);

    return ESP_OK;
}

Stop Function

esp_err_t offline_sensing_stop(void)
{
    if (!s_is_running)
    {
        return ESP_OK;
    }

    if (s_sampling_timer != NULL)
    {
        esp_timer_stop(s_sampling_timer);
        esp_timer_delete(s_sampling_timer);
        s_sampling_timer = NULL;
    }

    s_is_running = false;
    return ESP_OK;
}

Deinitialization Function

esp_err_t offline_sensing_deinit(void)
{
    if (!s_is_initialized)
    {
        return ESP_OK;
    }

    // Stop if running
    offline_sensing_stop();

    // Free memory buffer
    if (s_memory_buffer != NULL)
    {
        heap_caps_free(s_memory_buffer);
        s_memory_buffer = NULL;
    }

    // Delete mutex
    if (s_memory_mutex != NULL)
    {
        vSemaphoreDelete(s_memory_mutex);
        s_memory_mutex = NULL;
    }

    // Clear report file path
    s_last_report.sd_file_path[0] = '\0';

    s_is_initialized = false;
    return ESP_OK;
}

Get Memory Data Function

esp_err_t offline_sensing_get_memory_data(offline_sensing_sample_t *samples, uint32_t max_samples, uint32_t *actual_samples)
{
    if (samples == NULL || actual_samples == NULL)
    {
        return ESP_ERR_INVALID_ARG;
    }

    if (!s_config.enable_memory || s_memory_buffer == NULL)
    {
        *actual_samples = 0;
        return ESP_ERR_INVALID_STATE;
    }

    if (xSemaphoreTake(s_memory_mutex, pdMS_TO_TICKS(100)) == pdTRUE)
    {
        uint32_t copy_count = (s_memory_buffer_index < max_samples) ? s_memory_buffer_index : max_samples;
        memcpy(samples, s_memory_buffer, copy_count * sizeof(offline_sensing_sample_t));
        *actual_samples = copy_count;
        xSemaphoreGive(s_memory_mutex);
        return ESP_OK;
    }

    return ESP_ERR_TIMEOUT;
}

Clear Memory Function

esp_err_t offline_sensing_clear_memory(void)
{
    if (s_memory_buffer == NULL)
    {
        return ESP_ERR_INVALID_STATE;
    }

    if (xSemaphoreTake(s_memory_mutex, pdMS_TO_TICKS(100)) == pdTRUE)
    {
        s_memory_buffer_index = 0;
        xSemaphoreGive(s_memory_mutex);
        return ESP_OK;
    }

    return ESP_ERR_TIMEOUT;
}

Is Running Function

esp_err_t offline_sensing_is_running(bool *is_running)
{
    if (is_running == NULL)
    {
        return ESP_ERR_INVALID_ARG;
    }
    *is_running = s_is_running;
    return ESP_OK;
}

Get Report Function

esp_err_t offline_sensing_get_report(offline_sensing_report_t *report)
{
    if (report == NULL)
    {
        return ESP_ERR_INVALID_ARG;
    }
    memcpy(report, &s_last_report, sizeof(offline_sensing_report_t));
    return ESP_OK;
}

USAGE EXAMPLE

#include "offline_sensing.h"
#include "node_acc_adxl355.h"

// Initialize sensor
adxl355_handle_t adxl355_handle;
adxl355_init(&adxl355_handle, ADXL355_RANGE_2G, ADXL355_ODR_1000);

// Set sensor handle
offline_sensing_set_sensor_handle(&adxl355_handle);

// Configure offline sensing
offline_sensing_config_t config = {
    .sampling_frequency_hz = 100.0f,
    .sampling_duration_sec = 10.0f,
    .enable_memory = true,
    .enable_sd = true,
    .enable_mqtt_report = true,
    .sd_file_path = NULL,  // Auto-generate filename
    .mqtt_report_topic = NULL  // Use default topic
};

// Initialize
offline_sensing_init(&config);

// Start sensing (blocks until complete)
offline_sensing_start();

// Get report
offline_sensing_report_t report;
offline_sensing_get_report(&report);
ESP_LOGI(TAG, "Samples: %u, Frequency: %.2f Hz, Duration: %.2f sec",
         report.total_samples, report.actual_frequency_hz, report.duration_sec);
ESP_LOGI(TAG, "SD file: %s", report.sd_file_path);

// Get memory data (optional)
offline_sensing_sample_t samples[1000];
uint32_t actual_samples;
offline_sensing_get_memory_data(samples, 1000, &actual_samples);
ESP_LOGI(TAG, "Retrieved %u samples from memory", actual_samples);

// Clear memory buffer (optional)
offline_sensing_clear_memory();

// Check if running (optional)
bool is_running;
offline_sensing_is_running(&is_running);

// Cleanup
offline_sensing_deinit();