Initial ESP32 RLCD metrics workspace

This commit is contained in:
Codex 2026-06-23 20:38:23 +08:00
commit 035f1e8cea
48 changed files with 12883 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
# Local/private config
main/wifi_private.h
.env
.env.*
*.local
.agents/
.codex/
# Build outputs and generated binaries
build/
managed_components/
*.bin
*.elf
*.map
*.uf2
build-output.log
build-error.log
sdkconfig.old
nul
# Logs and runtime state
*.log
*.err.log
*.pid
# Python caches and virtual environments
__pycache__/
*.py[cod]
.venv/
.venv-*/
tools/pc-metrics/.venv-ble/
# Local reference repositories, diagnostics, and archived one-off scripts
espcc-switch/
diagnostics/
scripts/_archive_*/
# OS/editor noise
.DS_Store
Thumbs.db

10
CMakeLists.txt Normal file
View File

@ -0,0 +1,10 @@
# ============================================================
# ESP32-S3-RLCD-4.2 横屏时钟项目
# 顶层 CMake 配置
# ============================================================
cmake_minimum_required(VERSION 3.16)
# 引入 ESP-IDF 的构建系统(必须)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(rlcd_clock)

32
dependencies.lock Normal file
View File

@ -0,0 +1,32 @@
dependencies:
espressif/esp_lvgl_port:
component_hash: 3264cc85d86a7c32b67d26a1353a57c67e7986bb293ebb1731801936b056e632
dependencies:
- name: idf
require: private
version: '>=5.1'
- name: lvgl/lvgl
registry_url: https://components.espressif.com
require: public
version: '>=8,<10'
source:
registry_url: https://components.espressif.com/
type: service
version: 2.6.3
idf:
source:
type: idf
version: 6.0.1
lvgl/lvgl:
component_hash: b702d642e03e95928046d5c6726558e6444e112420c77efa5fdb6650b0a13c5d
dependencies: []
source:
registry_url: https://components.espressif.com/
type: service
version: 9.3.0
direct_dependencies:
- espressif/esp_lvgl_port
- lvgl/lvgl
manifest_hash: 1c62eac3ca543fee78ebb97c6da4d93abeb675b6441edac6782a75d7457d809e
target: esp32s3
version: 3.0.0

View File

