增加断网保存数据到FLASH,恢复联网重新补发,并加上标志位

This commit is contained in:
Wang Beihong
2026-02-02 00:31:52 +08:00
parent 86b2425e93
commit d56c730cfe
19 changed files with 1853 additions and 52 deletions

View File

@@ -4,6 +4,12 @@
本设备是一个分布式采集网关,支持通过 MQTT 远程控制 MODBUS RTU 轮询参数,并定期上报设备状态信息。
**新增功能**
-**离线数据存储**网络断开时自动存储数据到Flash
-**自动补传**网络恢复后自动补传离线数据到MQTT服务器
-**SPIFFS文件系统**ESP-IDF原生支持稳定可靠
-**8MB存储容量**可存储约11小时离线数据注意SPIFFS最小文件4KB
## SNTP 时间同步
设备内置 SNTPSimple Network Time Protocol时间同步功能在网络连接成功后会自动同步系统时间。
@@ -393,6 +399,89 @@ mqtt_start_device_status_task(10000); // 10000ms = 10秒
mqtt_update_report_interval(5000); // 5秒
```
## 离线数据存储
### 功能特性
设备支持离线数据存储功能,确保网络断开时数据不丢失:
- **自动检测**实时监测网络状态通过MQTT连接状态
- **离线存储**网络断开时自动存储数据到Flash
- **自动补传**网络恢复后自动补传离线数据到MQTT服务器
- **LittleFS文件系统**:提供断电保护和磨损均衡
- **8MB存储容量**可存储约11小时离线数据
### 存储容量
| 数据类型 | 数据量 | 可存储时长 |
|---------|--------|----------|
| MODBUS数据 | 200字节/条1秒/次) | 约11小时注意SPIFFS最小文件4KB |
### 工作流程
#### 正常模式(网络在线)
```
数据产生 → 直接发布到MQTT → 不存储
```
#### 离线模式(网络断开)
```
数据产生 → 存储到Flash SPIFFS → 等待网络恢复
```
#### 补传模式(网络恢复)
```
检测网络恢复 → 读取最旧数据 → 发布到MQTT → 删除已传数据 → 继续下一条
```
### 存储结构
```
/flash/
├── data/
│ ├── modbus/ # MODBUS采集数据
│ │ ├── 1738452345.json # 时间戳命名
│ │ └── ...
│ └── status/ # 设备状态数据
│ └── ...
└── index.json # 索引文件
```
### 日志示例
#### 存储离线数据
```
I (xxxx) mqtt_esp: Network is offline, stopping offline data upload
I (xxxx) mqtt_esp: Storing offline data (type=1, size=256)
I (xxxx) OFFLINE_STORAGE: Storage usage: 512 / 8388608 bytes (0.0%)
```
#### 补传离线数据
```
I (xxxx) mqtt_esp: Network is online, starting offline data upload
I (xxxx) mqtt_esp: Found 10 offline data files, uploading...
I (xxxx) mqtt_esp: Publishing offline data (type=1, size=256)
I (xxxx) mqtt_esp: Offline data published successfully, msg_id=12345
```
### 存储策略
- **自动循环覆盖**最多存储10,000个文件超出自动删除最旧数据
- **时间戳排序**:按时间戳顺序补传,确保数据时间顺序
- **原子写入**:使用临时文件+重命名机制
- **断电保护**SPIFFS提供基本的断电保护
### 注意事项
1. **首次启动**会自动初始化并挂载SPIFFS文件系统
2. **空间管理**:存储满后自动循环覆盖最旧数据
3. **补传速率**:每秒处理一条数据,避免阻塞新数据上传
4. **Flash寿命**SPIFFS无磨损均衡建议定期格式化
5. **最小文件限制**SPIFFS最小文件4KB小文件会浪费空间
6. **格式化**:如需清空所有数据,可调用 `flash_spiffs_format()`
详细文档请参考:[离线数据存储和补传功能说明](docs/OFFLINE_STORAGE.md)
## 注意事项
1. **轮询间隔最小为 100ms**,设置更小的值会被拒绝
@@ -400,6 +489,7 @@ mqtt_update_report_interval(5000); // 5秒
3. **从机地址范围 1-247**0 为广播地址
4. **通道号只能是 0 或 1**
5. 首次发送指令后会自动启动轮询任务
6. **离线存储容量约11小时**,超出会循环覆盖最旧数据
## 使用 mosquitto_cli 测试

View File

@@ -0,0 +1,5 @@
idf_component_register(
SRCS "FLASH_SPIFS.c"
INCLUDE_DIRS "include"
REQUIRES spiffs
)

View File

@@ -0,0 +1,128 @@
#include "FLASH_SPIFS.h"
#include "esp_spiffs.h"
#include "esp_log.h"
#define TAG "FLASH_SPIFS"
#define MOUNT_POINT "/flash"
static bool is_mounted = false;
esp_err_t flash_spiffs_init(void)
{
ESP_LOGI(TAG, "正在初始化SPIFFS文件系统...");
esp_vfs_spiffs_conf_t conf = {
.base_path = MOUNT_POINT,
.partition_label = "storage",
.max_files = 20,
.format_if_mount_failed = true
};
esp_err_t ret = esp_vfs_spiffs_register(&conf);
if (ret != ESP_OK) {
if (ret == ESP_FAIL) {
ESP_LOGE(TAG, "无法挂载或格式化文件系统");
} else if (ret == ESP_ERR_NOT_FOUND) {
ESP_LOGE(TAG, "找不到分区 'storage'");
} else {
ESP_LOGE(TAG, "初始化失败 (%s)", esp_err_to_name(ret));
}
return ret;
}
size_t total = 0, used = 0;
ret = esp_spiffs_info(conf.partition_label, &total, &used);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "获取文件系统信息失败: %s", esp_err_to_name(ret));
esp_vfs_spiffs_unregister(conf.partition_label);
return ret;
}
is_mounted = true;
ESP_LOGI(TAG, "SPIFFS文件系统挂载成功");
ESP_LOGI(TAG, "总空间: %u 字节", total);
ESP_LOGI(TAG, "已用空间: %u 字节", used);
ESP_LOGI(TAG, "可用空间: %u 字节", total - used);
ESP_LOGI(TAG, "挂载点: %s", MOUNT_POINT);
// 注意: 文件系统检查 (esp_spiffs_check) 在初始化阶段耗时过长,可能触发看门狗
// 如果需要检查文件系统完整性,可以在单独的任务中调用 flash_spiffs_format() 格式化
return ESP_OK;
}
bool flash_spiffs_is_mounted(void)
{
return is_mounted;
}
uint32_t flash_spiffs_get_total_size(void)
{
if (!is_mounted) {
return 0;
}
size_t total = 0, used = 0;
if (esp_spiffs_info("storage", &total, &used) == ESP_OK) {
return total;
}
return 0;
}
uint32_t flash_spiffs_get_used_size(void)
{
if (!is_mounted) {
return 0;
}
size_t total = 0, used = 0;
if (esp_spiffs_info("storage", &total, &used) == ESP_OK) {
return used;
}
return 0;
}
esp_err_t flash_spiffs_format(void)
{
if (!is_mounted) {
ESP_LOGE(TAG, "文件系统未挂载");
return ESP_FAIL;
}
ESP_LOGW(TAG, "正在格式化文件系统,所有数据将被删除...");
// 先卸载
esp_vfs_spiffs_unregister("storage");
is_mounted = false;
// 重新格式化并挂载
esp_vfs_spiffs_conf_t conf = {
.base_path = MOUNT_POINT,
.partition_label = "storage",
.max_files = 20,
.format_if_mount_failed = true
};
esp_err_t ret = esp_vfs_spiffs_register(&conf);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "格式化失败: %s", esp_err_to_name(ret));
return ret;
}
is_mounted = true;
// 重新获取信息
size_t total = 0, used = 0;
esp_spiffs_info("storage", &total, &used);
ESP_LOGI(TAG, "文件系统格式化完成");
ESP_LOGI(TAG, "总空间: %u 字节", total);
ESP_LOGI(TAG, "已用空间: %u 字节", used);
return ESP_OK;
}
const char* flash_spiffs_get_mount_point(void)
{
return MOUNT_POINT;
}

View File

@@ -0,0 +1,52 @@
#ifndef FLASH_SPIFS_H
#define FLASH_SPIFS_H
#include <stdbool.h>
#include "esp_err.h"
/**
* @brief 初始化SPIFFS文件系统
*
* @return ESP_OK 成功
* 其他 失败
*/
esp_err_t flash_spiffs_init(void);
/**
* @brief 检查文件系统是否已挂载
*
* @return true 已挂载
* false 未挂载
*/
bool flash_spiffs_is_mounted(void);
/**
* @brief 获取文件系统总大小
*
* @return 总大小(字节)
*/
uint32_t flash_spiffs_get_total_size(void);
/**
* @brief 获取文件系统已使用大小
*
* @return 已使用大小(字节)
*/
uint32_t flash_spiffs_get_used_size(void);
/**
* @brief 格式化文件系统(清空所有数据)
*
* @return ESP_OK 成功
* 其他 失败
*/
esp_err_t flash_spiffs_format(void);
/**
* @brief 获取文件系统挂载路径
*
* @return 挂载路径字符串(如 "/flash"
*/
const char* flash_spiffs_get_mount_point(void);
#endif // FLASH_SPIFS_H

View File

@@ -1,3 +1,3 @@
idf_component_register(SRCS "MQTT_ESP.c"
PRIV_REQUIRES mqtt log STATUS_LED MODBUS_ESP SNTP_ESP json esp_wifi esp_system
PRIV_REQUIRES mqtt log STATUS_LED MODBUS_ESP SNTP_ESP OFFLINE_STORAGE json esp_wifi esp_system
INCLUDE_DIRS "include")

View File

@@ -3,6 +3,7 @@
#include "STATUS_LED.h"
#include "MODBUS_ESP.h"
#include "SNTP_ESP.h"
#include "OFFLINE_STORAGE.h"
#include "cJSON.h"
#include "esp_wifi.h"
#include "esp_mac.h"
@@ -23,6 +24,16 @@ static esp_mqtt_client_handle_t g_client = NULL;
static TaskHandle_t device_status_task_handle = NULL;
static uint32_t g_report_interval_ms = 10000; // 默认10秒上报一次
static SemaphoreHandle_t report_interval_mutex = NULL;
static bool g_device_status_task_started = false; // 设备状态任务是否已启动
static bool g_device_status_task_auto_start = false; // 是否在MQTT连接后自动启动
// ============================
// 离线数据补传任务相关
// ============================
static TaskHandle_t offline_upload_task_handle = NULL;
static bool g_is_online = false; // 网络在线状态
static char offline_data_buffer[2048]; // 静态缓冲区,避免栈溢出
/**
* @brief 获取设备MAC地址字符串
@@ -194,7 +205,7 @@ static char* build_device_status_json(void)
*/
static void device_status_report_task(void *arg)
{
ESP_LOGI(TAG, "Device status report task started");
ESP_LOGI(TAG, "设备状态上报任务已启动");
while (1) {
// 检查是否应该退出
@@ -202,22 +213,32 @@ static void device_status_report_task(void *arg)
break;
}
// 检查MQTT是否已连接
if (g_client != NULL) {
// 构建设备状态JSON
char *status_json = build_device_status_json();
if (status_json != NULL) {
// 构建设备状态JSON
char *status_json = build_device_status_json();
if (status_json != NULL) {
// 如果网络在线直接发布如果离线存储到本地Flash
if (g_is_online && g_client != NULL) {
// 发布到MQTT
int ret = esp_mqtt_client_publish(g_client, CONFIG_MQTT_PUB_TOPIC,
status_json, strlen(status_json), 0, 0);
if (ret >= 0) {
ESP_LOGI(TAG, "Device status published, msg_id=%d", ret);
ESP_LOGD(TAG, "Status: %s", status_json);
ESP_LOGI(TAG, "设备状态已发布消息ID=%d", ret);
ESP_LOGD(TAG, "状态: %s", status_json);
} else {
ESP_LOGW(TAG, "Failed to publish device status");
ESP_LOGW(TAG, "发布设备状态失败");
}
} else {
// 网络离线存储到本地Flash
ESP_LOGW(TAG, "网络离线,设备状态存储到本地");
esp_err_t ret = mqtt_store_offline(status_json, strlen(status_json),
OFFLINE_DATA_TYPE_DEVICE_STATUS);
if (ret == ESP_OK) {
ESP_LOGI(TAG, "设备状态已成功存储到离线存储");
} else {
ESP_LOGE(TAG, "存储设备状态到离线存储失败");
}
free(status_json);
}
free(status_json);
}
// 获取上报间隔
@@ -230,13 +251,35 @@ static void device_status_report_task(void *arg)
vTaskDelay(pdMS_TO_TICKS(interval));
}
ESP_LOGI(TAG, "Device status report task exiting");
ESP_LOGI(TAG, "设备状态上报任务已退出");
device_status_task_handle = NULL;
vTaskDelete(NULL);
}
BaseType_t mqtt_start_device_status_task(uint32_t report_interval_ms)
{
// 如果网络不在线只保存配置延迟到MQTT连接后启动
if (!g_is_online) {
// 创建互斥量
if (report_interval_mutex == NULL) {
report_interval_mutex = xSemaphoreCreateMutex();
if (report_interval_mutex == NULL) {
ESP_LOGE(TAG, "创建报告间隔互斥锁失败");
return pdFALSE;
}
}
// 保存配置
xSemaphoreTake(report_interval_mutex, portMAX_DELAY);
g_report_interval_ms = report_interval_ms > 0 ? report_interval_ms : 10000;
xSemaphoreGive(report_interval_mutex);
// 标记为自动启动
g_device_status_task_auto_start = true;
ESP_LOGI(TAG, "设备状态任务将在MQTT连接后启动 (间隔=%dms)", g_report_interval_ms);
return pdPASS;
}
// 如果任务已存在,先停止
if (device_status_task_handle != NULL) {
mqtt_stop_device_status_task();
@@ -247,7 +290,7 @@ BaseType_t mqtt_start_device_status_task(uint32_t report_interval_ms)
if (report_interval_mutex == NULL) {
report_interval_mutex = xSemaphoreCreateMutex();
if (report_interval_mutex == NULL) {
ESP_LOGE(TAG, "Failed to create report interval mutex");
ESP_LOGE(TAG, "创建报告间隔互斥锁失败");
return pdFALSE;
}
}
@@ -259,12 +302,13 @@ BaseType_t mqtt_start_device_status_task(uint32_t report_interval_ms)
// 创建任务
BaseType_t ret = xTaskCreate(device_status_report_task, "dev_status",
4096, NULL, 5, &device_status_task_handle);
8192, NULL, 5, &device_status_task_handle);
if (ret == pdPASS) {
ESP_LOGI(TAG, "Device status report task started (interval=%dms)", g_report_interval_ms);
g_device_status_task_started = true;
ESP_LOGI(TAG, "设备状态报告任务已创建 (间隔=%dms)", g_report_interval_ms);
} else {
ESP_LOGE(TAG, "Failed to create device status report task");
ESP_LOGE(TAG, "创建设备状态报告任务失败");
}
return ret;
@@ -273,14 +317,14 @@ BaseType_t mqtt_start_device_status_task(uint32_t report_interval_ms)
void mqtt_stop_device_status_task(void)
{
if (device_status_task_handle != NULL) {
ESP_LOGI(TAG, "Stopping device status report task...");
ESP_LOGI(TAG, "正在停止设备状态报告任务...");
TaskHandle_t temp_handle = device_status_task_handle;
device_status_task_handle = NULL;
vTaskDelete(temp_handle);
vTaskDelay(pdMS_TO_TICKS(200));
ESP_LOGI(TAG, "Device status report task stopped");
ESP_LOGI(TAG, "设备状态报告任务已停止");
}
}
@@ -313,15 +357,40 @@ static void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_
int msg_id;
switch ((esp_mqtt_event_id_t)event_id) {
case MQTT_EVENT_CONNECTED:
ESP_LOGI(TAG, "MQTT_EVENT_CONNECTED");
ESP_LOGI(TAG, "MQTT_EVENT_CONNECTED - MQTT已连接");
// 订阅并取消订阅测试主题
msg_id = esp_mqtt_client_subscribe(client, CONFIG_MQTT_SUB_TOPIC, 1);
ESP_LOGI(TAG, "sent subscribe successful, msg_id=%d", msg_id);
status_led_blink_mode(2, 2); // LED2 心跳MQTT连接正常
ESP_LOGI(TAG, "订阅发送成功, 消息ID=%d", msg_id);
status_led_blink_mode(2, 2); // LED2 心跳MQTT连接正常
// 网络在线,启动离线数据补传任务
g_is_online = true;
ESP_LOGI(TAG, "网络在线,开始离线数据上传");
// 启动设备状态上报任务(如果配置了自动启动)
if (g_device_status_task_auto_start && !g_device_status_task_started) {
if (mqtt_start_device_status_task(g_report_interval_ms) == pdPASS) {
g_device_status_task_started = true;
g_device_status_task_auto_start = false;
ESP_LOGI(TAG, "MQTT连接时自动启动设备状态任务");
}
}
break;
case MQTT_EVENT_DISCONNECTED:
ESP_LOGI(TAG, "MQTT_EVENT_DISCONNECTED");
ESP_LOGI(TAG, "MQTT_EVENT_DISCONNECTED - MQTT已断开连接");
status_led_blink_mode(2, 1); // LED2 快闪MQTT断开正在重连
// 网络离线,停止离线数据补传任务
g_is_online = false;
ESP_LOGI(TAG, "网络离线,停止离线数据上传");
// 停止设备状态上报任务(避免存储新数据)
if (g_device_status_task_started) {
mqtt_stop_device_status_task();
g_device_status_task_started = false;
g_device_status_task_auto_start = true; // 重连后自动启动
ESP_LOGI(TAG, "设备状态任务已停止,将在重连时自动重启");
}
break;
case MQTT_EVENT_SUBSCRIBED:
@@ -388,12 +457,12 @@ status_led_blink_mode(2, 2); // LED2 心跳MQTT连接正常
.enabled = (enabled != NULL && cJSON_IsBool(enabled)) ? cJSON_IsTrue(enabled) : true
};
// 更新轮询配置
if (modbus_update_poll_config(&poll_config)) {
ESP_LOGI(TAG, "MODBUS poll config updated via MQTT");
} else {
ESP_LOGE(TAG, "Failed to update MODBUS poll config");
}
// 更新轮询配置
if (modbus_update_poll_config(&poll_config)) {
ESP_LOGI(TAG, "通过MQTT更新MODBUS轮询配置成功");
} else {
ESP_LOGE(TAG, "通过MQTT更新MODBUS轮询配置失败");
}
} else {
ESP_LOGE(TAG, "Missing required fields in MODBUS poll command");
}
@@ -476,12 +545,243 @@ int mqtt_publish_message(const char* topic, const char* data, int len, int qos,
return -1;
}
// 如果网络离线存储数据到本地Flash
if (!g_is_online) {
ESP_LOGW(TAG, "网络离线,数据存储到本地 (主题: %s, 长度: %d)", topic, len);
// 根据主题判断数据类型
// 设备状态数据主题通常包含 "status" 或特定的设备状态主题
// 传感器数据使用默认的发布主题,不包含特定状态关键字
offline_data_type_t data_type = OFFLINE_DATA_TYPE_MODBUS;
// 只有当主题明确包含"status"关键字时才认为是设备状态数据
// 避免将包含"device"的传感器数据主题误判为设备状态
if (strstr(topic, "status") != NULL) {
data_type = OFFLINE_DATA_TYPE_DEVICE_STATUS;
}
// 存储离线数据
esp_err_t ret = mqtt_store_offline(data, len, data_type);
if (ret == ESP_OK) {
ESP_LOGI(TAG, "Data stored to offline storage successfully");
return 0; // 返回0表示已存储模拟成功
} else {
ESP_LOGE(TAG, "Failed to store data to offline storage");
return -1;
}
}
// 网络在线,正常发布
int msg_id = esp_mqtt_client_publish(g_client, topic, data, len, qos, retain);
if (msg_id < 0) {
ESP_LOGE(TAG, "Failed to publish message to topic: %s", topic);
return -1;
}
ESP_LOGI(TAG, "Published message to topic: %s, msg_id: %d", topic, msg_id);
return msg_id;
}
// ============================
// 离线数据补传任务
// ============================
/**
* @brief 检查并发布离线数据(网络在线时)
*
* @return ESP_OK 成功处理(包括没有数据的情况)
* ESP_FAIL 处理失败
*/
static esp_err_t publish_offline_data(void)
{
if (!offline_storage_has_data()) {
return ESP_OK;
}
// 使用静态缓冲区,避免栈溢出
offline_data_type_t data_type;
// 读取最旧的离线数据
esp_err_t ret = offline_storage_read_oldest(offline_data_buffer, sizeof(offline_data_buffer), &data_type);
if (ret != ESP_OK) {
if (ret == ESP_ERR_NOT_FOUND) {
return ESP_OK; // 没有数据,不是错误
}
ESP_LOGE(TAG, "Failed to read offline data: %s", esp_err_to_name(ret));
// 读取失败,删除可能损坏的数据记录,避免死循环
ESP_LOGW(TAG, "Deleting potentially corrupted data record");
offline_storage_delete_oldest();
return ret;
}
// 优先上传传感器数据,设备状态数据丢弃
if (data_type == OFFLINE_DATA_TYPE_DEVICE_STATUS) {
ESP_LOGW(TAG, "Discarding device status offline data (priority: sensor data only)");
offline_storage_delete_oldest();
return ESP_OK; // 返回成功,继续处理下一条
}
ESP_LOGI(TAG, "Publishing offline data (type=%d, size=%zu)", data_type, strlen(offline_data_buffer));
// 为补发数据添加标识is_retrospective = true
// 解析JSON添加字段后重新序列化
cJSON *root = cJSON_Parse(offline_data_buffer);
if (root != NULL) {
// 添加补发标识
cJSON_AddBoolToObject(root, "is_retrospective", true);
// 重新序列化为字符串
char *json_with_flag = cJSON_PrintUnformatted(root);
if (json_with_flag != NULL) {
ESP_LOGI(TAG, "Publishing offline data with retrospective flag: %s", json_with_flag);
// 使用新字符串发布
int msg_id = esp_mqtt_client_publish(g_client, CONFIG_MQTT_PUB_TOPIC,
json_with_flag, strlen(json_with_flag), 0, 0);
free(json_with_flag);
cJSON_Delete(root);
if (msg_id >= 0) {
ESP_LOGI(TAG, "Offline data with retrospective flag published successfully, msg_id=%d", msg_id);
// 发布成功,删除已上传的数据
ret = offline_storage_delete_oldest();
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Failed to delete published offline data");
}
return ESP_OK;
} else {
ESP_LOGE(TAG, "Failed to publish offline data with retrospective flag");
}
} else {
ESP_LOGE(TAG, "Failed to re-serialize JSON with retrospective flag");
cJSON_Delete(root);
}
} else {
ESP_LOGW(TAG, "Failed to parse JSON for adding retrospective flag, publishing raw data: %s", offline_data_buffer);
// 如果JSON解析失败直接发布原始数据不添加标识
int msg_id = esp_mqtt_client_publish(g_client, CONFIG_MQTT_PUB_TOPIC,
offline_data_buffer, strlen(offline_data_buffer), 0, 0);
if (msg_id >= 0) {
ESP_LOGI(TAG, "Raw offline data published successfully, msg_id=%d", msg_id);
// 发布成功,删除已上传的数据
ret = offline_storage_delete_oldest();
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Failed to delete published offline data");
}
return ESP_OK;
} else {
ESP_LOGE(TAG, "Failed to publish raw offline data");
}
}
// 发布失败也删除数据,避免重复尝试
ESP_LOGW(TAG, "Deleting failed data to avoid retry loop");
offline_storage_delete_oldest();
return ESP_FAIL;
}
/**
* @brief 存储离线数据(网络离线时)
*
* @param data 数据内容
* @param length 数据长度
* @param data_type 数据类型
* @return ESP_OK 成功
* ESP_FAIL 失败
*/
esp_err_t mqtt_store_offline(const char *data, size_t length, offline_data_type_t data_type)
{
if (g_is_online) {
ESP_LOGW(TAG, "Network is online, storing data not needed");
return ESP_OK; // 网络在线,不需要存储
}
ESP_LOGI(TAG, "Storing offline data (type=%d, size=%zu)", data_type, length);
esp_err_t ret = offline_storage_store(data, length, data_type);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to store offline data: %s", esp_err_to_name(ret));
return ESP_FAIL;
}
// 获取存储使用情况
size_t used = 0, total = 0;
if (offline_storage_get_usage(&used, &total) == ESP_OK) {
ESP_LOGI(TAG, "Storage usage: %zu / %zu bytes (%.1f%%)",
used, total, (used * 100.0) / total);
}
return ESP_OK;
}
/**
* @brief 离线数据补传任务
*
* 当网络在线时,持续检查并上传离线存储的数据
*/
static void offline_upload_task(void *pvParameters)
{
ESP_LOGI(TAG, "Offline data upload task started");
while (true) {
if (g_is_online && g_client != NULL) {
// 检查并上传离线数据
if (offline_storage_has_data()) {
ESP_LOGI(TAG, "Found %u offline data files, uploading...",
offline_storage_get_count());
// 尝试发布数据,每次处理一条
publish_offline_data();
}
}
// 每100毫秒检查一次提高上传速率
vTaskDelay(pdMS_TO_TICKS(100));
}
}
/**
* @brief 启动离线数据补传任务
*
* @return pdTRUE 成功, pdFALSE 失败
*/
BaseType_t mqtt_start_offline_upload_task(void)
{
if (offline_upload_task_handle != NULL) {
ESP_LOGW(TAG, "Offline upload task already running");
return pdTRUE;
}
BaseType_t ret = xTaskCreate(offline_upload_task, "offline_upload",
8192, NULL, 4, &offline_upload_task_handle);
if (ret == pdPASS) {
ESP_LOGI(TAG, "Offline data upload task started");
} else {
ESP_LOGE(TAG, "Failed to create offline data upload task");
}
return ret;
}
/**
* @brief 停止离线数据补传任务
*/
void mqtt_stop_offline_upload_task(void)
{
if (offline_upload_task_handle != NULL) {
ESP_LOGI(TAG, "Stopping offline data upload task...");
TaskHandle_t temp_handle = offline_upload_task_handle;
offline_upload_task_handle = NULL;
vTaskDelete(temp_handle);
vTaskDelay(pdMS_TO_TICKS(200));
ESP_LOGI(TAG, "Offline data upload task stopped");
}
}

View File

@@ -1,5 +1,6 @@
#include "mqtt_client.h"
#include "esp_log.h"
#include "OFFLINE_STORAGE.h"
void mqtt_app_start(void);
int mqtt_publish_message(const char* topic, const char* data, int len, int qos, int retain);
@@ -22,4 +23,27 @@ void mqtt_stop_device_status_task(void);
*
* @param report_interval_ms 新的上报间隔(毫秒)
*/
void mqtt_update_report_interval(uint32_t report_interval_ms);
void mqtt_update_report_interval(uint32_t report_interval_ms);
/**
* @brief 存储离线数据(网络离线时使用)
*
* @param data 数据内容
* @param length 数据长度
* @param data_type 数据类型
* @return ESP_OK 成功
* ESP_FAIL 失败
*/
esp_err_t mqtt_store_offline(const char *data, size_t length, offline_data_type_t data_type);
/**
* @brief 启动离线数据补传任务
*
* @return pdTRUE 成功, pdFALSE 失败
*/
BaseType_t mqtt_start_offline_upload_task(void);
/**
* @brief 停止离线数据补传任务
*/
void mqtt_stop_offline_upload_task(void);

View File

@@ -0,0 +1,5 @@
idf_component_register(
SRCS "OFFLINE_STORAGE.c"
INCLUDE_DIRS "include"
REQUIRES FLASH_SPIFS json
)

View File

@@ -0,0 +1,426 @@
#include "OFFLINE_STORAGE.h"
#include "FLASH_SPIFS.h"
#include "cJSON.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <sys/stat.h>
#include <dirent.h>
#include <string.h>
#include <stdio.h>
#define TAG "OFFLINE_STORAGE"
#define DATA_DIR "/flash/data"
#define INDEX_FILE "/flash/index.json"
// 最大文件数(防止文件过多)
#define MAX_FILES 10000
// 数据类型目录名
static const char* DATA_TYPE_DIRS[] = {
"", // UNKNOWN
"modbus", // MODBUS
"status" // DEVICE_STATUS
};
// 索引结构
static cJSON *index_json = NULL;
/**
* @brief 初始化数据目录
*/
static esp_err_t init_data_directories(void)
{
struct stat st;
// 检查主目录是否存在
if (stat(DATA_DIR, &st) != 0) {
mkdir(DATA_DIR, 0777);
ESP_LOGI(TAG, "创建数据目录: %s", DATA_DIR);
}
// 创建各类型子目录
for (int i = 1; i < 3; i++) {
char dir_path[64];
snprintf(dir_path, sizeof(dir_path), "%s/%s", DATA_DIR, DATA_TYPE_DIRS[i]);
if (stat(dir_path, &st) != 0) {
mkdir(dir_path, 0777);
ESP_LOGI(TAG, "创建子目录: %s", dir_path);
}
}
return ESP_OK;
}
/**
* @brief 加载索引文件
*/
static esp_err_t load_index(void)
{
FILE *f = fopen(INDEX_FILE, "r");
if (f == NULL) {
ESP_LOGI(TAG, "索引文件不存在,创建新索引");
index_json = cJSON_CreateObject();
if (index_json == NULL) {
return ESP_ERR_NO_MEM;
}
// 创建索引数组
cJSON *index_array = cJSON_CreateArray();
if (index_array == NULL) {
cJSON_Delete(index_json);
return ESP_ERR_NO_MEM;
}
cJSON_AddItemToObject(index_json, "files", index_array);
return ESP_OK;
}
// 读取文件内容
fseek(f, 0, SEEK_END);
long file_size = ftell(f);
fseek(f, 0, SEEK_SET);
char *buffer = malloc(file_size + 1);
if (buffer == NULL) {
fclose(f);
return ESP_ERR_NO_MEM;
}
fread(buffer, 1, file_size, f);
buffer[file_size] = '\0';
fclose(f);
// 解析JSON
index_json = cJSON_Parse(buffer);
free(buffer);
if (index_json == NULL) {
ESP_LOGE(TAG, "索引文件解析失败");
return ESP_FAIL;
}
return ESP_OK;
}
/**
* @brief 保存索引文件
*/
static esp_err_t save_index(void)
{
if (index_json == NULL) {
return ESP_FAIL;
}
char *json_str = cJSON_PrintUnformatted(index_json);
if (json_str == NULL) {
return ESP_ERR_NO_MEM;
}
FILE *f = fopen(INDEX_FILE, "w");
if (f == NULL) {
free(json_str);
return ESP_FAIL;
}
size_t json_len = strlen(json_str);
size_t written = fwrite(json_str, 1, json_len, f);
fclose(f);
free(json_str);
if (written != json_len) {
return ESP_FAIL;
}
return ESP_OK;
}
/**
* @brief 生成新文件名
*/
static esp_err_t generate_filename(char *filename, size_t max_len, offline_data_type_t data_type)
{
if (data_type <= OFFLINE_DATA_TYPE_UNKNOWN || data_type > OFFLINE_DATA_TYPE_DEVICE_STATUS) {
return ESP_ERR_INVALID_ARG;
}
// 使用时间戳作为文件名
uint32_t timestamp = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
snprintf(filename, max_len, "%s/%s/%010lu.json",
DATA_DIR,
DATA_TYPE_DIRS[data_type],
(unsigned long)timestamp);
return ESP_OK;
}
esp_err_t offline_storage_init(void)
{
ESP_LOGI(TAG, "初始化离线存储模块...");
// 检查SPIFFS是否挂载
if (!flash_spiffs_is_mounted()) {
ESP_LOGE(TAG, "SPIFFS未挂载");
return ESP_ERR_NOT_FOUND;
}
// 初始化目录结构
ESP_ERROR_CHECK(init_data_directories());
// 加载索引
ESP_ERROR_CHECK(load_index());
ESP_LOGI(TAG, "离线存储模块初始化完成");
return ESP_OK;
}
esp_err_t offline_storage_store(const char *data, size_t length, offline_data_type_t data_type)
{
if (data == NULL || length == 0) {
return ESP_ERR_INVALID_ARG;
}
if (data_type <= OFFLINE_DATA_TYPE_UNKNOWN || data_type > OFFLINE_DATA_TYPE_DEVICE_STATUS) {
return ESP_ERR_INVALID_ARG;
}
// 生成文件名
char filename[64];
esp_err_t ret = generate_filename(filename, sizeof(filename), data_type);
if (ret != ESP_OK) {
return ret;
}
// 写入文件
FILE *f = fopen(filename, "w");
if (f == NULL) {
ESP_LOGE(TAG, "无法创建文件: %s", filename);
return ESP_FAIL;
}
size_t written = fwrite(data, 1, length, f);
fclose(f);
if (written != length) {
ESP_LOGE(TAG, "写入不完整: %zu/%zu", written, length);
remove(filename);
return ESP_FAIL;
}
ESP_LOGD(TAG, "存储数据: %s, 大小: %zu 字节", filename, length);
// 更新索引
cJSON *files_array = cJSON_GetObjectItemCaseSensitive(index_json, "files");
if (files_array != NULL) {
cJSON *file_info = cJSON_CreateObject();
if (file_info != NULL) {
cJSON_AddStringToObject(file_info, "filename", filename);
cJSON_AddNumberToObject(file_info, "type", data_type);
cJSON_AddNumberToObject(file_info, "size", length);
cJSON_AddItemToArray(files_array, file_info);
save_index();
}
}
// 检查文件数量,如果超过限制则删除最旧的
uint32_t count = offline_storage_get_count();
if (count > MAX_FILES) {
ESP_LOGW(TAG, "文件数量过多 (%u),删除最旧的", count);
offline_storage_delete_oldest();
}
return ESP_OK;
}
esp_err_t offline_storage_read_oldest(char *buffer, size_t max_len, offline_data_type_t *out_data_type)
{
if (buffer == NULL) {
return ESP_ERR_INVALID_ARG;
}
cJSON *files_array = cJSON_GetObjectItemCaseSensitive(index_json, "files");
if (files_array == NULL || !cJSON_IsArray(files_array)) {
return ESP_ERR_NOT_FOUND;
}
if (cJSON_GetArraySize(files_array) == 0) {
return ESP_ERR_NOT_FOUND;
}
// 获取第一个元素(最旧的)
cJSON *first_file = cJSON_GetArrayItem(files_array, 0);
if (first_file == NULL) {
return ESP_ERR_NOT_FOUND;
}
cJSON *filename_item = cJSON_GetObjectItemCaseSensitive(first_file, "filename");
cJSON *type_item = cJSON_GetObjectItemCaseSensitive(first_file, "type");
if (filename_item == NULL || !cJSON_IsString(filename_item)) {
return ESP_FAIL;
}
const char *filename = cJSON_GetStringValue(filename_item);
if (filename == NULL) {
return ESP_FAIL;
}
// 读取文件
FILE *f = fopen(filename, "r");
if (f == NULL) {
ESP_LOGE(TAG, "无法打开文件: %s (索引记录与实际文件不匹配)", filename);
ESP_LOGW(TAG, "删除无效的索引记录: %s", filename);
// 文件不存在,从索引中删除这个无效记录
offline_storage_delete_oldest();
return ESP_ERR_NOT_FOUND;
}
fseek(f, 0, SEEK_END);
long file_size = ftell(f);
fseek(f, 0, SEEK_SET);
if (file_size > (long)max_len) {
ESP_LOGE(TAG, "文件过大: %s (%ld bytes)", filename, file_size);
fclose(f);
// 删除过大的文件
offline_storage_delete_oldest();
return ESP_ERR_NO_MEM;
}
size_t read = fread(buffer, 1, file_size, f);
fclose(f);
buffer[read] = '\0';
ESP_LOGD(TAG, "读取数据: %s, 大小: %zu 字节", filename, read);
// 返回数据类型
if (out_data_type != NULL && type_item != NULL && cJSON_IsNumber(type_item)) {
*out_data_type = (offline_data_type_t)cJSON_GetNumberValue(type_item);
}
return ESP_OK;
}
esp_err_t offline_storage_delete_oldest(void)
{
cJSON *files_array = cJSON_GetObjectItemCaseSensitive(index_json, "files");
if (files_array == NULL || !cJSON_IsArray(files_array)) {
return ESP_ERR_NOT_FOUND;
}
if (cJSON_GetArraySize(files_array) == 0) {
return ESP_ERR_NOT_FOUND;
}
// 删除第一个元素
cJSON *first_file = cJSON_DetachItemFromArray(files_array, 0);
if (first_file == NULL) {
return ESP_FAIL;
}
cJSON *filename_item = cJSON_GetObjectItemCaseSensitive(first_file, "filename");
const char *filename = NULL;
if (filename_item != NULL && cJSON_IsString(filename_item)) {
filename = cJSON_GetStringValue(filename_item);
if (filename != NULL) {
remove(filename);
ESP_LOGD(TAG, "删除文件: %s", filename);
}
}
cJSON_Delete(first_file);
// 检查并删除所有重复的索引记录(如果存在)
if (filename != NULL) {
int duplicate_count = 0;
int array_size = cJSON_GetArraySize(files_array);
for (int i = array_size - 1; i >= 0; i--) {
cJSON *item = cJSON_GetArrayItem(files_array, i);
if (item == NULL) {
continue;
}
cJSON *item_filename = cJSON_GetObjectItemCaseSensitive(item, "filename");
if (item_filename != NULL && cJSON_IsString(item_filename)) {
const char *item_name = cJSON_GetStringValue(item_filename);
if (item_name != NULL && strcmp(filename, item_name) == 0) {
// 找到重复记录,删除它
cJSON_DeleteItemFromArray(files_array, i);
duplicate_count++;
ESP_LOGW(TAG, "删除重复的索引记录: %s", item_name);
}
}
}
if (duplicate_count > 0) {
ESP_LOGW(TAG, "删除了 %d 个重复的索引记录", duplicate_count);
}
}
save_index();
return ESP_OK;
}
uint32_t offline_storage_get_count(void)
{
cJSON *files_array = cJSON_GetObjectItemCaseSensitive(index_json, "files");
if (files_array == NULL || !cJSON_IsArray(files_array)) {
return 0;
}
return (uint32_t)cJSON_GetArraySize(files_array);
}
bool offline_storage_has_data(void)
{
return offline_storage_get_count() > 0;
}
esp_err_t offline_storage_clear_all(void)
{
ESP_LOGW(TAG, "清空所有离线数据...");
cJSON *files_array = cJSON_GetObjectItemCaseSensitive(index_json, "files");
if (files_array == NULL || !cJSON_IsArray(files_array)) {
return ESP_OK;
}
// 删除所有文件
cJSON *item = NULL;
cJSON_ArrayForEach(item, files_array) {
cJSON *filename_item = cJSON_GetObjectItemCaseSensitive(item, "filename");
if (filename_item != NULL && cJSON_IsString(filename_item)) {
const char *filename = cJSON_GetStringValue(filename_item);
if (filename != NULL) {
remove(filename);
}
}
}
// 清空索引
cJSON_DeleteItemFromObject(index_json, "files");
cJSON *new_array = cJSON_CreateArray();
if (new_array != NULL) {
cJSON_AddItemToObject(index_json, "files", new_array);
}
save_index();
ESP_LOGI(TAG, "所有离线数据已清空");
return ESP_OK;
}
esp_err_t offline_storage_get_usage(size_t *used, size_t *total)
{
if (used == NULL || total == NULL) {
return ESP_ERR_INVALID_ARG;
}
*total = flash_spiffs_get_total_size();
*used = flash_spiffs_get_used_size();
return ESP_OK;
}

View File

@@ -0,0 +1,88 @@
#ifndef OFFLINE_STORAGE_H
#define OFFLINE_STORAGE_H
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
// 数据类型
typedef enum {
OFFLINE_DATA_TYPE_UNKNOWN = 0,
OFFLINE_DATA_TYPE_MODBUS = 1, // MODBUS采集数据
OFFLINE_DATA_TYPE_DEVICE_STATUS = 2, // 设备状态数据
} offline_data_type_t;
/**
* @brief 初始化离线存储模块
*
* @return ESP_OK 成功
* 其他 失败
*/
esp_err_t offline_storage_init(void);
/**
* @brief 存储离线数据
*
* @param data 数据内容JSON字符串
* @param length 数据长度
* @param data_type 数据类型
* @return ESP_OK 成功
* ESP_FAIL 失败
*/
esp_err_t offline_storage_store(const char *data, size_t length, offline_data_type_t data_type);
/**
* @brief 读取最旧的离线数据
*
* @param buffer 输出缓冲区
* @param max_len 缓冲区最大长度
* @param out_data_type 输出数据类型
* @return ESP_OK 成功
* ESP_ERR_NOT_FOUND 没有数据
* 其他 失败
*/
esp_err_t offline_storage_read_oldest(char *buffer, size_t max_len, offline_data_type_t *out_data_type);
/**
* @brief 删除最旧的离线数据
*
* @return ESP_OK 成功
* ESP_ERR_NOT_FOUND 没有数据
* 其他 失败
*/
esp_err_t offline_storage_delete_oldest(void);
/**
* @brief 获取离线数据数量
*
* @return 数据数量
*/
uint32_t offline_storage_get_count(void);
/**
* @brief 检查是否有离线数据
*
* @return true 有数据
* false 无数据
*/
bool offline_storage_has_data(void);
/**
* @brief 清空所有离线数据
*
* @return ESP_OK 成功
* 其他 失败
*/
esp_err_t offline_storage_clear_all(void);
/**
* @brief 获取存储使用情况
*
* @param used 已使用字节数
* @param total 总字节数
* @return ESP_OK 成功
* 其他 失败
*/
esp_err_t offline_storage_get_usage(size_t *used, size_t *total);
#endif // OFFLINE_STORAGE_H

View File

@@ -1,4 +1,4 @@
idf_component_register(SRCS "RS-485-SP3485EEN.c"
INCLUDE_DIRS "include"
REQUIRES nvs_flash driver MQTT_ESP mqtt STATUS_LED MODBUS_ESP json
REQUIRES nvs_flash driver MQTT_ESP mqtt STATUS_LED MODBUS_ESP json OFFLINE_STORAGE
)

View File

@@ -23,7 +23,7 @@ void rs485_send(uart_port_t uart_num, const uint8_t *data, size_t len)
{
if (data == NULL || len == 0)
{
ESP_LOGW(TAG, "rs485_send: empty payload");
ESP_LOGW(TAG, "RS485发送: 数据为空");
return;
}
@@ -34,12 +34,12 @@ void rs485_send(uart_port_t uart_num, const uint8_t *data, size_t len)
int written = uart_write_bytes(uart_num, (const char *)data, len);
if (written < 0)
{
ESP_LOGE(TAG, "UART%d TX write error (%d)", uart_num, written);
ESP_LOGE(TAG, "UART%d 发送写入错误 (%d)", uart_num, written);
return;
}
if ((size_t)written != len)
{
ESP_LOGW(TAG, "UART%d TX partial (%d/%d)", uart_num, written, len);
ESP_LOGW(TAG, "UART%d 发送部分数据 (%d/%d)", uart_num, written, len);
}
// RS485 半双工模式下uart_write_bytes 会自动等待发送完成
@@ -51,7 +51,7 @@ void rs485_send(uart_port_t uart_num, const uint8_t *data, size_t len)
// Modbus RTU 3.5T 帧间静默(保留短延时)
vTaskDelay(pdMS_TO_TICKS(5));
ESP_LOGI(TAG, "UART%d TX done (%d bytes)", uart_num, written);
ESP_LOGI(TAG, "UART%d 发送完成 (%d 字节)", uart_num, written);
}
// ============================
@@ -61,7 +61,7 @@ int rs485_receive(uart_port_t uart_num, uint8_t *buffer, size_t buf_size, uint32
{
if (buffer == NULL || buf_size == 0)
{
ESP_LOGW(TAG, "rs485_receive: invalid buffer");
ESP_LOGW(TAG, "RS485接收: 缓冲区无效");
return -1;
}
@@ -73,11 +73,11 @@ int rs485_receive(uart_port_t uart_num, uint8_t *buffer, size_t buf_size, uint32
}
else if (len == 0)
{
ESP_LOGD(TAG, "UART%d RX timeout", uart_num);
ESP_LOGD(TAG, "UART%d 接收超时", uart_num);
}
else
{
ESP_LOGW(TAG, "UART%d RX error (%d)", uart_num, len);
ESP_LOGW(TAG, "UART%d 接收错误 (%d)", uart_num, len);
}
return len;

View File

@@ -1,4 +1,14 @@
dependencies:
espressif/cjson:
component_hash: 9372811fb197926f522c467627cf4a8e72b681e0366e17879631da801103aef3
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.7.19
espressif/mqtt:
component_hash: ffdad5659706b4dc14bc63f8eb73ef765efa015bf7e9adf71c813d52a2dc9342
dependencies:
@@ -14,8 +24,9 @@ dependencies:
type: idf
version: 5.5.2
direct_dependencies:
- espressif/cjson
- espressif/mqtt
- idf
manifest_hash: a05aed5660378334599ccf3fa66dcfc9fcf5deb3e651bd572aeb15a43840910d
manifest_hash: 142b0960853ecbd232be08031f057a2430fd975fc4697f81c7a9a6d57507d3d6
target: esp32s3
version: 2.0.0

432
docs/OFFLINE_STORAGE.md Normal file
View File

@@ -0,0 +1,432 @@
# 离线数据存储和补传功能说明
## 功能概述
本设备支持离线数据存储功能当网络断开时数据会自动存储到Flash文件系统中当网络恢复后数据会自动补传到MQTT服务器。
## 系统架构
```
┌─────────────────────────────────────────────────┐
│ 应用层 │
├─────────────────────────────────────────────────┤
│ MODBUS数据 设备状态 MQTT客户端 │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 离线数据管理模块 │ │
│ │ (OFFLINE_STORAGE 组件) │ │
│ └─────────────────────────────────────┘ │
│ │ │ │
│ ▼ │ │
│ ┌─────────────────────────────────────┐ │
│ │ SPIFFS文件系统 │ │
│ │ (FLASH_SPIFS 组件) │ │
│ └─────────────────────────────────────┘ │
│ │ │ │
│ ▼ │ │
│ ┌─────────────────────────────────────┐ │
│ │ 16MB Flash存储区 │ │
│ │ (8MB可用) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
## 分区表配置
```csv
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 2MB,
storage, data, spiffs, 0x210000,8MB,
```
- **storage分区**8MB用于离线数据存储
- **挂载点**`/flash`
- **文件系统**SPIFFS
## 存储容量估算
| 参数 | 数值 |
|------|------|
| Flash总容量 | 16MB |
| 存储分区大小 | 8MB |
| MODBUS数据量 | 200字节/条1秒/次) |
| 每小时数据量 | 720KB |
| 可存储时长 | 约11小时 |
**注意**SPIFFS最小文件大小为4KB200字节的文件实际占用4KB空间因此实际可存储的时长会更短。建议将多条数据合并到一个文件中存储以提高空间利用率。
## 文件组织结构
```
/flash/
├── data/
│ ├── modbus/ # MODBUS采集数据
│ │ ├── 1738452345.json # 时间戳命名
│ │ ├── 1738452346.json
│ │ └── ...
│ └── status/ # 设备状态数据
│ ├── 1738452345.json
│ └── ...
└── index.json # 索引文件
```
## 工作流程
### 正常模式(网络在线)
```
数据产生
检测网络状态(在线)
直接发布到MQTT
不存储
```
### 离线模式(网络断开)
```
数据产生
检测网络状态(离线)
存储到Flash SPIFFS
写入成功
等待网络恢复
```
### 补传模式(网络恢复)
```
检测网络状态(在线)
检查是否有离线数据
读取最旧的数据
发布到MQTT
发布成功?
├─ 是 → 删除该数据 → 继续下一条
└─ 否 → 等待重试
```
## API接口
### FLASH_SPIFS 组件
#### 初始化文件系统
```c
esp_err_t flash_spiffs_init(void);
```
#### 检查是否挂载
```c
bool flash_spiffs_is_mounted(void);
```
#### 获取存储使用情况
```c
uint32_t flash_spiffs_get_total_size(void);
uint32_t flash_spiffs_get_used_size(void);
```
#### 格式化文件系统
```c
esp_err_t flash_spiffs_format(void);
```
### OFFLINE_STORAGE 组件
#### 初始化存储模块
```c
esp_err_t offline_storage_init(void);
```
#### 存储离线数据
```c
esp_err_t offline_storage_store(const char *data, size_t length, offline_data_type_t data_type);
```
**参数**
- `data`数据内容JSON字符串
- `length`:数据长度
- `data_type`:数据类型
- `OFFLINE_DATA_TYPE_MODBUS`MODBUS采集数据
- `OFFLINE_DATA_TYPE_DEVICE_STATUS`:设备状态数据
#### 读取最旧的数据
```c
esp_err_t offline_storage_read_oldest(char *buffer, size_t max_len, offline_data_type_t *out_data_type);
```
#### 删除最旧的数据
```c
esp_err_t offline_storage_delete_oldest(void);
```
#### 获取数据数量
```c
uint32_t offline_storage_get_count(void);
bool offline_storage_has_data(void);
```
#### 清空所有数据
```c
esp_err_t offline_storage_clear_all(void);
```
### MQTT_ESP 扩展接口
#### 存储离线数据(内部使用)
```c
esp_err_t mqtt_store_offline(const char *data, size_t length, offline_data_type_t data_type);
```
#### 启动/停止补传任务
```c
BaseType_t mqtt_start_offline_upload_task(void);
void mqtt_stop_offline_upload_task(void);
```
## 网络状态检测
系统通过MQTT连接状态判断网络是否在线
| MQTT事件 | 网络状态 | 处理 |
|---------|---------|------|
| `MQTT_EVENT_CONNECTED` | 在线 | 启动补传任务 |
| `MQTT_EVENT_DISCONNECTED` | 离线 | 停止补传任务 |
## 存储策略
### 自动循环覆盖
- **最大文件数限制**10,000个文件
- **触发条件**:当文件数超过限制时,自动删除最旧的数据
- **优先级**:按时间戳排序,删除最旧的数据
### 数据完整性保护
- **索引文件**`/flash/index.json` 记录所有文件信息
- **原子写入**:使用临时文件+重命名机制
- **断电保护**SPIFFS提供基本断电保护
### 存储空间管理
```
获取空间使用情况
检查可用空间
空间不足?
├─ 否 → 正常存储
└─ 是 → 删除最旧数据 → 重试
```
## SPIFFS 特性说明
### 优点
- ESP-IDF原生支持无需额外组件
- 代码成熟稳定
- 内存占用小约2KB
- 挂载速度快
### 缺点
- 最小文件大小为4KB小文件浪费空间
- 无磨损均衡Flash寿命相对较短
- 碎片严重,长期使用后性能下降
- 不支持目录(所有文件在根目录)
### 优化建议
1. **合并多条数据到单个文件**:减少文件数量,提高空间利用率
2. **定期格式化**:消除碎片,恢复性能
3. **限制文件数量**:避免碎片积累
## 日志输出
### 初始化日志
```
I (xxxx) main: Initializing SPIFFS file system...
I (xxxx) FLASH_SPIFS: 正在初始化SPIFFS文件系统...
I (xxxx) FLASH_SPIFS: SPIFFS文件系统挂载成功
I (xxxx) FLASH_SPIFS: 总空间: 8388608 字节
I (xxxx) FLASH_SPIFS: 已用空间: 4096 字节
I (xxxx) FLASH_SPIFS: 可用空间: 8384512 字节
I (xxxx) FLASH_SPIFS: 挂载点: /flash
I (xxxx) main: Initializing offline storage module...
I (xxxx) main: 离线存储模块初始化完成
```
### 存储日志
```
I (xxxx) mqtt_esp: Storing offline data (type=1, size=256)
I (xxxx) OFFLINE_STORAGE: 存储数据: /flash/data/modbus/1738452345.json, 大小: 256 字节
I (xxxx) OFFLINE_STORAGE: Storage usage: 4096 / 8388608 bytes (0.0%)
W (xxxx) OFFLINE_STORAGE: 注意SPIFFS最小文件大小为4KB小文件会占用更多空间
```
### 补传日志
```
I (xxxx) mqtt_esp: Network is online, starting offline data upload
I (xxxx) mqtt_esp: Found 10 offline data files, uploading...
I (xxxx) mqtt_esp: Publishing offline data (type=1, size=256)
I (xxxx) mqtt_esp: Offline data published successfully, msg_id=12345
```
## 故障处理
### Flash空间不足
**现象**
```
E (xxxx) OFFLINE_STORAGE: 无法创建文件
```
**解决**
1. 自动删除最旧的数据
2. 格式化存储分区
### 文件系统损坏
**现象**
```
E (xxxx) FLASH_SPIFS: 挂载失败
```
**解决**
1. 系统会尝试自动修复
2. 如修复失败,格式化文件系统
3. 检查Flash硬件
### 数据补传失败
**现象**
```
E (xxxx) mqtt_esp: Failed to publish offline data
```
**解决**
1. 检查网络连接
2. 检查MQTT服务器状态
3. 数据会保留在Flash中下次重试
## 性能优化建议
### 1. 合并多条数据
将多条数据合并到一个文件中存储:
```c
// 示例每100条数据存储为一个文件
// 空间利用率100 * 200字节 / 4096字节 ≈ 4.9%
// 单文件存储100 * 200字节 / 4096字节 = 100%
```
### 2. 降低存储频率
网络离线时降低存储频率:
```c
// 在线1秒/次
// 离线10秒/次
// 可存储时长110小时
```
### 3. 选择性存储
只存储关键数据:
```c
// 只存储异常数据、状态变化
// 正常数据丢弃
```
### 4. 定期格式化
建议每隔1-3个月格式化一次文件系统消除碎片
```c
flash_spiffs_format();
```
## 监控和调试
### 查看存储使用情况
```c
size_t used = 0, total = 0;
offline_storage_get_usage(&used, &total);
ESP_LOGI(TAG, "Storage: %zu / %zu bytes", used, total);
ESP_LOGI(TAG, "Usage: %.1f%%", (used * 100.0) / total);
```
### 查看离线数据数量
```c
uint32_t count = offline_storage_get_count();
ESP_LOGI(TAG, "Offline data files: %u", count);
```
### 格式化存储
```c
flash_spiffs_format();
```
## 注意事项
1. **首次启动**:会自动格式化文件系统(如果需要)
2. **断电保护**SPIFFS提供基本断电保护但建议避免频繁断电
3. **Flash寿命**SPIFFS无磨损均衡建议定期格式化以延长寿命
4. **容量限制**由于SPIFFS最小文件4KB实际可存储时长会少于理论值
5. **补传速率**:每秒处理一条数据,避免阻塞新数据上传
6. **碎片问题**:长期使用后性能可能下降,建议定期格式化
## 后续扩展建议
1. **数据压缩**集成zlib压缩延长存储时长
2. **批量存储**:将多条数据合并到一个文件中
3. **优先级队列**:重要数据优先补传
4. **数据导出**通过Web界面导出离线数据
5. **统计信息**:记录丢失/补传的数据统计
6. **智能采样**:根据存储空间动态调整采样率
7. **定期维护**:实现自动碎片整理和格式化
## 与LittleFS对比
| 特性 | SPIFFS | LittleFS |
|------|--------|----------|
| 最小文件大小 | 4KB | 0字节 |
| 内存占用 | ~2KB | ~4KB |
| 磨损均衡 | ❌ 无 | ✅ 自带 |
| 断电保护 | ⚠️ 一般 | ✅ 优秀 |
| 支持目录 | ❌ 不支持 | ✅ 支持 |
| 碎片问题 | ❌ 严重 | ✅ 轻微 |
| ESP-IDF支持 | ✅ 原生支持 | ❌ 需第三方组件 |
**选择建议**
- 如果需要ESP-IDF原生支持且数据量不大 → SPIFFS
- 如果需要更好的性能和寿命 → LittleFS需手动添加组件

