Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ac50ecc7 | ||
|
|
2d82b4bcb8 | ||
|
|
6ca234a205 |
No files matched your search
Binary file not shown.
@@ -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
@@ -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">上报主题</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>系统状态</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");
|
||||
|
||||
Reference in new issue
Block a user