@ -0,0 +1,548 @@
# 智谱 GLM Coding Plan 用量查询对接文档
> **文档目的**:为后续开发者(或 AI 助手)提供智谱 GLM Coding Plan 用量查询的完整对接方案,基于 cc-switch 源码逆向 + 官方文档查证,内容**经过用户实测验证**(用户在 cc-switch 里能看到自己的套餐用量)。
>
> **权威来源**:以 `farion1231/cc-switch` 源码实现为准。该实现用户已验证可用。
>
> **本地参考源码**:`D:\AI Code\ESP-32\espcc-switch\`(已克隆,可直接查阅)
---
## 一、背景
ESP32-S3-RLCD-4.2 小屏需要显示智谱 GLM Coding Plan 的:
- 5 小时限额已用百分比 + 重置时间
- 周限额已用百分比 + 重置时间(新套餐才有)
- token 消耗统计
- 套餐层级
智谱官方**没有公开的用量查询 API 文档**,但通过逆向 cc-switch 找到了实际可用的工作端点。
## 二、关键结论(一句话总结)
| 问题 | 答案 |
|------|------|
| 用什么端点查? | `GET /api/monitor/usage/quota/limit` |
| 怎么鉴权? | **纯 API Key**,放 Authorization 头,**不加 `Bearer ` 前缀** |
| 需要浏览器 Cookie 吗? | **不需要**(跟之前社区工具 coding-plan-monitor 不一样) |
| ESP-32 能直接用吗? | **能**,纯 HTTP GET,`esp_http_client` 可 1:1 复现 |
| 怎么区分 5h vs 周? | 看 `limits[].unit` 字段:**3=5h, 6=周** |
| 怎么判断老/新套餐? | `limits[]` 里 `TOKENS_LIMIT` 条目数量(1=老无周, 2=新有周) |
| 能拿到绝对 token 数吗? | **不能**,quota/limit 只给百分比。绝对 token 要调 `model-usage` 端点 |
---
## 三、核心 API
### 端点
```
国内:GET https://open.bigmodel.cn/api/monitor/usage/quota/limit
国际:GET https://api.z.ai/api/monitor/usage/quota/limit
```
**域名选择**:
- 用户的 API Key 配在 `open.bigmodel.cn` → 用国内
- 用户的 API Key 配在 `api.z.ai` → 用国际
- 两条路径(path)完全一致,只是域名不同
**域名选择参考代码**(来自 cc-switch `coding_plan.rs:302-308`):
```rust
fn zhipu_quota_base(base_url: &str) -> &str {
if base_url.contains("bigmodel.cn") {
"https://open.bigmodel.cn"
} else {
"https://api.z.ai"
}
}
```
### 请求
```http
GET /api/monitor/usage/quota/limit HTTP/1.1
Host: open.bigmodel.cn
Authorization: <你的智谱 API_KEY>
Content-Type: application/json
Accept-Language: en-US,en
```
**关键点**:
- 方法:`GET`
- 鉴权:`Authorization` 头直接放 API Key 原值,**不要加 `Bearer ` 前缀**
- cc-switch 在 `coding_plan.rs:319` 显式注释:`// 注意:智谱不加 Bearer 前缀`
- 这跟 OpenAI、Anthropic 等标准 Bearer 鉴权不同,**是个例外**
- Content-Type: `application/json`
- Accept-Language: `en-US,en`(cc-switch 加了这个,建议照抄)
- **无请求体**
- **无查询参数**
- 建议超时:15 秒
- **不需要浏览器 Cookie**
- **不需要伪装 User-Agent**
### 返回 JSON 结构
```jsonc
{
"success": true,
"msg": "操作成功",
"data": {
"level": "Max",
"limits": [
{
"type": "TOKENS_LIMIT",
"unit": 3,
"number": 5,
"percentage": 32.5,
"nextResetTime": 1773734366338
},
{
"type": "TOKENS_LIMIT",
"unit": 6,
"number": 7,
"percentage": 45.0,
"nextResetTime": 1774663282997
}
]
}
}
```
### 字段说明
| 字段路径 | 类型 | 含义 |
|---------|------|------|
| `success` | bool | 业务级成功标记,false 时读 `msg` |
| `msg` | string | 错误文案(success=false 时有用) |
| `data.level` | string | 套餐层级(如 `"Max"` / `"Pro"` / `"Lite"`) |
| `data.limits[]` | array | 限额窗口数组 |
| `data.limits[].type` | string | `"TOKENS_LIMIT"`(我们要的) / `"TIME_LIMIT"`(使用时长,跳过) |
| `data.limits[].unit` | int | **窗口类型,核心字段**:`3` = 5 小时,`6` = 每周 |
| `data.limits[].number` | int | 数量(5h 时为 5,周时为 7) |
| `data.limits[].percentage` | float | 已用百分比 0-100 |
| `data.limits[].nextResetTime` | int(ms) | 重置毫秒时间戳;0% 用量时可能缺失 |
---
## 四、字段→业务含义映射
| 屏幕显示 | 字段路径 | 备注 |
|---------|----------|------|
| 5 小时限额已用% | `limits[type=TOKENS_LIMIT && unit=3].percentage` | 0-100 |
| 5 小时重置时间 | `limits[type=TOKENS_LIMIT && unit=3].nextResetTime` | ms 时间戳 |
| 周限额已用% | `limits[type=TOKENS_LIMIT && unit=6].percentage` | **新套餐才有**,老套餐查不到 |
| 周限额重置时间 | `limits[type=TOKENS_LIMIT && unit=6].nextResetTime` | 同上 |
| 套餐层级 | `data.level` | 字符串 |
### 时间戳转换规则(用于显示)
根据之前 UI 设计讨论的结果,重置时间的显示策略:
- 距离当前时间 **≤ 24 小时** → 显示 `HH:MM`(如 `18:42`)
- 距离当前时间 **> 24 小时** → 显示 `MM-DD`(如 `06-28`)
参考实现(`formatResetTime`):
```javascript
function formatResetTime(resetMs) {
const diff = resetMs - Date.now();
const absDiff = Math.abs(diff);
const date = new Date(resetMs);
const pad = n => String(n).padStart(2, '0');
if (absDiff <= 24 * 3600 * 1000) {
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
} else {
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
}
```
ESP-32 C 实现可用 `<time.h>` 的 `localtime_r` + `difftime`。
---
## 五、⚠️ 三个必避的坑
### 坑 1:必须用 `unit` 字段区分 5h 和周,**不能按重置时间排序**
**问题**:cc-switch 在 issue #3036 踩过——每周周期末尾,周窗口会比 5 小时窗口**更早**重置,按 `nextResetTime` 升序排序必然把"5h"和"周"标反。
**正确做法**:只看 `unit` 字段:
- `unit == 3` → 5 小时窗口
- `unit == 6` → 周窗口
**只有当 `unit` 缺失或未识别时**,才回落到启发式(无 reset 优先归 5h,其余按 reset 升序填空槽)。
**源码引用**:`cc-switch/src-tauri/src/services/coding_plan.rs:226-234` 有显式注释说明这个坑。
### 坑 2:老套餐 vs 新套餐的判定
**判定方式不是某个标志位,而是 `limits[]` 数组里 `type==TOKENS_LIMIT` 条目的数量**:
- **老套餐**(2026-02-12 前订阅):只返回 **1 条** `TOKENS_LIMIT`(只有 5h,无周)
- **新套餐**:返回 **2 条** `TOKENS_LIMIT`(5h + 周)
**当前用户**:用的是老套餐(无周限额),所以查出来只会看到 1 条 `TOKENS_LIMIT`,没有 `unit=6` 的条目。
**代码处理建议**:用两个可空 slot(`five_hour` / `weekly`),有就填、没有就留空。屏幕显示时:
- 5h 没查到 → 显示 "—"
- 周限额没查到 → 显示 "无限制" 或 "—" 或 "OLD PLAN"
**源码参考**:`cc-switch/src-tauri/src/services/coding_plan.rs:279-291` 实现了天然降级逻辑。
### 坑 3:智谱接口**只给百分比,不给绝对 token 数**
**问题**:cc-switch 在 `coding_plan.rs:282-289` 明确:`used_value_usd` / `max_value_usd` 都是 `None`。智谱接口只返回 `percentage`,**不返回绝对 token 数**。
**影响**:之前 UI 设计里说的"token 消耗按 M/B 显示"**不能从 quota/limit 接口拿到**。
**解决方案**:要拿绝对 token 数,得调另一个端点:
```
GET /api/monitor/usage/model-usage?startTime=...&endTime=...
```
- 参数:`startTime` / `endTime` URL 编码后的 `"yyyy-MM-dd HH:mm:ss"` 格式
- 官方插件 zai-coding-plugins 的默认窗口:昨天当前小时整点 → 今天当前小时末秒
- **cc-switch 没用这个接口,所以返回字段结构需要实测确认**(官方插件只是 `JSON.stringify` 原样打印,没固定字段名)
**建议**:
1. 先做百分比显示(已用 X% + 重置时间),这是 quota/limit 能直接拿到的
2. 绝对 token 数等 model-usage 端点字段确认后再加,或者干脆**本地累加**(每次调 LLM 时把 `response.usage.total_tokens` 累计起来,自己统计今日/本周消耗)
---
## 六、ESP-32 实现参考(伪代码)
```c
#include "esp_http_client.h"
#include "cJSON.h"
// 1. 构造请求
esp_http_client_config_t config = {
.url = "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
.method = HTTP_METHOD_GET,
.timeout_ms = 15000,
.cert_pem = bigmodel_root_ca, // 需要 HTTPS 根证书
.user_agent = "esp32-rlcd", // 随便写,没限制
};
esp_http_client_handle_t client = esp_http_client_init(&config);
// 关键:Authorization 不加 Bearer 前缀!
char auth_header[256];
snprintf(auth_header, sizeof(auth_header), "%s", api_key);
esp_http_client_set_header(client, "Authorization", auth_header);
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_header(client, "Accept-Language", "en-US,en");
// 2. 发请求
esp_err_t err = esp_http_client_perform(client);
if (err != ESP_OK) { /* 错误处理 */ }
int status = esp_http_client_get_status_code(client);
if (status == 401 || status == 403) {
// 凭据失效
} else if (status != 200) {
// 其他错误
}
// 3. 读响应体
char buffer[4096];
int len = esp_http_client_read(client, buffer, sizeof(buffer) - 1);
buffer[len] = '\0';
// 4. 解析 JSON
cJSON *root = cJSON_Parse(buffer);
cJSON *data = cJSON_GetObjectItem(root, "data");
cJSON *level = cJSON_GetObjectItem(data, "level");
const char *plan_level = level->valuestring; // "Max"
cJSON *limits = cJSON_GetObjectItem(data, "limits");
cJSON *limit;
cJSON_ArrayForEach(limit, limits) {
cJSON *type = cJSON_GetObjectItem(limit, "type");
if (!cJSON_IsString(type) || strcmp(type->valuestring, "TOKENS_LIMIT") != 0) {
continue; // 跳过 TIME_LIMIT
}
cJSON *unit = cJSON_GetObjectItem(limit, "unit");
cJSON *pct = cJSON_GetObjectItem(limit, "percentage");
cJSON *reset = cJSON_GetObjectItem(limit, "nextResetTime");
switch (unit->valueint) {
case 3: // 5 小时窗口
five_hour_pct = pct->valuedouble;
five_hour_reset_ms = reset ? reset->valuedouble : 0;
break;
case 6: // 周窗口(新套餐才有)
weekly_pct = pct->valuedouble;
weekly_reset_ms = reset ? reset->valuedouble : 0;
break;
}
}
cJSON_Delete(root);
esp_http_client_cleanup(client);
// 5. 转换时间戳为显示字符串(24h 内显示 HH:MM, 否则 MM-DD)
// 使用 <time.h> 的 localtime_r + difftime
```
---
## 七、鉴权验证(写代码前先做)
写代码前,**先用 curl 验证 API Key 能不能直接调通**。这是最省时间的步骤——避免代码写完才发现鉴权走不通。
```bash
curl -X GET \
-H "Authorization: <你的智谱 API_KEY>" \
-H "Content-Type: application/json" \
-H "Accept-Language: en-US,en" \
https://open.bigmodel.cn/api/monitor/usage/quota/limit
```
**预期响应**(老套餐):
```json
{
"success": true,
"msg": "操作成功",
"data": {
"level": "Max",
"limits": [
{
"type": "TOKENS_LIMIT",
"unit": 3,
"number": 5,
"percentage": 32.5,
"nextResetTime": 1773734366338
}
]
}
}
```
**判定**:
- ✅ 返回 200 + 有 `data.limits` → API Key 路线可行,ESP-32 可以直接复现
- ❌ 返回 401/403 → API Key 无效或权限不够
- ❌ 返回空 body 或非 JSON → 可能被反爬,但根据 cc-switch 经验这种情况**不应该发生**(cc-switch 用纯 API Key 跑通了)
---
## 八、源码引用清单
**权威实现**(cc-switch,用户已验证):
| 文件 | 行 | 作用 |
|------|-----|------|
| `src-tauri/src/services/coding_plan.rs` | 302-324 | `query_zhipu` 端点+请求头构造 |
| `src-tauri/src/services/coding_plan.rs` | 319 | "// 注意:智谱不加 Bearer 前缀" |
| `src-tauri/src/services/coding_plan.rs` | 218-224 | `classify_zhipu_window` 按 unit 分类 |
| `src-tauri/src/services/coding_plan.rs` | 226-234 | unit 字段坑的注释(issue #3036) |
| `src-tauri/src/services/coding_plan.rs` | 238-292 | `parse_zhipu_token_tiers` 解析逻辑 |
| `src-tauri/src/services/coding_plan.rs` | 279-291 | 老/新套餐天然降级(可空 slot) |
| `src-tauri/src/services/coding_plan.rs` | 282-289 | 只读 percentage,绝对值为 None |
| `src-tauri/src/services/subscription.rs` | 301, 308 | `TIER_FIVE_HOUR` / `TIER_WEEKLY_LIMIT` 常量 |
| `src-tauri/src/services/coding_plan.rs` | 1224-1240 | 测试:老套餐单 tier 降级到 5h |
| `src-tauri/src/services/coding_plan.rs` | 1316-1333 | 测试:unit 字段覆盖 reset 排序 |
**本地路径**:`D:\AI Code\ESP-32\espcc-switch\`(已克隆)
**辅助参考**(官方插件):
- `zai-coding-plugins/plugins/glm-plan-usage/skills/usage-query-skill/scripts/query-usage.mjs` — 调用了 3 个端点(quota/limit + model-usage + tool-usage),quota/limit 字段处理跟 cc-switch 一致
**辅助参考**(社区工具,鉴权方式不同,**不要照抄**):
- `JinHanAI/coding-plan-monitor/src/providers/zhipu.ts` — 走浏览器 Cookie,不支持 API Key 直连
---
## 九、官方文档(背景参考)
- [智谱AI开放文档 - Coding Plan FAQ](https://docs.bigmodel.cn/cn/coding-plan/faq) — 5h + 周限额机制说明
- [智谱AI开放文档 - 套餐概览](https://docs.bigmodel.cn/cn/coding-plan/overview) — 各套餐限额参考表
- [智谱AI开放文档 - 用量查询插件](https://docs.bigmodel.cn/cn/coding-plan/extension/usage-query-plugin) — 官方插件介绍
- [智谱AI开放文档 - HTTP API 调用](https://docs.bigmodel.cn/cn/guide/develop/http/introduction) — 基础 HTTP 调用规范
- [cc-switch GitHub](https://github.com/farion1231/cc-switch) — 权威实现来源
- [cc-switch Issue #1857 - 用量统计与计费追踪](https://github.com/farion1231/cc-switch/issues/1857)
- [cc-switch Issue #1588 - 智谱用量查询脚本示例](https://github.com/farion1231/cc-switch/issues/1588)
---
## 十、对接 Checklist(给 codex 用的执行清单)
- [ ] **Step 1**:用 curl 验证 API Key 能调通(参考第七节)
- [ ] **Step 2**:在 ESP-32 项目里加 HTTPS 根证书(open.bigmodel.cn 的根 CA)
- [ ] **Step 3**:实现 `zhipu_query_quota()` 函数,封装 HTTP GET + JSON 解析
- [ ] **Step 4**:实现 `unit` 字段分类(3=5h, 6=周),不要按时间排序
- [ ] **Step 5**:实现老/新套餐降级(周限额查不到时显示"无限制")
- [ ] **Step 6**:实现时间戳→显示字符串转换(24h 内 HH:MM,否则 MM-DD)
- [ ] **Step 7**:绝对 token 数(如果需要)→ 调 `model-usage` 端点实测字段,或本地累加每次 LLM 调用的 `usage.total_tokens`
- [ ] **Step 8**:把查询结果通过 BLE/USB 推送到板子(参考现有 `metrics_*` 模块的推送方式)
---
## 附:接口对比速查表
| 项目 | 端点 | 鉴权 | 用户验证 | 推荐用 |
|------|------|------|----------|--------|
| **cc-switch** | `/api/monitor/usage/quota/limit` | 纯 API Key(无 Bearer) | ✅ 用户已验证 | ✅ **本文档基准** |
| zai-coding-plugins(官方) | 同上 + `model-usage` + `tool-usage` | API Key(无 Bearer) | ✅ 官方插件 | 辅助参考 |
| coding-plan-monitor(社区) | 同上 | 浏览器 Cookie | ⚠️ 仅浏览器 | ❌ 不照抄 |
---
## 十一、token 使用量获取方案
> **核心结论**:`quota/limit` 端点**只返回百分比,不返回绝对 token 数**。要拿"今日用了多少 token",有**三种方案**,各有取舍。
### 三种方案对比
| 方案 | 原理 | 优点 | 缺点 | 推荐度 |
|------|------|------|------|--------|
| **A. 调 model-usage 端点** | 查询时间窗口内的 token 消耗明细 | 服务端权威数据,无需本地存储 | ⚠️ 字段结构官方未公开,需实测 | ⭐⭐⭐ |
| **B. 本地累加(推荐)** | 每次调 LLM 时累计响应里的 `usage.totalTokens` | 字段确定(智谱对话补全 API 标准),实现简单 | 只统计"经过本代理的"调用,跨设备/跨工具会漏 | ⭐⭐⭐⭐⭐ |
| **C. 只显示百分比** | 直接用 quota/limit 的 `percentage` | 零开发量,已有数据 | 没有绝对值,信息量少 | ⭐⭐ |
### 方案 A:model-usage 端点(需实测字段)
**端点**:
```
GET https://open.bigmodel.cn/api/monitor/usage/model-usage?startTime=...&endTime=...
```
**请求**(跟 quota/limit 同款鉴权):
```http
GET /api/monitor/usage/model-usage?startTime=2026-06-23%2000%3A00%3A00&endTime=2026-06-23%2023%3A59%3A59 HTTP/1.1
Host: open.bigmodel.cn
Authorization: <API_KEY> ← 不加 Bearer
Content-Type: application/json
Accept-Language: en-US,en
```
**参数**:
- `startTime` / `endTime`:URL 编码后的 `"yyyy-MM-dd HH:mm:ss"` 格式
- 官方插件默认窗口:昨天当前小时整点 → 今天当前小时末秒
**返回 JSON 结构(⚠️ 推断,需实测)**:
```jsonc
{
"success": true,
"code": 200,
"msg": "ok",
"data": {
"usageList": [ // 字段名推断, 实测可能不同
{
"modelName": "glm-4.6",
"totalTokens": 123456,
"promptTokens": 100000, // 智谱用驼峰命名(不是 prompt_tokens)
"completionTokens": 23456
}
]
}
}
```
**⚠️ 字段未公开的证据**:
- 官方插件 `query-usage.mjs:167` 调 model-usage 时**没传 postProcessor**,line 147 直接 `JSON.stringify(outputData)` 原样打印给 LLM
- 官方 agent 文档 `usage-query-agent.md` 也没描述字段结构
- 说明官方故意不固定字段文档(可能经常变),让 LLM 运行时自适应解读
**实测步骤**(必做):
```bash
curl -X GET \
-H "Authorization: <你的 API_KEY>" \
-H "Content-Type: application/json" \
-H "Accept-Language: en-US,en" \
"https://open.bigmodel.cn/api/monitor/usage/model-usage?startTime=$(date -d 'yesterday' +%Y-%m-%d)%2000%3A00%3A00&endTime=$(date +%Y-%m-%d)%2023%3A59%3A59"
```
拿到真实 JSON 后,根据实际字段名写解析代码。
### 方案 B:本地累加(推荐 ⭐)
**原理**:智谱对话补全 API 的**每次响应**都带 `usage` 字段,本地累加即可。
**智谱对话补全响应里的 usage 字段**(官方文档确认):
```jsonc
{
"choices": [ ... ],
"usage": {
"promptTokens": 123, // 输入 token(驼峰命名!)
"completionTokens": 456, // 输出 token
"totalTokens": 579 // 总计
}
}
```
**⚠️ 命名坑**:智谱用**驼峰**(`promptTokens`),不是 OpenAI 的下划线(`prompt_tokens`)。解析时别搞混。
**实现方式**(cc-switch 就是这么做的):
- cc-switch 在 `src-tauri/src/proxy/usage/parser.rs:17-22` 定义了 `TokenUsage` 结构
- 每次代理 LLM 请求时,从响应里解析 `usage`,写入本地 SQLite(`usage_stats.rs`)
- 查询时 `get_usage_summary` 聚合统计
**ESP-32 上的简化实现**:
- 如果电脑端有代理服务(像 cc-switch 那样),在代理层累加 token,定期推给板子
- 如果板子自己调 LLM,在板子上累加(但 ESP-32 存储有限,建议只存今日/本周两个计数器)
**为什么推荐**:
1. 字段确定(智谱对话补全 API 标准,有官方文档)
2. 实现简单(就是累加)
3. 不依赖未公开的 model-usage 端点
4. cc-switch 验证过这条路可行
### 方案 C:只显示百分比
直接用 `quota/limit` 的 `TOKENS_LIMIT.percentage`,显示"5H 已用 32%"。
适合 MVP 阶段——零开发量,数据现成。后续再加方案 A 或 B 补绝对值。
### 给 codex 的建议
1. **第一阶段**:用方案 C(百分比),先把 quota/limit 跑通
2. **第二阶段**:如果电脑端有代理服务,用方案 B(本地累加)——字段确定、实现简单
3. **第三阶段**:如果需要服务端权威数据,用方案 A——但**必须先 curl 实测字段**,不要按本文档的推断结构写代码
---
## 十二、cc-switch 的 token 统计实现(方案 B 参考)
cc-switch 不调 model-usage 端点,而是**本地 SQLite 累加**。这是方案 B 的最佳参考。
### 核心文件
| 文件 | 作用 |
|------|------|
| `src-tauri/src/proxy/usage/parser.rs` | 从 LLM 响应解析 `TokenUsage`(支持 Claude/OpenAI 格式) |
| `src-tauri/src/services/usage_stats.rs` | SQLite 存储 + 聚合查询(`get_usage_summary`) |
| `src-tauri/src/services/session_usage_codex.rs` | Codex 专用 session 用量 |
### TokenUsage 结构(parser.rs:17-22)
```rust
pub struct TokenUsage {
pub input_tokens: u32,
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_creation_tokens: u32,
}
```
### 解析入口
- `from_claude_response`(parser.rs:66)— 解析 Claude API 响应的 `usage.input_tokens` / `output_tokens`
- 也支持 OpenAI 兼容格式(`prompt_tokens` / `completion_tokens`)
### 统计查询
`get_usage_summary`(usage_stats.rs:505-664)支持按时间范围聚合,返回各模型的 token 总量。
**本地路径**:`D:\AI Code\ESP-32\espcc-switch\src-tauri\src\proxy\usage\parser.rs`
---
## Sources(token 使用量部分)
- [智谱AI开放文档 - 核心参数(Token 定义)](https://docs.bigmodel.cn/cn/guide/start/concept-param) — promptTokens/completionTokens/totalTokens 定义
- [智谱AI开放文档 - 对话补全 API](https://docs.bigmodel.cn/api-reference/%E6%A8%A1%E5%9E%8B-api/%E5%AF%B9%E8%AF%9D%E8%A1%A5%E5%85%A8) — usage 字段在响应里的位置
- [智谱AI开放文档 - API 价格](https://open.bigmodel.cn/pricing) — token 计费说明
- [GitHub Issue zhayujie/CowAgent#2289](https://github.com/zhayujie/CowAgent/issues/2289) — 智谱驼峰命名 vs OpenAI 下划线命名的兼容性问题
- [cc-switch Discussion #1038 - 智谱用量查询脚本](https://github.com/farion1231/cc-switch/discussions/1038) — model-usage 端点调用示例
- 本地源码:`D:\esp\zai-coding-plugins\plugins\glm-plan-usage\skills\usage-query-skill\scripts\query-usage.mjs`(官方插件,证实 model-usage 字段未公开)
- 本地源码:`D:\AI Code\ESP-32\espcc-switch\src-tauri\src\proxy\usage\parser.rs`(本地累加实现)

View File

@ -0,0 +1,21 @@
{
"templateId": "dual-metric",
"title": "ROOM STATUS",
"orientation": "landscape",
"density": "comfortable",
"effects": {
"ghost": true,
"grid": true
},
"fields": {
"status": "SENSOR",
"primaryLabel": "TEMP",
"primaryValue": "26",
"primaryUnit": "C",
"secondaryLabel": "HUM",
"secondaryValue": "58",
"secondaryUnit": "%",
"footerLeft": "Last refresh 2m",
"footerRight": "Battery 86%"
}
}

1063
frontend-preview/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"firmwares": [
{
"id": "factory-v1",
"name": "微雪出厂固件 V1",
"type": "whole-image",
"path": "D:\\esp\\waveshare-rlcd-4.2\\03_Firmware\\01_Factory_V1.bin",
"offset": "0x0",
"description": "官方出厂界面整包。"
},
{
"id": "xiaozhi-v2",
"name": "微雪 XiaoZhi V2.1.0",
"type": "whole-image",
"path": "D:\\esp\\waveshare-rlcd-4.2\\03_Firmware\\02_XiaoZhi_V2.1.0.bin",
"offset": "0x0",
"description": "官方 XiaoZhi 示例整包。"
},
{
"id": "custom-workbench",
"name": "自制模板固件",
"type": "idf-project",
"projectPath": "D:\\AI Code\\ESP-32",
"artifact": "build\\rlcd_clock.bin",
"description": "由当前预览配置生成并编译。"
}
]
}

125
frontend-preview/index.html Normal file
View File

@ -0,0 +1,125 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ESP32-S3-RLCD Workbench</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="workbench">
<!-- ============ 左侧:预览舞台 ============ -->
<section class="stage">
<!-- Header -->
<header class="header">
<div class="header-title">
<span class="product">ESP32-S3-RLCD-4.2</span>
<span class="sub">Workbench · Canvas Pixel Preview</span>
</div>
<span class="bridge-pill" id="bridgeState">桥接服务检测中</span>
</header>
<!-- 设备预览 -->
<div class="stage-area">
<div class="device-shell" id="deviceShell">
<div class="screen-bezel">
<div class="canvas-frame show-grid" id="canvasFrame">
<canvas id="screen" width="400" height="300"></canvas>
</div>
</div>
<div class="device-meta">
<span>400 × 300 · ST7305</span>
<span class="dots"><span></span><span></span><span></span></span>
<span id="screenSizeLabel">monochrome RLCD</span>
</div>
</div>
<!-- 预览控件 -->
<div class="preview-controls">
<div class="zoom-group">
<button class="zoom-btn" id="zoomOut" type="button" aria-label="缩小">−</button>
<span class="zoom-label" id="zoomLabel">100%</span>
<button class="zoom-btn" id="zoomIn" type="button" aria-label="放大">+</button>
</div>
<button class="toggle-chip" id="gridToggle" type="button">
<span class="dot"></span><span>网格</span>
</button>
<button class="toggle-chip" id="ghostToggle" type="button">
<span class="dot"></span><span>残影</span>
</button>
<button class="toggle-chip" id="invertToggle" type="button">
<span class="dot"></span><span>反色</span>
</button>
</div>
</div>
</section>
<!-- ============ 右侧:控制面板 ============ -->
<aside class="panel">
<!-- 数据卡片 -->
<div class="card" id="dataCard">
<div class="card-head" id="dataCardHead">
<h2>仪表盘数据</h2>
<span class="chevron">▾</span>
</div>
<div class="card-body" id="dataBody">
<!-- 字段会由 JS 动态生成 -->
</div>
</div>
<!-- 操作卡片 -->
<div class="card" id="opCard">
<div class="card-head" id="opCardHead">
<h2>操作</h2>
<span class="chevron">▾</span>
</div>
<div class="card-body">
<div class="btn-row">
<button class="btn btn-secondary" id="saveButton" type="button">保存配置</button>
<button class="btn btn-secondary" id="resetButton" type="button">重置默认</button>
</div>
<div class="data-group">
<div class="data-group-title">固件包</div>
<div class="fw-list" id="firmwareList"></div>
<div class="field" style="margin-top: 8px;">
<label for="portInput">端口</label>
<input id="portInput" type="text" value="COM3" autocomplete="off" />
</div>
<div class="btn-row three" style="margin-top: 6px;">
<button class="btn btn-secondary" id="generateButton" type="button">生成</button>
<button class="btn btn-secondary" id="buildButton" type="button">编译</button>
<button class="btn btn-danger" id="flashButton" type="button">烧录</button>
</div>
</div>
</div>
</div>
<!-- 日志卡片 -->
<div class="card collapsed" id="logCard">
<div class="card-head" id="logCardHead">
<h2>日志</h2>
<span class="chevron">▾</span>
</div>
<div class="card-body">
<div class="log-box" id="logBox"></div>
</div>
</div>
</aside>
</main>
<!-- 烧录确认模态框 -->
<div class="modal-backdrop" id="flashModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="flashTitle">
<h2 id="flashTitle">确认烧录</h2>
<p id="flashSummary"></p>
<div class="modal-actions">
<button class="btn btn-secondary" id="cancelFlashButton" type="button">取消</button>
<button class="btn btn-danger" id="confirmFlashButton" type="button">确认烧录</button>
</div>
</div>
</div>
<script src="./app.js"></script>
</body>
</html>

828
frontend-preview/styles.css Normal file
View File

@ -0,0 +1,828 @@
/* ============================================================
ESP32-S3-RLCD Workbench — macOS 柔和风
Canvas 像素级预览 + 浅色毛玻璃工具面板
============================================================ */
:root {
/* 背景层 */
--bg-gradient-start: #f5f5f7;
--bg-gradient-end: #e8e8ed;
--noise-opacity: 0.04;
/* 表面层(毛玻璃卡片) */
--surface: rgba(255, 255, 255, 0.72);
--surface-strong: rgba(255, 255, 255, 0.92);
--surface-sunken: rgba(0, 0, 0, 0.03);
/* 文字 */
--ink: #1d1d1f;
--ink-soft: #424245;
--ink-mute: #86868b;
--ink-faint: #aeaeb2;
/* 线条 */
--hairline: rgba(0, 0, 0, 0.08);
--hairline-strong: rgba(0, 0, 0, 0.14);
/* 强调色(iOS 系统色) */
--accent: #007aff;
--accent-soft: rgba(0, 122, 255, 0.12);
--accent-strong: #0051d5;
--success: #34c759;
--warning: #ff9f0a;
--danger: #ff3b30;
--danger-soft: rgba(255, 59, 48, 0.12);
/* 设备外壳 */
--device-shell: #2c2c2e;
--device-bezel: #1c1c1e;
/* 屏幕底色(反射屏灰白) */
--screen-bg: #d8d8d0;
/* 阴影 */
--shadow-card: 0 8px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-device: 0 18px 50px rgba(0, 0, 0, 0.18), 0 4px 12px rgba(0, 0, 0, 0.08);
/* 圆角 */
--r-card: 12px;
--r-button: 8px;
--r-pill: 999px;
--r-device: 18px;
--r-bezel: 10px;
color-scheme: light;
font-family:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
"Segoe UI", "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei",
sans-serif;
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background:
radial-gradient(circle at 20% 0%, rgba(0, 122, 255, 0.04), transparent 50%),
radial-gradient(circle at 80% 100%, rgba(255, 55, 95, 0.03), transparent 50%),
linear-gradient(180deg, var(--bg-gradient-start), var(--bg-gradient-end));
background-attachment: fixed;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
/* ============================================================
主布局
============================================================ */
.workbench {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
padding: 16px;
}
.stage {
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
min-width: 0;
}
.panel {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
max-height: calc(100vh - 32px);
overflow-y: auto;
padding-right: 4px;
}
/* ============================================================
顶部 Header
============================================================ */
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: var(--surface);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--hairline);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
}
.header-title {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.header-title .product {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
}
.header-title .sub {
font-size: 11px;
color: var(--ink-mute);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bridge-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 8px;
background: var(--surface-sunken);
border: 1px solid var(--hairline);
border-radius: var(--r-pill);
font-size: 11px;
color: var(--ink-mute);
white-space: nowrap;
transition: all 160ms ease;
}
.bridge-pill::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ink-faint);
transition: all 160ms ease;
}
.bridge-pill.ok {
color: var(--success);
border-color: rgba(52, 199, 89, 0.3);
background: rgba(52, 199, 89, 0.08);
}
.bridge-pill.ok::before {
background: var(--success);
box-shadow: 0 0 0 3px rgba(52, 199, 89, 0.18);
}
.bridge-pill.bad {
color: var(--danger);
border-color: rgba(255, 59, 48, 0.24);
background: var(--danger-soft);
}
.bridge-pill.bad::before {
background: var(--danger);
}
/* ============================================================
舞台 - 设备预览
============================================================ */
.stage-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px 16px;
background: var(--surface);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--hairline);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
min-height: 0;
}
/* 设备外壳 - 模拟开发板外观 */
.device-shell {
position: relative;
padding: 14px 14px 10px;
background: linear-gradient(160deg, #3a3a3c, var(--device-shell));
border-radius: var(--r-device);
box-shadow: var(--shadow-device);
border: 1px solid #000;
}
/* 屏幕外框(黑色 bezel) */
.screen-bezel {
padding: 14px;
background: var(--device-bezel);
border-radius: var(--r-bezel);
border: 1px solid #000;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
/* Canvas 容器 - 关键:固定 400×300, scale 放大整个 device-shell */
.canvas-frame {
position: relative;
width: 400px;
height: 300px;
background: var(--screen-bg);
border: 1px solid #000;
overflow: hidden;
/* 反射屏的微妙纹理 */
background-image:
repeating-linear-gradient(
45deg,
transparent 0 2px,
rgba(0, 0, 0, 0.015) 2px 4px
);
}
/* Canvas 本身 - 物理像素 400×300, 不单独缩放 */
.canvas-frame canvas {
display: block;
width: 400px;
height: 300px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
/* 关键:scale 应用到整个 device-shell, 让外框+bezel+canvas 一起放大 */
.device-shell {
transform-origin: center center;
transition: transform 120ms ease-out;
}
/* 像素网格叠加(可选) */
.canvas-frame.show-grid::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(0, 122, 255, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 122, 255, 0.08) 1px, transparent 1px);
background-size: 8px 8px;
}
/* 设备底部信息条 */
.device-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
padding: 0 4px;
color: rgba(255, 255, 255, 0.42);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
}
.device-meta .dots {
display: flex;
gap: 4px;
}
.device-meta .dots span {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
}
/* ============================================================
预览控件
============================================================ */
.preview-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.zoom-group {
display: inline-flex;
align-items: center;
gap: 0;
background: var(--surface-sunken);
border: 1px solid var(--hairline);
border-radius: var(--r-button);
overflow: hidden;
}
.zoom-btn {
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--ink-soft);
font-size: 16px;
font-weight: 500;
display: grid;
place-items: center;
transition: background 120ms ease;
}
.zoom-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.zoom-btn:active {
background: rgba(0, 0, 0, 0.08);
}
.zoom-label {
padding: 0 10px;
font-size: 11px;
color: var(--ink-mute);
font-variant-numeric: tabular-nums;
min-width: 48px;
text-align: center;
}
.toggle-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--surface-sunken);
border: 1px solid var(--hairline);
border-radius: var(--r-button);
font-size: 11px;
color: var(--ink-mute);
cursor: pointer;
user-select: none;
transition: all 120ms ease;
}
.toggle-chip:hover {
background: rgba(0, 0, 0, 0.05);
}
.toggle-chip.on {
background: var(--accent-soft);
border-color: rgba(0, 122, 255, 0.24);
color: var(--accent-strong);
}
.toggle-chip .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ink-faint);
transition: all 120ms ease;
}
.toggle-chip.on .dot {
background: var(--accent);
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
}
/* ============================================================
右侧控制面板
============================================================ */
.card {
background: var(--surface);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--hairline);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--hairline);
background: rgba(255, 255, 255, 0.4);
cursor: pointer;
user-select: none;
transition: background 120ms ease;
}
.card-head:hover {
background: rgba(255, 255, 255, 0.6);
}
.card-head h2 {
margin: 0;
font-size: 11px;
font-weight: 600;
color: var(--ink-soft);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.card-head .chevron {
color: var(--ink-faint);
font-size: 10px;
transition: transform 160ms ease;
}
.card.collapsed .chevron {
transform: rotate(-90deg);
}
.card-body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.card.collapsed .card-body {
display: none;
}
/* 数据字段分组 */
.data-group {
padding-top: 8px;
border-top: 1px solid var(--hairline);
}
.data-group:first-child {
padding-top: 0;
border-top: 0;
}
.data-group-title {
font-size: 10px;
font-weight: 600;
color: var(--ink-mute);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 8px;
}
/* 字段输入 */
.field {
display: grid;
grid-template-columns: 88px 1fr;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.field label {
font-size: 11px;
color: var(--ink-soft);
font-weight: 400;
}
.field input[type="text"],
.field input[type="number"],
.field select {
width: 100%;
height: 26px;
padding: 0 8px;
background: var(--surface-strong);
border: 1px solid var(--hairline);
border-radius: 6px;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--ink);
transition: all 120ms ease;
}
.field input:focus,
.field select:focus {
outline: 0;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.field input[type="range"] {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--hairline);
border-radius: var(--r-pill);
outline: 0;
}
.field input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.field input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border: 0;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
/* 按钮 */
.btn-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.btn-row.three {
grid-template-columns: 1fr 1fr 1fr;
}
.btn {
height: 32px;
border-radius: var(--r-button);
border: 1px solid transparent;
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 120ms ease;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-strong);
border-color: var(--accent-strong);
}
.btn-secondary {
background: var(--surface-strong);
color: var(--ink);
border-color: var(--hairline-strong);
}
.btn-secondary:hover:not(:disabled) {
background: #fff;
border-color: rgba(0, 0, 0, 0.2);
}
.btn-danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn-danger:hover:not(:disabled) {
background: #d70015;
border-color: #d70015;
}
.btn-text {
background: transparent;
color: var(--accent);
border: 0;
padding: 2px 4px;
height: auto;
font-size: 11px;
}
.btn-text:hover:not(:disabled) {
color: var(--accent-strong);
text-decoration: underline;
}
/* 固件列表 */
.fw-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.fw-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
background: var(--surface-strong);
border: 1px solid var(--hairline);
border-radius: 6px;
cursor: pointer;
transition: all 120ms ease;
}
.fw-item:hover {
background: #fff;
}
.fw-item.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.fw-name {
font-size: 11px;
font-weight: 500;
color: var(--ink);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fw-meta {
font-size: 10px;
color: var(--ink-mute);
}
.badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: var(--r-pill);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.02em;
background: rgba(0, 122, 255, 0.12);
color: var(--accent);
}
.badge.warn {
background: rgba(255, 159, 10, 0.16);
color: var(--warning);
}
.badge.muted {
background: var(--surface-sunken);
color: var(--ink-mute);
}
/* 日志框 */
.log-box {
margin-top: 8px;
padding: 8px 10px;
background: var(--surface-sunken);
border: 1px solid var(--hairline);
border-radius: 6px;
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: 10px;
line-height: 1.5;
color: var(--ink-soft);
min-height: 60px;
max-height: 140px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.log-box:empty::before {
content: "等待操作…";
color: var(--ink-faint);
font-style: italic;
}
/* ============================================================
模态框
============================================================ */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.32);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 20px;
animation: backdrop-fade 160ms ease;
}
@keyframes backdrop-fade {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
width: min(380px, 100%);
background: var(--surface-strong);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border: 1px solid var(--hairline);
border-radius: 14px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.24);
padding: 18px;
animation: modal-rise 200ms ease;
}
@keyframes modal-rise {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal h2 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
}
.modal p {
margin: 0;
color: var(--ink-soft);
font-size: 12px;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1100px) {
.workbench {
grid-template-columns: 1fr;
}
.panel {
max-height: none;
}
}
@media (max-width: 640px) {
.workbench {
padding: 10px;
}
.device-shell {
transform: scale(0.85);
transform-origin: top center;
}
}
/* 滚动条(macOS 风) */
.panel::-webkit-scrollbar,
.log-box::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.panel::-webkit-scrollbar-thumb,
.log-box::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
border-radius: 3px;
}
.panel::-webkit-scrollbar-thumb:hover,
.log-box::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
.panel::-webkit-scrollbar-track,
.log-box::-webkit-scrollbar-track {
background: transparent;
}

View File

@ -0,0 +1,98 @@
{
"templates": [
{
"id": "clock",
"name": "时钟状态屏",
"tag": "Clock",
"description": "大号时间、日期、状态和底部说明。",
"defaults": {
"title": "LOCAL CLOCK",
"orientation": "landscape",
"density": "comfortable",
"effects": { "ghost": true, "grid": true },
"fields": {
"status": "ONLINE",
"time": "12:00",
"date": "2026-06-20",
"footerLeft": "ESP32-S3-RLCD",
"footerRight": "COM3"
}
},
"fields": [
{ "key": "status", "label": "状态", "type": "text", "span": "half" },
{ "key": "time", "label": "时间", "type": "text", "span": "half" },
{ "key": "date", "label": "日期", "type": "text", "span": "full" },
{ "key": "footerLeft", "label": "底部左侧", "type": "text", "span": "half" },
{ "key": "footerRight", "label": "底部右侧", "type": "text", "span": "half" }
]
},
{
"id": "dual-metric",
"name": "双大数字屏",
"tag": "Dual",
"description": "两个核心数字并排展示,适合温湿度、计数、状态值。",
"defaults": {
"title": "ROOM STATUS",
"orientation": "landscape",
"density": "comfortable",
"effects": { "ghost": true, "grid": true },
"fields": {
"status": "SENSOR",
"primaryLabel": "TEMP",
"primaryValue": "26",
"primaryUnit": "C",
"secondaryLabel": "HUM",
"secondaryValue": "58",
"secondaryUnit": "%",
"footerLeft": "Last refresh 2m",
"footerRight": "Battery 86%"
}
},
"fields": [
{ "key": "status", "label": "状态", "type": "text", "span": "half" },
{ "key": "primaryLabel", "label": "数字一名称", "type": "text", "span": "half" },
{ "key": "primaryValue", "label": "数字一数值", "type": "number", "span": "half", "min": -99, "max": 999 },
{ "key": "primaryUnit", "label": "数字一单位", "type": "text", "span": "half" },
{ "key": "secondaryLabel", "label": "数字二名称", "type": "text", "span": "half" },
{ "key": "secondaryValue", "label": "数字二数值", "type": "number", "span": "half", "min": -99, "max": 999 },
{ "key": "secondaryUnit", "label": "数字二单位", "type": "text", "span": "half" },
{ "key": "footerLeft", "label": "底部左侧", "type": "text", "span": "half" },
{ "key": "footerRight", "label": "底部右侧", "type": "text", "span": "half" }
]
},
{
"id": "meter",
"name": "仪表状态屏",
"tag": "Meter",
"description": "两条进度仪表,适合电量、容量、任务进度。",
"defaults": {
"title": "SYSTEM LOAD",
"orientation": "landscape",
"density": "comfortable",
"effects": { "ghost": true, "grid": true },
"fields": {
"status": "LIVE",
"primaryLabel": "CPU",
"primaryValue": "64",
"primaryUnit": "%",
"secondaryLabel": "BATTERY",
"secondaryValue": "86",
"secondaryUnit": "%",
"footerLeft": "LAN READY",
"footerRight": "Refresh 5m"
}
},
"fields": [
{ "key": "status", "label": "状态", "type": "text", "span": "half" },
{ "key": "primaryLabel", "label": "仪表一名称", "type": "text", "span": "half" },
{ "key": "primaryValue", "label": "仪表一进度", "type": "range", "span": "full", "min": 0, "max": 100 },
{ "key": "primaryUnit", "label": "仪表一单位", "type": "text", "span": "half" },
{ "key": "secondaryLabel", "label": "仪表二名称", "type": "text", "span": "half" },
{ "key": "secondaryValue", "label": "仪表二进度", "type": "range", "span": "full", "min": 0, "max": 100 },
{ "key": "secondaryUnit", "label": "仪表二单位", "type": "text", "span": "half" },
{ "key": "footerLeft", "label": "底部左侧", "type": "text", "span": "half" },
{ "key": "footerRight", "label": "底部右侧", "type": "text", "span": "half" }
]
}
]
}

25
main/CMakeLists.txt Normal file
View File

@ -0,0 +1,25 @@
# ============================================================
# main 组件 CMakeLists
# 编译 main.c 和 st7305_rlcd.c 两个源文件
# ============================================================
idf_component_register(
SRCS
"battery_monitor.c"
"main.c"
"metrics_ble.c"
"metrics_protocol.c"
"metrics_serial.c"
"temp_humidity_monitor.c"
"ui_screen.c"
"st7305_rlcd.c"
INCLUDE_DIRS
"."
"generated"
REQUIRES
esp_driver_uart
esp_driver_usb_serial_jtag
esp_driver_i2c
esp_adc
bt
nvs_flash
)

230
main/battery_monitor.c Normal file
View File

@ -0,0 +1,230 @@
#include "battery_monitor.h"
#include <stdbool.h>
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_check.h"
#include "esp_err.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
#include "freertos/task.h"
static const char *TAG = "battery";
#define BATTERY_ADC_UNIT ADC_UNIT_1
#define BATTERY_ADC_CHANNEL ADC_CHANNEL_3
#define BATTERY_ADC_ATTEN ADC_ATTEN_DB_12
#define BATTERY_DIVIDER 3
static adc_oneshot_unit_handle_t s_adc_handle;
static adc_cali_handle_t s_cali_handle;
static bool s_cali_enabled;
static portMUX_TYPE s_battery_mux = portMUX_INITIALIZER_UNLOCKED;
static int s_latest_percent;
static int s_latest_millivolts;
static bool s_has_latest;
typedef struct {
int millivolts;
int raw_avg;
int pin_millivolts;
int samples;
} battery_reading_t;
typedef struct {
int millivolts;
int percent;
} battery_curve_point_t;
static const battery_curve_point_t s_battery_curve[] = {
{ 4200, 100 },
{ 4150, 95 },
{ 4110, 90 },
{ 4080, 85 },
{ 4020, 80 },
{ 3980, 75 },
{ 3920, 70 },
{ 3870, 60 },
{ 3820, 50 },
{ 3790, 40 },
{ 3770, 30 },
{ 3740, 20 },
{ 3700, 10 },
{ 3600, 5 },
{ 3300, 0 },
};
static int clamp_percent(int value)
{
if (value < 0) {
return 0;
}
if (value > 100) {
return 100;
}
return value;
}
static int battery_percent_from_mv(int mv)
{
if (mv <= 0) {
return 0;
}
const int count = sizeof(s_battery_curve) / sizeof(s_battery_curve[0]);
if (mv >= s_battery_curve[0].millivolts) {
return s_battery_curve[0].percent;
}
for (int i = 1; i < count; i++) {
const battery_curve_point_t high = s_battery_curve[i - 1];
const battery_curve_point_t low = s_battery_curve[i];
if (mv >= low.millivolts) {
int mv_range = high.millivolts - low.millivolts;
int percent_range = high.percent - low.percent;
int percent = low.percent;
if (mv_range > 0) {
percent += ((mv - low.millivolts) * percent_range + mv_range / 2) / mv_range;
}
return clamp_percent(percent);
}
}
return s_battery_curve[count - 1].percent;
}
static esp_err_t battery_adc_init(void)
{
adc_oneshot_unit_init_cfg_t unit_config = {
.unit_id = BATTERY_ADC_UNIT,
.clk_src = ADC_RTC_CLK_SRC_DEFAULT,
.ulp_mode = ADC_ULP_MODE_DISABLE,
};
ESP_RETURN_ON_ERROR(adc_oneshot_new_unit(&unit_config, &s_adc_handle), TAG, "adc unit init failed");
adc_oneshot_chan_cfg_t channel_config = {
.atten = BATTERY_ADC_ATTEN,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
ESP_RETURN_ON_ERROR(adc_oneshot_config_channel(s_adc_handle, BATTERY_ADC_CHANNEL, &channel_config),
TAG, "adc channel config failed");
#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED
adc_cali_curve_fitting_config_t cali_config = {
.unit_id = BATTERY_ADC_UNIT,
.chan = BATTERY_ADC_CHANNEL,
.atten = BATTERY_ADC_ATTEN,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
esp_err_t cali_ret = adc_cali_create_scheme_curve_fitting(&cali_config, &s_cali_handle);
s_cali_enabled = (cali_ret == ESP_OK);
if (!s_cali_enabled) {
ESP_LOGW(TAG, "ADC calibration unavailable: %s", esp_err_to_name(cali_ret));
}
#endif
return ESP_OK;
}
static battery_reading_t read_battery(void)
{
int raw_sum = 0;
int mv_sum = 0;
int samples = 0;
battery_reading_t reading = { 0 };
for (int i = 0; i < 8; i++) {
int raw = 0;
if (adc_oneshot_read(s_adc_handle, BATTERY_ADC_CHANNEL, &raw) != ESP_OK) {
continue;
}
raw_sum += raw;
samples++;
if (s_cali_enabled) {
int pin_mv = 0;
if (adc_cali_raw_to_voltage(s_cali_handle, raw, &pin_mv) == ESP_OK) {
mv_sum += pin_mv;
}
}
}
if (samples == 0) {
return reading;
}
reading.samples = samples;
reading.raw_avg = raw_sum / samples;
if (s_cali_enabled && mv_sum > 0) {
reading.pin_millivolts = mv_sum / samples;
reading.millivolts = reading.pin_millivolts * BATTERY_DIVIDER;
return reading;
}
reading.pin_millivolts = (reading.raw_avg * 3100) / 4095;
reading.millivolts = reading.pin_millivolts * BATTERY_DIVIDER;
return reading;
}
static void battery_task(void *arg)
{
(void)arg;
int log_ticks = 0;
while (1) {
battery_reading_t reading = read_battery();
int percent = battery_percent_from_mv(reading.millivolts);
portENTER_CRITICAL(&s_battery_mux);
s_latest_percent = percent;
s_latest_millivolts = reading.millivolts;
s_has_latest = true;
portEXIT_CRITICAL(&s_battery_mux);
if (log_ticks < 6 || (log_ticks % 6) == 0) {
ESP_LOGI(TAG, "adc raw=%d pin_mv=%d battery_mv=%d percent=%d cali=%d samples=%d",
reading.raw_avg, reading.pin_millivolts, reading.millivolts,
percent, s_cali_enabled ? 1 : 0, reading.samples);
}
log_ticks++;
vTaskDelay(pdMS_TO_TICKS(10000));
}
}
void battery_monitor_start(void)
{
esp_err_t ret = battery_adc_init();
if (ret != ESP_OK) {
ESP_LOGW(TAG, "battery monitor disabled: %s", esp_err_to_name(ret));
portENTER_CRITICAL(&s_battery_mux);
s_latest_percent = 0;
s_latest_millivolts = 0;
s_has_latest = true;
portEXIT_CRITICAL(&s_battery_mux);
return;
}
xTaskCreate(battery_task, "battery", 4096, NULL, 2, NULL);
}
bool battery_monitor_take_latest(int *percent, int *millivolts)
{
if (percent == NULL || millivolts == NULL) {
return false;
}
bool has_latest = false;
portENTER_CRITICAL(&s_battery_mux);
if (s_has_latest) {
*percent = s_latest_percent;
*millivolts = s_latest_millivolts;
s_has_latest = false;
has_latest = true;
}
portEXIT_CRITICAL(&s_battery_mux);
return has_latest;
}

14
main/battery_monitor.h Normal file
View File

@ -0,0 +1,14 @@
#pragma once
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
void battery_monitor_start(void);
bool battery_monitor_take_latest(int *percent, int *millivolts);
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,30 @@
/* Generated by tools/preview-bridge/server.py. */
#pragma once
#define UI_TEMPLATE_CLOCK 1
#define UI_TEMPLATE_DUAL_METRIC 2
#define UI_TEMPLATE_METER 3
#define UI_ACTIVE_TEMPLATE UI_TEMPLATE_DUAL_METRIC
#define UI_ORIENTATION_LANDSCAPE 0
#define UI_ORIENTATION_PORTRAIT 1
#define UI_ACTIVE_ORIENTATION 0
#define UI_DENSITY_COMFORTABLE 0
#define UI_DENSITY_COMPACT 1
#define UI_ACTIVE_DENSITY 0
#define UI_TITLE_TEXT "ROOM STATUS"
#define UI_STATUS_TEXT "SENSOR"
#define UI_TIME_TEXT "12:00"
#define UI_DATE_TEXT "2026-06-20"
#define UI_PRIMARY_LABEL_TEXT "TEMP"
#define UI_PRIMARY_VALUE_TEXT "26"
#define UI_PRIMARY_VALUE_INT 26
#define UI_PRIMARY_UNIT_TEXT "C"
#define UI_SECONDARY_LABEL_TEXT "HUM"
#define UI_SECONDARY_VALUE_TEXT "58"
#define UI_SECONDARY_VALUE_INT 58
#define UI_SECONDARY_UNIT_TEXT "%"
#define UI_FOOTER_LEFT_TEXT "Last refresh 2m"
#define UI_FOOTER_RIGHT_TEXT "Battery 86%"

8
main/idf_component.yml Normal file
View File

@ -0,0 +1,8 @@
## IDF Component Manager Manifest File
## 声明我们这个 main 组件依赖的外部组件
dependencies:
# LVGL 图形库(官方 XiaoZhi 用 9.3,我们跟进)
lvgl/lvgl: "~9.3.0"
# esp_lvgl_port - 乐鑫提供的 LVGL 与 ESP-IDF 集成胶水层
# (帮我们管 LVGL 任务、锁、tick)
espressif/esp_lvgl_port: "~2.6.0"

123
main/main.c Normal file
View File

@ -0,0 +1,123 @@
#include <stdint.h>
#include "esp_err.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "lvgl.h"
#include "battery_monitor.h"
#include "metrics_ble.h"
#include "metrics_serial.h"
#include "st7305_rlcd.h"
#include "temp_humidity_monitor.h"
#include "ui_screen.h"
static const char *TAG = "rlcd_app";
static void lvgl_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *color_p)
{
uint16_t *buffer = (uint16_t *)color_p;
for (int y = area->y1; y <= area->y2; y++) {
for (int x = area->x1; x <= area->x2; x++) {
uint8_t color = (*buffer < 0x7FFF) ? ST7305_COLOR_BLACK : ST7305_COLOR_WHITE;
st7305_rlcd_set_pixel(x, y, color);
buffer++;
}
}
st7305_rlcd_flush();
lv_display_flush_ready(disp);
}
static void lvgl_tick_task(void *arg)
{
(void)arg;
while (1) {
vTaskDelay(pdMS_TO_TICKS(5));
lv_lock();
lv_tick_inc(5);
lv_unlock();
}
}
static esp_err_t init_lvgl(void)
{
lv_init();
lv_display_t *disp = lv_display_create(st7305_rlcd_get_width(), st7305_rlcd_get_height());
lv_display_set_flush_cb(disp, lvgl_flush_cb);
size_t buffer_size = LV_COLOR_FORMAT_GET_SIZE(LV_COLOR_FORMAT_RGB565)
* st7305_rlcd_get_width()
* st7305_rlcd_get_height();
uint8_t *buffer = (uint8_t *)heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM);
if (buffer == NULL) {
ESP_LOGE(TAG, "Failed to allocate LVGL buffer.");
return ESP_ERR_NO_MEM;
}
lv_display_set_buffers(disp, buffer, NULL, buffer_size, LV_DISPLAY_RENDER_MODE_PARTIAL);
xTaskCreate(lvgl_tick_task, "lv_tick", 4096, NULL, 1, NULL);
return ESP_OK;
}
void app_main(void)
{
ESP_LOGI(TAG, "Starting ESP32-S3-RLCD workbench firmware.");
ESP_ERROR_CHECK(st7305_rlcd_init());
ESP_ERROR_CHECK(init_lvgl());
lv_lock();
ui_screen_create();
lv_unlock();
metrics_serial_start();
metrics_ble_start();
battery_monitor_start();
temp_humidity_monitor_start();
int frame_counter = 0;
uint32_t ble_ui_update_count = 0;
while (1) {
vTaskDelay(pdMS_TO_TICKS(30));
ui_metrics_t metrics;
if (metrics_ble_take_latest(&metrics)) {
ui_screen_update_metrics_from_source(&metrics, "BLE");
ble_ui_update_count++;
if ((ble_ui_update_count % 10) == 0) {
ESP_LOGI(TAG, "BLE UI updated: %lu", (unsigned long)ble_ui_update_count);
}
}
if (metrics_serial_take_latest(&metrics)) {
ui_screen_update_metrics_from_source(&metrics, "USB");
}
int battery_percent = 0;
int battery_mv = 0;
if (battery_monitor_take_latest(&battery_percent, &battery_mv)) {
ui_screen_update_battery(battery_percent, battery_mv);
}
int temperature_c10 = 0;
int humidity_percent = 0;
if (temp_humidity_monitor_take_latest(&temperature_c10, &humidity_percent)) {
ui_screen_update_environment(temperature_c10, humidity_percent);
}
lv_lock();
lv_timer_handler();
frame_counter++;
if (frame_counter >= 33) {
frame_counter = 0;
ui_screen_tick();
}
lv_unlock();
}
}

271
main/metrics_ble.c Normal file
View File

@ -0,0 +1,271 @@
#include "metrics_ble.h"
#include <assert.h>
#include <string.h>
#include "esp_err.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "host/ble_gap.h"
#include "host/ble_hs.h"
#include "host/ble_uuid.h"
#include "host/util/util.h"
#include "metrics_protocol.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "nvs_flash.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "ui_screen.h"
static const char *TAG = "metrics_ble";
static const char *DEVICE_NAME = "RLCD-Metrics";
// Service: 9f4af548-1a14-4fc1-8f5a-4b9f00000001
// Write char: 9f4af548-1a14-4fc1-8f5a-4b9f00000002
static const ble_uuid128_t metrics_svc_uuid =
BLE_UUID128_INIT(0x01, 0x00, 0x00, 0x00, 0x9f, 0x4b, 0x5a, 0x8f,
0xc1, 0x4f, 0x14, 0x1a, 0x48, 0xf5, 0x4a, 0x9f);
static const ble_uuid128_t metrics_chr_uuid =
BLE_UUID128_INIT(0x02, 0x00, 0x00, 0x00, 0x9f, 0x4b, 0x5a, 0x8f,
0xc1, 0x4f, 0x14, 0x1a, 0x48, 0xf5, 0x4a, 0x9f);
static uint8_t own_addr_type;
static uint16_t metrics_chr_val_handle;
static QueueHandle_t s_metrics_queue;
static uint32_t s_ble_write_count;
void ble_store_config_init(void);
static int metrics_gap_event(struct ble_gap_event *event, void *arg);
static const char *metrics_phy_name(uint8_t phy)
{
switch (phy) {
case BLE_GAP_LE_PHY_1M:
return "1M";
case BLE_GAP_LE_PHY_2M:
return "2M";
case BLE_GAP_LE_PHY_CODED:
return "CODED";
default:
return "UNKNOWN";
}
}
static void metrics_request_2m_phy(uint16_t conn_handle)
{
int rc = ble_gap_set_prefered_le_phy(conn_handle,
BLE_GAP_LE_PHY_2M_MASK,
BLE_GAP_LE_PHY_2M_MASK,
BLE_GAP_LE_PHY_CODED_ANY);
if (rc == 0) {
ESP_LOGI(TAG, "requested BLE 5 2M PHY");
} else {
ESP_LOGW(TAG, "request BLE 5 2M PHY failed: %d", rc);
}
}
static int metrics_gatt_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)arg;
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR || attr_handle != metrics_chr_val_handle) {
return BLE_ATT_ERR_UNLIKELY;
}
char line[512] = {0};
uint16_t len = 0;
int rc = ble_hs_mbuf_to_flat(ctxt->om, line, sizeof(line) - 1, &len);
if (rc != 0) {
return BLE_ATT_ERR_UNLIKELY;
}
line[len] = '\0';
ui_metrics_t metrics;
if (!metrics_parse_line(line, &metrics)) {
ESP_LOGW(TAG, "bad BLE metrics: %s", line);
ui_screen_set_status("BAD BLE DATA");
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
if (s_metrics_queue != NULL) {
xQueueOverwrite(s_metrics_queue, &metrics);
}
s_ble_write_count++;
if ((s_ble_write_count % 10) == 0) {
ESP_LOGI(TAG, "BLE metrics received: %lu", (unsigned long)s_ble_write_count);
}
return 0;
}
static const struct ble_gatt_svc_def metrics_gatt_svcs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &metrics_svc_uuid.u,
.characteristics = (struct ble_gatt_chr_def[]) {
{
.uuid = &metrics_chr_uuid.u,
.access_cb = metrics_gatt_access,
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP,
.val_handle = &metrics_chr_val_handle,
},
{0},
},
},
{0},
};
static void metrics_advertise(void)
{
struct ble_hs_adv_fields fields;
memset(&fields, 0, sizeof(fields));
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
fields.name = (uint8_t *)DEVICE_NAME;
fields.name_len = strlen(DEVICE_NAME);
fields.name_is_complete = 1;
int rc = ble_gap_adv_set_fields(&fields);
if (rc != 0) {
ESP_LOGE(TAG, "set adv fields failed: %d", rc);
return;
}
struct ble_gap_adv_params adv_params;
memset(&adv_params, 0, sizeof(adv_params));
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER,
&adv_params, metrics_gap_event, NULL);
if (rc != 0) {
ESP_LOGE(TAG, "start advertising failed: %d", rc);
return;
}
ESP_LOGI(TAG, "BLE advertising as %s", DEVICE_NAME);
}
static int metrics_gap_event(struct ble_gap_event *event, void *arg)
{
(void)arg;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
ESP_LOGI(TAG, "BLE connected");
metrics_request_2m_phy(event->connect.conn_handle);
} else {
metrics_advertise();
}
return 0;
case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE:
ESP_LOGI(TAG, "BLE PHY updated: status=%d tx=%s rx=%s",
event->phy_updated.status,
metrics_phy_name(event->phy_updated.tx_phy),
metrics_phy_name(event->phy_updated.rx_phy));
return 0;
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI(TAG, "BLE disconnected");
metrics_advertise();
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
metrics_advertise();
return 0;
default:
return 0;
}
}
static void metrics_on_reset(int reason)
{
ESP_LOGE(TAG, "BLE reset: %d", reason);
}
static void metrics_on_sync(void)
{
int rc = ble_hs_util_ensure_addr(0);
assert(rc == 0);
rc = ble_hs_id_infer_auto(0, &own_addr_type);
assert(rc == 0);
rc = ble_gap_set_prefered_default_le_phy(BLE_GAP_LE_PHY_2M_MASK,
BLE_GAP_LE_PHY_2M_MASK);
if (rc == 0) {
ESP_LOGI(TAG, "BLE 5 default PHY preference set to 2M");
} else {
ESP_LOGW(TAG, "set BLE 5 default PHY preference failed: %d", rc);
}
metrics_advertise();
}
static void metrics_host_task(void *param)
{
(void)param;
nimble_port_run();
nimble_port_freertos_deinit();
}
void metrics_ble_start(void)
{
esp_err_t nvs_ret = nvs_flash_init();
if (nvs_ret == ESP_ERR_NVS_NO_FREE_PAGES || nvs_ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
nvs_ret = nvs_flash_init();
}
ESP_ERROR_CHECK(nvs_ret);
if (s_metrics_queue == NULL) {
s_metrics_queue = xQueueCreate(1, sizeof(ui_metrics_t));
if (s_metrics_queue == NULL) {
ESP_LOGE(TAG, "failed to create BLE metrics queue");
return;
}
}
int rc = nimble_port_init();
if (rc != ESP_OK) {
ESP_LOGE(TAG, "nimble init failed: %d", rc);
return;
}
ble_hs_cfg.reset_cb = metrics_on_reset;
ble_hs_cfg.sync_cb = metrics_on_sync;
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
ble_svc_gap_init();
ble_svc_gatt_init();
rc = ble_gatts_count_cfg(metrics_gatt_svcs);
assert(rc == 0);
rc = ble_gatts_add_svcs(metrics_gatt_svcs);
assert(rc == 0);
rc = ble_svc_gap_device_name_set(DEVICE_NAME);
assert(rc == 0);
ble_store_config_init();
nimble_port_freertos_init(metrics_host_task);
}
bool metrics_ble_take_latest(ui_metrics_t *metrics)
{
if (metrics == NULL || s_metrics_queue == NULL) {
return false;
}
return xQueueReceive(s_metrics_queue, metrics, 0) == pdTRUE;
}

16
main/metrics_ble.h Normal file
View File

@ -0,0 +1,16 @@
#pragma once
#include <stdbool.h>
#include "ui_screen.h"
#ifdef __cplusplus
extern "C" {
#endif
void metrics_ble_start(void);
bool metrics_ble_take_latest(ui_metrics_t *metrics);
#ifdef __cplusplus
}
#endif

551
main/metrics_protocol.c Normal file
View File

@ -0,0 +1,551 @@
#include "metrics_protocol.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int clamp_percent(int value)
{
if (value < 0) {
return 0;
}
if (value > 100) {
return 100;
}
return value;
}
static bool parse_int_token(char **cursor, int *out)
{
char *token = strsep(cursor, ",");
if (token == NULL || token[0] == '\0') {
return false;
}
char *end = NULL;
long value = strtol(token, &end, 10);
if (end == token || *end != '\0') {
return false;
}
*out = (int)value;
return true;
}
static void set_disk_text(ui_metrics_t *metrics, const char *disk_text)
{
if (disk_text == NULL || disk_text[0] == '\0') {
metrics->disk_text[0] = '\0';
return;
}
snprintf(metrics->disk_text, sizeof(metrics->disk_text), "%s", disk_text);
}
static void set_optional_defaults(ui_metrics_t *metrics)
{
metrics->codex_5h_percent = -1;
metrics->codex_week_percent = -1;
metrics->codex_5h_reset_mins = -1;
metrics->codex_week_reset_mins = -1;
metrics->codex_reset_credits = -1;
metrics->codex_today_tokens_m10 = -1;
metrics->codex_daily_count = 0;
for (int i = 0; i < UI_CODEX_DAILY_BAR_COUNT; i++) {
metrics->codex_daily_percent[i] = -1;
}
metrics->zhipu_status = 0;
metrics->zhipu_quota_percent = -1;
metrics->zhipu_latency_ms = -1;
metrics->zhipu_5h_percent = -1;
metrics->zhipu_week_percent = -1;
metrics->zhipu_5h_reset_mins = -1;
metrics->zhipu_week_reset_mins = -1;
}
static void parse_codex_daily_bars(ui_metrics_t *metrics, const char *daily_text)
{
metrics->codex_daily_count = 0;
for (int i = 0; i < UI_CODEX_DAILY_BAR_COUNT; i++) {
metrics->codex_daily_percent[i] = -1;
}
if (daily_text == NULL || daily_text[0] == '\0') {
return;
}
char buffer[96];
snprintf(buffer, sizeof(buffer), "%s", daily_text);
char *cursor = buffer;
char *token = NULL;
while ((token = strsep(&cursor, ":")) != NULL && metrics->codex_daily_count < UI_CODEX_DAILY_BAR_COUNT) {
if (token[0] == '\0') {
continue;
}
char *end = NULL;
long value = strtol(token, &end, 10);
if (end == token || *end != '\0') {
continue;
}
metrics->codex_daily_percent[metrics->codex_daily_count] = clamp_percent((int)value);
metrics->codex_daily_count++;
}
}
static bool parse_sys2_line(const char *line, ui_metrics_t *metrics)
{
char buffer[320];
snprintf(buffer, sizeof(buffer), "%s", line);
char *cursor = buffer;
char *tag = strsep(&cursor, ",");
if (tag == NULL || strcmp(tag, "SYS2") != 0) {
return false;
}
int cpu = 0;
int cpu_mhz = 0;
int mem = 0;
int mem_used = 0;
int mem_total = 0;
int disk_count = 0;
int disk_percent = 0;
int disk_read = 0;
int disk_write = 0;
int rx = 0;
int tx = 0;
if (!parse_int_token(&cursor, &cpu) ||
!parse_int_token(&cursor, &cpu_mhz) ||
!parse_int_token(&cursor, &mem) ||
!parse_int_token(&cursor, &mem_used) ||
!parse_int_token(&cursor, &mem_total) ||
!parse_int_token(&cursor, &disk_count) ||
!parse_int_token(&cursor, &disk_percent) ||
!parse_int_token(&cursor, &disk_read) ||
!parse_int_token(&cursor, &disk_write) ||
!parse_int_token(&cursor, &rx) ||
!parse_int_token(&cursor, &tx)) {
return false;
}
metrics->cpu_percent = clamp_percent(cpu);
metrics->cpu_mhz = cpu_mhz;
metrics->memory_percent = clamp_percent(mem);
metrics->memory_used_mb = mem_used;
metrics->memory_total_mb = mem_total;
metrics->disk_count = disk_count;
metrics->disk_percent = clamp_percent(disk_percent);
metrics->disk_read_kbps = disk_read;
metrics->disk_write_kbps = disk_write;
metrics->rx_kbps = rx;
metrics->tx_kbps = tx;
set_disk_text(metrics, cursor);
return true;
}
static bool parse_sys3_line(const char *line, ui_metrics_t *metrics)
{
char buffer[384];
snprintf(buffer, sizeof(buffer), "%s", line);
char *cursor = buffer;
char *tag = strsep(&cursor, ",");
if (tag == NULL || strcmp(tag, "SYS3") != 0) {
return false;
}
int cpu = 0;
int cpu_mhz = 0;
int mem = 0;
int mem_used = 0;
int mem_total = 0;
int disk_count = 0;
int disk_percent = 0;
int disk_read = 0;
int disk_write = 0;
int rx = 0;
int tx = 0;
int codex_5h = -1;
int codex_week = -1;
int codex_5h_reset = -1;
int codex_week_reset = -1;
int codex_credits = -1;
int zhipu_status = 0;
int zhipu_quota = -1;
int zhipu_latency = -1;
if (!parse_int_token(&cursor, &cpu) ||
!parse_int_token(&cursor, &cpu_mhz) ||
!parse_int_token(&cursor, &mem) ||
!parse_int_token(&cursor, &mem_used) ||
!parse_int_token(&cursor, &mem_total) ||
!parse_int_token(&cursor, &disk_count) ||
!parse_int_token(&cursor, &disk_percent) ||
!parse_int_token(&cursor, &disk_read) ||
!parse_int_token(&cursor, &disk_write) ||
!parse_int_token(&cursor, &rx) ||
!parse_int_token(&cursor, &tx) ||
!parse_int_token(&cursor, &codex_5h) ||
!parse_int_token(&cursor, &codex_week) ||
!parse_int_token(&cursor, &codex_5h_reset) ||
!parse_int_token(&cursor, &codex_week_reset) ||
!parse_int_token(&cursor, &codex_credits) ||
!parse_int_token(&cursor, &zhipu_status) ||
!parse_int_token(&cursor, &zhipu_quota) ||
!parse_int_token(&cursor, &zhipu_latency)) {
return false;
}
metrics->cpu_percent = clamp_percent(cpu);
metrics->cpu_mhz = cpu_mhz;
metrics->memory_percent = clamp_percent(mem);
metrics->memory_used_mb = mem_used;
metrics->memory_total_mb = mem_total;
metrics->disk_count = disk_count;
metrics->disk_percent = clamp_percent(disk_percent);
metrics->disk_read_kbps = disk_read;
metrics->disk_write_kbps = disk_write;
metrics->rx_kbps = rx;
metrics->tx_kbps = tx;
metrics->codex_5h_percent = (codex_5h >= 0) ? clamp_percent(codex_5h) : -1;
metrics->codex_week_percent = (codex_week >= 0) ? clamp_percent(codex_week) : -1;
metrics->codex_5h_reset_mins = codex_5h_reset;
metrics->codex_week_reset_mins = codex_week_reset;
metrics->codex_reset_credits = codex_credits;
metrics->zhipu_status = zhipu_status;
metrics->zhipu_quota_percent = (zhipu_quota >= 0) ? clamp_percent(zhipu_quota) : -1;
metrics->zhipu_latency_ms = zhipu_latency;
metrics->zhipu_5h_percent = metrics->zhipu_quota_percent;
metrics->zhipu_week_percent = -1;
metrics->zhipu_5h_reset_mins = -1;
metrics->zhipu_week_reset_mins = -1;
set_disk_text(metrics, cursor);
return true;
}
static bool parse_sys4_line(const char *line, ui_metrics_t *metrics)
{
char buffer[384];
snprintf(buffer, sizeof(buffer), "%s", line);
char *cursor = buffer;
char *tag = strsep(&cursor, ",");
if (tag == NULL || strcmp(tag, "SYS4") != 0) {
return false;
}
int cpu = 0;
int cpu_mhz = 0;
int mem = 0;
int mem_used = 0;
int mem_total = 0;
int disk_count = 0;
int disk_percent = 0;
int disk_read = 0;
int disk_write = 0;
int rx = 0;
int tx = 0;
int codex_5h = -1;
int codex_week = -1;
int codex_5h_reset = -1;
int codex_week_reset = -1;
int codex_credits = -1;
int zhipu_status = 0;
int zhipu_5h = -1;
int zhipu_week = -1;
int zhipu_5h_reset = -1;
int zhipu_week_reset = -1;
if (!parse_int_token(&cursor, &cpu) ||
!parse_int_token(&cursor, &cpu_mhz) ||
!parse_int_token(&cursor, &mem) ||
!parse_int_token(&cursor, &mem_used) ||
!parse_int_token(&cursor, &mem_total) ||
!parse_int_token(&cursor, &disk_count) ||
!parse_int_token(&cursor, &disk_percent) ||
!parse_int_token(&cursor, &disk_read) ||
!parse_int_token(&cursor, &disk_write) ||
!parse_int_token(&cursor, &rx) ||
!parse_int_token(&cursor, &tx) ||
!parse_int_token(&cursor, &codex_5h) ||
!parse_int_token(&cursor, &codex_week) ||
!parse_int_token(&cursor, &codex_5h_reset) ||
!parse_int_token(&cursor, &codex_week_reset) ||
!parse_int_token(&cursor, &codex_credits) ||
!parse_int_token(&cursor, &zhipu_status) ||
!parse_int_token(&cursor, &zhipu_5h) ||
!parse_int_token(&cursor, &zhipu_week) ||
!parse_int_token(&cursor, &zhipu_5h_reset) ||
!parse_int_token(&cursor, &zhipu_week_reset)) {
return false;
}
metrics->cpu_percent = clamp_percent(cpu);
metrics->cpu_mhz = cpu_mhz;
metrics->memory_percent = clamp_percent(mem);
metrics->memory_used_mb = mem_used;
metrics->memory_total_mb = mem_total;
metrics->disk_count = disk_count;
metrics->disk_percent = clamp_percent(disk_percent);
metrics->disk_read_kbps = disk_read;
metrics->disk_write_kbps = disk_write;
metrics->rx_kbps = rx;
metrics->tx_kbps = tx;
metrics->codex_5h_percent = (codex_5h >= 0) ? clamp_percent(codex_5h) : -1;
metrics->codex_week_percent = (codex_week >= 0) ? clamp_percent(codex_week) : -1;
metrics->codex_5h_reset_mins = codex_5h_reset;
metrics->codex_week_reset_mins = codex_week_reset;
metrics->codex_reset_credits = codex_credits;
metrics->zhipu_status = zhipu_status;
metrics->zhipu_5h_percent = (zhipu_5h >= 0) ? clamp_percent(zhipu_5h) : -1;
metrics->zhipu_week_percent = (zhipu_week >= 0) ? clamp_percent(zhipu_week) : -1;
metrics->zhipu_5h_reset_mins = zhipu_5h_reset;
metrics->zhipu_week_reset_mins = zhipu_week_reset;
metrics->zhipu_quota_percent = metrics->zhipu_5h_percent;
metrics->zhipu_latency_ms = -1;
set_disk_text(metrics, cursor);
return true;
}
static bool parse_sys5_line(const char *line, ui_metrics_t *metrics)
{
char buffer[384];
snprintf(buffer, sizeof(buffer), "%s", line);
char *cursor = buffer;
char *tag = strsep(&cursor, ",");
if (tag == NULL || strcmp(tag, "SYS5") != 0) {
return false;
}
int cpu = 0;
int cpu_mhz = 0;
int mem = 0;
int mem_used = 0;
int mem_total = 0;
int disk_count = 0;
int disk_percent = 0;
int disk_read = 0;
int disk_write = 0;
int rx = 0;
int tx = 0;
int codex_5h = -1;
int codex_week = -1;
int codex_5h_reset = -1;
int codex_week_reset = -1;
int codex_credits = -1;
int codex_today_tokens_m10 = -1;
int zhipu_status = 0;
int zhipu_5h = -1;
int zhipu_week = -1;
int zhipu_5h_reset = -1;
int zhipu_week_reset = -1;
if (!parse_int_token(&cursor, &cpu) ||
!parse_int_token(&cursor, &cpu_mhz) ||
!parse_int_token(&cursor, &mem) ||
!parse_int_token(&cursor, &mem_used) ||
!parse_int_token(&cursor, &mem_total) ||
!parse_int_token(&cursor, &disk_count) ||
!parse_int_token(&cursor, &disk_percent) ||
!parse_int_token(&cursor, &disk_read) ||
!parse_int_token(&cursor, &disk_write) ||
!parse_int_token(&cursor, &rx) ||
!parse_int_token(&cursor, &tx) ||
!parse_int_token(&cursor, &codex_5h) ||
!parse_int_token(&cursor, &codex_week) ||
!parse_int_token(&cursor, &codex_5h_reset) ||
!parse_int_token(&cursor, &codex_week_reset) ||
!parse_int_token(&cursor, &codex_credits) ||
!parse_int_token(&cursor, &codex_today_tokens_m10) ||
!parse_int_token(&cursor, &zhipu_status) ||
!parse_int_token(&cursor, &zhipu_5h) ||
!parse_int_token(&cursor, &zhipu_week) ||
!parse_int_token(&cursor, &zhipu_5h_reset) ||
!parse_int_token(&cursor, &zhipu_week_reset)) {
return false;
}
metrics->cpu_percent = clamp_percent(cpu);
metrics->cpu_mhz = cpu_mhz;
metrics->memory_percent = clamp_percent(mem);
metrics->memory_used_mb = mem_used;
metrics->memory_total_mb = mem_total;
metrics->disk_count = disk_count;
metrics->disk_percent = clamp_percent(disk_percent);
metrics->disk_read_kbps = disk_read;
metrics->disk_write_kbps = disk_write;
metrics->rx_kbps = rx;
metrics->tx_kbps = tx;
metrics->codex_5h_percent = (codex_5h >= 0) ? clamp_percent(codex_5h) : -1;
metrics->codex_week_percent = (codex_week >= 0) ? clamp_percent(codex_week) : -1;
metrics->codex_5h_reset_mins = codex_5h_reset;
metrics->codex_week_reset_mins = codex_week_reset;
metrics->codex_reset_credits = codex_credits;
metrics->codex_today_tokens_m10 = codex_today_tokens_m10;
metrics->zhipu_status = zhipu_status;
metrics->zhipu_5h_percent = (zhipu_5h >= 0) ? clamp_percent(zhipu_5h) : -1;
metrics->zhipu_week_percent = (zhipu_week >= 0) ? clamp_percent(zhipu_week) : -1;
metrics->zhipu_5h_reset_mins = zhipu_5h_reset;
metrics->zhipu_week_reset_mins = zhipu_week_reset;
metrics->zhipu_quota_percent = metrics->zhipu_5h_percent;
metrics->zhipu_latency_ms = -1;
set_disk_text(metrics, cursor);
return true;
}
static bool parse_sys6_line(const char *line, ui_metrics_t *metrics)
{
char buffer[512];
snprintf(buffer, sizeof(buffer), "%s", line);
char *cursor = buffer;
char *tag = strsep(&cursor, ",");
if (tag == NULL || strcmp(tag, "SYS6") != 0) {
return false;
}
int cpu = 0;
int cpu_mhz = 0;
int mem = 0;
int mem_used = 0;
int mem_total = 0;
int disk_count = 0;
int disk_percent = 0;
int disk_read = 0;
int disk_write = 0;
int rx = 0;
int tx = 0;
int codex_5h = -1;
int codex_week = -1;
int codex_5h_reset = -1;
int codex_week_reset = -1;
int codex_credits = -1;
int codex_today_tokens_m10 = -1;
int zhipu_status = 0;
int zhipu_5h = -1;
int zhipu_week = -1;
int zhipu_5h_reset = -1;
int zhipu_week_reset = -1;
if (!parse_int_token(&cursor, &cpu) ||
!parse_int_token(&cursor, &cpu_mhz) ||
!parse_int_token(&cursor, &mem) ||
!parse_int_token(&cursor, &mem_used) ||
!parse_int_token(&cursor, &mem_total) ||
!parse_int_token(&cursor, &disk_count) ||
!parse_int_token(&cursor, &disk_percent) ||
!parse_int_token(&cursor, &disk_read) ||
!parse_int_token(&cursor, &disk_write) ||
!parse_int_token(&cursor, &rx) ||
!parse_int_token(&cursor, &tx) ||
!parse_int_token(&cursor, &codex_5h) ||
!parse_int_token(&cursor, &codex_week) ||
!parse_int_token(&cursor, &codex_5h_reset) ||
!parse_int_token(&cursor, &codex_week_reset) ||
!parse_int_token(&cursor, &codex_credits) ||
!parse_int_token(&cursor, &codex_today_tokens_m10)) {
return false;
}
char *daily_text = strsep(&cursor, ",");
if (daily_text == NULL ||
!parse_int_token(&cursor, &zhipu_status) ||
!parse_int_token(&cursor, &zhipu_5h) ||
!parse_int_token(&cursor, &zhipu_week) ||
!parse_int_token(&cursor, &zhipu_5h_reset) ||
!parse_int_token(&cursor, &zhipu_week_reset)) {
return false;
}
metrics->cpu_percent = clamp_percent(cpu);
metrics->cpu_mhz = cpu_mhz;
metrics->memory_percent = clamp_percent(mem);
metrics->memory_used_mb = mem_used;
metrics->memory_total_mb = mem_total;
metrics->disk_count = disk_count;
metrics->disk_percent = clamp_percent(disk_percent);
metrics->disk_read_kbps = disk_read;
metrics->disk_write_kbps = disk_write;
metrics->rx_kbps = rx;
metrics->tx_kbps = tx;
metrics->codex_5h_percent = (codex_5h >= 0) ? clamp_percent(codex_5h) : -1;
metrics->codex_week_percent = (codex_week >= 0) ? clamp_percent(codex_week) : -1;
metrics->codex_5h_reset_mins = codex_5h_reset;
metrics->codex_week_reset_mins = codex_week_reset;
metrics->codex_reset_credits = codex_credits;
metrics->codex_today_tokens_m10 = codex_today_tokens_m10;
parse_codex_daily_bars(metrics, daily_text);
metrics->zhipu_status = zhipu_status;
metrics->zhipu_5h_percent = (zhipu_5h >= 0) ? clamp_percent(zhipu_5h) : -1;
metrics->zhipu_week_percent = (zhipu_week >= 0) ? clamp_percent(zhipu_week) : -1;
metrics->zhipu_5h_reset_mins = zhipu_5h_reset;
metrics->zhipu_week_reset_mins = zhipu_week_reset;
metrics->zhipu_quota_percent = metrics->zhipu_5h_percent;
metrics->zhipu_latency_ms = -1;
set_disk_text(metrics, cursor);
return true;
}
static bool parse_legacy_sys_line(const char *line, ui_metrics_t *metrics)
{
int cpu = 0;
int mem = 0;
int disk = 0;
int rx = 0;
int tx = 0;
int matched = sscanf(line, "SYS,%d,%d,%d,%d,%d", &cpu, &mem, &disk, &rx, &tx);
if (matched != 5) {
return false;
}
metrics->cpu_percent = clamp_percent(cpu);
metrics->cpu_mhz = 0;
metrics->memory_percent = clamp_percent(mem);
metrics->memory_used_mb = 0;
metrics->memory_total_mb = 0;
metrics->disk_percent = clamp_percent(disk);
metrics->disk_read_kbps = 0;
metrics->disk_write_kbps = 0;
metrics->rx_kbps = rx;
metrics->tx_kbps = tx;
metrics->disk_count = 1;
snprintf(metrics->disk_text, sizeof(metrics->disk_text), "C:%d:-1:-1", metrics->disk_percent);
return true;
}
bool metrics_parse_line(const char *line, ui_metrics_t *metrics)
{
if (line == NULL || metrics == NULL) {
return false;
}
memset(metrics, 0, sizeof(*metrics));
set_optional_defaults(metrics);
if (strncmp(line, "SYS6,", 5) == 0) {
return parse_sys6_line(line, metrics);
}
if (strncmp(line, "SYS5,", 5) == 0) {
return parse_sys5_line(line, metrics);
}
if (strncmp(line, "SYS4,", 5) == 0) {
return parse_sys4_line(line, metrics);
}
if (strncmp(line, "SYS3,", 5) == 0) {
return parse_sys3_line(line, metrics);
}
if (strncmp(line, "SYS2,", 5) == 0) {
return parse_sys2_line(line, metrics);
}
return parse_legacy_sys_line(line, metrics);
}

15
main/metrics_protocol.h Normal file
View File

@ -0,0 +1,15 @@
#pragma once
#include <stdbool.h>
#include "ui_screen.h"
#ifdef __cplusplus
extern "C" {
#endif
bool metrics_parse_line(const char *line, ui_metrics_t *metrics);
#ifdef __cplusplus
}
#endif

122
main/metrics_serial.c Normal file
View File

@ -0,0 +1,122 @@
#include "metrics_serial.h"
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "driver/uart.h"
#include "driver/usb_serial_jtag.h"
#include "esp_err.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "metrics_protocol.h"
#include "ui_screen.h"
static const char *TAG = "metrics_serial";
static QueueHandle_t s_serial_metrics_queue;
static void consume_byte(char ch, char *line, size_t *length)
{
if (ch == '\r') {
return;
}
if (ch == '\n') {
line[*length] = '\0';
*length = 0;
ui_metrics_t metrics;
if (metrics_parse_line(line, &metrics)) {
if (s_serial_metrics_queue != NULL) {
xQueueOverwrite(s_serial_metrics_queue, &metrics);
}
}
return;
}
if (*length < 511) {
line[*length] = ch;
*length += 1;
} else {
*length = 0;
}
}
static void usb_serial_task(void *arg)
{
(void)arg;
char line[512];
size_t length = 0;
uint8_t buffer[64];
while (1) {
int read = usb_serial_jtag_read_bytes(buffer, sizeof(buffer), pdMS_TO_TICKS(100));
for (int i = 0; i < read; i++) {
consume_byte((char)buffer[i], line, &length);
}
}
}
static void uart_serial_task(void *arg)
{
(void)arg;
char line[512];
size_t length = 0;
uint8_t buffer[64];
while (1) {
int read = uart_read_bytes(UART_NUM_0, buffer, sizeof(buffer), pdMS_TO_TICKS(100));
for (int i = 0; i < read; i++) {
consume_byte((char)buffer[i], line, &length);
}
}
}
void metrics_serial_start(void)
{
if (s_serial_metrics_queue == NULL) {
s_serial_metrics_queue = xQueueCreate(1, sizeof(ui_metrics_t));
if (s_serial_metrics_queue == NULL) {
ESP_LOGW(TAG, "serial metrics queue unavailable");
return;
}
}
usb_serial_jtag_driver_config_t usb_config = {
.tx_buffer_size = 256,
.rx_buffer_size = 1024,
};
esp_err_t usb_ret = usb_serial_jtag_driver_install(&usb_config);
if (usb_ret != ESP_OK && usb_ret != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "USB Serial/JTAG install failed: %s", esp_err_to_name(usb_ret));
} else {
xTaskCreate(usb_serial_task, "metrics_usb", 4096, NULL, 4, NULL);
}
const uart_config_t uart_config = {
.baud_rate = 115200,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
uart_param_config(UART_NUM_0, &uart_config);
esp_err_t uart_ret = uart_driver_install(UART_NUM_0, 1024, 0, 0, NULL, 0);
if (uart_ret != ESP_OK && uart_ret != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "UART0 install failed: %s", esp_err_to_name(uart_ret));
} else {
xTaskCreate(uart_serial_task, "metrics_uart", 4096, NULL, 4, NULL);
}
}
bool metrics_serial_take_latest(ui_metrics_t *metrics)
{
if (metrics == NULL || s_serial_metrics_queue == NULL) {
return false;
}
return xQueueReceive(s_serial_metrics_queue, metrics, 0) == pdTRUE;
}

16
main/metrics_serial.h Normal file
View File

@ -0,0 +1,16 @@
#pragma once
#include <stdbool.h>
#include "ui_screen.h"
#ifdef __cplusplus
extern "C" {
#endif
void metrics_serial_start(void);
bool metrics_serial_take_latest(ui_metrics_t *metrics);
#ifdef __cplusplus
}
#endif

148
main/metrics_wifi.c Normal file
View File

@ -0,0 +1,148 @@
#include "metrics_wifi.h"
#include <string.h>
#include "esp_err.h"
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "metrics_protocol.h"
#include "nvs_flash.h"
#include "ui_screen.h"
static const char *TAG = "metrics_ap";
#define METRICS_AP_SSID "RLCD-Metrics"
#define METRICS_AP_PASSWORD "rlcd123456"
#define METRICS_AP_CHANNEL 6
#define METRICS_AP_MAX_CLIENTS 2
static esp_err_t send_text(httpd_req_t *req, const char *text)
{
httpd_resp_set_type(req, "text/plain; charset=utf-8");
return httpd_resp_sendstr(req, text);
}
static esp_err_t health_handler(httpd_req_t *req)
{
return send_text(req, "ok\n");
}
static esp_err_t metrics_post_handler(httpd_req_t *req)
{
char line[512] = {0};
int total = 0;
while (total < (int)sizeof(line) - 1) {
int read = httpd_req_recv(req, line + total, (sizeof(line) - 1) - total);
if (read <= 0) {
if (read == HTTPD_SOCK_ERR_TIMEOUT) {
continue;
}
break;
}
total += read;
if (total >= req->content_len) {
break;
}
}
line[total] = '\0';
ui_metrics_t metrics;
if (!metrics_parse_line(line, &metrics)) {
ui_screen_set_status("BAD WIFI DATA");
httpd_resp_set_status(req, "400 Bad Request");
return send_text(req, "bad data\n");
}
ui_screen_update_metrics_from_source(&metrics, "LIVE FROM AP");
return send_text(req, "ok\n");
}
static esp_err_t start_http_server(void)
{
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = 80;
config.ctrl_port = 32768;
config.stack_size = 6144;
httpd_handle_t server = NULL;
esp_err_t ret = httpd_start(&server, &config);
if (ret != ESP_OK) {
return ret;
}
httpd_uri_t health_uri = {
.uri = "/health",
.method = HTTP_GET,
.handler = health_handler,
.user_ctx = NULL,
};
httpd_register_uri_handler(server, &health_uri);
httpd_uri_t metrics_uri = {
.uri = "/metrics",
.method = HTTP_POST,
.handler = metrics_post_handler,
.user_ctx = NULL,
};
httpd_register_uri_handler(server, &metrics_uri);
return ESP_OK;
}
static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
{
(void)arg;
(void)event_data;
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STACONNECTED) {
ui_screen_set_status("PC CONNECTED");
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) {
ui_screen_set_status("AP WAITING PC");
}
}
void metrics_wifi_start(void)
{
esp_err_t nvs_ret = nvs_flash_init();
if (nvs_ret == ESP_ERR_NVS_NO_FREE_PAGES || nvs_ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
nvs_ret = nvs_flash_init();
}
ESP_ERROR_CHECK(nvs_ret);
ESP_ERROR_CHECK(esp_netif_init());
esp_err_t loop_ret = esp_event_loop_create_default();
if (loop_ret != ESP_OK && loop_ret != ESP_ERR_INVALID_STATE) {
ESP_ERROR_CHECK(loop_ret);
}
esp_netif_create_default_wifi_ap();
wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&init_config));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL));
wifi_config_t ap_config = {0};
strlcpy((char *)ap_config.ap.ssid, METRICS_AP_SSID, sizeof(ap_config.ap.ssid));
strlcpy((char *)ap_config.ap.password, METRICS_AP_PASSWORD, sizeof(ap_config.ap.password));
ap_config.ap.ssid_len = strlen(METRICS_AP_SSID);
ap_config.ap.channel = METRICS_AP_CHANNEL;
ap_config.ap.max_connection = METRICS_AP_MAX_CLIENTS;
ap_config.ap.authmode = WIFI_AUTH_WPA2_PSK;
ap_config.ap.pmf_cfg.required = false;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_ERROR_CHECK(start_http_server());
ESP_LOGI(TAG, "AP started: SSID=%s password=%s url=http://192.168.4.1/metrics", METRICS_AP_SSID, METRICS_AP_PASSWORD);
ui_screen_set_status("AP RLCD-Metrics");
}

11
main/metrics_wifi.h Normal file
View File

@ -0,0 +1,11 @@
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
void metrics_wifi_start(void);
#ifdef __cplusplus
}
#endif

365
main/st7305_rlcd.c Normal file
View File

@ -0,0 +1,365 @@
/**
* @file st7305_rlcd.c
* @brief ST7305 反射式 LCD 驱动实现(纯 C)
*
* 移植自微雪官方 custom_lcd_display.cc。
* 关键逻辑(初始化序列、横屏 LUT、像素打包)100% 保留,
* 只是把 C++ class 改成模块级全局变量 + 函数。
*/
#include "st7305_rlcd.h"
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "driver/gpio.h"
static const char *TAG = "st7305";
/* ===========================================================
* 模块内部状态(等价于原来 C++ 类的成员变量)
* =========================================================== */
/* SPI 设备句柄:用 ESP-IDF 的 esp_lcd panel io 框架来管 SPI 通信 */
static esp_lcd_panel_io_handle_t s_io_handle = NULL;
/* 显存:每 8 像素打包成 1 字节,所以显存大小 = W*H/8 = 400*300/8 = 15000 字节。
* 这个 buffer 放在 PSRAM 里(8MB PSRAM 完全够)。 */
#define DISPLAY_BUFFER_LEN ((ST7305_RLCD_WIDTH * ST7305_RLCD_HEIGHT) / 8)
static uint8_t *s_disp_buffer = NULL;
/* 横屏 LUT:把 (x,y) 像素坐标映射到 (字节索引, 位掩码)。
* ST7305 的显存布局很奇怪(2列×4行打包),没有 LUT 的话
* 每次画一个像素都要重新算一遍位运算,LUT 让它变成 O(1) 查表。
* 索引方式:pixel_index = x * HEIGHT + y(跟官方一致) */
static uint16_t *s_pixel_index_lut = NULL; /* 大小: W*H 个 uint16 */
static uint8_t *s_pixel_bit_lut = NULL; /* 大小: W*H 个 uint8 */
/* ===========================================================
* SPI 底层:发命令 / 发数据
* 用 esp_lcd panel io 框架,它会自动管 DC 引脚
* (DC=0 表示命令, DC=1 表示数据)
* =========================================================== */
static void send_command(uint8_t cmd)
{
/* 用 -1 作为 dis_flags 表示"不带参数的命令" */
esp_lcd_panel_io_tx_param(s_io_handle, cmd, NULL, 0);
}
static void send_data(uint8_t data)
{
esp_lcd_panel_io_tx_param(s_io_handle, -1, &data, 1);
}
static void send_buffer(uint8_t *data, int len)
{
esp_lcd_panel_io_tx_color(s_io_handle, -1, data, len);
}
/* ===========================================================
* 硬件复位(官方 RLCD_Reset 翻译)
* =========================================================== */
static void hardware_reset(void)
{
/* RST 引脚高 → 低 → 高 的脉冲,跟官方时序一致 */
gpio_set_level(ST7305_RLCD_PIN_RST, 1);
vTaskDelay(pdMS_TO_TICKS(50));
gpio_set_level(ST7305_RLCD_PIN_RST, 0);
vTaskDelay(pdMS_TO_TICKS(20));
gpio_set_level(ST7305_RLCD_PIN_RST, 1);
vTaskDelay(pdMS_TO_TICKS(50));
}
/* ===========================================================
* ST7305 初始化序列(官方 RLCD_Init 完整翻译)
* 这 50+ 条寄存器命令是从微雪官方代码原样复制来的,
* 不要随便改顺序或值 —— 它们是微雪调好的参数。
* 每条命令的中文注释参考 ST7305 datasheet 和官方命名。
* =========================================================== */
static void init_display(void)
{
/* NVM Load Control - 从非易失存储加载设置 */
send_command(0xD6);
send_data(0x17);
send_data(0x02);
/* Booster Enable - 使能电荷泵 */
send_command(0xD1);
send_data(0x01);
/* Gate Voltage Control - VGH/VGL 电压 */
send_command(0xC0);
send_data(0x11);
send_data(0x04);
/* VSHP Setting - 正向源电压(高功耗模式) */
send_command(0xC1);
send_data(0x69); send_data(0x69); send_data(0x69); send_data(0x69);
/* VSLP Setting - 正向源电压(低功耗模式) */
send_command(0xC2);
send_data(0x19); send_data(0x19); send_data(0x19); send_data(0x19);
/* VSHN Setting - 负向源电压(高功耗模式) */
send_command(0xC4);
send_data(0x4B); send_data(0x4B); send_data(0x4B); send_data(0x4B);
/* VSLN Setting - 负向源电压(低功耗模式) */
send_command(0xC5);
send_data(0x19); send_data(0x19); send_data(0x19); send_data(0x19);
/* OSC Setting - 振荡器频率控制 */
send_command(0xD8);
send_data(0x80);
send_data(0xE9);
/* Frame Rate Control - 帧率 */
send_command(0xB2);
send_data(0x02);
/* Gate EQ Control (High Power Mode) - 更新周期时序 */
send_command(0xB3);
send_data(0xE5); send_data(0xF6); send_data(0x05); send_data(0x46);
send_data(0x77); send_data(0x77); send_data(0x77); send_data(0x77);
send_data(0x76); send_data(0x45);
/* Gate EQ Control (Low Power Mode) - 更新周期时序 */
send_command(0xB4);
send_data(0x05); send_data(0x46); send_data(0x77); send_data(0x77);
send_data(0x77); send_data(0x77); send_data(0x76); send_data(0x45);
/* Gate Timing Control */
send_command(0x62);
send_data(0x32); send_data(0x03); send_data(0x1F);
/* Source EQ Enable */
send_command(0xB7);
send_data(0x13);
/* Gate Line Setting - 门极线数(板子相关)
* 0x64 = 100,100*3 = 300 行,对应 300 高 */
send_command(0xB0);
send_data(0x64);
/* Sleep Out - 退出睡眠(datasheet 要求 200ms 延时) */
send_command(0x11);
vTaskDelay(pdMS_TO_TICKS(200));
/* Source Voltage Select - 选 VSHP1/VSLP1/VSHN1/VSLN1 */
send_command(0xC9);
send_data(0x00);
/* MADCTL (Memory Data Access Control) - MX=1, DO=1 */
send_command(0x36);
send_data(0x48);
/* 1-bit 单色模式 */
send_command(0x3A);
send_data(0x11);
/* Gamma Mode - 单色 */
send_command(0xB9);
send_data(0x20);
/* Panel Setting - 1-dot 反转,帧反转,隔行 */
send_command(0xB8);
send_data(0x29);
/* Display Inversion On */
send_command(0x21);
/* Column Address Set - 板子相关 */
send_command(0x2A);
send_data(0x12);
send_data(0x2A);
/* Row Address Set - 板子相关 */
send_command(0x2B);
send_data(0x00);
send_data(0xC7);
/* Tearing Effect Line On */
send_command(0x35);
send_data(0x00);
/* Auto Power Down Control */
send_command(0xD0);
send_data(0xFF);
/* High Power Mode On */
send_command(0x38);
/* Display On */
send_command(0x29);
}
/* ===========================================================
* 横屏像素 LUT 构造(官方 InitLandscapeLUT 翻译)
*
* ST7305 横屏显存布局:每字节装 8 个像素,排列是 2列×4行 的块
*
* 一个字节(8 bit)的物理含义:
* Bit 7: (row 0, col 0) Bit 6: (row 0, col 1)
* Bit 5: (row 1, col 0) Bit 4: (row 1, col 1)
* Bit 3: (row 2, col 0) Bit 2: (row 2, col 1)
* Bit 1: (row 3, col 0) Bit 0: (row 3, col 1)
*
* 所以给一个逻辑坐标 (x, y),要算出:
* 1. 它在显存里的字节索引
* 2. 在该字节里的位掩码
* 这就是 LUT 干的事,预计算一次,后面画像素就 O(1) 查表。
* =========================================================== */
static void init_landscape_lut(void)
{
const uint16_t H4 = ST7305_RLCD_HEIGHT >> 2; /* 高度方向块数 = 300/4 = 75 */
for (uint16_t y = 0; y < ST7305_RLCD_HEIGHT; y++) {
/* y 方向:ST7305 是从下往上扫的,所以要反转 */
uint16_t inv_y = ST7305_RLCD_HEIGHT - 1 - y;
uint16_t block_y = inv_y >> 2; /* 在哪个 4 行块里 */
uint8_t local_y = inv_y & 3; /* 块内第几行 */
for (uint16_t x = 0; x < ST7305_RLCD_WIDTH; x++) {
uint16_t byte_x = x >> 1; /* 2 列共用 1 字节 */
uint8_t local_x = x & 1; /* 块内第几列 */
/* 字节索引 = 列块号 * 高度块数 + 行块号 */
uint32_t buffer_idx = (uint32_t)byte_x * H4 + block_y;
/* 位掩码:bit 位置 = 7 - (local_y * 2 + local_x) */
uint8_t bit_pos = 7 - ((local_y << 1) | local_x);
uint8_t bit_mask = (uint8_t)(1u << bit_pos);
/* LUT 索引方式:x * HEIGHT + y(跟官方一致) */
uint32_t pixel_idx = (uint32_t)x * ST7305_RLCD_HEIGHT + y;
s_pixel_index_lut[pixel_idx] = (uint16_t)buffer_idx;
s_pixel_bit_lut[pixel_idx] = bit_mask;
}
}
}
/* ===========================================================
* 对外 API 实现
* =========================================================== */
esp_err_t st7305_rlcd_init(void)
{
esp_err_t ret;
ESP_LOGI(TAG, "Step 1: 配置 RST 引脚为输出");
gpio_config_t gpio_conf = {
.pin_bit_mask = (1ULL << ST7305_RLCD_PIN_RST),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&gpio_conf);
ESP_LOGI(TAG, "Step 2: 初始化 SPI 总线 (SPI3, MOSI=%d, SCK=%d)",
ST7305_RLCD_PIN_MOSI, ST7305_RLCD_PIN_SCK);
spi_bus_config_t buscfg = {0};
buscfg.mosi_io_num = ST7305_RLCD_PIN_MOSI;
buscfg.miso_io_num = -1; /* 屏幕只写不读,不需要 MISO */
buscfg.sclk_io_num = ST7305_RLCD_PIN_SCK;
buscfg.quadwp_io_num = -1;
buscfg.quadhd_io_num = -1;
buscfg.max_transfer_sz = ST7305_RLCD_WIDTH * ST7305_RLCD_HEIGHT; /* 一次传整屏 */
ret = spi_bus_initialize(ST7305_RLCD_SPI_HOST, &buscfg, SPI_DMA_CH_AUTO);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "SPI 总线初始化失败: %s", esp_err_to_name(ret));
return ret;
}
ESP_LOGI(TAG, "Step 3: 把屏幕挂到 SPI 总线上(用 esp_lcd panel io 框架)");
esp_lcd_panel_io_spi_config_t io_config = {0};
io_config.dc_gpio_num = ST7305_RLCD_PIN_DC;
io_config.cs_gpio_num = ST7305_RLCD_PIN_CS;
io_config.pclk_hz = ST7305_RLCD_SPI_HZ;
io_config.lcd_cmd_bits = 8;
io_config.lcd_param_bits = 8;
io_config.spi_mode = 0;
io_config.trans_queue_depth = 7;
ret = esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)ST7305_RLCD_SPI_HOST,
&io_config, &s_io_handle);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "panel io 创建失败: %s", esp_err_to_name(ret));
return ret;
}
ESP_LOGI(TAG, "Step 4: 分配显存到 PSRAM (%d 字节)", DISPLAY_BUFFER_LEN);
s_disp_buffer = (uint8_t *)heap_caps_malloc(DISPLAY_BUFFER_LEN, MALLOC_CAP_SPIRAM);
if (s_disp_buffer == NULL) {
ESP_LOGE(TAG, "显存分配失败(PSRAM?)");
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "Step 5: 分配像素 LUT (index=%dKB, bit=%dKB)",
(ST7305_RLCD_WIDTH * ST7305_RLCD_HEIGHT * 2) / 1024,
(ST7305_RLCD_WIDTH * ST7305_RLCD_HEIGHT) / 1024);
uint32_t total_pixels = (uint32_t)ST7305_RLCD_WIDTH * ST7305_RLCD_HEIGHT;
s_pixel_index_lut = (uint16_t *)heap_caps_malloc(total_pixels * sizeof(uint16_t),
MALLOC_CAP_SPIRAM);
s_pixel_bit_lut = (uint8_t *)heap_caps_malloc(total_pixels * sizeof(uint8_t),
MALLOC_CAP_SPIRAM);
if (!s_pixel_index_lut || !s_pixel_bit_lut) {
ESP_LOGE(TAG, "LUT 分配失败(PSRAM?)");
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "Step 6: 构建横屏 LUT");
init_landscape_lut();
ESP_LOGI(TAG, "Step 7: 硬件复位 + 发送 ST7305 初始化序列");
hardware_reset();
init_display();
ESP_LOGI(TAG, "Step 8: 清屏(全白)");
st7305_rlcd_clear(ST7305_COLOR_WHITE);
st7305_rlcd_flush();
ESP_LOGI(TAG, "屏幕初始化完成 ✓");
return ESP_OK;
}
void st7305_rlcd_set_pixel(int x, int y, uint8_t color)
{
if (x < 0 || x >= ST7305_RLCD_WIDTH) return;
if (y < 0 || y >= ST7305_RLCD_HEIGHT) return;
/* O(1) 查 LUT */
uint32_t pixel_idx = (uint32_t)x * ST7305_RLCD_HEIGHT + y;
uint16_t buffer_idx = s_pixel_index_lut[pixel_idx];
uint8_t bit_mask = s_pixel_bit_lut[pixel_idx];
uint8_t *p = &s_disp_buffer[buffer_idx];
if (color == ST7305_COLOR_BLACK) {
*p &= ~bit_mask; /* 该位清零 = 像素亮(黑) */
} else {
*p |= bit_mask; /* 该位置 1 = 像素灭(白) */
}
}
void st7305_rlcd_flush(void)
{
/* 官方 RLCD_Display:每次刷新都重新设置地址窗口,然后发整屏数据 */
send_command(0x2A); /* Column Address Set */
send_data(0x12);
send_data(0x2A);
send_command(0x2B); /* Page/Row Address Set */
send_data(0x00);
send_data(0xC7);
send_command(0x2C); /* Memory Write */
send_buffer(s_disp_buffer, DISPLAY_BUFFER_LEN);
}
void st7305_rlcd_clear(uint8_t color)
{
memset(s_disp_buffer, color, DISPLAY_BUFFER_LEN);
}
int st7305_rlcd_get_width(void) { return ST7305_RLCD_WIDTH; }
int st7305_rlcd_get_height(void) { return ST7305_RLCD_HEIGHT; }

98
main/st7305_rlcd.h Normal file
View File

@ -0,0 +1,98 @@
/**
* @file st7305_rlcd.h
* @brief ST7305 反射式 LCD 驱动(纯 C, ESP-IDF 原生)
*
* 移植自微雪官方 waveshareteam/ESP32-S3-RLCD-4.2 仓库里的
* 02_Example/XiaoZhi/.../boards/waveshare-s3-rlcd-4.2/custom_lcd_display.cc
*
* 改动:
* - C++ 改纯 C
* - 去掉 LcdDisplay / WifiBoard 等 XiaoZhi 框架依赖
* - 去掉 LVGL 集成(由调用方自己接,本驱动只负责把像素写到显存并刷新)
* - 横屏 LUT 算法原样保留(InitLandscapeLUT)
* - ST7305 寄存器初始化序列原样保留(RLCD_Init)
*
* 板子:Waveshare ESP32-S3-RLCD-4.2
* - 屏幕:4.2 寸 400×300 黑白反射屏(ST7305)
* - 接口:SPI3, 40MHz
* - 引脚:DC=5, CS=40, SCK=11, MOSI=12, RST=41
*
* 显存布局(横屏,关键!):
* 每字节装 8 个像素,排列是 2列×4行 的块
* 详见 st7305_rlcd.c 里 init_landscape_lut() 的注释
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include "esp_err.h"
#include "esp_lcd_panel_io.h"
#include "driver/spi_master.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ===========================================================
* 屏幕参数(来自官方 config.h)
* =========================================================== */
#define ST7305_RLCD_WIDTH 400 /* 横屏宽 */
#define ST7305_RLCD_HEIGHT 300 /* 横屏高 */
#define ST7305_RLCD_SPI_HOST SPI3_HOST
#define ST7305_RLCD_SPI_HZ (40 * 1000 * 1000) /* 40 MHz */
#define ST7305_RLCD_PIN_DC 5
#define ST7305_RLCD_PIN_CS 40
#define ST7305_RLCD_PIN_SCK 11
#define ST7305_RLCD_PIN_MOSI 12
#define ST7305_RLCD_PIN_RST 41
/* ===========================================================
* 颜色(反射屏只有黑白两色)
* =========================================================== */
typedef enum {
ST7305_COLOR_WHITE = 0xFF, /* 像素关闭 = 白(反射屏底色) */
ST7305_COLOR_BLACK = 0x00, /* 像素点亮 = 黑 */
} st7305_color_t;
/* ===========================================================
* 对外 API
* =========================================================== */
/**
* 初始化屏幕:配置 SPI 总线、复位 ST7305、发送初始化序列、
* 分配显存、构建横屏像素 LUT、清屏。
* 整个流程只调用一次,在 app_main 开头。
*/
esp_err_t st7305_rlcd_init(void);
/**
* 把 (x, y) 处的像素设成指定颜色。
* 颜色取值见 st7305_color_t。
* 越界坐标会被忽略。
*/
void st7305_rlcd_set_pixel(int x, int y, uint8_t color);
/**
* 把整个显存一次性推到屏幕(SPI DMA 传输)。
* 调完才会真正在屏幕上看到变化。
*/
void st7305_rlcd_flush(void);
/**
* 用指定颜色填满整个显存(不会自动刷新到屏幕,
* 还要再调 st7305_rlcd_flush)。
*/
void st7305_rlcd_clear(uint8_t color);
/**
* 获取屏幕宽度 / 高度(主要是给 LVGL 用)。
*/
int st7305_rlcd_get_width(void);
int st7305_rlcd_get_height(void);
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,159 @@
#include "temp_humidity_monitor.h"
#include <stdint.h>
#include "driver/i2c_master.h"
#include "esp_check.h"
#include "esp_err.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
#include "freertos/task.h"
static const char *TAG = "shtc3";
#define SHTC3_I2C_PORT I2C_NUM_0
#define SHTC3_I2C_SDA GPIO_NUM_13
#define SHTC3_I2C_SCL GPIO_NUM_14
#define SHTC3_I2C_ADDRESS 0x70
#define SHTC3_I2C_HZ 100000
#define SHTC3_WAKEUP_CMD 0x3517
#define SHTC3_SLEEP_CMD 0xB098
#define SHTC3_MEASURE_CMD 0x7866
static i2c_master_bus_handle_t s_i2c_bus;
static i2c_master_dev_handle_t s_shtc3_dev;
static portMUX_TYPE s_env_mux = portMUX_INITIALIZER_UNLOCKED;
static int s_latest_temperature_c10;
static int s_latest_humidity_percent;
static bool s_has_latest;
static uint8_t shtc3_crc(const uint8_t *data)
{
uint8_t crc = 0xFF;
for (int i = 0; i < 2; i++) {
crc ^= data[i];
for (int bit = 0; bit < 8; bit++) {
if ((crc & 0x80) != 0) {
crc = (uint8_t)((crc << 1) ^ 0x31);
} else {
crc <<= 1;
}
}
}
return crc;
}
static esp_err_t shtc3_send_command(uint16_t command)
{
uint8_t data[2] = {
(uint8_t)(command >> 8),
(uint8_t)(command & 0xFF),
};
return i2c_master_transmit(s_shtc3_dev, data, sizeof(data), 1000);
}
static esp_err_t shtc3_read_once(int *temperature_c10, int *humidity_percent)
{
uint8_t data[6] = { 0 };
ESP_RETURN_ON_ERROR(shtc3_send_command(SHTC3_WAKEUP_CMD), TAG, "wake failed");
vTaskDelay(pdMS_TO_TICKS(2));
ESP_RETURN_ON_ERROR(shtc3_send_command(SHTC3_MEASURE_CMD), TAG, "measure failed");
vTaskDelay(pdMS_TO_TICKS(15));
ESP_RETURN_ON_ERROR(i2c_master_receive(s_shtc3_dev, data, sizeof(data), 1000), TAG, "read failed");
(void)shtc3_send_command(SHTC3_SLEEP_CMD);
if (shtc3_crc(&data[0]) != data[2] || shtc3_crc(&data[3]) != data[5]) {
return ESP_ERR_INVALID_CRC;
}
uint16_t raw_temp = ((uint16_t)data[0] << 8) | data[1];
uint16_t raw_humidity = ((uint16_t)data[3] << 8) | data[4];
*temperature_c10 = -450 + (int)((1750L * raw_temp + 32767) / 65535);
*humidity_percent = (int)((100L * raw_humidity + 32767) / 65535);
if (*humidity_percent < 0) {
*humidity_percent = 0;
} else if (*humidity_percent > 100) {
*humidity_percent = 100;
}
return ESP_OK;
}
static esp_err_t shtc3_init(void)
{
i2c_master_bus_config_t bus_config = {
.i2c_port = SHTC3_I2C_PORT,
.sda_io_num = SHTC3_I2C_SDA,
.scl_io_num = SHTC3_I2C_SCL,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true,
};
ESP_RETURN_ON_ERROR(i2c_new_master_bus(&bus_config, &s_i2c_bus), TAG, "i2c bus init failed");
i2c_device_config_t device_config = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = SHTC3_I2C_ADDRESS,
.scl_speed_hz = SHTC3_I2C_HZ,
};
ESP_RETURN_ON_ERROR(i2c_master_bus_add_device(s_i2c_bus, &device_config, &s_shtc3_dev),
TAG,
"shtc3 device add failed");
return ESP_OK;
}
static void temp_humidity_task(void *arg)
{
(void)arg;
while (1) {
int temperature_c10 = 0;
int humidity_percent = 0;
esp_err_t ret = shtc3_read_once(&temperature_c10, &humidity_percent);
if (ret == ESP_OK) {
portENTER_CRITICAL(&s_env_mux);
s_latest_temperature_c10 = temperature_c10;
s_latest_humidity_percent = humidity_percent;
s_has_latest = true;
portEXIT_CRITICAL(&s_env_mux);
} else {
ESP_LOGW(TAG, "read disabled this round: %s", esp_err_to_name(ret));
}
vTaskDelay(pdMS_TO_TICKS(10000));
}
}
void temp_humidity_monitor_start(void)
{
esp_err_t ret = shtc3_init();
if (ret != ESP_OK) {
ESP_LOGW(TAG, "temperature/humidity monitor disabled: %s", esp_err_to_name(ret));
return;
}
xTaskCreate(temp_humidity_task, "shtc3", 4096, NULL, 2, NULL);
}
bool temp_humidity_monitor_take_latest(int *temperature_c10, int *humidity_percent)
{
if (temperature_c10 == NULL || humidity_percent == NULL) {
return false;
}
bool has_latest = false;
portENTER_CRITICAL(&s_env_mux);
if (s_has_latest) {
*temperature_c10 = s_latest_temperature_c10;
*humidity_percent = s_latest_humidity_percent;
s_has_latest = false;
has_latest = true;
}
portEXIT_CRITICAL(&s_env_mux);
return has_latest;
}

View File

@ -0,0 +1,14 @@
#pragma once
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
void temp_humidity_monitor_start(void);
bool temp_humidity_monitor_take_latest(int *temperature_c10, int *humidity_percent);
#ifdef __cplusplus
}
#endif

795
main/ui_screen.c Normal file
View File

@ -0,0 +1,795 @@
#include "ui_screen.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "lvgl.h"
#define CPU_HISTORY_POINTS 64
#define CPU_CHART_W 184
#define CPU_CHART_H 58
#define HALO_LABEL_COUNT 8
#define DISK_ROW_COUNT 4
#define CODEX_DAILY_BAR_W 8
#define CODEX_DAILY_BAR_GAP 5
#define CODEX_DAILY_BAR_BASE_Y 292
#define CODEX_DAILY_BAR_MAX_H 40
typedef enum {
CONN_USB = 0,
CONN_WIFI,
CONN_BLE,
} connection_slot_t;
static lv_obj_t *s_status_label;
static lv_obj_t *s_battery_label;
static lv_obj_t *s_conn_label;
static lv_obj_t *s_env_label;
static lv_obj_t *s_cpu_value_label;
static lv_obj_t *s_cpu_freq_label;
static lv_obj_t *s_cpu_value_halo[HALO_LABEL_COUNT];
static lv_obj_t *s_cpu_freq_halo[HALO_LABEL_COUNT];
static lv_obj_t *s_cpu_line;
static lv_point_precise_t s_cpu_points[CPU_HISTORY_POINTS];
static int s_cpu_history[CPU_HISTORY_POINTS];
static lv_obj_t *s_memory_value_label;
static lv_obj_t *s_memory_value_halo[HALO_LABEL_COUNT];
static lv_obj_t *s_memory_detail_label;
static lv_obj_t *s_memory_bar;
static lv_obj_t *s_disk_name_labels[DISK_ROW_COUNT];
static lv_obj_t *s_disk_rate_labels[DISK_ROW_COUNT];
static lv_obj_t *s_disk_bars[DISK_ROW_COUNT];
static lv_obj_t *s_net_line_label;
static lv_obj_t *s_codex_5h_label;
static lv_obj_t *s_codex_week_label;
static lv_obj_t *s_codex_5h_value_label;
static lv_obj_t *s_codex_week_value_label;
static lv_obj_t *s_codex_5h_reset_label;
static lv_obj_t *s_codex_week_reset_label;
static lv_obj_t *s_codex_token_label;
static lv_obj_t *s_codex_5h_bar;
static lv_obj_t *s_codex_week_bar;
static lv_obj_t *s_codex_daily_bars[UI_CODEX_DAILY_BAR_COUNT];
static lv_obj_t *s_zhipu_status_label;
static lv_obj_t *s_zhipu_5h_label;
static lv_obj_t *s_zhipu_week_label;
static lv_obj_t *s_zhipu_5h_value_label;
static lv_obj_t *s_zhipu_week_value_label;
static lv_obj_t *s_zhipu_5h_reset_label;
static lv_obj_t *s_zhipu_week_reset_label;
static lv_obj_t *s_zhipu_5h_bar;
static lv_obj_t *s_zhipu_week_bar;
static int s_age_seconds;
static connection_slot_t s_active_connection = CONN_USB;
typedef struct {
int x;
int y;
} halo_offset_t;
static const halo_offset_t s_halo_offsets[HALO_LABEL_COUNT] = {
{ -1, 0 },
{ 1, 0 },
{ 0, -1 },
{ 0, 1 },
{ -1, -1 },
{ 1, -1 },
{ -1, 1 },
{ 1, 1 },
};
static lv_color_t ink(void)
{
return lv_color_black();
}
static lv_color_t paper(void)
{
return lv_color_white();
}
static int clamp_percent(int value)
{
if (value < 0) {
return 0;
}
if (value > 100) {
return 100;
}
return value;
}
static lv_obj_t *make_colored_label(lv_obj_t *parent, const char *text, const lv_font_t *font, lv_color_t color)
{
lv_obj_t *label = lv_label_create(parent);
lv_label_set_text(label, text);
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_font(label, font, 0);
lv_obj_set_style_text_color(label, color, 0);
lv_obj_set_style_pad_all(label, 0, 0);
return label;
}
static lv_obj_t *make_label(lv_obj_t *parent, const char *text, const lv_font_t *font)
{
return make_colored_label(parent, text, font, ink());
}
static void make_label_halo(lv_obj_t *parent,
lv_obj_t *halos[HALO_LABEL_COUNT],
const char *text,
const lv_font_t *font,
int width,
lv_align_t align,
int x,
int y,
int spread,
lv_text_align_t text_align)
{
for (int i = 0; i < HALO_LABEL_COUNT; i++) {
halos[i] = make_colored_label(parent, text, font, paper());
lv_obj_set_width(halos[i], width);
lv_obj_set_style_text_align(halos[i], text_align, 0);
lv_obj_align(halos[i], align, x + s_halo_offsets[i].x * spread, y + s_halo_offsets[i].y * spread);
}
}
static void set_label_with_halo(lv_obj_t *label, lv_obj_t *halos[HALO_LABEL_COUNT], const char *text)
{
for (int i = 0; i < HALO_LABEL_COUNT; i++) {
if (halos[i] != NULL) {
lv_label_set_text(halos[i], text);
}
}
lv_label_set_text(label, text);
}
static void set_bar_percent(lv_obj_t *bar, int percent)
{
if (bar == NULL) {
return;
}
lv_bar_set_value(bar, percent >= 0 ? clamp_percent(percent) : 0, LV_ANIM_OFF);
}
static lv_obj_t *make_rule(lv_obj_t *parent, int x, int y, int w, int h)
{
lv_obj_t *rule = lv_obj_create(parent);
lv_obj_set_size(rule, w, h);
lv_obj_align(rule, LV_ALIGN_TOP_LEFT, x, y);
lv_obj_set_style_bg_color(rule, ink(), 0);
lv_obj_set_style_bg_opa(rule, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(rule, 0, 0);
lv_obj_set_style_radius(rule, 0, 0);
lv_obj_set_style_pad_all(rule, 0, 0);
lv_obj_clear_flag(rule, LV_OBJ_FLAG_SCROLLABLE);
return rule;
}
static lv_obj_t *make_bar(lv_obj_t *parent, int w, int h)
{
lv_obj_t *bar = lv_bar_create(parent);
lv_obj_set_size(bar, w, h);
lv_bar_set_range(bar, 0, 100);
lv_bar_set_value(bar, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(bar, paper(), 0);
lv_obj_set_style_border_color(bar, ink(), 0);
lv_obj_set_style_border_width(bar, 1, 0);
lv_obj_set_style_radius(bar, 1, 0);
lv_obj_set_style_bg_color(bar, ink(), LV_PART_INDICATOR);
lv_obj_set_style_radius(bar, 1, LV_PART_INDICATOR);
return bar;
}
static void format_capacity(int mb, char *out, size_t out_size)
{
if (mb <= 0) {
snprintf(out, out_size, "--");
return;
}
if (mb >= 1024) {
int gb10 = (mb * 10 + 512) / 1024;
snprintf(out, out_size, "%d.%dGB", gb10 / 10, gb10 % 10);
} else {
snprintf(out, out_size, "%dMB", mb);
}
}
static void format_rate_short(int kbps, char *out, size_t out_size)
{
if (kbps < 0) {
snprintf(out, out_size, "--");
return;
}
if (kbps >= 1024 * 1024) {
int gb10 = (kbps * 10 + (1024 * 512)) / (1024 * 1024);
if (gb10 >= 100) {
snprintf(out, out_size, "%dG", gb10 / 10);
} else {
snprintf(out, out_size, "%d.%dG", gb10 / 10, gb10 % 10);
}
} else if (kbps >= 1000) {
int mb10 = (kbps * 10 + 512) / 1024;
if (mb10 >= 100) {
snprintf(out, out_size, "%dM", mb10 / 10);
} else {
snprintf(out, out_size, "%d.%dM", mb10 / 10, mb10 % 10);
}
} else {
snprintf(out, out_size, "%dK", kbps);
}
}
static void format_reset_mins(int mins, char *out, size_t out_size)
{
if (mins < 0) {
snprintf(out, out_size, "--");
} else if (mins >= 1440) {
int days = mins / 1440;
int hours = (mins % 1440) / 60;
snprintf(out, out_size, "%dd%dh", days, hours);
} else if (mins >= 60) {
snprintf(out, out_size, "%dh%02d", mins / 60, mins % 60);
} else {
snprintf(out, out_size, "%dm", mins);
}
}
static void format_percent_compact(int percent, char *out, size_t out_size)
{
if (percent < 0) {
snprintf(out, out_size, "--");
} else {
snprintf(out, out_size, "%d%%", clamp_percent(percent));
}
}
static void format_tokens_m10(int tokens_m10, char *out, size_t out_size)
{
if (tokens_m10 < 0) {
snprintf(out, out_size, "--");
} else if (tokens_m10 >= 10000) {
snprintf(out, out_size, "%d.%dB", tokens_m10 / 10000, (tokens_m10 % 10000) / 1000);
} else if (tokens_m10 >= 1000) {
snprintf(out, out_size, "%dM", (tokens_m10 + 5) / 10);
} else {
snprintf(out, out_size, "%d.%dM", tokens_m10 / 10, tokens_m10 % 10);
}
}
static const char *zhipu_status_text(int status)
{
switch (status) {
case 1:
return "KEY";
case 2:
return "ERR";
default:
return "WAIT";
}
}
static const char *connection_symbol(connection_slot_t active)
{
switch (active) {
case CONN_BLE:
return LV_SYMBOL_BLUETOOTH;
case CONN_WIFI:
return LV_SYMBOL_WIFI;
case CONN_USB:
default:
return LV_SYMBOL_USB;
}
}
static void set_connection(connection_slot_t active)
{
s_active_connection = active;
if (s_conn_label != NULL) {
lv_label_set_text(s_conn_label, connection_symbol(active));
}
}
static connection_slot_t connection_from_source(const char *source)
{
if (source == NULL) {
return CONN_USB;
}
if (strstr(source, "BLE") != NULL) {
return CONN_BLE;
}
if (strstr(source, "WIFI") != NULL || strstr(source, "AP") != NULL) {
return CONN_WIFI;
}
return CONN_USB;
}
static void set_history_curve(int *history, lv_point_precise_t *points, int count,
int chart_w, int chart_h, int percent, lv_obj_t *line)
{
for (int i = 0; i < count - 1; i++) {
history[i] = history[i + 1];
}
history[count - 1] = clamp_percent(percent);
for (int i = 0; i < count; i++) {
points[i].x = (i * chart_w) / (count - 1);
points[i].y = chart_h - ((history[i] * chart_h) / 100);
}
lv_line_set_points_mutable(line, points, count);
lv_obj_invalidate(line);
}
static void set_cpu_curve(int cpu_percent)
{
set_history_curve(s_cpu_history, s_cpu_points, CPU_HISTORY_POINTS,
CPU_CHART_W, CPU_CHART_H, cpu_percent, s_cpu_line);
}
static int parse_int_field(char *field, int fallback)
{
if (field == NULL || field[0] == '\0') {
return fallback;
}
char *end = NULL;
long value = strtol(field, &end, 10);
if (end == field) {
return fallback;
}
return (int)value;
}
static int parse_disk_token(const char *token, char *name, size_t name_size,
int *percent, int *read_kbps, int *write_kbps)
{
char buffer[40];
snprintf(buffer, sizeof(buffer), "%s", token);
char *cursor = buffer;
char *disk_name = strsep(&cursor, ":");
char *usage = strsep(&cursor, ":");
char *read_rate = strsep(&cursor, ":");
char *write_rate = strsep(&cursor, ":");
if (disk_name == NULL || disk_name[0] == '\0' || usage == NULL) {
return 0;
}
size_t len = strlen(disk_name);
if (len >= name_size) {
len = name_size - 1;
}
memcpy(name, disk_name, len);
name[len] = '\0';
*percent = clamp_percent(parse_int_field(usage, 0));
*read_kbps = parse_int_field(read_rate, -1);
*write_kbps = parse_int_field(write_rate, -1);
return 1;
}
static void update_disk_rows(const char *disk_text)
{
char buffer[UI_MAX_DISK_TEXT];
char name[8];
int row = 0;
snprintf(buffer, sizeof(buffer), "%s", disk_text && disk_text[0] ? disk_text : "");
char *cursor = buffer;
char *token = strtok(cursor, " ");
while (token != NULL && row < DISK_ROW_COUNT) {
int percent = 0;
int read_kbps = -1;
int write_kbps = -1;
if (parse_disk_token(token, name, sizeof(name), &percent, &read_kbps, &write_kbps)) {
char text[28];
char read[10];
char write[10];
snprintf(text, sizeof(text), "%s", name);
lv_label_set_text(s_disk_name_labels[row], text);
format_rate_short(read_kbps, read, sizeof(read));
format_rate_short(write_kbps, write, sizeof(write));
snprintf(text, sizeof(text), "%s/%s", read, write);
lv_label_set_text(s_disk_rate_labels[row], text);
lv_bar_set_value(s_disk_bars[row], percent, LV_ANIM_OFF);
lv_obj_clear_flag(s_disk_name_labels[row], LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(s_disk_rate_labels[row], LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(s_disk_bars[row], LV_OBJ_FLAG_HIDDEN);
row++;
}
token = strtok(NULL, " ");
}
for (int i = row; i < DISK_ROW_COUNT; i++) {
lv_obj_add_flag(s_disk_name_labels[i], LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(s_disk_rate_labels[i], LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(s_disk_bars[i], LV_OBJ_FLAG_HIDDEN);
}
}
static void update_codex_panel(const ui_metrics_t *metrics)
{
char text[40];
char value[12];
char reset_5h[12];
char reset_week[12];
format_percent_compact(metrics->codex_5h_percent, value, sizeof(value));
lv_label_set_text(s_codex_5h_value_label, value);
set_bar_percent(s_codex_5h_bar, metrics->codex_5h_percent);
format_percent_compact(metrics->codex_week_percent, value, sizeof(value));
lv_label_set_text(s_codex_week_value_label, value);
set_bar_percent(s_codex_week_bar, metrics->codex_week_percent);
format_reset_mins(metrics->codex_5h_reset_mins, reset_5h, sizeof(reset_5h));
format_reset_mins(metrics->codex_week_reset_mins, reset_week, sizeof(reset_week));
lv_label_set_text(s_codex_5h_reset_label, reset_5h);
lv_label_set_text(s_codex_week_reset_label, reset_week);
format_tokens_m10(metrics->codex_today_tokens_m10, value, sizeof(value));
snprintf(text, sizeof(text), "TOK %s", value);
lv_label_set_text(s_codex_token_label, text);
for (int i = 0; i < UI_CODEX_DAILY_BAR_COUNT; i++) {
lv_obj_t *bar = s_codex_daily_bars[i];
if (bar == NULL) {
continue;
}
if (i >= metrics->codex_daily_count || metrics->codex_daily_percent[i] < 0) {
lv_obj_add_flag(bar, LV_OBJ_FLAG_HIDDEN);
continue;
}
int percent = clamp_percent(metrics->codex_daily_percent[i]);
int height = percent <= 0 ? 1 : 1 + ((percent * (CODEX_DAILY_BAR_MAX_H - 1)) + 50) / 100;
int x = 10 + i * (CODEX_DAILY_BAR_W + CODEX_DAILY_BAR_GAP);
int y = CODEX_DAILY_BAR_BASE_Y - height;
lv_obj_set_size(bar, CODEX_DAILY_BAR_W, height);
lv_obj_align(bar, LV_ALIGN_TOP_LEFT, x, y);
lv_obj_clear_flag(bar, LV_OBJ_FLAG_HIDDEN);
}
}
static void update_zhipu_panel(const ui_metrics_t *metrics)
{
char value[12];
char reset[12];
char text[24];
snprintf(text, sizeof(text), "STAT %s", zhipu_status_text(metrics->zhipu_status));
lv_label_set_text(s_zhipu_status_label, text);
format_percent_compact(metrics->zhipu_5h_percent, value, sizeof(value));
lv_label_set_text(s_zhipu_5h_value_label, value);
set_bar_percent(s_zhipu_5h_bar, metrics->zhipu_5h_percent);
format_reset_mins(metrics->zhipu_5h_reset_mins, reset, sizeof(reset));
lv_label_set_text(s_zhipu_5h_reset_label, reset);
format_percent_compact(metrics->zhipu_week_percent, value, sizeof(value));
lv_label_set_text(s_zhipu_week_value_label, value);
set_bar_percent(s_zhipu_week_bar, metrics->zhipu_week_percent);
format_reset_mins(metrics->zhipu_week_reset_mins, reset, sizeof(reset));
lv_label_set_text(s_zhipu_week_reset_label, reset);
}
static void update_net_line(const ui_metrics_t *metrics)
{
char rx[12];
char tx[12];
char text[44];
format_rate_short(metrics->rx_kbps, rx, sizeof(rx));
format_rate_short(metrics->tx_kbps, tx, sizeof(tx));
snprintf(text, sizeof(text), "D %s U %s", rx, tx);
size_t text_len = strlen(text);
const lv_font_t *font = &lv_font_montserrat_20;
if (text_len >= 18) {
font = &lv_font_montserrat_14;
}
lv_obj_set_style_text_font(s_net_line_label, font, 0);
lv_label_set_long_mode(s_net_line_label, LV_LABEL_LONG_CLIP);
lv_label_set_text(s_net_line_label, text);
}
void ui_screen_create(void)
{
lv_obj_t *screen = lv_screen_active();
lv_obj_set_style_bg_color(screen, paper(), 0);
lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0);
lv_obj_clear_flag(screen, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clean(screen);
make_rule(screen, 200, 6, 1, 288);
make_rule(screen, 6, 174, 388, 1);
s_status_label = make_label(screen, "WAITING", &lv_font_montserrat_14);
lv_obj_add_flag(s_status_label, LV_OBJ_FLAG_HIDDEN);
s_cpu_line = lv_line_create(screen);
lv_obj_set_size(s_cpu_line, CPU_CHART_W + 2, CPU_CHART_H + 2);
lv_obj_align(s_cpu_line, LV_ALIGN_TOP_LEFT, 8, 112);
lv_obj_set_style_line_color(s_cpu_line, ink(), 0);
lv_obj_set_style_line_width(s_cpu_line, 3, 0);
for (int i = 0; i < CPU_HISTORY_POINTS; i++) {
s_cpu_history[i] = 0;
}
set_cpu_curve(0);
s_battery_label = make_label(screen, LV_SYMBOL_BATTERY_EMPTY " --%", &lv_font_montserrat_14);
lv_obj_set_width(s_battery_label, 54);
lv_obj_align(s_battery_label, LV_ALIGN_TOP_LEFT, 8, 8);
s_conn_label = make_label(screen, LV_SYMBOL_USB, &lv_font_montserrat_14);
lv_obj_set_width(s_conn_label, 22);
lv_obj_align(s_conn_label, LV_ALIGN_TOP_LEFT, 58, 8);
s_env_label = make_label(screen, "--C --%", &lv_font_montserrat_14);
lv_obj_set_width(s_env_label, 110);
lv_obj_align(s_env_label, LV_ALIGN_TOP_LEFT, 82, 8);
lv_obj_t *cpu_title = make_label(screen, "CPU", &lv_font_montserrat_14);
lv_obj_align(cpu_title, LV_ALIGN_TOP_LEFT, 8, 36);
make_label_halo(screen, s_cpu_value_halo, "--%", &lv_font_montserrat_48, 126,
LV_ALIGN_TOP_LEFT, 8, 52, 3, LV_TEXT_ALIGN_LEFT);
s_cpu_value_label = make_label(screen, "--%", &lv_font_montserrat_48);
lv_obj_set_width(s_cpu_value_label, 126);
lv_obj_set_style_text_align(s_cpu_value_label, LV_TEXT_ALIGN_LEFT, 0);
lv_obj_align(s_cpu_value_label, LV_ALIGN_TOP_LEFT, 8, 52);
make_label_halo(screen, s_cpu_freq_halo, "--GHz", &lv_font_montserrat_28, 128,
LV_ALIGN_TOP_LEFT, 8, 96, 2, LV_TEXT_ALIGN_LEFT);
s_cpu_freq_label = make_label(screen, "--GHz", &lv_font_montserrat_28);
lv_obj_set_width(s_cpu_freq_label, 128);
lv_obj_align(s_cpu_freq_label, LV_ALIGN_TOP_LEFT, 8, 96);
lv_obj_t *memory_title = make_label(screen, "MEM", &lv_font_montserrat_14);
lv_obj_align(memory_title, LV_ALIGN_TOP_LEFT, 208, 8);
make_label_halo(screen, s_memory_value_halo, "--%", &lv_font_montserrat_20, 50,
LV_ALIGN_TOP_RIGHT, -8, 8, 2, LV_TEXT_ALIGN_RIGHT);
s_memory_value_label = make_label(screen, "--%", &lv_font_montserrat_20);
lv_obj_set_width(s_memory_value_label, 50);
lv_obj_set_style_text_align(s_memory_value_label, LV_TEXT_ALIGN_RIGHT, 0);
lv_obj_align(s_memory_value_label, LV_ALIGN_TOP_RIGHT, -8, 8);
s_memory_detail_label = make_label(screen, "--/--", &lv_font_montserrat_14);
lv_obj_set_width(s_memory_detail_label, 178);
lv_obj_align(s_memory_detail_label, LV_ALIGN_TOP_LEFT, 208, 24);
s_memory_bar = make_bar(screen, 126, 10);
lv_obj_align(s_memory_bar, LV_ALIGN_TOP_LEFT, 262, 46);
lv_obj_t *disk_title = make_label(screen, "DSK", &lv_font_montserrat_14);
lv_obj_align(disk_title, LV_ALIGN_TOP_LEFT, 208, 64);
for (int i = 0; i < DISK_ROW_COUNT; i++) {
int y = 80 + i * 16;
s_disk_name_labels[i] = make_label(screen, "--", &lv_font_montserrat_14);
lv_obj_set_width(s_disk_name_labels[i], 16);
lv_obj_align(s_disk_name_labels[i], LV_ALIGN_TOP_LEFT, 208, y);
s_disk_bars[i] = make_bar(screen, 56, 8);
lv_obj_align(s_disk_bars[i], LV_ALIGN_TOP_LEFT, 226, y + 4);
s_disk_rate_labels[i] = make_label(screen, "--/--", &lv_font_montserrat_14);
lv_label_set_long_mode(s_disk_rate_labels[i], LV_LABEL_LONG_CLIP);
lv_obj_set_width(s_disk_rate_labels[i], 104);
lv_obj_align(s_disk_rate_labels[i], LV_ALIGN_TOP_LEFT, 288, y);
}
s_net_line_label = make_label(screen, "D -- U --", &lv_font_montserrat_20);
lv_label_set_long_mode(s_net_line_label, LV_LABEL_LONG_CLIP);
lv_obj_set_width(s_net_line_label, 184);
lv_obj_set_height(s_net_line_label, 24);
lv_obj_align(s_net_line_label, LV_ALIGN_TOP_LEFT, 208, 150);
lv_obj_t *codex_title = make_label(screen, "CODEX", &lv_font_montserrat_14);
lv_obj_align(codex_title, LV_ALIGN_TOP_LEFT, 8, 184);
s_codex_5h_label = make_label(screen, "5H", &lv_font_montserrat_14);
lv_obj_set_width(s_codex_5h_label, 28);
lv_obj_align(s_codex_5h_label, LV_ALIGN_TOP_LEFT, 8, 204);
s_codex_5h_bar = make_bar(screen, 76, 8);
lv_obj_align(s_codex_5h_bar, LV_ALIGN_TOP_LEFT, 36, 208);
s_codex_5h_value_label = make_label(screen, "--", &lv_font_montserrat_14);
lv_obj_set_width(s_codex_5h_value_label, 38);
lv_obj_align(s_codex_5h_value_label, LV_ALIGN_TOP_LEFT, 118, 204);
s_codex_5h_reset_label = make_label(screen, "--", &lv_font_montserrat_14);
lv_label_set_long_mode(s_codex_5h_reset_label, LV_LABEL_LONG_CLIP);
lv_obj_set_width(s_codex_5h_reset_label, 38);
lv_obj_align(s_codex_5h_reset_label, LV_ALIGN_TOP_LEFT, 158, 204);
s_codex_week_label = make_label(screen, "WK", &lv_font_montserrat_14);
lv_obj_set_width(s_codex_week_label, 28);
lv_obj_align(s_codex_week_label, LV_ALIGN_TOP_LEFT, 8, 224);
s_codex_week_bar = make_bar(screen, 76, 8);
lv_obj_align(s_codex_week_bar, LV_ALIGN_TOP_LEFT, 36, 228);
s_codex_week_value_label = make_label(screen, "--", &lv_font_montserrat_14);
lv_obj_set_width(s_codex_week_value_label, 38);
lv_obj_align(s_codex_week_value_label, LV_ALIGN_TOP_LEFT, 118, 224);
s_codex_week_reset_label = make_label(screen, "--", &lv_font_montserrat_14);
lv_label_set_long_mode(s_codex_week_reset_label, LV_LABEL_LONG_CLIP);
lv_obj_set_width(s_codex_week_reset_label, 38);
lv_obj_align(s_codex_week_reset_label, LV_ALIGN_TOP_LEFT, 158, 224);
s_codex_token_label = make_label(screen, "TOK --", &lv_font_montserrat_14);
lv_obj_set_width(s_codex_token_label, 96);
lv_obj_set_style_text_align(s_codex_token_label, LV_TEXT_ALIGN_RIGHT, 0);
lv_obj_align(s_codex_token_label, LV_ALIGN_TOP_RIGHT, -204, 184);
make_rule(screen, 8, CODEX_DAILY_BAR_BASE_Y, 188, 1);
for (int i = 0; i < UI_CODEX_DAILY_BAR_COUNT; i++) {
s_codex_daily_bars[i] = lv_obj_create(screen);
lv_obj_set_size(s_codex_daily_bars[i], CODEX_DAILY_BAR_W, 1);
lv_obj_align(s_codex_daily_bars[i], LV_ALIGN_TOP_LEFT,
10 + i * (CODEX_DAILY_BAR_W + CODEX_DAILY_BAR_GAP),
CODEX_DAILY_BAR_BASE_Y - 1);
lv_obj_set_style_bg_color(s_codex_daily_bars[i], ink(), 0);
lv_obj_set_style_bg_opa(s_codex_daily_bars[i], LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_codex_daily_bars[i], 0, 0);
lv_obj_set_style_radius(s_codex_daily_bars[i], 0, 0);
lv_obj_set_style_pad_all(s_codex_daily_bars[i], 0, 0);
lv_obj_clear_flag(s_codex_daily_bars[i], LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(s_codex_daily_bars[i], LV_OBJ_FLAG_HIDDEN);
}
lv_obj_t *zhipu_title = make_label(screen, "ZHIPU", &lv_font_montserrat_14);
lv_obj_align(zhipu_title, LV_ALIGN_TOP_LEFT, 208, 184);
s_zhipu_status_label = make_label(screen, "STAT WAIT", &lv_font_montserrat_14);
lv_obj_set_width(s_zhipu_status_label, 96);
lv_obj_set_style_text_align(s_zhipu_status_label, LV_TEXT_ALIGN_RIGHT, 0);
lv_obj_align(s_zhipu_status_label, LV_ALIGN_TOP_RIGHT, -8, 184);
s_zhipu_5h_label = make_label(screen, "5H", &lv_font_montserrat_14);
lv_obj_set_width(s_zhipu_5h_label, 28);
lv_obj_align(s_zhipu_5h_label, LV_ALIGN_TOP_LEFT, 208, 214);
s_zhipu_5h_bar = make_bar(screen, 74, 9);
lv_obj_align(s_zhipu_5h_bar, LV_ALIGN_TOP_LEFT, 238, 218);
s_zhipu_5h_value_label = make_label(screen, "--", &lv_font_montserrat_14);
lv_obj_set_width(s_zhipu_5h_value_label, 38);
lv_obj_align(s_zhipu_5h_value_label, LV_ALIGN_TOP_LEFT, 318, 214);
s_zhipu_5h_reset_label = make_label(screen, "--", &lv_font_montserrat_14);
lv_label_set_long_mode(s_zhipu_5h_reset_label, LV_LABEL_LONG_CLIP);
lv_obj_set_width(s_zhipu_5h_reset_label, 38);
lv_obj_align(s_zhipu_5h_reset_label, LV_ALIGN_TOP_LEFT, 358, 214);
s_zhipu_week_label = make_label(screen, "WK", &lv_font_montserrat_14);
lv_obj_set_width(s_zhipu_week_label, 28);
lv_obj_align(s_zhipu_week_label, LV_ALIGN_TOP_LEFT, 208, 242);
s_zhipu_week_bar = make_bar(screen, 74, 9);
lv_obj_align(s_zhipu_week_bar, LV_ALIGN_TOP_LEFT, 238, 246);
s_zhipu_week_value_label = make_label(screen, "--", &lv_font_montserrat_14);
lv_obj_set_width(s_zhipu_week_value_label, 38);
lv_obj_align(s_zhipu_week_value_label, LV_ALIGN_TOP_LEFT, 318, 242);
s_zhipu_week_reset_label = make_label(screen, "--", &lv_font_montserrat_14);
lv_label_set_long_mode(s_zhipu_week_reset_label, LV_LABEL_LONG_CLIP);
lv_obj_set_width(s_zhipu_week_reset_label, 38);
lv_obj_align(s_zhipu_week_reset_label, LV_ALIGN_TOP_LEFT, 358, 242);
}
void ui_screen_tick(void)
{
s_age_seconds++;
if (s_age_seconds > 5 && s_status_label != NULL) {
lv_label_set_text(s_status_label, "STALE");
}
}
void ui_screen_set_status(const char *status)
{
if (s_status_label == NULL || status == NULL) {
return;
}
lv_lock();
lv_label_set_text(s_status_label, status);
lv_unlock();
}
void ui_screen_update_battery(int percent, int millivolts)
{
if (s_battery_label == NULL) {
return;
}
const char *symbol = LV_SYMBOL_BATTERY_EMPTY;
percent = clamp_percent(percent);
if (percent >= 90) {
symbol = LV_SYMBOL_BATTERY_FULL;
} else if (percent >= 65) {
symbol = LV_SYMBOL_BATTERY_3;
} else if (percent >= 35) {
symbol = LV_SYMBOL_BATTERY_2;
} else if (percent >= 10) {
symbol = LV_SYMBOL_BATTERY_1;
}
char text[24];
if (millivolts > 0) {
snprintf(text, sizeof(text), "%s %d%%", symbol, percent);
} else {
snprintf(text, sizeof(text), "%s --%%", symbol);
}
lv_lock();
lv_label_set_text(s_battery_label, text);
lv_unlock();
}
void ui_screen_update_environment(int temperature_c10, int humidity_percent)
{
if (s_env_label == NULL) {
return;
}
if (humidity_percent < 0) {
humidity_percent = 0;
} else if (humidity_percent > 100) {
humidity_percent = 100;
}
char text[32];
int abs_temp_c10 = abs(temperature_c10);
if (temperature_c10 < 0) {
snprintf(text, sizeof(text), "-%d.%dC %d%%", abs_temp_c10 / 10, abs_temp_c10 % 10, humidity_percent);
} else {
snprintf(text, sizeof(text), "%d.%dC %d%%", abs_temp_c10 / 10, abs_temp_c10 % 10, humidity_percent);
}
lv_lock();
lv_label_set_text(s_env_label, text);
lv_unlock();
}
void ui_screen_update_metrics_from_source(const ui_metrics_t *metrics, const char *source)
{
if (metrics == NULL || s_cpu_value_label == NULL) {
return;
}
char text[96];
char used[16];
char total[16];
lv_lock();
s_age_seconds = 0;
lv_label_set_text(s_status_label, source ? source : "LIVE");
set_connection(connection_from_source(source));
snprintf(text, sizeof(text), "%d%%", clamp_percent(metrics->cpu_percent));
set_label_with_halo(s_cpu_value_label, s_cpu_value_halo, text);
if (metrics->cpu_mhz > 0) {
snprintf(text, sizeof(text), "%d.%02dGHz", metrics->cpu_mhz / 1000, (metrics->cpu_mhz % 1000) / 10);
} else {
snprintf(text, sizeof(text), "--GHz");
}
set_label_with_halo(s_cpu_freq_label, s_cpu_freq_halo, text);
set_cpu_curve(metrics->cpu_percent);
snprintf(text, sizeof(text), "%d%%", clamp_percent(metrics->memory_percent));
set_label_with_halo(s_memory_value_label, s_memory_value_halo, text);
format_capacity(metrics->memory_used_mb, used, sizeof(used));
format_capacity(metrics->memory_total_mb, total, sizeof(total));
snprintf(text, sizeof(text), "%s / %s", used, total);
lv_label_set_text(s_memory_detail_label, text);
set_bar_percent(s_memory_bar, metrics->memory_percent);
update_disk_rows(metrics->disk_text);
update_net_line(metrics);
update_codex_panel(metrics);
update_zhipu_panel(metrics);
lv_obj_invalidate(lv_screen_active());
lv_unlock();
}
void ui_screen_update_metrics(const ui_metrics_t *metrics)
{
ui_screen_update_metrics_from_source(metrics, "USB");
}

50
main/ui_screen.h Normal file
View File

@ -0,0 +1,50 @@
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
#define UI_MAX_DISK_TEXT 128
#define UI_CODEX_DAILY_BAR_COUNT 14
typedef struct {
int cpu_percent;
int cpu_mhz;
int memory_percent;
int memory_used_mb;
int memory_total_mb;
int disk_percent;
int disk_read_kbps;
int disk_write_kbps;
int rx_kbps;
int tx_kbps;
int disk_count;
int codex_5h_percent;
int codex_week_percent;
int codex_5h_reset_mins;
int codex_week_reset_mins;
int codex_reset_credits;
int codex_today_tokens_m10;
int codex_daily_count;
int codex_daily_percent[UI_CODEX_DAILY_BAR_COUNT];
int zhipu_status;
int zhipu_quota_percent;
int zhipu_latency_ms;
int zhipu_5h_percent;
int zhipu_week_percent;
int zhipu_5h_reset_mins;
int zhipu_week_reset_mins;
char disk_text[UI_MAX_DISK_TEXT];
} ui_metrics_t;
void ui_screen_create(void);
void ui_screen_tick(void);
void ui_screen_update_metrics(const ui_metrics_t *metrics);
void ui_screen_update_metrics_from_source(const ui_metrics_t *metrics, const char *source);
void ui_screen_set_status(const char *status);
void ui_screen_update_battery(int percent, int millivolts);
void ui_screen_update_environment(int temperature_c10, int humidity_percent);
#ifdef __cplusplus
}
#endif

4
partitions.csv Normal file
View File

@ -0,0 +1,4 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 0x400000,
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 0x400000

15
scripts/README.md Normal file
View File

@ -0,0 +1,15 @@
# Scripts
Old one-off diagnostic scripts are archived in `_archive_2026-06-20`.
Use the RLCD workbench bridge as the normal entry point:
```powershell
python .\tools\preview-bridge\server.py
```
Then open:
```text
http://127.0.0.1:8788
```

5177
sdkconfig Normal file

File diff suppressed because it is too large Load Diff

66
sdkconfig.defaults Normal file
View File

@ -0,0 +1,66 @@
# ============================================================
# sdkconfig.defaults - 板级默认配置
# 对应 Waveshare ESP32-S3-RLCD-4.2 (ESP32-S3-WROOM-1-N16R8)
# 16MB Flash / 8MB PSRAM (Octal)
# ============================================================
# --- 目标芯片 ---
CONFIG_IDF_TARGET="esp32s3"
# --- 主任务栈:LVGL 渲染需要大量栈,默认 4KB 不够,调到 16KB ---
CONFIG_ESP_MAIN_TASK_STACK_SIZE=32768
# --- 编译优化:用 -O0 而不是 -Og,规避 esp_lcd_panel_rgb.c 触发的 gcc ICE ---
# CONFIG_COMPILER_OPTIMIZATION_NONE is not set
CONFIG_COMPILER_OPTIMIZATION_SIZE=n
CONFIG_COMPILER_OPTIMIZATION_PERF=n
CONFIG_COMPILER_OPTIMIZATION_DEBUG=y
CONFIG_COMPILER_WARN_WRITE_STRINGS=n
# --- Flash 配置 (16MB) ---
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
# CONFIG_ESPTOOLPY_FLASHSIZE_2MB is not set
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="16MB"
# --- Partition table: HTTP/AP firmware is larger than the 1MB default app slot ---
# CONFIG_PARTITION_TABLE_SINGLE_APP is not set
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
# --- PSRAM 配置 (8MB Octal PSRAM) ---
# N16R8 模块用的是 Octal PSRAM(R8 = Octal, R2 = Quad)
# 注意 ESP-IDF v6.0 里选项名是 SPIRAM_MODE_OCT(不是 _OCTAL)
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
# CONFIG_SPIRAM_MODE_QUAD is not set
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_SPIRAM_USE_MALLOC=y
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384
# --- FreeRTOS ---
CONFIG_FREERTOS_HZ=1000
# --- LVGL 字体(开大字号给时钟数字用) ---
CONFIG_LV_FONT_MONTSERRAT_14=y
CONFIG_LV_FONT_MONTSERRAT_20=y
CONFIG_LV_FONT_MONTSERRAT_28=y
CONFIG_LV_FONT_MONTSERRAT_48=y
# --- 关闭不需要的功能(减小固件体积) ---
CONFIG_BT_ENABLED=y
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
CONFIG_BT_NIMBLE_MAX_BONDS=1
CONFIG_BT_NIMBLE_MAX_CCCDS=1
CONFIG_BT_NIMBLE_ROLE_CENTRAL=n
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=185
CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=4096
CONFIG_BT_NIMBLE_50_FEATURE_SUPPORT=y
CONFIG_BT_NIMBLE_LL_CFG_FEAT_LE_2M_PHY=y
CONFIG_BT_NIMBLE_LL_CFG_FEAT_LE_CODED_PHY=y
# CONFIG_BT_NIMBLE_EXT_ADV is not set

View File

@ -0,0 +1,786 @@
import argparse
import asyncio
import ctypes
from datetime import date, timedelta
import json
import os
from pathlib import Path
import queue
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from ctypes import wintypes
import psutil
from bleak import BleakClient, BleakScanner
DEFAULT_DEVICE_NAME = "RLCD-Metrics"
DEFAULT_CHAR_UUID = "9f4af548-1a14-4fc1-8f5a-4b9f00000002"
MAX_DISK_ROWS = 4
MAX_DISK_TEXT = 124
CODEX_DAILY_BAR_COUNT = 14
CODEX_REFRESH_SECONDS = 60.0
CODEX_USAGE_REFRESH_SECONDS = 300.0
ZHIPU_REFRESH_SECONDS = 60.0
def clamp_percent(value: float) -> int:
if value != value:
return 0
return max(0, min(100, int(round(value))))
def mb(value: float) -> int:
return int(round(value / 1024 / 1024))
def clamp_kbps(value: float) -> int:
return max(0, min(999999, int(round(value))))
class _PdhCounterValue(ctypes.Structure):
_fields_ = [
("status", wintypes.LONG),
("double_value", ctypes.c_double),
]
class CpuPercentReader:
def __init__(self) -> None:
self.pdh = None
self.query = ctypes.c_void_p()
self.counter = ctypes.c_void_p()
self.available = False
if sys.platform != "win32":
return
try:
self.pdh = ctypes.WinDLL("pdh")
self.pdh.PdhOpenQueryW.restype = wintypes.LONG
self.pdh.PdhOpenQueryW.argtypes = [
wintypes.LPCWSTR,
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_void_p),
]
self.pdh.PdhCollectQueryData.restype = wintypes.LONG
self.pdh.PdhCollectQueryData.argtypes = [ctypes.c_void_p]
self.pdh.PdhGetFormattedCounterValue.restype = wintypes.LONG
self.pdh.PdhGetFormattedCounterValue.argtypes = [
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.POINTER(_PdhCounterValue),
]
add_counter = getattr(self.pdh, "PdhAddEnglishCounterW", self.pdh.PdhAddCounterW)
add_counter.restype = wintypes.LONG
add_counter.argtypes = [
ctypes.c_void_p,
wintypes.LPCWSTR,
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_void_p),
]
if self.pdh.PdhOpenQueryW(None, None, ctypes.byref(self.query)) != 0:
return
if add_counter(
self.query,
r"\Processor Information(_Total)\% Processor Utility",
None,
ctypes.byref(self.counter),
) != 0:
return
self.pdh.PdhCollectQueryData(self.query)
self.available = True
except OSError:
self.available = False
def read_percent(self) -> int:
if self.available and self.pdh is not None:
value = _PdhCounterValue()
counter_type = wintypes.DWORD()
if self.pdh.PdhCollectQueryData(self.query) == 0:
rc = self.pdh.PdhGetFormattedCounterValue(
self.counter,
0x00000200,
ctypes.byref(counter_type),
ctypes.byref(value),
)
if rc == 0 and value.status == 0 and value.double_value >= 0:
return clamp_percent(value.double_value)
return clamp_percent(psutil.cpu_percent(interval=None))
class CpuFrequencyReader:
def __init__(self) -> None:
self.pdh = None
self.query = ctypes.c_void_p()
self.counter = ctypes.c_void_p()
self.available = False
if sys.platform != "win32":
return
try:
self.pdh = ctypes.WinDLL("pdh")
self.pdh.PdhOpenQueryW.restype = wintypes.LONG
self.pdh.PdhOpenQueryW.argtypes = [
wintypes.LPCWSTR,
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_void_p),
]
self.pdh.PdhCollectQueryData.restype = wintypes.LONG
self.pdh.PdhCollectQueryData.argtypes = [ctypes.c_void_p]
self.pdh.PdhGetFormattedCounterValue.restype = wintypes.LONG
self.pdh.PdhGetFormattedCounterValue.argtypes = [
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.POINTER(_PdhCounterValue),
]
add_counter = getattr(self.pdh, "PdhAddEnglishCounterW", self.pdh.PdhAddCounterW)
add_counter.restype = wintypes.LONG
add_counter.argtypes = [
ctypes.c_void_p,
wintypes.LPCWSTR,
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_void_p),
]
if self.pdh.PdhOpenQueryW(None, None, ctypes.byref(self.query)) != 0:
return
if add_counter(
self.query,
r"\Processor Information(_Total)\Actual Frequency",
None,
ctypes.byref(self.counter),
) != 0:
return
self.pdh.PdhCollectQueryData(self.query)
self.available = True
except OSError:
self.available = False
def read_mhz(self) -> int:
if self.available and self.pdh is not None:
value = _PdhCounterValue()
counter_type = wintypes.DWORD()
if self.pdh.PdhCollectQueryData(self.query) == 0:
rc = self.pdh.PdhGetFormattedCounterValue(
self.counter,
0x00000200,
ctypes.byref(counter_type),
ctypes.byref(value),
)
if rc == 0 and value.status == 0 and value.double_value > 0:
return int(round(value.double_value))
freq = psutil.cpu_freq()
return int(round(freq.current)) if freq and freq.current else 0
class LogicalDiskRateReader:
def __init__(self, labels: list[str]) -> None:
self.pdh = None
self.query = ctypes.c_void_p()
self.counters: dict[str, tuple[ctypes.c_void_p, ctypes.c_void_p]] = {}
self.available = False
if sys.platform != "win32" or not labels:
return
try:
self.pdh = ctypes.WinDLL("pdh")
self.pdh.PdhOpenQueryW.restype = wintypes.LONG
self.pdh.PdhOpenQueryW.argtypes = [
wintypes.LPCWSTR,
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_void_p),
]
self.pdh.PdhCollectQueryData.restype = wintypes.LONG
self.pdh.PdhCollectQueryData.argtypes = [ctypes.c_void_p]
self.pdh.PdhGetFormattedCounterValue.restype = wintypes.LONG
self.pdh.PdhGetFormattedCounterValue.argtypes = [
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.POINTER(_PdhCounterValue),
]
add_counter = getattr(self.pdh, "PdhAddEnglishCounterW", self.pdh.PdhAddCounterW)
add_counter.restype = wintypes.LONG
add_counter.argtypes = [
ctypes.c_void_p,
wintypes.LPCWSTR,
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_void_p),
]
if self.pdh.PdhOpenQueryW(None, None, ctypes.byref(self.query)) != 0:
return
for label in labels:
read_counter = ctypes.c_void_p()
write_counter = ctypes.c_void_p()
read_path = rf"\LogicalDisk({label}:)\Disk Read Bytes/sec"
write_path = rf"\LogicalDisk({label}:)\Disk Write Bytes/sec"
if add_counter(self.query, read_path, None, ctypes.byref(read_counter)) != 0:
continue
if add_counter(self.query, write_path, None, ctypes.byref(write_counter)) != 0:
continue
self.counters[label] = (read_counter, write_counter)
if self.counters:
self.pdh.PdhCollectQueryData(self.query)
self.available = True
except OSError:
self.available = False
def _read_kbps(self, counter: ctypes.c_void_p) -> int:
if self.pdh is None:
return -1
value = _PdhCounterValue()
counter_type = wintypes.DWORD()
rc = self.pdh.PdhGetFormattedCounterValue(
counter,
0x00000200,
ctypes.byref(counter_type),
ctypes.byref(value),
)
if rc != 0 or value.status != 0 or value.double_value < 0:
return -1
return clamp_kbps(value.double_value / 1024)
def read_rates(self) -> dict[str, tuple[int, int]]:
if not self.available or self.pdh is None:
return {}
if self.pdh.PdhCollectQueryData(self.query) != 0:
return {}
rates: dict[str, tuple[int, int]] = {}
for label, (read_counter, write_counter) in self.counters.items():
rates[label] = (self._read_kbps(read_counter), self._read_kbps(write_counter))
return rates
class CodexRateLimitReader:
def __init__(self, refresh_seconds: float = CODEX_REFRESH_SECONDS) -> None:
self.refresh_seconds = refresh_seconds
self.next_refresh = 0.0
self.cached = (-1, -1, -1, -1, -1)
self.usage_refresh_seconds = CODEX_USAGE_REFRESH_SECONDS
self.next_usage_refresh = 0.0
self.usage_cached = (-1, "")
self.process: subprocess.Popen | None = None
self.responses: queue.Queue[dict] = queue.Queue()
self.next_id = 1
self.initialized = False
def _find_codex_exe(self) -> str | None:
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
root = Path(local_app_data) / "OpenAI" / "Codex" / "bin"
candidates = sorted(root.glob("*/codex.exe"), key=lambda p: p.stat().st_mtime, reverse=True)
if candidates:
return str(candidates[0])
return "codex.exe"
def _reader_loop(self, stdout) -> None:
for line in stdout:
line = line.strip()
if not line:
continue
try:
self.responses.put(json.loads(line))
except json.JSONDecodeError:
continue
def _stop(self) -> None:
if self.process is not None:
try:
self.process.kill()
except OSError:
pass
self.process = None
self.initialized = False
def _send(self, method: str, params: dict | None = None, expect_response: bool = True) -> int | None:
if self.process is None or self.process.stdin is None:
return None
request_id = None
message: dict = {"method": method}
if params is not None:
message["params"] = params
if expect_response:
request_id = self.next_id
self.next_id += 1
message["id"] = request_id
self.process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
self.process.stdin.flush()
return request_id
def _wait_for(self, request_id: int, timeout: float) -> dict | None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
message = self.responses.get(timeout=max(0.05, deadline - time.monotonic()))
except queue.Empty:
return None
if message.get("id") == request_id:
return message
return None
def _ensure_started(self) -> bool:
if self.process is not None and self.process.poll() is None and self.initialized:
return True
self._stop()
codex_exe = self._find_codex_exe()
if not codex_exe:
return False
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
try:
self.process = subprocess.Popen(
[codex_exe, "app-server", "--stdio"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
bufsize=1,
creationflags=creationflags,
)
except OSError:
self.process = None
return False
assert self.process.stdout is not None
threading.Thread(target=self._reader_loop, args=(self.process.stdout,), daemon=True).start()
init_id = self._send(
"initialize",
{
"clientInfo": {
"name": "rlcd_metrics_bridge",
"title": "RLCD Metrics Bridge",
"version": "0.1.0",
}
},
)
if init_id is None or self._wait_for(init_id, 5.0) is None:
self._stop()
return False
self._send("initialized", {}, expect_response=False)
self.initialized = True
return True
@staticmethod
def _mins_until(epoch_seconds) -> int:
if not isinstance(epoch_seconds, (int, float)):
return -1
return max(0, min(99999, int(round((epoch_seconds - time.time()) / 60))))
@staticmethod
def _read_percent(value) -> int:
if not isinstance(value, (int, float)):
return -1
return clamp_percent(value)
def read(self) -> tuple[int, int, int, int, int]:
now = time.monotonic()
if now < self.next_refresh:
return self.cached
self.next_refresh = now + self.refresh_seconds
if not self._ensure_started():
return self.cached
request_id = self._send("account/rateLimits/read")
if request_id is None:
return self.cached
message = self._wait_for(request_id, 8.0)
result = message.get("result") if message else None
rate_limits = result.get("rateLimits") if isinstance(result, dict) else None
if not isinstance(rate_limits, dict):
self._stop()
return self.cached
primary = rate_limits.get("primary") or {}
secondary = rate_limits.get("secondary") or {}
reset_credits = result.get("rateLimitResetCredits") or {}
self.cached = (
self._read_percent(primary.get("usedPercent")),
self._read_percent(secondary.get("usedPercent")),
self._mins_until(primary.get("resetsAt")),
self._mins_until(secondary.get("resetsAt")),
int(reset_credits.get("availableCount", -1)) if isinstance(reset_credits, dict) else -1,
)
return self.cached
@staticmethod
def _tokens_to_m10(tokens) -> int:
if not isinstance(tokens, (int, float)) or tokens < 0:
return -1
return max(0, min(2147483647, int(round(tokens / 100000))))
@staticmethod
def _daily_bars_from_buckets(buckets: list[dict]) -> tuple[int, str]:
today = date.today()
wanted_days = [(today - timedelta(days=offset)).isoformat() for offset in range(CODEX_DAILY_BAR_COUNT - 1, -1, -1)]
tokens_by_day: dict[str, int] = {}
for bucket in buckets:
if not isinstance(bucket, dict):
continue
start_date = bucket.get("startDate")
tokens = bucket.get("tokens")
if isinstance(start_date, str) and isinstance(tokens, (int, float)) and tokens >= 0:
tokens_by_day[start_date] = int(tokens)
values = [tokens_by_day.get(day, 0) for day in wanted_days]
max_value = max(values) if values else 0
if max_value > 0:
percents = [max(0, min(100, int(round(value * 100 / max_value)))) for value in values]
else:
percents = [0 for _ in values]
today_tokens_m10 = CodexRateLimitReader._tokens_to_m10(tokens_by_day.get(today.isoformat(), -1))
return today_tokens_m10, ":".join(str(value) for value in percents)
def read_usage_snapshot(self) -> tuple[int, str]:
now = time.monotonic()
if now < self.next_usage_refresh:
return self.usage_cached
self.next_usage_refresh = now + self.usage_refresh_seconds
if not self._ensure_started():
return self.usage_cached
request_id = self._send("account/usage/read")
if request_id is None:
return self.usage_cached
message = self._wait_for(request_id, 8.0)
result = message.get("result") if message else None
buckets = result.get("dailyUsageBuckets") if isinstance(result, dict) else None
if not isinstance(buckets, list) or not buckets:
return self.usage_cached
self.usage_cached = self._daily_bars_from_buckets(buckets)
return self.usage_cached
class ZhipuStatusReader:
def __init__(self, refresh_seconds: float = ZHIPU_REFRESH_SECONDS) -> None:
self.refresh_seconds = refresh_seconds
self.next_refresh = 0.0
self.cached = (0, -1, -1, -1, -1)
@staticmethod
def _quota_url() -> str:
base_url = os.environ.get("ZHIPU_BASE_URL", "https://open.bigmodel.cn").strip()
if "bigmodel.cn" in base_url.lower():
host = "https://open.bigmodel.cn"
else:
host = "https://api.z.ai"
return f"{host}/api/monitor/usage/quota/limit"
@staticmethod
def _mins_until(epoch_ms) -> int:
if not isinstance(epoch_ms, (int, float)) or epoch_ms <= 0:
return -1
return max(0, min(99999, int(round((epoch_ms / 1000 - time.time()) / 60))))
@staticmethod
def _read_percent(value) -> int:
if isinstance(value, (int, float)):
return clamp_percent(value)
try:
return clamp_percent(float(value))
except (TypeError, ValueError):
return -1
def _query(self, api_key: str) -> tuple[int, int, int, int, int]:
request = urllib.request.Request(
self._quota_url(),
headers={
"Authorization": api_key,
"Content-Type": "application/json",
"Accept-Language": "en-US,en",
},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
body = response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
return (2, -1, -1, -1, -1)
return (2, -1, -1, -1, -1)
except OSError:
return (2, -1, -1, -1, -1)
try:
payload = json.loads(body)
except json.JSONDecodeError:
return (2, -1, -1, -1, -1)
if payload.get("success") is False:
return (2, -1, -1, -1, -1)
data = payload.get("data")
limits = data.get("limits") if isinstance(data, dict) else None
if not isinstance(limits, list):
return (2, -1, -1, -1, -1)
five_percent = -1
week_percent = -1
five_reset = -1
week_reset = -1
for item in limits:
if not isinstance(item, dict):
continue
if str(item.get("type", "")).upper() != "TOKENS_LIMIT":
continue
unit = item.get("unit")
percent = self._read_percent(item.get("percentage"))
if percent < 0:
continue
reset_mins = self._mins_until(item.get("nextResetTime"))
if unit == 3 and five_percent < 0:
five_percent = percent
five_reset = reset_mins
elif unit == 6 and week_percent < 0:
week_percent = percent
week_reset = reset_mins
return (1, five_percent, week_percent, five_reset, week_reset)
def read(self) -> tuple[int, int, int, int, int]:
api_key = os.environ.get("ZHIPU_API_KEY", "").strip()
if not api_key:
self.cached = (0, -1, -1, -1, -1)
self.next_refresh = time.monotonic() + self.refresh_seconds
return self.cached
now = time.monotonic()
if now < self.next_refresh:
return self.cached
self.next_refresh = now + self.refresh_seconds
self.cached = self._query(api_key)
return self.cached
class MetricsSampler:
def __init__(self, disk_path: str) -> None:
self.disk_path = disk_path
self.last_net = psutil.net_io_counters()
self.last_disk = psutil.disk_io_counters()
self.last_ts = time.monotonic()
self.cpu_percent = CpuPercentReader()
self.cpu_frequency = CpuFrequencyReader()
self.disk_rates = LogicalDiskRateReader(self._disk_labels())
self.codex = CodexRateLimitReader()
self.zhipu = ZhipuStatusReader()
psutil.cpu_percent(interval=None)
def _disk_labels(self) -> list[str]:
labels = []
seen = set()
for part in psutil.disk_partitions(all=False):
device = (part.device or part.mountpoint or "").upper()
if not device or device in seen:
continue
seen.add(device)
opts = (part.opts or "").lower()
if "cdrom" in opts or not part.fstype:
continue
label = device.replace("\\", "").replace("/", "")
if label.endswith(":"):
label = label[:-1]
if label:
labels.append(label)
return labels
def _disk_summary(self, rates_by_label: dict[str, tuple[int, int]]) -> tuple[int, int, str]:
total = 0
used = 0
parts = []
seen = set()
for part in psutil.disk_partitions(all=False):
device = (part.device or part.mountpoint or "").upper()
if not device or device in seen:
continue
seen.add(device)
opts = (part.opts or "").lower()
if "cdrom" in opts or not part.fstype:
continue
try:
usage = psutil.disk_usage(part.mountpoint)
except OSError:
continue
total += usage.total
used += usage.used
label = device.replace("\\", "").replace("/", "")
if label.endswith(":"):
label = label[:-1]
read_kbps, write_kbps = rates_by_label.get(label, (-1, -1))
parts.append(f"{label}:{clamp_percent(usage.percent)}:{read_kbps}:{write_kbps}")
if total <= 0:
try:
usage = psutil.disk_usage(self.disk_path)
return 1, clamp_percent(usage.percent), f"C:{clamp_percent(usage.percent)}:-1:-1"
except OSError:
return 0, 0, ""
disk_percent = clamp_percent((used / total) * 100)
selected = []
text_len = 0
for part in parts[:MAX_DISK_ROWS]:
extra = len(part) + (1 if selected else 0)
if text_len + extra > MAX_DISK_TEXT:
break
selected.append(part)
text_len += extra
text = " ".join(selected)
return len(parts), disk_percent, text
def sample_line(self) -> str:
now = time.monotonic()
elapsed = max(0.001, now - self.last_ts)
net = psutil.net_io_counters()
rx_kbps = int(max(0, round((net.bytes_recv - self.last_net.bytes_recv) / elapsed / 1024)))
tx_kbps = int(max(0, round((net.bytes_sent - self.last_net.bytes_sent) / elapsed / 1024)))
self.last_net = net
disk_io = psutil.disk_io_counters()
if disk_io is None or self.last_disk is None:
disk_read_kbps = 0
disk_write_kbps = 0
else:
disk_read_kbps = int(max(0, round((disk_io.read_bytes - self.last_disk.read_bytes) / elapsed / 1024)))
disk_write_kbps = int(max(0, round((disk_io.write_bytes - self.last_disk.write_bytes) / elapsed / 1024)))
self.last_disk = disk_io
self.last_ts = now
disk_rates = self.disk_rates.read_rates()
cpu = self.cpu_percent.read_percent()
cpu_mhz = self.cpu_frequency.read_mhz()
mem_info = psutil.virtual_memory()
mem_percent = clamp_percent(mem_info.percent)
mem_used_mb = mb(mem_info.used)
mem_total_mb = mb(mem_info.total)
disk_count, disk_percent, disk_text = self._disk_summary(disk_rates)
codex_5h, codex_week, codex_5h_reset, codex_week_reset, codex_credits = self.codex.read()
codex_today_tokens_m10, codex_daily_bars = self.codex.read_usage_snapshot()
zhipu_status, zhipu_5h, zhipu_week, zhipu_5h_reset, zhipu_week_reset = self.zhipu.read()
return (
f"SYS6,{cpu},{cpu_mhz},{mem_percent},{mem_used_mb},{mem_total_mb},"
f"{disk_count},{disk_percent},{disk_read_kbps},{disk_write_kbps},"
f"{rx_kbps},{tx_kbps},"
f"{codex_5h},{codex_week},{codex_5h_reset},{codex_week_reset},{codex_credits},{codex_today_tokens_m10},"
f"{codex_daily_bars},"
f"{zhipu_status},{zhipu_5h},{zhipu_week},{zhipu_5h_reset},{zhipu_week_reset},{disk_text}"
)
async def find_device(name: str, address: str | None, timeout: float):
if address:
return await BleakScanner.find_device_by_address(address, timeout=timeout)
def match(device, adv):
return device.name == name or adv.local_name == name
return await BleakScanner.find_device_by_filter(match, timeout=timeout)
async def push_loop(args) -> int:
sampler = MetricsSampler(args.disk)
while True:
print(f"Scanning for {args.name}...", flush=True)
device = await find_device(args.name, args.address, args.scan_timeout)
if device is None:
print(f"Device not found: {args.name}", flush=True)
if args.once:
return 2
await asyncio.sleep(args.retry_seconds)
continue
print(f"Connecting to {device.name or args.name} ({device.address})...", flush=True)
try:
async with BleakClient(device, timeout=args.connect_timeout) as client:
print("BLE connected. Streaming metrics.", flush=True)
while True:
line = sampler.sample_line()
await client.write_gatt_char(args.char_uuid, line.encode("ascii"), response=True)
print(line, flush=True)
if args.once:
return 0
await asyncio.sleep(args.interval)
except KeyboardInterrupt:
return 130
except Exception as exc:
print(f"BLE push failed: {exc}", file=sys.stderr, flush=True)
if args.once:
return 3
await asyncio.sleep(args.retry_seconds)
def parse_args():
parser = argparse.ArgumentParser(description="Push PC metrics to ESP32 RLCD over BLE.")
parser.add_argument("--name", default=DEFAULT_DEVICE_NAME)
parser.add_argument("--address", default=None)
parser.add_argument("--char-uuid", default=DEFAULT_CHAR_UUID)
parser.add_argument("--interval", type=float, default=1.0)
parser.add_argument("--scan-timeout", type=float, default=20.0)
parser.add_argument("--connect-timeout", type=float, default=20.0)
parser.add_argument("--retry-seconds", type=float, default=5.0)
parser.add_argument("--disk", default="C:\\")
parser.add_argument("--once", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
return asyncio.run(push_loop(args))
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,100 @@
param(
[string]$HostName = "192.168.4.1",
[int]$Port = 80,
[double]$IntervalSeconds = 1
)
$ErrorActionPreference = "Stop"
function Get-Percent {
param([double]$Value)
if ([double]::IsNaN($Value) -or [double]::IsInfinity($Value)) {
return 0
}
$rounded = [math]::Round($Value)
if ($rounded -lt 0) { return 0 }
if ($rounded -gt 100) { return 100 }
return [int]$rounded
}
function Get-SystemMetricLine {
$cpu = Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor |
Where-Object { $_.Name -eq "_Total" } |
Select-Object -First 1
$os = Get-CimInstance Win32_OperatingSystem
$totalMemory = [double]$os.TotalVisibleMemorySize
$freeMemory = [double]$os.FreePhysicalMemory
$memoryPercent = if ($totalMemory -gt 0) {
(($totalMemory - $freeMemory) / $totalMemory) * 100
} else {
0
}
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$diskPercent = if ($disk -and [double]$disk.Size -gt 0) {
(([double]$disk.Size - [double]$disk.FreeSpace) / [double]$disk.Size) * 100
} else {
0
}
$network = Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface |
Where-Object {
$_.Name -notmatch "Loopback|isatap|Teredo" -and
([double]$_.BytesReceivedPersec -gt 0 -or [double]$_.BytesSentPersec -gt 0)
}
$rxBytes = 0.0
$txBytes = 0.0
foreach ($adapter in $network) {
$rxBytes += [double]$adapter.BytesReceivedPersec
$txBytes += [double]$adapter.BytesSentPersec
}
$cpuPercent = Get-Percent ([double]$cpu.PercentProcessorTime)
$memPercent = Get-Percent $memoryPercent
$diskUsedPercent = Get-Percent $diskPercent
$rxKbps = [int][math]::Max(0, [math]::Round($rxBytes / 1024))
$txKbps = [int][math]::Max(0, [math]::Round($txBytes / 1024))
return "SYS,$cpuPercent,$memPercent,$diskUsedPercent,$rxKbps,$txKbps"
}
function Send-MetricLine {
param([string]$Line)
$body = [System.Text.Encoding]::ASCII.GetBytes($Line)
$header = "POST /metrics HTTP/1.1`r`nHost: $HostName`r`nContent-Type: text/plain`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n"
$headerBytes = [System.Text.Encoding]::ASCII.GetBytes($header)
$client = [System.Net.Sockets.TcpClient]::new()
$connect = $client.BeginConnect($HostName, $Port, $null, $null)
if (-not $connect.AsyncWaitHandle.WaitOne(1200)) {
$client.Close()
throw "connect timeout"
}
$client.EndConnect($connect)
$client.SendTimeout = 1200
$client.ReceiveTimeout = 1200
try {
$stream = $client.GetStream()
$stream.Write($headerBytes, 0, $headerBytes.Length)
$stream.Write($body, 0, $body.Length)
$stream.Flush()
} finally {
$client.Close()
}
}
Write-Host "Pushing PC metrics to http://$HostName/metrics. Press Ctrl+C to stop."
while ($true) {
$line = Get-SystemMetricLine
try {
Send-MetricLine -Line $line
Write-Host $line
} catch {
Write-Host "send failed: $($_.Exception.Message)"
}
Start-Sleep -Seconds $IntervalSeconds
}

