3 Commits
Author SHA1 Message Date
clear d0ac50ecc7 Release RC1.0.4 2026-05-08 13:44:03 +08:00
clear 2d82b4bcb8 Release RC1.0.3 2026-05-08 13:27:00 +08:00
clear 6ca234a205 Release RC1.0.2 2026-05-08 13:13:13 +08:00
3 changed files with 727 additions and 268 deletions

No files matched your search

Binary file not shown.
+173 -217
View File
@@ -1,295 +1,251 @@
# 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 风速风向传感器采集网关固件,面向现场部署、批量站号配置、Web 运维和 MQTT 上报场景。
This firmware is designed for commercial field deployment and provides:
当前固件版本:`RC1.0.4`
- 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 实时数据上报
- OTA 在线检查更新与固件升级
- 掉电配置保存
## 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
- 串口:`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..N` 顺序为传感器分配站号。
现场操作规则:
- 每次配置时,总线上只保留 `1` 个待配置传感器
- 已配置好的设备先断开,避免误改地址
- 配置成功后,再接入下一个传感器
推荐流程:
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. MQTT 上报
Supported settings:
配置 MQTT 后,系统会自动连接并周期发布实时数据。
- MQTT server host
- MQTT port
- MQTT username
- MQTT password
- Topic prefix
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
Default topic prefix:
默认主题前缀:
- `clearesp/wind`
Published topics:
完整主题:
- `<topicPrefix>/<MAC>/sensors`
- `<topicPrefix>/<MAC>/realtime`
### 2.6 Read-Only HTTP API
说明:
The firmware exposes read-only HTTP endpoints for third-party systems.
- `/realtime` 已包含传感器 ID、在线状态和实时风速风向数据
- 不再单独发布 `/sensors`
Available endpoints:
### 6. OTA
云服务页提供 OTA 卡片,面向现场人员只展示:
- 当前固件版本
- 最新版本
- 检查更新按钮
- 下载并升级按钮
说明:
- OTA 需要设备先连接外部 WiFi
- 检测到新版本后才允许执行升级
- 升级完成后设备会自动重启
### 7. 恢复出厂
“恢复出厂”会清空全部持久化配置,包括:
- 传感器配置
- WiFi 配置
- MQTT 配置
执行后系统会自动重启。
## HTTP API
所有接口均返回 `JSON`。
### 状态接口
- `GET /api/state`
返回整机完整状态,包括:
- AP / STA 状态
- MQTT 状态
- OTA 状态
- 引导配置状态
- 传感器列表
- 固件版本
### 传感器接口
- `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
{
"mac": "B43A455471D4",
"sensor_count": 2,
"sensors": [
{ "id": 1 },
{ "id": 2 }
]
}
```
### 6.2 Real-Time Data Topic
Topic:
主题:
```text
<topicPrefix>/<MAC>/realtime
```
Payload example:
示例:
```json
{
"mac": "B43A455471D4",
"firmware_version": "RC1.0.4",
"uptime_ms": 123456,
"sensors": [
{
"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.4 变更
- MQTT 仅保留 `/realtime` 上报与页面展示
- 系统状态页显示固件版本
- 云服务页 OTA 卡片改为现场可用的检查更新 / 下载升级流程
- OTA 页面不再向用户展示项目地址或固件直链
- 补齐 OTA 检查与升级接口
- 修正文档中文乱码并同步更新说明
+554 -51
View File
@@ -2,6 +2,8 @@
#include <ModbusMaster.h>
#include <Preferences.h>
#include <PubSubClient.h>
#include <HTTPClient.h>
#include <Update.h>
#include <WebServer.h>
#include <WiFi.h>
#include <esp_rom_sys.h>
@@ -28,6 +30,8 @@ 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.4";
constexpr char OTA_PROJECT_URL[] = "http://107.173.157.41:3000/clear/wind_vt_esp32";
constexpr uint16_t MQTT_DEFAULT_PORT = 1883;
constexpr uint32_t MQTT_RETRY_INTERVAL_MS = 10000;
constexpr uint32_t MQTT_PUBLISH_INTERVAL_MS = 1000;
@@ -84,6 +88,10 @@ struct AppState {
uint32_t mqttLastAttemptMs = 0;
uint32_t mqttLastPublishMs = 0;
uint8_t lastPublishedSensorCount = 0xFF;
String otaStatus;
String otaLatestVersion;
String otaBinUrl;
bool otaUpdateAvailable = false;
} state;
HardwareSerial *rs485Serial = nullptr;
@@ -156,9 +164,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 +175,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 +390,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 +458,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 +478,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">
@@ -611,12 +640,26 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
<div class="label">&#19978;&#25253;&#20027;&#39064;</div>
<code class="mono" id="mqttTopics">--</code>
</div>
<div class="api-card">
<div class="label">OTA</div>
<div class="subtext">当前固件可直接通过 Gitea 发布页中的 bin 文件进行升级。</div>
<div class="subtext" id="otaSummary">当前固件版本 -- | 最新版本 --</div>
<div class="subtext" id="otaStatusText" style="color:#cfe7de;">--</div>
<div class="actions">
<button id="btnOtaCheck" class="action ghost" onclick="checkOtaUpdate()">检查更新</button>
<button id="btnOtaUpdate" class="action" onclick="startOtaUpdate()">下载并升级</button>
</div>
</div>
</div>
</section>
<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>
@@ -742,14 +785,29 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
document.getElementById('btnMqttSave').disabled = busy;
document.getElementById('btnMqttRetry').disabled = busy || !data.mqttEnabled;
document.getElementById('btnMqttClear').disabled = busy || (!data.mqttEnabled && !data.mqttHost);
document.getElementById('mqttTopics').textContent =
`${data.mqttResolvedTopic}/sensors\n${data.mqttResolvedTopic}/realtime`;
document.getElementById('mqttTopics').textContent = `${data.mqttResolvedTopic}/realtime`;
}
function renderSystem(data) {
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),
text('MQTT', data.mqttStatus),
text('\u5df2\u914d\u7f6e\u4f20\u611f\u5668', String(data.configuredCount)),
text('\u5f53\u524d\u6a21\u5f0f', data.setupComplete ? '\u8fd0\u884c\u4e2d' : '\u5f15\u5bfc\u914d\u7f6e\u4e2d'),
text('\u4e0b\u4e00\u76ee\u6807\u7ad9\u53f7', String(data.nextTargetId))
].join('');
}
function renderSystemPanel(data) {
document.getElementById('systemWrap').innerHTML = [
text('AP SSID', data.apSsid),
text('AP IP', data.apIp),
text('\u56fa\u4ef6\u7248\u672c', data.firmwareVersion),
text('STA SSID', data.staSsid || 'NA'),
text('STA IP', data.staIp),
text('\u5916\u90e8 WiFi \u72b6\u6001', data.staStatus),
@@ -765,7 +823,8 @@ const char WEB_PAGE[] PROGMEM = R"HTML(
renderSetup(data);
renderWifi(data);
renderMqtt(data);
renderSystem(data);
renderOtaPanel(data);
renderSystemPanel(data);
document.getElementById('apiBase').textContent = window.location.origin || ('http://' + data.apIp);
}
@@ -884,6 +943,53 @@ 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');
}
function checkOtaUpdate() {
postAction('/api/ota/check');
}
function startOtaUpdate() {
if (!confirm('确定下载并升级到最新固件吗?升级完成后系统会自动重启。')) return;
postAction('/api/ota/update');
}
function renderOtaPanel(data) {
const summary = document.getElementById('otaSummary');
const status = document.getElementById('otaStatusText');
const btnCheck = document.getElementById('btnOtaCheck');
const btnUpdate = document.getElementById('btnOtaUpdate');
const card = summary ? summary.closest('.api-card') : null;
if (card) {
const intro = card.querySelector('.subtext');
if (intro) {
intro.textContent = 'OTA 需要设备先连接外部 WiFi。检测到新版本后,可直接下载并升级。';
}
}
if (summary) {
summary.textContent = `当前固件版本 ${data.firmwareVersion} | 最新版本 ${data.otaLatestVersion || '--'}`;
}
if (status) {
status.textContent = data.otaStatus || '--';
}
if (btnCheck) {
btnCheck.disabled = busy;
btnCheck.textContent = '检查更新';
}
if (btnUpdate) {
btnUpdate.disabled = busy || !data.otaUpdateAvailable;
btnUpdate.textContent = '下载并升级';
}
}
document.addEventListener('visibilitychange', () => {
scheduleRefresh(document.hidden ? 1200 : 100);
});
@@ -989,6 +1095,113 @@ const char *modbusResultText(uint8_t code) {
}
}
String modbusResultSummary(const char *label, uint8_t code) {
return String(label) + "=" + String(code) + "(" + String(modbusResultText(code)) + ")";
}
String currentReleaseTag() {
String tag = FIRMWARE_VERSION;
if (tag.startsWith("RC")) {
tag.remove(0, 2);
}
return "v" + tag;
}
String otaReleaseTag() {
return currentReleaseTag();
}
String otaAssetName() {
return "ClearESP-WindSensors-ESP32C3-" + otaReleaseTag() + ".bin";
}
String otaReleaseUrl() {
return String(OTA_PROJECT_URL) + "/releases/tag/" + otaReleaseTag();
}
String otaBinUrl() {
return otaReleaseUrl() + "/" + otaAssetName();
}
String otaAssetNameForTag(const String &tag) {
return "ClearESP-WindSensors-ESP32C3-" + tag + ".bin";
}
String otaReleaseUrlForTag(const String &tag) {
return String(OTA_PROJECT_URL) + "/releases/tag/" + tag;
}
String otaBinUrlForTag(const String &tag) {
return String(OTA_PROJECT_URL) + "/releases/download/" + tag + "/" + otaAssetNameForTag(tag);
}
String normalizeVersion(const String &version) {
String out;
for (size_t i = 0; i < version.length(); ++i) {
const char c = version[i];
if ((c >= '0' && c <= '9') || c == '.') {
out += c;
}
}
return out;
}
int compareVersionText(const String &lhs, const String &rhs) {
String a = normalizeVersion(lhs);
String b = normalizeVersion(rhs);
int ai = 0;
int bi = 0;
while (ai < a.length() || bi < b.length()) {
long av = 0;
long bv = 0;
while (ai < a.length() && a[ai] != '.') {
av = av * 10 + (a[ai] - '0');
++ai;
}
while (bi < b.length() && b[bi] != '.') {
bv = bv * 10 + (b[bi] - '0');
++bi;
}
if (av < bv) {
return -1;
}
if (av > bv) {
return 1;
}
if (ai < a.length() && a[ai] == '.') {
++ai;
}
if (bi < b.length() && b[bi] == '.') {
++bi;
}
}
return 0;
}
String extractJsonStringValue(const String &json, const String &key) {
const String marker = "\"" + key + "\":\"";
const int start = json.indexOf(marker);
if (start < 0) {
return "";
}
const int valueStart = start + marker.length();
int valueEnd = valueStart;
while (valueEnd < static_cast<int>(json.length())) {
if (json[valueEnd] == '"' && json[valueEnd - 1] != '\\') {
break;
}
++valueEnd;
}
if (valueEnd >= static_cast<int>(json.length())) {
return "";
}
String value = json.substring(valueStart, valueEnd);
value.replace("\\/", "/");
return value;
}
const char *directionCodeToText(uint16_t code, float angle) {
static constexpr const char *kDir8[] = {
"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
@@ -1106,6 +1319,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);
}
@@ -1144,11 +1386,30 @@ void setMqttStatus(const String &status) {
state.mqttStatus = status;
}
void setOtaStatus(const String &status) {
state.otaStatus = status;
}
void initializeStateTexts() {
setWizardText(
"请进入引导配置模式,并确保总线上只保留 1 个待配置传感器。",
"系统已就绪。请先断开所有已配置设备,只保留当前要配置的那 1 个传感器。");
setStaStatus("未配置", "NA");
setOtaStatus("未检查");
state.otaLatestVersion = currentReleaseTag();
state.otaBinUrl = otaBinUrl();
state.otaUpdateAvailable = false;
}
void initializeUiTextsSafe() {
setWizardText(
"请进入引导配置模式,并确保总线上只保留 1 个待配置传感器。",
"系统已就绪。请先断开所有已配置设备,只保留当前要配置的那 1 个传感器。");
setStaStatus("未配置", "NA");
setOtaStatus("未检查");
state.otaLatestVersion = currentReleaseTag();
state.otaBinUrl = otaBinUrl();
state.otaUpdateAvailable = false;
}
void loadConfig() {
@@ -1195,29 +1456,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{};
initializeUiTextsSafe();
setStaStatus("未配置", "NA");
setMqttStatus("Not configured");
}
void disconnectSta() {
@@ -1331,7 +1590,7 @@ String mqttHintText() {
return "MQTT is not configured. No cloud publish is active.";
}
if (state.mqttConnected) {
return "MQTT connected. Inventory and realtime sensor data are being published with the device MAC.";
return "MQTT connected. Realtime sensor data are being published with the device MAC.";
}
return "MQTT is saved but offline. The device will retry automatically.";
}
@@ -1343,6 +1602,115 @@ bool mqttNeedsRetry() {
return millis() - state.mqttLastAttemptMs >= MQTT_RETRY_INTERVAL_MS;
}
bool fetchLatestOtaRelease(String &latestTag, String &latestBin, String &error) {
if (!state.staConnected) {
error = "OTA 需要先连接外部 WiFi";
return false;
}
HTTPClient http;
WiFiClient client;
const String url = String(OTA_PROJECT_URL) + "/api/v1/repos/clear/wind_vt_esp32/releases/latest";
if (!http.begin(client, url)) {
error = "无法建立 OTA 检查连接";
return false;
}
http.setTimeout(8000);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
const int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
error = "OTA 检查失败 HTTP " + String(httpCode);
http.end();
return false;
}
const String body = http.getString();
http.end();
latestTag = extractJsonStringValue(body, "tag_name");
if (latestTag.isEmpty()) {
error = "未能解析最新版本号";
return false;
}
latestBin = otaBinUrlForTag(latestTag);
return true;
}
bool refreshOtaState(String &error) {
String latestTag;
String latestBin;
if (!fetchLatestOtaRelease(latestTag, latestBin, error)) {
state.otaUpdateAvailable = false;
return false;
}
state.otaLatestVersion = latestTag;
state.otaBinUrl = latestBin;
state.otaUpdateAvailable = compareVersionText(currentReleaseTag(), latestTag) < 0;
if (state.otaUpdateAvailable) {
setOtaStatus("发现新版本 " + latestTag);
} else {
setOtaStatus("当前已是最新版本");
}
return true;
}
bool performOtaUpdate(const String &binUrl, String &error) {
HTTPClient http;
WiFiClient client;
if (!http.begin(client, binUrl)) {
error = "无法建立 OTA 下载连接";
return false;
}
http.setTimeout(15000);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
const int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
error = "OTA 下载失败 HTTP " + String(httpCode);
http.end();
return false;
}
const int contentLength = http.getSize();
if (contentLength <= 0) {
error = "OTA 固件大小无效";
http.end();
return false;
}
if (!Update.begin(contentLength)) {
error = "OTA 无法开始写入";
http.end();
return false;
}
WiFiClient *stream = http.getStreamPtr();
const size_t written = Update.writeStream(*stream);
if (written != static_cast<size_t>(contentLength)) {
error = "OTA 写入不完整";
Update.abort();
http.end();
return false;
}
if (!Update.end()) {
error = "OTA 结束失败";
http.end();
return false;
}
if (!Update.isFinished()) {
error = "OTA 未完成";
http.end();
return false;
}
http.end();
return true;
}
void disconnectMqtt() {
if (mqttClient != nullptr && mqttClient->connected()) {
mqttClient->disconnect();
@@ -1401,6 +1769,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 +1883,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 +2022,10 @@ String buildStateJson() {
json += "\"mqttResolvedTopic\":\"" + jsonEscaped(mqttResolvedTopicPrefix()) + "\",";
json += "\"mqttStatus\":\"" + jsonEscaped(state.mqttStatus) + "\",";
json += "\"mqttHint\":\"" + jsonEscaped(mqttHintText()) + "\",";
json += "\"firmwareVersion\":\"" + String(FIRMWARE_VERSION) + "\",";
json += "\"otaStatus\":\"" + jsonEscaped(state.otaStatus) + "\",";
json += "\"otaLatestVersion\":\"" + jsonEscaped(state.otaLatestVersion) + "\",";
json += "\"otaUpdateAvailable\":" + String(state.otaUpdateAvailable ? "true" : "false") + ",";
json += "\"configuredCount\":" + String(state.configuredCount) + ",";
json += "\"setupComplete\":" + String(state.setupComplete ? "true" : "false") + ",";
json += "\"nextTargetId\":" + String(state.configuredCount + 1) + ",";
@@ -1660,6 +2113,10 @@ String buildSystemJson() {
String json = "{";
json += "\"device\":\"ClearESP-Wind\",";
json += "\"mac\":\"" + hexMacString() + "\",";
json += "\"firmware_version\":\"" + String(FIRMWARE_VERSION) + "\",";
json += "\"ota_latest_version\":\"" + jsonEscaped(state.otaLatestVersion) + "\",";
json += "\"ota_update_available\":" + String(state.otaUpdateAvailable ? "true" : "false") + ",";
json += "\"ota_status\":\"" + jsonEscaped(state.otaStatus) + "\",";
json += "\"uptime_ms\":" + String(millis()) + ",";
json += "\"free_heap\":" + String(ESP.getFreeHeap()) + ",";
json += "\"configured_sensor_count\":" + String(state.configuredCount) + ",";
@@ -1669,24 +2126,10 @@ String buildSystemJson() {
return json;
}
String buildMqttInventoryPayload() {
String json = "{";
json += "\"mac\":\"" + hexMacString() + "\",";
json += "\"sensor_count\":" + String(state.configuredCount) + ",";
json += "\"sensors\":[";
for (uint8_t i = 0; i < state.configuredCount; ++i) {
if (i > 0) {
json += ",";
}
json += "{\"id\":" + String(sensors[i].id) + "}";
}
json += "]}";
return json;
}
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) {
@@ -1711,22 +2154,16 @@ String buildMqttRealtimePayload() {
return json;
}
void publishMqttTelemetry(bool forceInventory) {
void publishMqttTelemetry(bool /*forceInventory*/) {
if (!state.mqttConnected || mqttClient == nullptr || !mqttClient->connected()) {
return;
}
const String baseTopic = mqttResolvedTopicPrefix();
if (forceInventory || state.lastPublishedSensorCount != state.configuredCount) {
const String inventoryTopic = baseTopic + "/sensors";
const String inventoryPayload = buildMqttInventoryPayload();
mqttClient->publish(inventoryTopic.c_str(), inventoryPayload.c_str(), true);
state.lastPublishedSensorCount = state.configuredCount;
}
const String realtimeTopic = baseTopic + "/realtime";
const String realtimePayload = buildMqttRealtimePayload();
mqttClient->publish(realtimeTopic.c_str(), realtimePayload.c_str(), false);
state.lastPublishedSensorCount = state.configuredCount;
state.mqttLastPublishMs = millis();
}
@@ -1784,6 +2221,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");
@@ -1883,6 +2333,55 @@ void handleMqttClear() {
sendStateJson();
}
void handleOtaCheck() {
String error;
if (!refreshOtaState(error)) {
setOtaStatus(error.isEmpty() ? "检查更新失败" : error);
}
sendStateJson();
}
void handleOtaUpdate() {
if (!state.staConnected) {
setOtaStatus("OTA 需要先连接外部 WiFi");
sendStateJson();
return;
}
String error;
if (!refreshOtaState(error)) {
setOtaStatus(error.isEmpty() ? "检查更新失败" : error);
sendStateJson();
return;
}
if (!state.otaUpdateAvailable) {
setOtaStatus("当前已是最新版本");
sendStateJson();
return;
}
if (state.otaBinUrl.isEmpty()) {
setOtaStatus("未获取到升级文件");
sendStateJson();
return;
}
setOtaStatus("正在下载并升级");
sendStateJson();
if (!performOtaUpdate(state.otaBinUrl, error)) {
setOtaStatus(error.isEmpty() ? "升级失败" : error);
sendStateJson();
return;
}
setOtaStatus("升级完成,正在重启");
sendStateJson();
delay(300);
ESP.restart();
}
void sendRootRedirect() {
server->sendHeader("Cache-Control", "no-store, no-cache, must-revalidate");
server->sendHeader("Pragma", "no-cache");
@@ -1908,12 +2407,16 @@ 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);
server->on("/api/mqtt/save", HTTP_POST, handleMqttSave);
server->on("/api/mqtt/retry", HTTP_POST, handleMqttRetry);
server->on("/api/mqtt/clear", HTTP_POST, handleMqttClear);
server->on("/api/ota/check", HTTP_POST, handleOtaCheck);
server->on("/api/ota/update", HTTP_POST, handleOtaUpdate);
server->onNotFound([]() {
const String uri = server->uri();
if (!uri.startsWith("/api/")) {
@@ -1956,7 +2459,7 @@ void setup() {
mqttClient = new PubSubClient(mqttNetClient);
esp_rom_printf("BOOT: runtime objects ok\n");
initializeStateTexts();
initializeUiTextsSafe();
esp_rom_printf("BOOT: init texts ok\n");
preferences->begin(PREF_NAMESPACE, false);
esp_rom_printf("BOOT: preferences ok\n");