1 Commits
Author SHA1 Message Date
clear 6ca234a205 Release RC1.0.2 2026-05-08 13:13:13 +08:00
2 changed files with 344 additions and 217 deletions

No files matched your search

+155 -194
View File
@@ -1,224 +1,192 @@
# ClearESP Wind Gateway
# ClearESP WindSensors
## 1. Product Overview
## 项目简介
ClearESP Wind Gateway is an ESP32-C3 based industrial gateway for RS485 Modbus-RTU wind speed and wind direction sensors.
ClearESP WindSensors 是基于 ESP32-C3 的 RS485 风速风向传感器采集网关固件,面向现场部署和批量配置使用。
This firmware is designed for commercial field deployment and provides:
当前固件版本:`RC1.0.2`
- Guided one-by-one sensor address provisioning
- Multi-sensor polling on a shared RS485 bus
- Local AP maintenance access
- Optional STA uplink to external WiFi
- Read-only HTTP API for third-party systems
- MQTT cloud publishing with device MAC identification
- Persistent configuration storage across power cycles
主要能力:
The current firmware targets `esp32-c3-devkitm-1` and is managed with PlatformIO.
- 单个传感器逐个引导配置站号
- 多传感器共线轮询
- AP 本地维护入口
- STA 外部 WiFi 接入
- 只读 HTTP API
- MQTT 云端上报
- 断电配置保存
## 2. Core Capabilities
当前硬件目标板:
### 2.1 Sensor Provisioning
- `esp32-c3-devkitm-1`
The gateway supports guided station ID assignment for multiple sensors.
## 通信参数
- Configuration uses special address `0xFE`
- The target sensor address register is `0x0030`
- Sensors are assigned sequentially as `1..N`
- Address write is followed by automatic verification
- Verification retries are built in
- Configuration count is stored in non-volatile storage
### RS485 / Modbus
Important field rule:
- 协议:Modbus RTU
- 波特率:`9600`
- 数据位:`8`
- 校验位:`None`
- 停止位:`1`
- During provisioning, only one target sensor may remain connected on the bus
- Previously configured sensors must be disconnected before assigning the next station ID
### 当前接线
### 2.2 Runtime Polling
- UART:`UART1`
- TX:`GPIO0`
- RX:`GPIO1`
In runtime mode, the gateway polls all configured sensors cyclically.
默认假设使用自动收发方向的 TTL 转 RS485 模块。
Current data model:
## 寄存器说明
- Wind speed: `m/s`
- Wind level
- Wind speed: `km/h`
- Direction angle: `deg`
- Direction code / direction text
固件当前使用以下寄存器:
### 2.3 Networking
- `0x0000`:风速 `m/s`
- `0x0001`:风级
- `0x0002`:风速 `km/h`
- `0x0003`:风向角度
- `0x0004`:风向方位
- `0x0030`:设备地址
The firmware runs in `AP + STA` mode.
## 功能说明
- AP is always retained for local maintenance
- STA is optional and uses DHCP
- STA reconnect is automatic after link loss
- AP access is not affected by STA failure
### 1. 引导配置
AP naming rule:
系统支持逐个给传感器分配站号。
规则:
- 每次配置时,总线上只能保留 `1` 个待配置传感器
- 已经配置好的设备必须先断开
- 站号按 `1..N` 顺序分配
配置流程:
1. 进入设备 AP
2. 打开网页中的“引导配置”
3. 只接入 1 个待配置传感器
4. 点击“配置下一个传感器”
5. 等待系统自动读取、写入和验证
6. 成功后拔掉当前传感器
7. 接入下一个传感器继续
8. 全部完成后点击“结束配置并进入运行”
说明:
- 某些传感器改址后不会返回标准写应答
- 当前固件会在写入后自动读回验证
- 只要新站号验证通过,就按“已生效,不影响使用”处理
### 2. 实时轮询
运行模式下,系统会循环轮询所有已配置传感器,并显示:
- 风速 `m/s`
- 风级
- 风速 `km/h`
- 风向角度
- 风向方位
- 在线 / 离线状态
### 3. 网络模式
设备运行在 `AP + STA` 模式:
- AP 始终保留,便于现场维护
- STA 可选,用于接入现场网络
- STA 掉线后自动重连
- STA 异常不影响 AP 本地访问
AP 名称格式:
- `ClearESP-Wind-<MAC>`
Example:
### 4. Web 控制台
- `ClearESP-Wind-B43A455471D4`
当前网页包含以下页面:
### 2.4 Web Console
The built-in web console includes the following sections:
- Real-time Data
- Guided Setup
- STA Settings
- 实时数据
- 引导配置
- STA 设置
- API
- Cloud Service
- System Status
- 云服务
- 系统状态
The interface is optimized for mobile portrait access in field use.
系统状态页包含:
### 2.5 MQTT Cloud Service
- AP / STA 信息
- MQTT 状态
- 固件版本
- 已配置传感器数量
- 恢复出厂
- 系统重启
The firmware supports MQTT cloud publishing after configuration.
### 5. 恢复出厂
Supported settings:
“恢复出厂”会清空全部持久化配置,包括:
- MQTT server host
- MQTT port
- MQTT username
- MQTT password
- Topic prefix
- 传感器配置
- WiFi 配置
- MQTT 配置
Behavior:
执行后系统会自动重启。
- MQTT settings are stored in non-volatile memory
- The gateway retries automatically when disconnected
- Sensor inventory and real-time data are published
- The device MAC is included in both topic path and payload
### 6. MQTT 上报
Default topic prefix:
配置 MQTT 后,系统会自动连接并定时发布:
- 传感器清单
- 实时数据
默认主题前缀:
- `clearesp/wind`
Published topics:
完整主题:
- `<topicPrefix>/<MAC>/sensors`
- `<topicPrefix>/<MAC>/realtime`
### 2.6 Read-Only HTTP API
## HTTP API
The firmware exposes read-only HTTP endpoints for third-party systems.
所有接口均为只读 JSON。
Available endpoints:
### 状态接口
- `GET /api/state`
返回整机完整状态,包括:
- AP / STA 状态
- MQTT 状态
- 引导配置状态
- 传感器列表
- 固件版本
### 传感器接口
- `GET /api/ro/sensors`
### 网络接口
- `GET /api/ro/network`
### 系统接口
- `GET /api/ro/system`
All API responses are JSON.
## MQTT 数据示例
## 3. Hardware and Bus Parameters
### 传感器清单
### 3.1 Controller
- MCU: ESP32-C3
- Board: `esp32-c3-devkitm-1`
- Framework: Arduino
- Build system: PlatformIO
### 3.2 RS485 / UART
Current wiring:
- UART: `UART1`
- TX: `GPIO0`
- RX: `GPIO1`
This firmware assumes an auto-direction TTL-to-RS485 transceiver.
### 3.3 Modbus Parameters
- Baud rate: `9600`
- Data bits: `8`
- Parity: `None`
- Stop bits: `1`
Registers used by the firmware:
- Data start register: `0x0000`
- Data register count: `5`
- Device address register: `0x0030`
## 4. Web Workflow
### 4.1 Initial Access
1. Power on the gateway
2. Connect to the AP `ClearESP-Wind-<MAC>`
3. Open `http://192.168.4.1/`
### 4.2 Guided Sensor Setup
1. Disconnect all already-configured sensors
2. Connect exactly one target sensor
3. Open `Guided Setup`
4. Press `Setup Next Sensor`
5. Wait for address read, write, and verification
6. Disconnect that sensor
7. Connect the next target sensor
8. Repeat until all sensors are provisioned
9. Press `Finish And Run`
### 4.3 External WiFi Setup
1. Open `STA Settings`
2. Enter external WiFi SSID and password
3. Save configuration
4. Confirm STA status and acquired IP
### 4.4 MQTT Setup
1. Open `Cloud Service`
2. Enter MQTT server parameters
3. Save and connect
4. Confirm MQTT status
5. Verify topic paths shown in the page
## 5. HTTP API
### `GET /api/state`
Full device state including:
- AP information
- STA information
- MQTT information
- Sensor list
- Guided setup state
### `GET /api/ro/sensors`
Sensor list and measurement data.
### `GET /api/ro/network`
AP and STA network state.
### `GET /api/ro/system`
System-level device information.
## 6. MQTT Payload Model
### 6.1 Sensor Inventory Topic
Topic:
主题:
```text
<topicPrefix>/<MAC>/sensors
```
Payload example:
示例:
```json
{
@@ -231,15 +199,15 @@ Payload example:
}
```
### 6.2 Real-Time Data Topic
### 实时数据
Topic:
主题:
```text
<topicPrefix>/<MAC>/realtime
```
Payload example:
示例:
```json
{
@@ -249,47 +217,40 @@ Payload example:
{
"id": 1,
"online": true,
"wind_speed_ms": 0.0,
"wind_level": 0,
"wind_speed_kmh": 0.0,
"angle_deg": 206.3,
"direction_code": 9,
"direction_name": "SSW",
"wind_speed_ms": 3.8,
"wind_level": 3,
"wind_speed_kmh": 13.68,
"angle_deg": 45.0,
"direction_code": 2,
"direction_name": "NE",
"age_ms": 180
}
]
}
```
## 7. Build and Flash
## 编译与烧录
### 7.1 Dependencies
Configured in `platformio.ini`:
- `ModbusMaster`
- `PubSubClient`
### 7.2 Build
### 编译
```powershell
platformio run
```
### 7.3 Upload
### 烧录
```powershell
platformio run -t upload
```
## 8. Project Status
## 当前版本更新点
This repository contains the current commercial firmware baseline including:
- AP + STA networking
- Guided RS485 sensor provisioning
- Real-time web dashboard
- Read-only HTTP API
- MQTT cloud publishing
- Release-ready firmware artifact workflow
`RC1.0.2` 包含以下调整:
- 页面标题更新为 `ClearESP WindSensors`
- 顶部新增“数字植物实验室”标识
- 改址成功提示改为更适合现场使用的话术
- 系统状态页新增固件版本显示
- 系统状态页新增“恢复出厂”和“系统重启”
- “清空配置并重新引导”不再误删 STA / MQTT 配置
- 针对部分不返回标准写应答的传感器,采用写后读回验证确认站号是否生效
+189 -23
View File
@@ -28,6 +28,7 @@ constexpr uint32_t MODBUS_RESPONSE_TIMEOUT_MS = 500;
constexpr uint32_t WIFI_RETRY_INTERVAL_MS = 15000;
constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 20000;
constexpr bool ENABLE_BOOT_DIAGNOSTICS = false;
constexpr char FIRMWARE_VERSION[] = "RC1.0.2";
constexpr uint16_t MQTT_DEFAULT_PORT = 1883;
constexpr uint32_t MQTT_RETRY_INTERVAL_MS = 10000;
constexpr uint32_t MQTT_PUBLISH_INTERVAL_MS = 1000;
@@ -156,9 +157,10 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
}
.brand {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
width: 100%;
}
.brand h1 {
margin: 0;
@@ -166,6 +168,12 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
line-height: 1;
letter-spacing: 0.02em;
}
.brand-note {
font-size: clamp(13px, 2vw, 16px);
color: var(--cyan2);
font-weight: 700;
text-align: right;
}
.nav {
display: grid;
gap: 8px;
@@ -375,6 +383,12 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sys-actions {
margin-top: 14px;
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.api-grid {
display: grid;
gap: 10px;
@@ -437,6 +451,13 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
.topbar, .panel { border-radius: 20px; }
.topbar { padding: 12px; }
.panel { padding: 14px; }
.brand {
align-items: flex-start;
flex-direction: column;
}
.brand-note {
text-align: left;
}
.sensor-grid { grid-template-columns: 1fr; }
.stat-line { gap: 8px; padding: 10px; }
.stat .label { font-size: 10px; }
@@ -450,7 +471,8 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
<section class="topbar">
<div class="brandline">
<div class="brand">
<h1>ClearESP Wind</h1>
<h1>ClearESP WindSensors</h1>
<div class="brand-note">数字植物实验室</div>
</div>
</div>
<nav class="nav">
@@ -617,6 +639,10 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
<section id="page-system" class="panel page">
<h2>&#31995;&#32479;&#29366;&#24577;</h2>
<div id="systemWrap" class="sys-grid"></div>
<div class="sys-actions">
<button class="action warn" onclick="factoryReset()">恢复出厂</button>
<button class="action ghost" onclick="rebootSystem()">系统重启</button>
</div>
</section>
</div>
@@ -750,6 +776,7 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
document.getElementById('systemWrap').innerHTML = [
text('AP SSID', data.apSsid),
text('AP IP', data.apIp),
text('固件版本', data.firmwareVersion),
text('STA SSID', data.staSsid || 'NA'),
text('STA IP', data.staIp),
text('\u5916\u90e8 WiFi \u72b6\u6001', data.staStatus),
@@ -884,6 +911,16 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
postAction('/api/config/reset');
}
function factoryReset() {
if (!confirm('确定恢复出厂设置吗?这将清空传感器、WiFi 和 MQTT 的全部已保存配置。')) return;
postAction('/api/system/factory-reset');
}
function rebootSystem() {
if (!confirm('确定立即重启系统吗?')) return;
postAction('/api/system/reboot');
}
document.addEventListener('visibilitychange', () => {
scheduleRefresh(document.hidden ? 1200 : 100);
});
@@ -989,6 +1026,10 @@ const char *modbusResultText(uint8_t code) {
}
}
String modbusResultSummary(const char *label, uint8_t code) {
return String(label) + "=" + String(code) + "(" + String(modbusResultText(code)) + ")";
}
const char *directionCodeToText(uint16_t code, float angle) {
static constexpr const char *kDir8[] = {
"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
@@ -1106,6 +1147,35 @@ uint8_t sendSpecialWriteAddress(uint8_t newAddress, uint8_t &replyAddress) {
return ModbusMaster::ku8MBSuccess;
}
uint8_t sendWriteAddressRaw(uint8_t slaveAddress, uint8_t newAddress, uint8_t &replyAddress) {
uint8_t request[8] = {slaveAddress, 0x06, 0x00, 0x30, 0x00, newAddress, 0x00, 0x00};
const uint16_t requestCrc = modbusCrc16(request, 6);
request[6] = static_cast<uint8_t>(requestCrc & 0x00FF);
request[7] = static_cast<uint8_t>((requestCrc >> 8) & 0x00FF);
clearModbusRxBuffer();
rs485Serial->write(request, sizeof(request));
rs485Serial->flush();
uint8_t response[8] = {};
if (!readModbusFrame(response, sizeof(response))) {
return ModbusMaster::ku8MBResponseTimedOut;
}
const uint16_t responseCrc = static_cast<uint16_t>(response[6]) |
(static_cast<uint16_t>(response[7]) << 8);
if (modbusCrc16(response, 6) != responseCrc) {
return ModbusMaster::ku8MBInvalidCRC;
}
if (response[0] != slaveAddress || response[1] != 0x06 || response[2] != 0x00 ||
response[3] != 0x30 || response[4] != 0x00 || response[5] != newAddress) {
return ModbusMaster::ku8MBInvalidFunction;
}
replyAddress = response[0];
return ModbusMaster::ku8MBSuccess;
}
void setNodeAddress(uint8_t address) {
modbus->begin(address, *rs485Serial);
}
@@ -1195,29 +1265,27 @@ void clearRuntimeData() {
}
void resetConfiguration() {
preferences->clear();
preferences->remove(PREF_COUNT);
preferences->remove(PREF_DONE);
clearRuntimeData();
state.configuredCount = 0;
state.setupComplete = false;
state.staSsid = "";
state.staPassword = "";
state.staEnabled = false;
state.staConnected = false;
state.mqttHost = "";
state.mqttPort = MQTT_DEFAULT_PORT;
state.mqttUsername = "";
state.mqttPassword = "";
state.mqttTopicPrefix = "clearesp/wind";
state.mqttEnabled = false;
state.mqttConnected = false;
state.mqttStatus = "Not configured";
state.mqttLastAttemptMs = 0;
state.mqttLastPublishMs = 0;
for (uint8_t i = 0; i < MAX_SENSORS; ++i) {
sensors[i].id = 0;
}
state.lastPublishedSensorCount = 0xFF;
setStaStatus("未配置", "NA");
setWizardText(
"已清空全部配置。",
"请先断开全部传感器,再只接入 1 个待配置传感器,然后点击“配置下一个传感器”。");
"已清空传感器配置。",
"WiFi 和 MQTT 配置已保留。请先断开全部传感器,再只接入 1 个待配置传感器,然后点击“配置下一个传感器”。");
}
void resetToFactoryDefaults() {
preferences->clear();
clearRuntimeData();
state = AppState{};
initializeStateTexts();
setStaStatus("未配置", "NA");
setMqttStatus("Not configured");
}
void disconnectSta() {
@@ -1401,6 +1469,18 @@ bool writeAddressViaSpecial(uint8_t newAddress, uint8_t &resultCode) {
return resultCode == modbus->ku8MBSuccess;
}
bool writeAddressAtAddress(uint8_t currentAddress, uint8_t newAddress, uint8_t &resultCode) {
setNodeAddress(currentAddress);
resultCode = modbus->writeSingleRegister(REG_DEVICE_ADDRESS, newAddress);
return resultCode == modbus->ku8MBSuccess;
}
bool writeAddressAtAddressRaw(uint8_t currentAddress, uint8_t newAddress, uint8_t &resultCode) {
uint8_t replyAddress = 0;
resultCode = sendWriteAddressRaw(currentAddress, newAddress, replyAddress);
return resultCode == modbus->ku8MBSuccess;
}
bool verifySensorPresenceAtAddress(uint8_t address, uint8_t &resultCode) {
setNodeAddress(address);
resultCode = modbus->readHoldingRegisters(REG_DEVICE_ADDRESS, 1);
@@ -1503,10 +1583,79 @@ bool configureNextSensor() {
}
if (currentId != targetId) {
if (!writeAddressViaSpecial(targetId, resultCode)) {
uint8_t specialWriteCode = 0xFF;
uint8_t rawWriteCode = 0xFF;
uint8_t masterWriteCode = 0xFF;
bool writeOk = writeAddressAtAddressRaw(currentId, targetId, resultCode);
rawWriteCode = resultCode;
if (!writeOk) {
delay(150);
if (verifySensorWithRetry(targetId, resultCode, attemptsUsed)) {
sensors[state.configuredCount].id = targetId;
sensors[state.configuredCount].online = false;
sensors[state.configuredCount].modbusResult = 0xFF;
sensors[state.configuredCount].lastReadMs = 0;
state.configuredCount = targetId;
state.setupComplete = false;
saveConfig();
setWizardText(
"站号 " + String(targetId) + " 已生效,设备未返回标准写入应答。",
"已通过读回验证确认新站号生效,不影响使用。");
return true;
}
}
if (!writeOk) {
writeOk = writeAddressAtAddress(currentId, targetId, resultCode);
masterWriteCode = resultCode;
}
if (!writeOk) {
delay(150);
if (verifySensorWithRetry(targetId, resultCode, attemptsUsed)) {
sensors[state.configuredCount].id = targetId;
sensors[state.configuredCount].online = false;
sensors[state.configuredCount].modbusResult = 0xFF;
sensors[state.configuredCount].lastReadMs = 0;
state.configuredCount = targetId;
state.setupComplete = false;
saveConfig();
setWizardText(
"站号 " + String(targetId) + " 已生效,设备未返回标准 Modbus 应答。",
"已通过读回验证确认新站号生效,不影响使用。");
return true;
}
}
if (!writeOk) {
writeOk = writeAddressViaSpecial(targetId, resultCode);
specialWriteCode = resultCode;
}
if (!writeOk) {
delay(150);
if (verifySensorWithRetry(targetId, resultCode, attemptsUsed)) {
sensors[state.configuredCount].id = targetId;
sensors[state.configuredCount].online = false;
sensors[state.configuredCount].modbusResult = 0xFF;
sensors[state.configuredCount].lastReadMs = 0;
state.configuredCount = targetId;
state.setupComplete = false;
saveConfig();
setWizardText(
"站号 " + String(targetId) + " 已生效,设备未返回特殊地址应答。",
"已通过读回验证确认新站号生效,不影响使用。");
return true;
}
}
if (!writeOk) {
const String detail = modbusResultSummary("raw", rawWriteCode) + ", " +
modbusResultSummary("master", masterWriteCode) + ", " +
modbusResultSummary("special", specialWriteCode);
Serial.printf(
"CFG: write target=%u from current=%u failed, special=%u(%s), raw=%u(%s), master=%u(%s)\n",
targetId, currentId, specialWriteCode, modbusResultText(specialWriteCode), rawWriteCode,
modbusResultText(rawWriteCode), masterWriteCode, modbusResultText(masterWriteCode));
setWizardText(
"已读到当前地址 " + String(currentId) + ",但写入目标站号 " + String(targetId) + " 失败。",
"请检查接线、供电,并确认总线上仍然只有这 1 个待配置传感器。");
"结果: " + detail +
"。已按厂家文档依次尝试原地址裸帧、标准 Modbus、特殊地址改写,并在每步后读回验证。");
return false;
}
delay(150);
@@ -1573,6 +1722,7 @@ String buildStateJson() {
json += "\"mqttResolvedTopic\":\"" + jsonEscaped(mqttResolvedTopicPrefix()) + "\",";
json += "\"mqttStatus\":\"" + jsonEscaped(state.mqttStatus) + "\",";
json += "\"mqttHint\":\"" + jsonEscaped(mqttHintText()) + "\",";
json += "\"firmwareVersion\":\"" + String(FIRMWARE_VERSION) + "\",";
json += "\"configuredCount\":" + String(state.configuredCount) + ",";
json += "\"setupComplete\":" + String(state.setupComplete ? "true" : "false") + ",";
json += "\"nextTargetId\":" + String(state.configuredCount + 1) + ",";
@@ -1687,6 +1837,7 @@ String buildMqttInventoryPayload() {
String buildMqttRealtimePayload() {
String json = "{";
json += "\"mac\":\"" + hexMacString() + "\",";
json += "\"firmware_version\":\"" + String(FIRMWARE_VERSION) + "\",";
json += "\"uptime_ms\":" + String(millis()) + ",";
json += "\"sensors\":[";
for (uint8_t i = 0; i < state.configuredCount; ++i) {
@@ -1784,6 +1935,19 @@ void handleReset() {
sendStateJson();
}
void handleFactoryReset() {
resetToFactoryDefaults();
sendStateJson();
delay(200);
ESP.restart();
}
void handleReboot() {
sendStateJson();
delay(200);
ESP.restart();
}
void handleWiFiSave() {
const String ssid = server->arg("ssid");
const String password = server->arg("password");
@@ -1908,6 +2072,8 @@ void setupWebServer() {
server->on("/api/config/finish", HTTP_POST, handleFinish);
server->on("/api/config/resume", HTTP_POST, handleResume);
server->on("/api/config/reset", HTTP_POST, handleReset);
server->on("/api/system/factory-reset", HTTP_POST, handleFactoryReset);
server->on("/api/system/reboot", HTTP_POST, handleReboot);
server->on("/api/wifi/save", HTTP_POST, handleWiFiSave);
server->on("/api/wifi/retry", HTTP_POST, handleWiFiRetry);
server->on("/api/wifi/clear", HTTP_POST, handleWiFiClear);