View File

@ -0,0 +1,2 @@
bleak>=0.22,<2
psutil>=5.9,<8

View File

@ -0,0 +1,124 @@
param(
[int]$Port = 8789
)
$ErrorActionPreference = "Stop"
function Get-Percent {
param([double]$Value)
if ([double]::IsNaN($Value) -or [double]::IsInfinity($Value)) {
return 0
}
$rounded = [math]::Round($Value)
if ($rounded -lt 0) { return 0 }
if ($rounded -gt 100) { return 100 }
return [int]$rounded
}
function Get-SystemMetrics {
$cpu = Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor |
Where-Object { $_.Name -eq "_Total" } |
Select-Object -First 1
$os = Get-CimInstance Win32_OperatingSystem
$totalMemory = [double]$os.TotalVisibleMemorySize
$freeMemory = [double]$os.FreePhysicalMemory
$memoryPercent = if ($totalMemory -gt 0) {
(($totalMemory - $freeMemory) / $totalMemory) * 100
} else {
0
}
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$diskPercent = if ($disk -and [double]$disk.Size -gt 0) {
(([double]$disk.Size - [double]$disk.FreeSpace) / [double]$disk.Size) * 100
} else {
0
}
$network = Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface |
Where-Object {
$_.Name -notmatch "Loopback|isatap|Teredo" -and
([double]$_.BytesReceivedPersec -gt 0 -or [double]$_.BytesSentPersec -gt 0)
}
$rxBytes = 0.0
$txBytes = 0.0
foreach ($adapter in $network) {
$rxBytes += [double]$adapter.BytesReceivedPersec
$txBytes += [double]$adapter.BytesSentPersec
}
return [ordered]@{
cpu = Get-Percent ([double]$cpu.PercentProcessorTime)
mem = Get-Percent $memoryPercent
disk = Get-Percent $diskPercent
rx = [int][math]::Max(0, [math]::Round($rxBytes / 1024))
tx = [int][math]::Max(0, [math]::Round($txBytes / 1024))
}
}
function Get-MetricsLine {
$m = Get-SystemMetrics
return "SYS,$($m.cpu),$($m.mem),$($m.disk),$($m.rx),$($m.tx)"
}
function Send-Response {
param(
[System.Net.Sockets.TcpClient]$Client,
[string]$Status,
[string]$ContentType,
[string]$Body
)
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($Body)
$header = "HTTP/1.1 $Status`r`nContent-Type: $ContentType; charset=utf-8`r`nContent-Length: $($bodyBytes.Length)`r`nConnection: close`r`nCache-Control: no-store`r`n`r`n"
$headerBytes = [System.Text.Encoding]::ASCII.GetBytes($header)
$stream = $Client.GetStream()
$stream.Write($headerBytes, 0, $headerBytes.Length)
$stream.Write($bodyBytes, 0, $bodyBytes.Length)
$stream.Flush()
}
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, $Port)
$listener.Start()
Write-Host "PC metrics HTTP server listening on 0.0.0.0:$Port"
Write-Host "Text endpoint: /metrics.txt"
Write-Host "JSON endpoint: /metrics.json"
try {
while ($true) {
$client = $listener.AcceptTcpClient()
try {
$stream = $client.GetStream()
$buffer = New-Object byte[] 2048
$read = $stream.Read($buffer, 0, $buffer.Length)
$request = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read)
$firstLine = ($request -split "`r?`n")[0]
$path = "/"
if ($firstLine -match "^[A-Z]+\\s+([^\\s]+)") {
$path = $Matches[1]
}
if ($path -eq "/health") {
Send-Response $client "200 OK" "text/plain" "ok`n"
} elseif ($path -eq "/metrics.json") {
$metrics = Get-SystemMetrics
Send-Response $client "200 OK" "application/json" (($metrics | ConvertTo-Json -Compress) + "`n")
} elseif ($path -eq "/" -or $path -eq "/metrics.txt") {
Send-Response $client "200 OK" "text/plain" ((Get-MetricsLine) + "`n")
} else {
Send-Response $client "404 Not Found" "text/plain" "not found`n"
}
} catch {
try {
Send-Response $client "500 Internal Server Error" "text/plain" "error`n"
} catch {
}
} finally {
$client.Close()
}
}
} finally {
$listener.Stop()
}