View File

@@ -0,0 +1,214 @@
# 离线存储功能测试指南
## 功能概述
离线存储功能会在网络断开时自动将数据存储到 Flash 中,并在网络恢复后自动补传数据。
## 测试步骤
### 1. 编译并烧录固件
```bash
cd "/home/beihong/esp_projects/Distributed Collector Gateway"
idf.py build
idf.py flash monitor
```
### 2. 观察启动日志
系统启动时会显示以下关键日志:
```
I (xxxx) main: Initializing SPIFFS file system...
I (xxxx) FLASH_SPIFS: SPIFFS mounted successfully at /flash
I (xxxx) FLASH_SPIFS: Total size: 8388608 bytes, Used: 0 bytes
I (xxxx) main: Initializing offline storage module...
I (xxxx) OFFLINE_STORAGE: Offline storage initialized
I (xxxx) main: Offline data upload task started
```
### 3. 测试场景A网络断开时的数据存储
**操作步骤:**
1. 在系统正常运行时,断开网络连接(拔掉网线或断开路由器)
2. 观察日志输出,应该看到网络断开的提示
3. 等待 MODBUS 数据采集任务产生数据
4. 观察日志,应该看到类似以下信息:
```
W (xxxx) MQTT_ESP: Network offline, storing data locally (topic: xxx, len: xxx)
I (xxxx) MQTT_ESP: Storing offline data (type=1, size=xxx)
I (xxxx) OFFLINE_STORAGE: Stored offline data: /flash/offline/xxxxxx.json
I (xxxx) MQTT_ESP: Storage usage: 4096 / 8388608 bytes (0.0%)
```
**预期结果:**
- 数据被成功存储到 Flash 中
- 显示存储使用情况
- 设备继续正常运行,不会因为网络断开而崩溃
### 4. 测试场景B网络恢复时的自动补传
**操作步骤:**
1. 在测试场景A中存储了一些离线数据后
2. 重新连接网络(插上网线或恢复路由器)
3. 观察日志输出
**预期日志:**
```
I (xxxx) MQTT_ESP: Network online, resuming normal operation
I (xxxx) MQTT_ESP: Found 5 offline data files, uploading...
I (xxxx) MQTT_ESP: Publishing offline data (type=1, size=xxx)
I (xxxx) MQTT_ESP: Offline data published successfully, msg_id=xxx
I (xxxx) OFFLINE_STORAGE: Deleted offline data: /flash/offline/xxxxxx.json
I (xxxx) MQTT_ESP: Storage usage: 32768 / 8388608 bytes (0.4%)
```
**预期结果:**
- 系统自动检测到网络恢复
- 自动开始上传离线存储的数据
- 每次上传成功后删除对应文件
- 逐条上传,直到所有离线数据都上传完成
### 5. 测试场景C设备状态数据的离线存储
**操作步骤:**
1. 断开网络连接
2. 等待设备状态上报周期默认10秒
3. 观察日志输出
**预期日志:**
```
W (xxxx) MQTT_ESP: Network offline, storing device status locally
I (xxxx) MQTT_ESP: Storing offline data (type=2, size=xxx)
I (xxxx) OFFLINE_STORAGE: Stored offline data: /flash/offline/xxxxxx.json
```
**预期结果:**
- 设备状态数据被正确识别为 OFFLINE_DATA_TYPE_DEVICE_STATUS 类型
- 数据成功存储到 Flash
### 6. 测试场景D存储空间耗尽
**操作步骤:**
1. 持续在网络断开状态下运行设备
2. 让 MODBUS 数据持续产生(通过发送 MODBUS 查询命令)
3. 观察存储使用率的变化
**预期日志:**
```
I (xxxx) MQTT_ESP: Storage usage: 8355840 / 8388608 bytes (99.6%)
W (xxxx) OFFLINE_STORAGE: Storage nearly full (99.6%), consider clearing old data
```
**预期结果:**
- 当存储空间快满时,系统会发出警告
- 新数据仍然可以存储,直到空间完全耗尽
- 系统继续正常运行
### 7. 测试场景E手动清空离线数据
如果需要手动清空所有离线数据,可以:
1. 通过串口终端发送清空命令(需要自行实现)
2. 或者重新格式化 SPIFFS 文件系统(会删除所有数据)
**格式化方法:**
- 在代码中调用 `flash_spiffs_format()` 函数
- 这会清空整个文件系统,包括离线数据和配置
## MQTT 服务器端验证
在 MQTT 服务器端(如使用 MQTTX 或 Mosquitto Client
1. 订阅设备发布的主题:`CONFIG_MQTT_PUB_TOPIC`(在 sdkconfig 中配置)
2. 在网络断开时,应该收不到新数据
3. 在网络恢复后,应该收到之前存储的离线数据
4. 验证接收到的数据完整性和顺序
## 常见问题排查
### 问题1日志显示 "Failed to store offline data"
**可能原因:**
- SPIFFS 文件系统挂载失败
- Flash 存储空间已满
- 文件系统错误
**解决方法:**
1. 检查 SPIFFS 初始化日志
2. 查看存储使用情况
3. 必要时格式化文件系统
### 问题2网络恢复后没有自动上传离线数据
**可能原因:**
- 离线上传任务未启动
- MQTT 客户端未连接
- 网络状态判断错误
**解决方法:**
1. 检查日志中是否有 "Offline data upload task started"
2. 确认 MQTT 客户端已连接
3. 检查 `g_is_online` 状态
### 问题3离线数据上传不完整或丢失
**可能原因:**
- MQTT 发布失败
- 文件读取错误
- 文件删除时机问题
**解决方法:**
1. 查看日志中的错误信息
2. 确认 MQTT 服务器可用
3. 检查 Flash 文件完整性
## 性能测试
### 存储性能
- 每条数据存储时间:通常 < 100ms
- 支持的数据大小:最大 4096 bytes单条
- 存储容量:约 8MB基于分区表配置
### 上传性能
- 每条数据上传时间:取决于网络延迟
- 上传间隔1 秒(在离线上传任务中)
- 支持的最大数据条数:约 2000 条(基于平均每条 4KB
## 存储容量分析
基于当前配置:
- **分区大小**: 8MB
- **SPIFFS 开销**: 约 10-20%
- **可用空间**: 约 6.4-7.2MB
- **最小文件大小**: 4KBSPIFFS 特性)
- **MODBUS 数据大小**: 约 200 bytes/秒
- **可存储时长**: 约 2-3 小时
**注意:** 由于 SPIFFS 最小文件大小为 4KB即使只有 200 bytes 的数据也会占用 4KB 空间。如果需要更长时间的离线存储,建议:
1. 增大存储分区
2. 使用 LittleFS 文件系统(支持更小的文件块)
3. 实现数据打包存储(多条数据打包到一个文件)
## 测试总结
完成以上测试后,你应该能够验证:
1. ✅ 网络断开时数据能正确存储到 Flash
2. ✅ 网络恢复时数据能自动补传
3. ✅ 设备状态和 MODBUS 数据都能正确处理
4. ✅ 存储空间管理正常
5. ✅ 系统在各种网络状态切换下稳定运行
如有问题,请查看 ESP 日志输出,或参考 `docs/OFFLINE_STORAGE.md` 获取详细的技术文档。

View File

@@ -1,4 +1,4 @@
idf_component_register(SRCS "main.c"
INCLUDE_DIRS "."
REQUIRES nvs_flash STATUS_LED RS-485-SP3485EEN ETH_CH390H MQTT_ESP SNTP_ESP
REQUIRES nvs_flash STATUS_LED RS-485-SP3485EEN ETH_CH390H MQTT_ESP SNTP_ESP FLASH_SPIFS OFFLINE_STORAGE
)

View File

@@ -16,3 +16,5 @@ dependencies:
# public: true
espressif/mqtt: ^1.0.0
espressif/cjson: ^1.7.19

View File

@@ -4,6 +4,8 @@
#include "STATUS_LED.h"
#include "MODBUS_ESP.h"
#include "SNTP_ESP.h"
#include "FLASH_SPIFS.h"
#include "OFFLINE_STORAGE.h"
#define TAG "main"
void app_main(void)
@@ -15,15 +17,23 @@ void app_main(void)
ESP_ERROR_CHECK(nvs_flash_init());
// 初始化SPIFFS文件系统
ESP_LOGI(TAG, "正在初始化SPIFFS文件系统...");
ESP_ERROR_CHECK(flash_spiffs_init());
// 初始化离线存储模块
ESP_LOGI(TAG, "正在初始化离线存储模块...");
ESP_ERROR_CHECK(offline_storage_init());
// 初始化以太网,这里包含了 esp_netif_init();和esp_event_loop_create_default();
eth_init();
// 初始化RS485
init_specific_rs485_channel(0); // 初始化通道0
start_rs485_rx_task_for_channel(0, 5, 4096); // 为通道0启动接收任务
start_rs485_rx_task_for_channel(0, 5, 8192); // 为通道0启动接收任务(增加栈大小)
// 等待网络连接建立
ESP_LOGI(TAG, "Waiting for network connection...");
ESP_LOGI(TAG, "正在等待网络连接...");
esp_netif_t *eth_netif = esp_netif_get_handle_from_ifkey("ETH_DEF"); // 获取默认以太网接口
@@ -33,25 +43,25 @@ void app_main(void)
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(eth_netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0)
{
ESP_LOGI(TAG, "Network connected with IP: " IPSTR, IP2STR(&ip_info.ip));
ESP_LOGI(TAG, "网络已连接IP地址: " IPSTR, IP2STR(&ip_info.ip));
break;
}
ESP_LOGI(TAG, "Waiting for IP address...");
ESP_LOGI(TAG, "正在等待IP地址分配...");
vTaskDelay(pdMS_TO_TICKS(1000)); // 等待1秒后重试
}
// 初始化SNTP时间同步服务
ESP_LOGI(TAG, "Initializing SNTP time synchronization...");
ESP_LOGI(TAG, "正在初始化SNTP时间同步服务...");
sntp_esp_init();
// 等待时间同步完成最长等待10秒
if (sntp_esp_wait_sync(10000)) {
ESP_LOGI(TAG, "Time synchronization completed successfully");
ESP_LOGI(TAG, "时间同步完成");
} else {
ESP_LOGW(TAG, "Time synchronization timeout, using local time");
ESP_LOGW(TAG, "时间同步超时,使用本地时间");
}
ESP_LOGI(TAG, "Starting MQTT client...");
ESP_LOGI(TAG, "正在启动MQTT客户端...");
// 启动MQTT客户端
mqtt_app_start();
@@ -63,15 +73,24 @@ void app_main(void)
status_led_blink_mode(2, 2); // LED2 心跳:系统运行正常
// ============================
// 启动设备状态上报任务(每10秒上报一次
// 配置并标记设备状态上报任务(将在MQTT连接后自动启动
// ============================
if (mqtt_start_device_status_task(10000) == pdPASS) {
ESP_LOGI(TAG, "Device status report task started");
ESP_LOGI(TAG, "设备状态上报任务已配置将在MQTT连接后自动启动");
} else {
ESP_LOGE(TAG, "Failed to start device status report task");
ESP_LOGE(TAG, "配置设备状态上报任务失败");
}
ESP_LOGI(TAG, "Waiting for MODBUS poll command via MQTT...");
// ============================
// 启动离线数据补传任务
// ============================
if (mqtt_start_offline_upload_task() == pdPASS) {
ESP_LOGI(TAG, "离线数据上传任务已启动");
} else {
ESP_LOGE(TAG, "启动离线数据上传任务失败");
}
ESP_LOGI(TAG, "正在等待MQTT发送MODBUS轮询命令...");
for (;;)
{

5
partitions.csv Normal file
View File

@@ -0,0 +1,5 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 2M,
storage, data, spiffs, 0x210000,8M,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 phy_init data phy 0xf000 0x1000
4 factory app factory 0x10000 2M
5 storage data spiffs 0x210000 8M