Initial ESP32 RLCD metrics workspace
This commit is contained in:
commit
035f1e8cea
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal 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
10
CMakeLists.txt
Normal 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
32
dependencies.lock
Normal 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
|
||||
548
docs/zhipu-coding-plan-usage-api.md
Normal file
548
docs/zhipu-coding-plan-usage-api.md
Normal 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`(本地累加实现)
|
||||
21
frontend-preview/active-screen.json
Normal file
21
frontend-preview/active-screen.json
Normal 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
1063
frontend-preview/app.js
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend-preview/firmwares.json
Normal file
28
frontend-preview/firmwares.json
Normal 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
125
frontend-preview/index.html
Normal 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
828
frontend-preview/styles.css
Normal 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;
|
||||
}
|
||||
98
frontend-preview/templates.json
Normal file
98
frontend-preview/templates.json
Normal 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
25
main/CMakeLists.txt
Normal 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
230
main/battery_monitor.c
Normal 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
14
main/battery_monitor.h
Normal 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
|
||||
30
main/generated/ui_config.h
Normal file
30
main/generated/ui_config.h
Normal 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
8
main/idf_component.yml
Normal 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
123
main/main.c
Normal 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
271
main/metrics_ble.c
Normal 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
16
main/metrics_ble.h
Normal 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
551
main/metrics_protocol.c
Normal 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
15
main/metrics_protocol.h
Normal 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
122
main/metrics_serial.c
Normal 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
16
main/metrics_serial.h
Normal 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
148
main/metrics_wifi.c
Normal 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
11
main/metrics_wifi.h
Normal 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
365
main/st7305_rlcd.c
Normal 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
98
main/st7305_rlcd.h
Normal 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
|
||||
159
main/temp_humidity_monitor.c
Normal file
159
main/temp_humidity_monitor.c
Normal 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;
|
||||
}
|
||||
14
main/temp_humidity_monitor.h
Normal file
14
main/temp_humidity_monitor.h
Normal 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
795
main/ui_screen.c
Normal 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
50
main/ui_screen.h
Normal 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
4
partitions.csv
Normal 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,
|
||||
|
15
scripts/README.md
Normal file
15
scripts/README.md
Normal 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
|
||||
```
|
||||
66
sdkconfig.defaults
Normal file
66
sdkconfig.defaults
Normal 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
|
||||
786
tools/pc-metrics/push-metrics-ble.py
Normal file
786
tools/pc-metrics/push-metrics-ble.py
Normal 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())
|
||||
100
tools/pc-metrics/push-metrics-http.ps1
Normal file
100
tools/pc-metrics/push-metrics-http.ps1
Normal 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
|
||||
}
|
||||
2
tools/pc-metrics/requirements-ble.txt
Normal file
2
tools/pc-metrics/requirements-ble.txt
Normal file
@ -0,0 +1,2 @@
|
||||
bleak>=0.22,<2
|
||||
psutil>=5.9,<8
|
||||
124
tools/pc-metrics/serve-metrics-http.ps1
Normal file
124
tools/pc-metrics/serve-metrics-http.ps1
Normal 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()
|
||||
}
|
||||
41
tools/pc-metrics/start-ap-push.ps1
Normal file
41
tools/pc-metrics/start-ap-push.ps1
Normal 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"
|
||||
89
tools/pc-metrics/start-ble-metrics.ps1
Normal file
89
tools/pc-metrics/start-ble-metrics.ps1
Normal 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"
|
||||
37
tools/pc-metrics/start-http-metrics.ps1
Normal file
37
tools/pc-metrics/start-http-metrics.ps1
Normal 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"
|
||||
39
tools/pc-metrics/start-metrics.ps1
Normal file
39
tools/pc-metrics/start-metrics.ps1
Normal 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"
|
||||
22
tools/pc-metrics/stop-ap-push.ps1
Normal file
22
tools/pc-metrics/stop-ap-push.ps1
Normal 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
|
||||
42
tools/pc-metrics/stop-ble-metrics.ps1
Normal file
42
tools/pc-metrics/stop-ble-metrics.ps1
Normal 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
|
||||
22
tools/pc-metrics/stop-http-metrics.ps1
Normal file
22
tools/pc-metrics/stop-http-metrics.ps1
Normal 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
|
||||
22
tools/pc-metrics/stop-metrics.ps1
Normal file
22
tools/pc-metrics/stop-metrics.ps1
Normal 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
|
||||
90
tools/pc-metrics/stream-metrics.ps1
Normal file
90
tools/pc-metrics/stream-metrics.ps1
Normal 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()
|
||||
}
|
||||
}
|
||||
351
tools/preview-bridge/server.py
Normal file
351
tools/preview-bridge/server.py
Normal 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())
|
||||
Loading…
x
Reference in New Issue
Block a user