View File

@ -0,0 +1,41 @@
param(
[string]$HostName = "192.168.4.1",
[int]$Port = 80,
[double]$IntervalSeconds = 1
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$pushScript = Join-Path $scriptDir "push-metrics-http.ps1"
$pidFile = Join-Path $scriptDir "metrics-ap-push.pid"
$logFile = Join-Path $scriptDir "metrics-ap-push.log"
$errFile = Join-Path $scriptDir "metrics-ap-push.err.log"
if (Test-Path $pidFile) {
$oldPid = Get-Content $pidFile -ErrorAction SilentlyContinue
if ($oldPid -and (Get-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue)) {
Write-Host "AP metrics push is already running. PID: $oldPid"
exit 0
}
}
$args = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", "`"$pushScript`"",
"-HostName", $HostName,
"-Port", $Port,
"-IntervalSeconds", $IntervalSeconds
)
$process = Start-Process powershell.exe `
-ArgumentList $args `
-WindowStyle Hidden `
-RedirectStandardOutput $logFile `
-RedirectStandardError $errFile `
-PassThru
Set-Content -Path $pidFile -Value $process.Id -Encoding ascii
Write-Host "AP metrics push started. PID: $($process.Id)"
Write-Host "Log: $logFile"

View File

@ -0,0 +1,89 @@
param(
[string]$Name = "RLCD-Metrics",
[double]$IntervalSeconds = 1,
[string]$Disk = "C:\"
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$venvDir = Join-Path $scriptDir ".venv-ble"
$venvPythonExe = Join-Path $venvDir "Scripts\python.exe"
$pythonExe = $venvPythonExe
$venvSitePackages = Join-Path $venvDir "Lib\site-packages"
$requirements = Join-Path $scriptDir "requirements-ble.txt"
$pushScript = Join-Path $scriptDir "push-metrics-ble.py"
$pidFile = Join-Path $scriptDir "metrics-ble.pid"
$logFile = Join-Path $scriptDir "metrics-ble.log"
$errFile = Join-Path $scriptDir "metrics-ble.err.log"
function Get-MetricsProcesses {
Get-CimInstance Win32_Process -Filter "name='python.exe'" |
Where-Object { $_.CommandLine -and $_.CommandLine.Contains($pushScript) }
}
if (-not (Test-Path $pythonExe)) {
python -m venv $venvDir
& $venvPythonExe -m pip install --upgrade pip
& $venvPythonExe -m pip install -r $requirements
}
$venvConfig = Join-Path $venvDir "pyvenv.cfg"
if (Test-Path $venvConfig) {
$executableLine = Get-Content $venvConfig |
Where-Object { $_ -match '^executable\s*=' } |
Select-Object -First 1
if ($executableLine) {
$basePython = ($executableLine -replace '^executable\s*=\s*', '').Trim()
if ($basePython -and (Test-Path $basePython)) {
$pythonExe = $basePython
}
}
}
$running = @(Get-MetricsProcesses)
if ($running.Count -gt 0) {
$existingPid = $running[0].ProcessId
Set-Content -Path $pidFile -Value $existingPid -Encoding ascii
Write-Host "BLE metrics stream is already running. PID: $existingPid"
exit 0
}
if (Test-Path $pidFile) {
$oldPid = Get-Content $pidFile -ErrorAction SilentlyContinue
if ($oldPid -and (Get-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue)) {
Write-Host "BLE metrics stream is already running. PID: $oldPid"
exit 0
}
}
$args = @(
"`"$pushScript`"",
"--name", $Name,
"--interval", $IntervalSeconds,
"--disk", $Disk
)
$oldPythonPath = $env:PYTHONPATH
if ($pythonExe -ne $venvPythonExe -and (Test-Path $venvSitePackages)) {
if ($oldPythonPath) {
$env:PYTHONPATH = "$venvSitePackages;$oldPythonPath"
} else {
$env:PYTHONPATH = $venvSitePackages
}
}
try {
$process = Start-Process $pythonExe `
-ArgumentList $args `
-WindowStyle Hidden `
-RedirectStandardOutput $logFile `
-RedirectStandardError $errFile `
-PassThru
} finally {
$env:PYTHONPATH = $oldPythonPath
}
Set-Content -Path $pidFile -Value $process.Id -Encoding ascii
Write-Host "BLE metrics stream started. PID: $($process.Id)"
Write-Host "Log: $logFile"

View File

@ -0,0 +1,37 @@
param(
[int]$Port = 8789
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$serverScript = Join-Path $scriptDir "serve-metrics-http.ps1"
$pidFile = Join-Path $scriptDir "metrics-http.pid"
$logFile = Join-Path $scriptDir "metrics-http.log"
$errFile = Join-Path $scriptDir "metrics-http.err.log"
if (Test-Path $pidFile) {
$oldPid = Get-Content $pidFile -ErrorAction SilentlyContinue
if ($oldPid -and (Get-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue)) {
Write-Host "Metrics HTTP server is already running. PID: $oldPid"
exit 0
}
}
$args = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", "`"$serverScript`"",
"-Port", $Port
)
$process = Start-Process powershell.exe `
-ArgumentList $args `
-WindowStyle Hidden `
-RedirectStandardOutput $logFile `
-RedirectStandardError $errFile `
-PassThru
Set-Content -Path $pidFile -Value $process.Id -Encoding ascii
Write-Host "Metrics HTTP server started. PID: $($process.Id)"
Write-Host "URL: http://0.0.0.0:$Port/metrics.txt"

View File

@ -0,0 +1,39 @@
param(
[string]$Port = "COM3",
[double]$IntervalSeconds = 1
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$streamScript = Join-Path $scriptDir "stream-metrics.ps1"
$pidFile = Join-Path $scriptDir "metrics-stream.pid"
$logFile = Join-Path $scriptDir "metrics-stream.log"
$errFile = Join-Path $scriptDir "metrics-stream.err.log"
if (Test-Path $pidFile) {
$oldPid = Get-Content $pidFile -ErrorAction SilentlyContinue
if ($oldPid -and (Get-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue)) {
Write-Host "Metrics stream is already running. PID: $oldPid"
exit 0
}
}
$args = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", "`"$streamScript`"",
"-Port", $Port,
"-IntervalSeconds", $IntervalSeconds
)
$process = Start-Process powershell.exe `
-ArgumentList $args `
-WindowStyle Hidden `
-RedirectStandardOutput $logFile `
-RedirectStandardError $errFile `
-PassThru
Set-Content -Path $pidFile -Value $process.Id -Encoding ascii
Write-Host "Metrics stream started. PID: $($process.Id)"
Write-Host "Log: $logFile"

View File

@ -0,0 +1,22 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$pidFile = Join-Path $scriptDir "metrics-ap-push.pid"
if (-not (Test-Path $pidFile)) {
Write-Host "AP metrics push is not running."
exit 0
}
$pidValue = Get-Content $pidFile -ErrorAction SilentlyContinue
if ($pidValue) {
$process = Get-Process -Id ([int]$pidValue) -ErrorAction SilentlyContinue
if ($process) {
Stop-Process -Id $process.Id -Force
Write-Host "AP metrics push stopped. PID: $($process.Id)"
} else {
Write-Host "AP metrics push process was already gone."
}
}
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue

View File

@ -0,0 +1,42 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$pidFile = Join-Path $scriptDir "metrics-ble.pid"
$pushScript = Join-Path $scriptDir "push-metrics-ble.py"
function Get-MetricsProcesses {
Get-CimInstance Win32_Process -Filter "name='python.exe'" |
Where-Object { $_.CommandLine -and $_.CommandLine.Contains($pushScript) }
}
$processIds = @()
if (Test-Path $pidFile) {
$pidValue = Get-Content $pidFile -ErrorAction SilentlyContinue
if ($pidValue) {
$processIds += [int]$pidValue
}
}
$processIds += @(Get-MetricsProcesses | Select-Object -ExpandProperty ProcessId)
$processIds = @($processIds | Sort-Object -Unique)
if ($processIds.Count -eq 0) {
Write-Host "BLE metrics stream is not running."
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
exit 0
}
$children = Get-CimInstance Win32_Process |
Where-Object { $processIds -contains $_.ParentProcessId } |
Select-Object -ExpandProperty ProcessId
foreach ($childId in @($children | Sort-Object -Unique)) {
Stop-Process -Id $childId -Force -ErrorAction SilentlyContinue
}
foreach ($processId in $processIds) {
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
Write-Host "BLE metrics stream stopped. PID: $processId"
}
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue

View File

@ -0,0 +1,22 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$pidFile = Join-Path $scriptDir "metrics-http.pid"
if (-not (Test-Path $pidFile)) {
Write-Host "Metrics HTTP server is not running."
exit 0
}
$pidValue = Get-Content $pidFile -ErrorAction SilentlyContinue
if ($pidValue) {
$process = Get-Process -Id ([int]$pidValue) -ErrorAction SilentlyContinue
if ($process) {
Stop-Process -Id $process.Id -Force
Write-Host "Metrics HTTP server stopped. PID: $($process.Id)"
} else {
Write-Host "Metrics HTTP server process was already gone."
}
}
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue

View File

@ -0,0 +1,22 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$pidFile = Join-Path $scriptDir "metrics-stream.pid"
if (-not (Test-Path $pidFile)) {
Write-Host "Metrics stream is not running."
exit 0
}
$pidValue = Get-Content $pidFile -ErrorAction SilentlyContinue
if ($pidValue) {
$process = Get-Process -Id ([int]$pidValue) -ErrorAction SilentlyContinue
if ($process) {
Stop-Process -Id $process.Id -Force
Write-Host "Metrics stream stopped. PID: $($process.Id)"
} else {
Write-Host "Metrics stream process was already gone."
}
}
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue

View File

@ -0,0 +1,90 @@
param(
[string]$Port = "COM3",
[int]$BaudRate = 115200,
[double]$IntervalSeconds = 1
)
$ErrorActionPreference = "Stop"
function Get-Percent {
param([double]$Value)
if ([double]::IsNaN($Value) -or [double]::IsInfinity($Value)) {
return 0
}
$rounded = [math]::Round($Value)
if ($rounded -lt 0) { return 0 }
if ($rounded -gt 100) { return 100 }
return [int]$rounded
}
function Get-SystemMetricLine {
$cpu = Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor |
Where-Object { $_.Name -eq "_Total" } |
Select-Object -First 1
$os = Get-CimInstance Win32_OperatingSystem
$totalMemory = [double]$os.TotalVisibleMemorySize
$freeMemory = [double]$os.FreePhysicalMemory
$memoryPercent = if ($totalMemory -gt 0) {
(($totalMemory - $freeMemory) / $totalMemory) * 100
} else {
0
}
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$diskPercent = if ($disk -and [double]$disk.Size -gt 0) {
(([double]$disk.Size - [double]$disk.FreeSpace) / [double]$disk.Size) * 100
} else {
0
}
$network = Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface |
Where-Object {
$_.Name -notmatch "Loopback|isatap|Teredo" -and
([double]$_.BytesReceivedPersec -gt 0 -or [double]$_.BytesSentPersec -gt 0)
}
$rxBytes = 0.0
$txBytes = 0.0
foreach ($adapter in $network) {
$rxBytes += [double]$adapter.BytesReceivedPersec
$txBytes += [double]$adapter.BytesSentPersec
}
$cpuPercent = Get-Percent ([double]$cpu.PercentProcessorTime)
$memPercent = Get-Percent $memoryPercent
$diskUsedPercent = Get-Percent $diskPercent
$rxKbps = [int][math]::Max(0, [math]::Round($rxBytes / 1024))
$txKbps = [int][math]::Max(0, [math]::Round($txBytes / 1024))
return "SYS,$cpuPercent,$memPercent,$diskUsedPercent,$rxKbps,$txKbps"
}
$serial = [System.IO.Ports.SerialPort]::new(
$Port,
$BaudRate,
[System.IO.Ports.Parity]::None,
8,
[System.IO.Ports.StopBits]::One
)
$serial.NewLine = "`n"
$serial.DtrEnable = $false
$serial.RtsEnable = $false
$serial.WriteTimeout = 1000
try {
$serial.Open()
Write-Host "Streaming PC metrics to $Port at $BaudRate baud. Press Ctrl+C to stop."
while ($true) {
$line = Get-SystemMetricLine
$serial.WriteLine($line)
Write-Host $line
Start-Sleep -Seconds $IntervalSeconds
}
}
finally {
if ($serial.IsOpen) {
$serial.Close()
}
}

View File

@ -0,0 +1,351 @@
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
PROJECT_ROOT = Path(__file__).resolve().parents[2]
FRONTEND_DIR = PROJECT_ROOT / "frontend-preview"
MAIN_DIR = PROJECT_ROOT / "main"
GENERATED_DIR = MAIN_DIR / "generated"
ACTIVE_SCREEN_PATH = FRONTEND_DIR / "active-screen.json"
TEMPLATES_PATH = FRONTEND_DIR / "templates.json"
FIRMWARES_PATH = FRONTEND_DIR / "firmwares.json"
UI_CONFIG_PATH = GENERATED_DIR / "ui_config.h"
DEFAULT_PORT = 8788
IDF_VERSION = "v6.0.1"
EIM_EXE = Path(
r"C:\Users\54972\AppData\Local\Microsoft\WinGet\Packages"
r"\Espressif.EIM-CLI_Microsoft.Winget.Source_8wekyb3d8bbwe\eim.exe"
)
TEMPLATE_IDS = {
"clock": "UI_TEMPLATE_CLOCK",
"dual-metric": "UI_TEMPLATE_DUAL_METRIC",
"meter": "UI_TEMPLATE_METER",
}
def read_json(path: Path, fallback: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return fallback
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def response_bytes(data: Any) -> bytes:
return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
def c_string(value: Any, limit: int = 48) -> str:
text = str(value or "")[:limit]
text = text.replace("\\", "\\\\").replace('"', '\\"')
text = text.replace("\r", " ").replace("\n", " ")
return f'"{text}"'
def c_int(value: Any, default: int = 0, minimum: int = -999, maximum: int = 999) -> int:
try:
number = int(float(str(value).strip()))
except (TypeError, ValueError):
number = default
return max(minimum, min(maximum, number))
def normalize_screen(screen: dict[str, Any]) -> dict[str, Any]:
screen = dict(screen or {})
fields = dict(screen.get("fields") or {})
effects = dict(screen.get("effects") or {})
template_id = screen.get("templateId") or "dual-metric"
if template_id not in TEMPLATE_IDS:
template_id = "dual-metric"
orientation = screen.get("orientation") if screen.get("orientation") in {"landscape", "portrait"} else "landscape"
density = screen.get("density") if screen.get("density") in {"comfortable", "compact"} else "comfortable"
return {
"templateId": template_id,
"title": str(screen.get("title") or "RLCD STATUS"),
"orientation": orientation,
"density": density,
"effects": {
"ghost": bool(effects.get("ghost", True)),
"grid": bool(effects.get("grid", True)),
},
"fields": fields,
}
def firmware_catalog() -> list[dict[str, Any]]:
data = read_json(FIRMWARES_PATH, {"firmwares": []})
result: list[dict[str, Any]] = []
for item in data.get("firmwares", []):
firmware = dict(item)
if firmware.get("type") == "whole-image":
path = Path(firmware.get("path", ""))
firmware["ready"] = path.exists() and path.is_file()
firmware["size"] = path.stat().st_size if firmware["ready"] else 0
elif firmware.get("type") == "idf-project":
project_path = Path(firmware.get("projectPath", ""))
artifact = project_path / firmware.get("artifact", "")
firmware["ready"] = artifact.exists() and artifact.is_file()
firmware["size"] = artifact.stat().st_size if firmware["ready"] else 0
else:
firmware["ready"] = False
result.append(firmware)
return result
def find_firmware(firmware_id: str) -> dict[str, Any]:
for firmware in firmware_catalog():
if firmware.get("id") == firmware_id:
return firmware
raise ValueError(f"Unknown firmware id: {firmware_id}")
def ensure_known_path(path: Path, allowed_roots: list[Path]) -> Path:
resolved = path.resolve()
for root in allowed_roots:
try:
resolved.relative_to(root.resolve())
return resolved
except ValueError:
continue
raise ValueError(f"Path is not allowlisted: {resolved}")
def validate_port(port: str) -> str:
port = (port or "COM3").strip().upper()
if not re.fullmatch(r"COM\d{1,3}", port):
raise ValueError("Only COM ports are allowed on this Windows workbench.")
return port
def run_eim(command: str, timeout: int) -> dict[str, Any]:
if not EIM_EXE.exists():
raise RuntimeError(f"EIM CLI not found: {EIM_EXE}")
completed = subprocess.run(
[str(EIM_EXE), "run", command, IDF_VERSION],
cwd=str(PROJECT_ROOT),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
)
output = completed.stdout[-12000:] if completed.stdout else ""
return {
"ok": completed.returncode == 0,
"returncode": completed.returncode,
"output": output,
}
def generate_ui_config(screen: dict[str, Any]) -> str:
screen = normalize_screen(screen)
fields = screen["fields"]
template_macro = TEMPLATE_IDS[screen["templateId"]]
orientation = 1 if screen["orientation"] == "portrait" else 0
density = 1 if screen["density"] == "compact" else 0
primary_value = c_int(fields.get("primaryValue"), 0, -999, 999)
secondary_value = c_int(fields.get("secondaryValue"), 0, -999, 999)
time_text = fields.get("time") or "12:00"
lines = [
"/* Generated by tools/preview-bridge/server.py. */",
"#pragma once",
"",
"#define UI_TEMPLATE_CLOCK 1",
"#define UI_TEMPLATE_DUAL_METRIC 2",
"#define UI_TEMPLATE_METER 3",
f"#define UI_ACTIVE_TEMPLATE {template_macro}",
"",
"#define UI_ORIENTATION_LANDSCAPE 0",
"#define UI_ORIENTATION_PORTRAIT 1",
f"#define UI_ACTIVE_ORIENTATION {orientation}",
"",
"#define UI_DENSITY_COMFORTABLE 0",
"#define UI_DENSITY_COMPACT 1",
f"#define UI_ACTIVE_DENSITY {density}",
"",
f"#define UI_TITLE_TEXT {c_string(screen.get('title'), 32)}",
f"#define UI_STATUS_TEXT {c_string(fields.get('status') or 'READY', 18)}",
f"#define UI_TIME_TEXT {c_string(time_text, 12)}",
f"#define UI_DATE_TEXT {c_string(fields.get('date') or '2026-06-20', 24)}",
f"#define UI_PRIMARY_LABEL_TEXT {c_string(fields.get('primaryLabel') or 'A', 18)}",
f"#define UI_PRIMARY_VALUE_TEXT {c_string(fields.get('primaryValue') or '0', 10)}",
f"#define UI_PRIMARY_VALUE_INT {primary_value}",
f"#define UI_PRIMARY_UNIT_TEXT {c_string(fields.get('primaryUnit') or '', 8)}",
f"#define UI_SECONDARY_LABEL_TEXT {c_string(fields.get('secondaryLabel') or 'B', 18)}",
f"#define UI_SECONDARY_VALUE_TEXT {c_string(fields.get('secondaryValue') or '0', 10)}",
f"#define UI_SECONDARY_VALUE_INT {secondary_value}",
f"#define UI_SECONDARY_UNIT_TEXT {c_string(fields.get('secondaryUnit') or '', 8)}",
f"#define UI_FOOTER_LEFT_TEXT {c_string(fields.get('footerLeft') or '', 32)}",
f"#define UI_FOOTER_RIGHT_TEXT {c_string(fields.get('footerRight') or '', 32)}",
"",
]
return "\n".join(lines)
def save_generated_config(screen: dict[str, Any]) -> None:
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
UI_CONFIG_PATH.write_text(generate_ui_config(screen), encoding="utf-8")
class WorkbenchHandler(SimpleHTTPRequestHandler):
server_version = "RLCDWorkbench/1.0"
def end_headers(self) -> None:
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
super().end_headers()
def do_OPTIONS(self) -> None:
self.send_response(204)
self.end_headers()
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path.startswith("/api/"):
self.handle_api_get(parsed.path)
return
self.serve_frontend(parsed.path)
def do_POST(self) -> None:
parsed = urlparse(self.path)
if parsed.path.startswith("/api/"):
self.handle_api_post(parsed.path)
return
self.send_error(404)
def read_body_json(self) -> Any:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0:
return {}
raw = self.rfile.read(length).decode("utf-8")
return json.loads(raw or "{}")
def send_json(self, data: Any, status: int = 200) -> None:
body = response_bytes(data)
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_error_json(self, message: str, status: int = 400) -> None:
self.send_json({"ok": False, "error": message}, status=status)
def handle_api_get(self, path: str) -> None:
try:
if path == "/api/health":
self.send_json({"ok": True, "projectRoot": str(PROJECT_ROOT)})
elif path == "/api/templates":
self.send_json(read_json(TEMPLATES_PATH, {"templates": []}))
elif path == "/api/screen":
self.send_json({"screen": normalize_screen(read_json(ACTIVE_SCREEN_PATH, {}))})
elif path == "/api/firmwares":
self.send_json({"firmwares": firmware_catalog()})
else:
self.send_error_json("Unknown API endpoint", 404)
except Exception as exc: # noqa: BLE001
self.send_error_json(str(exc), 500)
def handle_api_post(self, path: str) -> None:
try:
body = self.read_body_json()
if path == "/api/screen":
screen = normalize_screen(body)
write_json(ACTIVE_SCREEN_PATH, screen)
self.send_json({"ok": True, "message": "Screen config saved."})
elif path == "/api/generate":
screen = normalize_screen(body.get("screen") or read_json(ACTIVE_SCREEN_PATH, {}))
write_json(ACTIVE_SCREEN_PATH, screen)
save_generated_config(screen)
self.send_json({"ok": True, "message": f"Generated {UI_CONFIG_PATH}"})
elif path == "/api/build":
screen = normalize_screen(read_json(ACTIVE_SCREEN_PATH, {}))
save_generated_config(screen)
result = run_eim("idf.py build", timeout=900)
self.send_json(result, status=200 if result["ok"] else 500)
elif path == "/api/flash":
self.flash_firmware(body)
else:
self.send_error_json("Unknown API endpoint", 404)
except subprocess.TimeoutExpired:
self.send_error_json("Command timed out.", 504)
except Exception as exc: # noqa: BLE001
self.send_error_json(str(exc), 500)
def flash_firmware(self, body: dict[str, Any]) -> None:
if body.get("confirmed") is not True:
raise ValueError("Flash requires explicit confirmation.")
port = validate_port(str(body.get("port") or "COM3"))
firmware = find_firmware(str(body.get("firmwareId") or ""))
firmware_type = firmware.get("type")
if firmware_type == "whole-image":
path = ensure_known_path(
Path(firmware.get("path", "")),
[Path(r"D:\esp\waveshare-rlcd-4.2\03_Firmware")],
)
if not path.exists():
raise FileNotFoundError(path)
offset = firmware.get("offset") or "0x0"
command = f'esptool.py --chip esp32s3 -p {port} -b 460800 write_flash {offset} "{path}"'
result = run_eim(command, timeout=900)
elif firmware_type == "idf-project":
project_path = ensure_known_path(Path(firmware.get("projectPath", "")), [PROJECT_ROOT])
if project_path != PROJECT_ROOT:
raise ValueError("Only the current ESP-IDF project can be flashed.")
result = run_eim(f"idf.py -p {port} flash", timeout=900)
else:
raise ValueError(f"Unsupported firmware type: {firmware_type}")
self.send_json(result, status=200 if result["ok"] else 500)
def serve_frontend(self, raw_path: str) -> None:
path = unquote(raw_path or "/")
if path == "/":
path = "/index.html"
candidate = (FRONTEND_DIR / path.lstrip("/")).resolve()
try:
candidate.relative_to(FRONTEND_DIR.resolve())
except ValueError:
self.send_error(403)
return
if not candidate.exists() or not candidate.is_file():
self.send_error(404)
return
self.path = "/" + str(candidate.relative_to(FRONTEND_DIR)).replace(os.sep, "/")
self.directory = str(FRONTEND_DIR)
return super().do_GET()
def main() -> int:
port = int(os.environ.get("RLCD_WORKBENCH_PORT", DEFAULT_PORT))
address = ("127.0.0.1", port)
httpd = ThreadingHTTPServer(address, WorkbenchHandler)
print(f"RLCD workbench bridge: http://127.0.0.1:{port}")
print(f"Project root: {PROJECT_ROOT}")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nStopping bridge.")
return 0
if __name__ == "__main__":
raise SystemExit(main())