refactor(logging): remove remote logging subsystem

This commit is contained in:
chenjintao
2026-10-08 16:08:41 +08:00
parent 5d849033c3
commit 3160a33f50
22 changed files with 12 additions and 2546 deletions
-1
View File
@@ -4,7 +4,6 @@ nvs, data, nvs, 0x9000, 0x4000,
otadata, data, ota, 0xd000, 0x2000, otadata, data, ota, 0xd000, 0x2000,
phy_init, data, phy, 0xf000, 0x1000, phy_init, data, phy, 0xf000, 0x1000,
gifpack, data, littlefs, , 0x200000, gifpack, data, littlefs, , 0x200000,
logfs, data, littlefs, , 0x40000,
ota_0, app, ota_0, , 0x380000, ota_0, app, ota_0, , 0x380000,
ota_1, app, ota_1, , 0x380000, ota_1, app, ota_1, , 0x380000,
model, data, spiffs, , 0x300000, model, data, spiffs, , 0x300000,
1 # ESP-IDF Partition Table
4 otadata, data, ota, 0xd000, 0x2000,
5 phy_init, data, phy, 0xf000, 0x1000,
6 gifpack, data, littlefs, , 0x200000,
logfs, data, littlefs, , 0x40000,
7 ota_0, app, ota_0, , 0x380000,
8 ota_1, app, ota_1, , 0x380000,
9 model, data, spiffs, , 0x300000,
-1
View File
@@ -17,7 +17,6 @@ idf_component_register(
"src/runtime/control_plane_network_feedback.c" "src/runtime/control_plane_network_feedback.c"
"src/runtime/control_plane_key_input.c" "src/runtime/control_plane_key_input.c"
"src/runtime/control_plane_reset_service.c" "src/runtime/control_plane_reset_service.c"
"src/runtime/control_plane_remote_log_service.c"
"src/runtime/control_plane_runtime_workers.c" "src/runtime/control_plane_runtime_workers.c"
"src/runtime/control_plane_ota.c" "src/runtime/control_plane_ota.c"
"src/runtime/control_plane_factory_cli.c" "src/runtime/control_plane_factory_cli.c"
@@ -187,10 +187,3 @@ esp_err_t control_plane_schedule_websocket_wait_task(void);
void control_plane_set_ptt_active(bool active); void control_plane_set_ptt_active(bool active);
void control_plane_set_ptt_active_from_local_wake(void); void control_plane_set_ptt_active_from_local_wake(void);
esp_err_t control_plane_enter_mini_program_reprovisioning(const char *reason); esp_err_t control_plane_enter_mini_program_reprovisioning(const char *reason);
esp_err_t control_plane_remote_log_start(void);
void control_plane_remote_log_stop(int timeout_ms);
void control_plane_remote_log_set_light_standby(bool standby);
esp_err_t control_plane_remote_log_flush_now(void);
esp_err_t control_plane_remote_log_clear(void);
esp_err_t control_plane_remote_log_format_status(char *out, size_t out_len);
@@ -561,41 +561,6 @@ static void handle_prepare_rotate(uint32_t key_version)
cli_write_ok(); cli_write_ok();
} }
static void handle_log_command(size_t argc, char *argv[])
{
if (argc < 2 || is_text_empty(argv[1])) {
cli_write_error("missing_log_command");
return;
}
if (strcmp(argv[1], "status") == 0) {
char json[FACTORY_CLI_JSON_MAX_LEN] = {0};
esp_err_t err = control_plane_remote_log_format_status(json, sizeof(json));
if (err != ESP_OK) {
cli_write_error("log_status_failed");
return;
}
cli_write_line(json);
cli_write_ok();
} else if (strcmp(argv[1], "flush") == 0) {
esp_err_t err = control_plane_remote_log_flush_now();
if (err != ESP_OK) {
cli_write_error("log_flush_failed");
return;
}
cli_write_ok();
} else if (strcmp(argv[1], "clear") == 0) {
esp_err_t err = control_plane_remote_log_clear();
if (err != ESP_OK) {
cli_write_error("log_clear_failed");
return;
}
cli_write_ok();
} else {
cli_write_error("unknown_log_command");
}
}
static void handle_lcd_test(void) static void handle_lcd_test(void)
{ {
const domain_expression_request_t request = { const domain_expression_request_t request = {
@@ -692,8 +657,6 @@ static void handle_command_line(char *line)
return; return;
} }
handle_prepare_rotate(key_version); handle_prepare_rotate(key_version);
} else if (strcmp(cmd, "log") == 0) {
handle_log_command(argc, argv);
} else if (strcmp(cmd, "lcd_test") == 0) { } else if (strcmp(cmd, "lcd_test") == 0) {
handle_lcd_test(); handle_lcd_test();
} else { } else {
@@ -67,10 +67,6 @@
#define CONFIG_TQ_LIGHT_STANDBY_WIFI_POWER_SAVE 0 #define CONFIG_TQ_LIGHT_STANDBY_WIFI_POWER_SAVE 0
#endif #endif
#ifndef CONFIG_TQ_LIGHT_STANDBY_PAUSE_REMOTE_LOG_UPLOAD
#define CONFIG_TQ_LIGHT_STANDBY_PAUSE_REMOTE_LOG_UPLOAD 0
#endif
#ifndef CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC #ifndef CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC
#define CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC 300 #define CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC 300
#endif #endif
@@ -214,9 +210,6 @@ static void control_plane_light_standby_unlock_transition(void)
static esp_err_t control_plane_light_standby_apply_runtime_policy(bool standby) static esp_err_t control_plane_light_standby_apply_runtime_policy(bool standby)
{ {
#if CONFIG_TQ_LIGHT_STANDBY_PAUSE_REMOTE_LOG_UPLOAD
control_plane_remote_log_set_light_standby(standby);
#endif
#if CONFIG_TQ_BATTERY_ENABLE #if CONFIG_TQ_BATTERY_ENABLE
control_plane_battery_service_set_light_standby(standby); control_plane_battery_service_set_light_standby(standby);
#endif #endif
@@ -233,9 +226,6 @@ static esp_err_t control_plane_light_standby_apply_runtime_policy(bool standby)
standby ? 1 : 0, standby ? 1 : 0,
esp_err_to_name(wifi_err)); esp_err_to_name(wifi_err));
if (standby) { if (standby) {
#if CONFIG_TQ_LIGHT_STANDBY_PAUSE_REMOTE_LOG_UPLOAD
control_plane_remote_log_set_light_standby(false);
#endif
#if CONFIG_TQ_BATTERY_ENABLE #if CONFIG_TQ_BATTERY_ENABLE
control_plane_battery_service_set_light_standby(false); control_plane_battery_service_set_light_standby(false);
#endif #endif
File diff suppressed because it is too large Load Diff
@@ -36,7 +36,6 @@
#define CONTROL_PLANE_TASK_MONITOR_TASK_STACK_SIZE (1024 * 4) #define CONTROL_PLANE_TASK_MONITOR_TASK_STACK_SIZE (1024 * 4)
#define CONTROL_PLANE_TASK_MONITOR_STACK_WARN_BYTES 1024U #define CONTROL_PLANE_TASK_MONITOR_STACK_WARN_BYTES 1024U
#define CONTROL_PLANE_TASK_MONITOR_FULL_REPORT_PERIOD 5U #define CONTROL_PLANE_TASK_MONITOR_FULL_REPORT_PERIOD 5U
#define CONTROL_PLANE_REMOTE_LOG_STOP_WAIT_MS 1500
#define CONTROL_PLANE_MODEL_CMD_QUEUE_LEN 4 #define CONTROL_PLANE_MODEL_CMD_QUEUE_LEN 4
#define CONTROL_PLANE_MODEL_CMD_RECV_TIMEOUT_MS 200 #define CONTROL_PLANE_MODEL_CMD_RECV_TIMEOUT_MS 200
#define CONTROL_PLANE_TASK_STOP_WAIT_MS 1500 #define CONTROL_PLANE_TASK_STOP_WAIT_MS 1500
@@ -2750,11 +2749,6 @@ esp_err_t control_plane_start_runtime_workers(void)
ESP_LOGI(TAG, "Local wake startup deferred for OTA HTTPS window"); ESP_LOGI(TAG, "Local wake startup deferred for OTA HTTPS window");
} }
esp_err_t remote_log_err = control_plane_remote_log_start();
if (remote_log_err != ESP_OK && remote_log_err != ESP_ERR_NOT_SUPPORTED) {
ESP_LOGW(TAG, "Remote log start failed: %s", esp_err_to_name(remote_log_err));
}
#if CONFIG_FORTUNE_ENABLE #if CONFIG_FORTUNE_ENABLE
if (control_plane_get_task_handle(&s_daily_fortune_task) == NULL) { if (control_plane_get_task_handle(&s_daily_fortune_task) == NULL) {
control_plane_request_fortune_refresh(); control_plane_request_fortune_refresh();
@@ -2808,7 +2802,6 @@ void control_plane_stop_event_observer_runtime(void)
control_plane_set_runtime_stop_requested(true); control_plane_set_runtime_stop_requested(true);
control_plane_stop_key_input_task(); control_plane_stop_key_input_task();
control_plane_stop_system_event_task(); control_plane_stop_system_event_task();
control_plane_remote_log_stop(CONTROL_PLANE_REMOTE_LOG_STOP_WAIT_MS);
control_plane_set_runtime_stop_requested(false); control_plane_set_runtime_stop_requested(false);
} }
-1
View File
@@ -337,7 +337,6 @@ bool domain_runtime_slot_feature_switch_is_active(void);
esp_err_t domain_runtime_prepare_storage(void); esp_err_t domain_runtime_prepare_storage(void);
esp_err_t domain_runtime_mount_littlefs(void); esp_err_t domain_runtime_mount_littlefs(void);
esp_err_t domain_runtime_mount_logfs(void);
void domain_runtime_read_device_identity(char *device_id, void domain_runtime_read_device_identity(char *device_id,
size_t device_id_len, size_t device_id_len,
char *serial_number, char *serial_number,
@@ -910,11 +910,6 @@ esp_err_t domain_runtime_mount_littlefs(void)
return platform_mount_littlefs(); return platform_mount_littlefs();
} }
esp_err_t domain_runtime_mount_logfs(void)
{
return platform_mount_logfs();
}
void domain_runtime_read_device_identity(char *device_id, void domain_runtime_read_device_identity(char *device_id,
size_t device_id_len, size_t device_id_len,
char *serial_number, char *serial_number,
-1
View File
@@ -222,7 +222,6 @@ esp_err_t platform_init(void);
esp_err_t platform_prepare_storage(void); esp_err_t platform_prepare_storage(void);
bool platform_storage_is_ready(void); bool platform_storage_is_ready(void);
esp_err_t platform_mount_littlefs(void); esp_err_t platform_mount_littlefs(void);
esp_err_t platform_mount_logfs(void);
void platform_read_device_identity(char *device_id, void platform_read_device_identity(char *device_id,
size_t device_id_len, size_t device_id_len,
char *serial_number, char *serial_number,
@@ -30,12 +30,10 @@
#error "CONFIG_TQ_POWER_HOLD_ACTIVE_LEVEL must be defined in Kconfig" #error "CONFIG_TQ_POWER_HOLD_ACTIVE_LEVEL must be defined in Kconfig"
#endif #endif
/* 提示音和运行日志使用独立分区/挂载点,避免日志写入影响只读媒体资源。 */ /* 提示音从 gifpack LittleFS 分区读取。 */
#define VOICE_PROMPT_DEFAULT_BASE_PATH "/littlefs/audio_prompts" #define VOICE_PROMPT_DEFAULT_BASE_PATH "/littlefs/audio_prompts"
#define VOICE_PROMPT_LITTLEFS_BASE_PATH "/littlefs" #define VOICE_PROMPT_LITTLEFS_BASE_PATH "/littlefs"
#define VOICE_PROMPT_LITTLEFS_PARTITION "gifpack" #define VOICE_PROMPT_LITTLEFS_PARTITION "gifpack"
#define PLATFORM_LOGFS_BASE_PATH "/logfs"
#define PLATFORM_LOGFS_PARTITION "logfs"
/* /*
* 提示音文件由4字节小端包长头和对应 raw-Opus payload 重复组成;单包上限2KB * 提示音文件由4字节小端包长头和对应 raw-Opus payload 重复组成;单包上限2KB
* 防止损坏文件触发异常分配。帧时长按60ms估算播放总时长,并额外留3秒用于队列 * 防止损坏文件触发异常分配。帧时长按60ms估算播放总时长,并额外留3秒用于队列
@@ -51,8 +49,6 @@ static const char *VOICE_TAG = "voice_prompt";
static bool s_power_hold_configured; static bool s_power_hold_configured;
static bool s_voice_prompt_littlefs_mount_attempted; static bool s_voice_prompt_littlefs_mount_attempted;
static esp_err_t s_voice_prompt_littlefs_mount_result = ESP_ERR_INVALID_STATE; static esp_err_t s_voice_prompt_littlefs_mount_result = ESP_ERR_INVALID_STATE;
static bool s_logfs_mount_attempted;
static esp_err_t s_logfs_mount_result = ESP_ERR_INVALID_STATE;
/* GPIO 掩码集中构造,负数配置代表该板型不使用电源保持引脚。 */ /* GPIO 掩码集中构造,负数配置代表该板型不使用电源保持引脚。 */
static uint64_t power_manager_gpio_bit_mask(int gpio_num) static uint64_t power_manager_gpio_bit_mask(int gpio_num)
@@ -168,50 +164,6 @@ esp_err_t platform_mount_littlefs(void)
return err; return err;
} }
esp_err_t platform_mount_logfs(void)
{
/* 日志分区与提示音分区独立挂载,任一失败不会覆盖另一分区状态。 */
if (s_logfs_mount_attempted) {
return s_logfs_mount_result;
}
esp_vfs_littlefs_conf_t conf = {
.base_path = PLATFORM_LOGFS_BASE_PATH,
.partition_label = PLATFORM_LOGFS_PARTITION,
.partition = NULL,
.format_if_mount_failed = true,
.dont_mount = false,
};
esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err == ESP_ERR_INVALID_STATE &&
esp_littlefs_mounted(PLATFORM_LOGFS_PARTITION)) {
err = ESP_OK;
}
if (err == ESP_OK) {
size_t total_bytes = 0;
size_t used_bytes = 0;
esp_err_t info_err = esp_littlefs_info(PLATFORM_LOGFS_PARTITION,
&total_bytes,
&used_bytes);
if (info_err == ESP_OK) {
ESP_LOGI(VOICE_TAG,
"LogFS mounted at %s, used=%u total=%u",
PLATFORM_LOGFS_BASE_PATH,
(unsigned)used_bytes,
(unsigned)total_bytes);
}
} else {
ESP_LOGW(VOICE_TAG,
"Mount LogFS failed: %s",
esp_err_to_name(err));
}
s_logfs_mount_attempted = true;
s_logfs_mount_result = err;
return err;
}
esp_err_t voice_prompt_play_file(const char *path) esp_err_t voice_prompt_play_file(const char *path)
{ {
/* 文件采用“长度头 + Opus 数据”帧格式,逐帧送入统一的下行播放管线。 */ /* 文件采用“长度头 + Opus 数据”帧格式,逐帧送入统一的下行播放管线。 */
+2 -41
View File
@@ -37,7 +37,6 @@ flowchart LR
Events["System event dispatch"] Events["System event dispatch"]
MultimodalPolicy["Multimodal session policy"] MultimodalPolicy["Multimodal session policy"]
Fortune["Daily fortune policy"] Fortune["Daily fortune policy"]
RemoteLog["Remote log service"]
FactoryCli["Factory CLI diagnostics"] FactoryCli["Factory CLI diagnostics"]
end end
@@ -53,7 +52,7 @@ flowchart LR
LCD["ST7789 direct LCD backend"] LCD["ST7789 direct LCD backend"]
Audio["ES8311 audio pipeline"] Audio["ES8311 audio pipeline"]
Network["Wi-Fi, BLE provisioning, HTTP, WebSocket"] Network["Wi-Fi, BLE provisioning, HTTP, WebSocket"]
Storage["NVS, LittleFS, LogFS"] Storage["NVS, LittleFS"]
GPIO["GPIO, stepper, console UART"] GPIO["GPIO, stepper, console UART"]
end end
@@ -62,11 +61,9 @@ flowchart LR
Wire --> Workers Wire --> Workers
Connectivity --> RuntimeAdapters Connectivity --> RuntimeAdapters
Workers --> Events Workers --> Events
Workers --> RemoteLog
MultimodalPolicy --> RuntimeAdapters MultimodalPolicy --> RuntimeAdapters
Fortune --> FortuneDomain Fortune --> FortuneDomain
Events --> ExpressionMap Events --> ExpressionMap
FactoryCli --> RemoteLog
RuntimeAdapters --> LCD RuntimeAdapters --> LCD
RuntimeAdapters --> Audio RuntimeAdapters --> Audio
RuntimeAdapters --> Network RuntimeAdapters --> Network
@@ -123,7 +120,7 @@ sequenceDiagram
Domain->>Platform: Wi-Fi and provisioning primitives Domain->>Platform: Wi-Fi and provisioning primitives
App->>CP: control_plane_start_runtime_workers() App->>CP: control_plane_start_runtime_workers()
CP->>CP: start model, websocket wait, LCD, Wi-Fi state, events, keys, remote log, fortune CP->>CP: start model, websocket wait, LCD, Wi-Fi state, events, keys, fortune
``` ```
## Runtime Worker Topology ## Runtime Worker Topology
@@ -139,7 +136,6 @@ flowchart TB
Start --> WifiTask["cp_wifi_state"] Start --> WifiTask["cp_wifi_state"]
Start --> EventTask["cp_system_event"] Start --> EventTask["cp_system_event"]
Start --> KeyTask["cp_key_input"] Start --> KeyTask["cp_key_input"]
Start --> RemoteLogTask["cp_remote_log"]
Start --> FortuneTask["cp_fortune\nif FORTUNE_ENABLE"] Start --> FortuneTask["cp_fortune\nif FORTUNE_ENABLE"]
KeyTask --> EventTask KeyTask --> EventTask
@@ -148,7 +144,6 @@ flowchart TB
Multimodal --> ModelTask Multimodal --> ModelTask
Multimodal --> Audio["audio capture/playback"] Multimodal --> Audio["audio capture/playback"]
WifiTask --> LcdTask WifiTask --> LcdTask
RemoteLogTask --> LogServer["remote log server"]
FortuneTask --> FortuneApi["daily fortune API"] FortuneTask --> FortuneApi["daily fortune API"]
``` ```
@@ -259,33 +254,6 @@ sequenceDiagram
end end
``` ```
## Remote Logging Flow
```mermaid
flowchart TD
EspLog["ESP_LOGx / printf log"]
Hook["esp_log_set_vprintf hook"]
Filter["filter by CONFIG_TQ_REMOTE_LOG_MIN_LEVEL"]
RamQueue["RAM queue\nCONFIG_TQ_REMOTE_LOG_QUEUE_LEN"]
FlushTimer["periodic flush\nCONFIG_TQ_REMOTE_LOG_FLUSH_INTERVAL_MS"]
LogFS["/logfs/remote_logs\nLittleFS persistent queue"]
StaReady{"STA connected\nand realtime path idle?"}
Upload["HTTP POST JSON batch\nCONFIG_TQ_REMOTE_LOG_ENDPOINT"]
Server["remote_log_receiver.py\n/opt/logTools/logs"]
Backoff["exponential retry backoff"]
DropOld["trim old files\nCONFIG_TQ_REMOTE_LOG_MAX_FILES"]
CoreDump["detect flash coredump\nupload metadata report"]
EspLog --> Hook --> Filter --> RamQueue
RamQueue --> FlushTimer --> LogFS --> DropOld
LogFS --> StaReady
CoreDump --> StaReady
StaReady -- yes --> Upload --> Server
StaReady -- no --> Backoff --> StaReady
Upload -- fail --> Backoff
Upload -- success --> Delete["delete uploaded local file"]
```
## Daily Fortune Flow ## Daily Fortune Flow
```mermaid ```mermaid
@@ -314,15 +282,8 @@ flowchart LR
UART["UART0 serial input"] UART["UART0 serial input"]
CLI["factory CLI parser"] CLI["factory CLI parser"]
Identity["identity / auth commands"] Identity["identity / auth commands"]
LogStatus["log status"]
LogFlush["log flush"]
LogClear["log clear"]
RemoteLog["control_plane_remote_log_*"]
UARTOut["UART0 JSON/OK/ERROR output"] UARTOut["UART0 JSON/OK/ERROR output"]
UART --> CLI UART --> CLI
CLI --> Identity --> UARTOut CLI --> Identity --> UARTOut
CLI --> LogStatus --> RemoteLog --> UARTOut
CLI --> LogFlush --> RemoteLog --> UARTOut
CLI --> LogClear --> RemoteLog --> UARTOut
``` ```
@@ -1,5 +1,7 @@
# Flash 与内存容量分析、风险及后续开发建议 # Flash 与内存容量分析、风险及后续开发建议
> 历史容量快照:文中远程日志与 `logfs` 已从当前固件移除,分区地址和容量应以现行 `16m.csv` 与最新构建产物为准。
本文记录当前固件的 Flash 分区使用情况、静态 RAM 使用情况、运行时内存风险、可优化内容和后续开发约束,用于功能评审、资源接入、OTA 设计和长期容量管理。 本文记录当前固件的 Flash 分区使用情况、静态 RAM 使用情况、运行时内存风险、可优化内容和后续开发约束,用于功能评审、资源接入、OTA 设计和长期容量管理。
LittleFS 资源从源码到设备的详细流程见: LittleFS 资源从源码到设备的详细流程见:
@@ -1,5 +1,7 @@
# OTA 后首次语音启动与后台上传隔离修复说明 # OTA 后首次语音启动与后台上传隔离修复说明
> 历史实现记录:文中远程日志及 LogFS 上传路径已从当前固件移除,相关描述不代表现行行为。
## 1. 提交信息 ## 1. 提交信息
```text ```text
@@ -414,4 +416,3 @@ Listening 到达时间
4. 模拟服务端 task 启动超过 10 秒,确认出现 `Local wake timed-out task cleanup`,下一次唤醒创建新 task。 4. 模拟服务端 task 启动超过 10 秒,确认出现 `Local wake timed-out task cleanup`,下一次唤醒创建新 task。
5. 在 ASR 结束到 TTS 开始期间观察远程日志,确认没有启动后台 HTTPS 上传。 5. 在 ASR 结束到 TTS 开始期间观察远程日志,确认没有启动后台 HTTPS 上传。
6. 开机 60 秒后保持语音空闲,确认历史日志和 coredump 能继续上传,而不是永久被阻塞。 6. 开机 60 秒后保持语音空闲,确认历史日志和 coredump 能继续上传,而不是永久被阻塞。
@@ -14,7 +14,7 @@
- Wi-Fi 连接、重连和配置热点; - Wi-Fi 连接、重连和配置热点;
- 设备身份、JWT 和会话令牌; - 设备身份、JWT 和会话令牌;
- 用户出生资料、每日运势和财富方向; - 用户出生资料、每日运势和财富方向;
- NVS、LittleFS、远程日志和工厂 CLI。 - NVS、LittleFS 和工厂 CLI。
## 2. 分层架构 ## 2. 分层架构
@@ -269,7 +269,6 @@ flowchart TB
LCD["cp_lcd_render<br/>LCD 启动"] LCD["cp_lcd_render<br/>LCD 启动"]
Events["cp_system_event<br/>事件分发"] Events["cp_system_event<br/>事件分发"]
Keys["cp_key_input<br/>GPIO 与消抖"] Keys["cp_key_input<br/>GPIO 与消抖"]
Logs["remote log worker"]
Fortune["cp_fortune<br/>每日运势"] Fortune["cp_fortune<br/>每日运势"]
Monitor["cp_task_monitor<br/>可选诊断"] Monitor["cp_task_monitor<br/>可选诊断"]
@@ -278,7 +277,6 @@ flowchart TB
Start --> LCD Start --> LCD
Start --> Events Start --> Events
Start --> Keys Start --> Keys
Start --> Logs
Start --> Fortune Start --> Fortune
Start --> Monitor Start --> Monitor
``` ```
@@ -705,4 +703,3 @@ control_plane_stop_runtime_workers()
- `components/platform/src/multimodal/transport_ws/transport_ws_runtime_impl.c` - `components/platform/src/multimodal/transport_ws/transport_ws_runtime_impl.c`
- `components/platform/src/lcd_direct.c` - `components/platform/src/lcd_direct.c`
- `components/platform/src/platform_stepper_driver.c` - `components/platform/src/platform_stepper_driver.c`
@@ -107,7 +107,7 @@ When enabled, the first multimodal HTTPS connection failure requests controlled
When disabled, HTTPS and WebSocket retry behavior remains active, but the firmware does not intentionally disconnect a Wi-Fi connection that is still reported as associated. When disabled, HTTPS and WebSocket retry behavior remains active, but the firmware does not intentionally disconnect a Wi-Fi connection that is still reported as associated.
Runtime impact when enabled: all device network traffic is interrupted during reassociation, including WebSocket, HTTPS, remote logging, LAN HTTP, and mDNS traffic. It is enabled by default because field testing demonstrated a stale STA data path that repeated transport-level reconnects could not recover. Disable it only when deliberate reassociation is unacceptable for a deployment. Runtime impact when enabled: all device network traffic is interrupted during reassociation, including WebSocket, HTTPS, LAN HTTP, and mDNS traffic. It is enabled by default because field testing demonstrated a stale STA data path that repeated transport-level reconnects could not recover. Disable it only when deliberate reassociation is unacceptable for a deployment.
The HTTPS preflight performs one application-level DNS lookup. lwIP already The HTTPS preflight performs one application-level DNS lookup. lwIP already
retransmits DNS queries internally, and the captured failure took about 14 retransmits DNS queries internally, and the captured failure took about 14
+3 -55
View File
@@ -9,7 +9,7 @@
| `988dc92` | 增加 LCD 背光 PWM 亮度控制 | | `988dc92` | 增加 LCD 背光 PWM 亮度控制 |
| `6a11aca` | 增加轻待机状态协调器和活动事件接入 | | `6a11aca` | 增加轻待机状态协调器和活动事件接入 |
| `baad9dc` | 增加背光、扬声器功放和电机的待机策略 | | `baad9dc` | 增加背光、扬声器功放和电机的待机策略 |
| `e773ebc` | 增加 Wi-Fi、远程日志、电池检测和内存监控的待机策略 | | `e773ebc` | 增加 Wi-Fi、电池检测和内存监控的待机策略(远程日志部分现已移除) |
| `cb203a7` | 进入轻待机前播放提示音,并等待真实播放完成 | | `cb203a7` | 进入轻待机前播放提示音,并等待真实播放完成 |
`bba99a0 fix(control-plane): trigger PTT on short press` 是独立的按键行为修复,不属于低功耗改动,因此不在本文范围内。 `bba99a0 fix(control-plane): trigger PTT on short press` 是独立的按键行为修复,不属于低功耗改动,因此不在本文范围内。
@@ -35,7 +35,6 @@
- 扬声器功放在无音频播放时仍可能保持供电。 - 扬声器功放在无音频播放时仍可能保持供电。
- 步进电机完成动作后可能继续保持线圈电流。 - 步进电机完成动作后可能继续保持线圈电流。
- Wi-Fi 始终使用活跃策略。 - Wi-Fi 始终使用活跃策略。
- 远程日志持续尝试 HTTP 上传。
- 电池检测和内存监控仍按活跃周期运行。 - 电池检测和内存监控仍按活跃周期运行。
- 缺少统一的待机状态、空闲计时、唤醒入口和失败回滚机制。 - 缺少统一的待机状态、空闲计时、唤醒入口和失败回滚机制。
- 如果进入待机时立即关闭功放,待机提示音将无法完整播放。 - 如果进入待机时立即关闭功放,待机提示音将无法完整播放。
@@ -79,8 +78,6 @@ CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE="standby_5min.opus"
CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT=y CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT=y
CONFIG_TQ_LIGHT_STANDBY_RELEASE_MOTOR_HOLD=y CONFIG_TQ_LIGHT_STANDBY_RELEASE_MOTOR_HOLD=y
CONFIG_TQ_LIGHT_STANDBY_WIFI_POWER_SAVE=y CONFIG_TQ_LIGHT_STANDBY_WIFI_POWER_SAVE=y
CONFIG_TQ_LIGHT_STANDBY_PAUSE_REMOTE_LOG_UPLOAD=y
CONFIG_TQ_LIGHT_STANDBY_REMOTE_LOG_POLL_MS=5000
CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC=300 CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC=300
CONFIG_TQ_LIGHT_STANDBY_MEMORY_SAMPLE_INTERVAL_MS=10000 CONFIG_TQ_LIGHT_STANDBY_MEMORY_SAMPLE_INTERVAL_MS=10000
CONFIG_TQ_LIGHT_STANDBY_MEMORY_REPORT_INTERVAL_MS=300000 CONFIG_TQ_LIGHT_STANDBY_MEMORY_REPORT_INTERVAL_MS=300000
@@ -372,51 +369,6 @@ esp_err_t platform_wifi_portal_set_light_standby(bool standby)
- 可能增加网络首包延迟,实际幅度受 AP 的 DTIM、信号质量和网络负载影响。 - 可能增加网络首包延迟,实际幅度受 AP 的 DTIM、信号质量和网络负载影响。
- 不等同于关闭 Wi-Fi,网络任务和 WebSocket 仍会消耗资源。 - 不等同于关闭 Wi-Fi,网络任务和 WebSocket 仍会消耗资源。
### 5.6 远程日志
#### 问题原因
待机期间继续发起 HTTP 上传会周期性唤醒网络和 CPU。完全停止日志采集又会丢失现场诊断信息。
#### 解决方式
待机期间继续接收日志并落盘,但暂停 coredump 和日志文件的 HTTP 上传;轮询间隔放宽至 5 秒。唤醒后恢复上传。
#### 具体代码
```c
while (!s_stop_requested) {
TickType_t now = xTaskGetTickCount();
bool light_standby = remote_log_light_standby();
if ((now - last_flush) >=
pdMS_TO_TICKS(CONFIG_TQ_REMOTE_LOG_FLUSH_INTERVAL_MS)) {
drain_memory_queue_to_flash();
last_flush = now;
}
if (!light_standby &&
voice_startup_upload_gate_open(startup_tick) &&
(backoff_ms == 0U || now >= next_upload_tick)) {
drain_memory_queue_to_flash();
upload_coredump_report();
upload_pending_files();
}
uint32_t poll_ms = light_standby ?
CONFIG_TQ_LIGHT_STANDBY_REMOTE_LOG_POLL_MS :
REMOTE_LOG_POLL_MS;
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(poll_ms));
}
```
#### 功耗与影响
- 减少待机期间 HTTP、TLS/TCP 和 Wi-Fi 活动。
- 日志仍会写入 Flash,不会因为轻待机直接丢失。
- 待机期间服务端无法立即看到新日志;唤醒后再补传。
- 持续落盘仍有 Flash 写入功耗和寿命影响,但保留了诊断能力。
### 5.7 电池检测 ### 5.7 电池检测
#### 问题原因 #### 问题原因
@@ -565,7 +517,6 @@ static esp_err_t control_plane_light_standby_apply_entry_policy(void)
```c ```c
static esp_err_t control_plane_light_standby_apply_runtime_policy(bool standby) static esp_err_t control_plane_light_standby_apply_runtime_policy(bool standby)
{ {
control_plane_remote_log_set_light_standby(standby);
control_plane_battery_service_set_light_standby(standby); control_plane_battery_service_set_light_standby(standby);
control_plane_runtime_diagnostics_set_light_standby(standby); control_plane_runtime_diagnostics_set_light_standby(standby);
@@ -573,7 +524,6 @@ static esp_err_t control_plane_light_standby_apply_runtime_policy(bool standby)
esp_err_t wifi_err = esp_err_t wifi_err =
domain_runtime_wifi_portal_set_light_standby(standby); domain_runtime_wifi_portal_set_light_standby(standby);
if (wifi_err != ESP_OK && standby) { if (wifi_err != ESP_OK && standby) {
control_plane_remote_log_set_light_standby(false);
control_plane_battery_service_set_light_standby(false); control_plane_battery_service_set_light_standby(false);
control_plane_runtime_diagnostics_set_light_standby(false); control_plane_runtime_diagnostics_set_light_standby(false);
return wifi_err; return wifi_err;
@@ -643,11 +593,10 @@ esp_err_t control_plane_light_standby_record_activity(
4. 待机时 Codec 输出静音并关闭扬声器 PA,同时保留麦克风、AFE 和唤醒词。 4. 待机时 Codec 输出静音并关闭扬声器 PA,同时保留麦克风、AFE 和唤醒词。
5. 释放步进电机线圈保持电流。 5. 释放步进电机线圈保持电流。
6. Wi-Fi 切换为最小 Modem 省电,同时保留连接。 6. Wi-Fi 切换为最小 Modem 省电,同时保留连接。
7. 暂停远程日志 HTTP 上传,日志采集和落盘继续工作。
8. 电池常规采样周期放宽到 300 秒,充电状态边沿仍及时处理。 8. 电池常规采样周期放宽到 300 秒,充电状态边沿仍及时处理。
9. 内存监控采样放宽到 10 秒、周期报告放宽到 300 秒,分配失败仍及时处理。 9. 内存监控采样放宽到 10 秒、周期报告放宽到 300 秒,分配失败仍及时处理。
10. 支持唤醒词、按键、SW2 抽签、模型命令和语音活动快速唤醒。 10. 支持唤醒词、按键、SW2 抽签、模型命令和语音活动快速唤醒。
11. 唤醒后恢复正常背光、功放、Wi-Fi、日志、电池和内存监控策略。 11. 唤醒后恢复正常背光、功放、Wi-Fi、电池和内存监控策略。
12. 增加进入失败回滚和状态竞态保护。 12. 增加进入失败回滚和状态竞态保护。
13. 将轻待机任务栈增至 4096 字节,解决已观察到的任务栈溢出。 13. 将轻待机任务栈增至 4096 字节,解决已观察到的任务栈溢出。
@@ -682,7 +631,6 @@ esp_err_t control_plane_light_standby_record_activity(
| 麦克风 | 保持运行,以满足唤醒词快速唤醒要求 | | 麦克风 | 保持运行,以满足唤醒词快速唤醒要求 |
| 电机 | 待机不保持静态扭矩,机构可能被外力移动 | | 电机 | 待机不保持静态扭矩,机构可能被外力移动 |
| Wi-Fi | 保持连接,但首包延迟可能增加 | | Wi-Fi | 保持连接,但首包延迟可能增加 |
| 远程日志 | 继续采集和落盘,服务端接收会延迟到唤醒后 |
| 电池检测 | 普通电量更新变慢,充电状态边沿仍及时检测 | | 电池检测 | 普通电量更新变慢,充电状态边沿仍及时检测 |
| 内存监控 | 普通水位报告变慢,分配失败仍及时处理 | | 内存监控 | 普通水位报告变慢,分配失败仍及时处理 |
| 提示音 | 真正进入待机的时间会比 5 分钟多出提示音播放时间 | | 提示音 | 真正进入待机的时间会比 5 分钟多出提示音播放时间 |
@@ -702,6 +650,6 @@ esp_err_t control_plane_light_standby_record_activity(
## 10. 结论 ## 10. 结论
本次改动已经完成当前产品约束下的主要轻待机优化:保留基础表情、唤醒词和按键响应,同时降低背光、功放、电机、Wi-Fi、远程日志、电池检测和内存监控的持续功耗。实现具备统一状态管理、真实音频完成等待、进入失败回滚和快速唤醒恢复。 本次改动已经完成当前产品约束下的主要轻待机优化:保留基础表情、唤醒词和按键响应,同时降低背光、功放、电机、Wi-Fi、电池检测和内存监控的持续功耗。实现具备统一状态管理、真实音频完成等待、进入失败回滚和快速唤醒恢复。
下一步重点不是继续盲目关闭模块,而是进行真机电流和唤醒延迟测试。测试数据可以确认主要耗电来源,并判断 LCD 刷新和音频下行链路是否值得进入下一轮优化。 下一步重点不是继续盲目关闭模块,而是进行真机电流和唤醒延迟测试。测试数据可以确认主要耗电来源,并判断 LCD 刷新和音频下行链路是否值得进入下一轮优化。
-96
View File
@@ -439,20 +439,6 @@ menu "TQ Cat Configuration"
Keep the STA and WebSocket connected while enabling minimum Keep the STA and WebSocket connected while enabling minimum
modem sleep. Active mode restores the configured Wi-Fi policy. modem sleep. Active mode restores the configured Wi-Fi policy.
config TQ_LIGHT_STANDBY_PAUSE_REMOTE_LOG_UPLOAD
bool "Pause remote log uploads in light standby"
default y
depends on TQ_LIGHT_STANDBY_ENABLE && TQ_REMOTE_LOG_ENABLE
help
Continue capturing and persisting logs, but defer HTTP
uploads until activity wakes the device.
config TQ_LIGHT_STANDBY_REMOTE_LOG_POLL_MS
int "Remote log standby poll interval (ms)"
default 5000
range 1000 60000
depends on TQ_LIGHT_STANDBY_PAUSE_REMOTE_LOG_UPLOAD
config TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC config TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC
int "Battery standby sample interval (seconds)" int "Battery standby sample interval (seconds)"
default 300 default 300
@@ -487,88 +473,6 @@ menu "TQ Cat Configuration"
endmenu endmenu
menu "Remote Logging"
config TQ_REMOTE_LOG_ENABLE
bool "Enable remote log upload"
default n
help
Capture ESP-IDF logs, persist them to the LittleFS-backed
queue, and upload batches to the configured HTTPS server.
config TQ_REMOTE_LOG_ENDPOINT
string "Remote log endpoint URL"
default "https://gz.api.talkingq.com/log/batch"
depends on TQ_REMOTE_LOG_ENABLE
config TQ_REMOTE_LOG_AUTH_TOKEN
string "Remote log bearer token"
default ""
depends on TQ_REMOTE_LOG_ENABLE
help
Optional bearer token sent as Authorization: Bearer
<token>. Leave empty for unauthenticated test receivers.
config TQ_REMOTE_LOG_MIN_LEVEL
int "Minimum remote log level"
default 2
range 0 4
depends on TQ_REMOTE_LOG_ENABLE
help
0=NONE, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG.
config TQ_REMOTE_LOG_PRESERVE_LOCAL_OUTPUT
bool "Preserve existing local log output"
default y
depends on TQ_REMOTE_LOG_ENABLE
help
Keep the original ESP-IDF vprintf output while also
enqueueing logs for server upload.
config TQ_REMOTE_LOG_QUEUE_LEN
int "Remote log memory queue length"
default 128
range 16 512
depends on TQ_REMOTE_LOG_ENABLE
config TQ_REMOTE_LOG_ENTRY_MAX_BYTES
int "Remote log entry max bytes"
default 256
range 128 1024
depends on TQ_REMOTE_LOG_ENABLE
config TQ_REMOTE_LOG_BATCH_MAX_ENTRIES
int "Remote log batch max entries"
default 30
range 5 100
depends on TQ_REMOTE_LOG_ENABLE
config TQ_REMOTE_LOG_FLUSH_INTERVAL_MS
int "Remote log flush interval"
default 5000
range 1000 60000
depends on TQ_REMOTE_LOG_ENABLE
config TQ_REMOTE_LOG_UPLOAD_TIMEOUT_MS
int "Remote log upload timeout"
default 8000
range 3000 30000
depends on TQ_REMOTE_LOG_ENABLE
config TQ_REMOTE_LOG_MAX_FILES
int "Remote log persisted file count"
default 32
range 4 128
depends on TQ_REMOTE_LOG_ENABLE
config TQ_REMOTE_LOG_FILE_MAX_BYTES
int "Remote log persisted file max bytes"
default 16384
range 4096 65536
depends on TQ_REMOTE_LOG_ENABLE
endmenu
menu "Buttons & Input" menu "Buttons & Input"
config TQ_KEY_INPUT_ENABLE config TQ_KEY_INPUT_ENABLE
-539
View File
@@ -1,539 +0,0 @@
# TQ_catN 远程日志系统设计方案
**版本**:v1.0(待审核)
**日期**:2026-06-27
**范围**:全设备日志收集、本地缓冲、网络上传
---
## 一、设计目标
| 目标 | 说明 |
|------|------|
| **可靠性** | 网络抖动或断开时,日志不丢失,具备本地持久化和重传能力 |
| **低开销** | 对实时语音交互(PTT/WS/audio)零干扰,CPU 占用 < 5%,内存占用 < 256KB |
| **分层合规** | 严格遵循现有四层单向依赖架构(platform → domain → control_plane → app_composition) |
| **可配置** | 支持按模块/级别动态开关,支持生产环境静默/调试环境全量 |
| **安全性** | 日志上传使用 HTTPS + device JWT 认证,敏感字段自动脱敏 |
---
## 二、架构设计
### 2.1 与现有分层架构的集成
```
app_composition
└── 初始化 remote_log_runtime(在 boot sequence 中启动)
control_plane
└── remote_log_policy.c # 策略:上传触发条件、级别控制、模块过滤
└── remote_log_config.h # 模块/级别开关配置
domain
└── remote_log_format.c # 日志格式化:JSON 序列化、时间戳、device_id 注入
└── remote_log_compress.c # 可选:压缩(miniz/lz4)
platform
└── remote_log_buffer.c # 双缓冲:内存 ring buffer + 文件队列(LittleFS)
└── remote_log_transport.c # 网络传输:复用 platform_https_post_json
└── remote_log_hook.c # ESP-IDF log hook:esp_log_set_vprintf 重定向
```
**依赖方向**:
- platform 层提供底层能力(buffer、transport、hook)
- domain 层提供格式化/压缩(无状态转换)
- control_plane 层提供策略(何时上传、上传什么)
- app_composition 层负责初始化编排
**禁止**:control_plane / domain 直接调用 esp_log_set_vprintf 或 esp_http_client API,必须通过 platform facade。
---
### 2.2 数据流
```
[App Code: ESP_LOGI/ESP_LOGE]
│
▼
[platform/remote_log_hook.c] ──→ 拦截 ESP-IDF 日志输出
│ (原始串口输出保留,不影响调试)
▼
[platform/remote_log_buffer.c]
├── 内存环形缓冲区(热路径,ISR/任务安全)
│ └── 容量:64KB(PSRAM 优先,内部 RAM 回退)
│
└── 文件队列(冷路径,网络断开时持久化)
└── 容量:512KB(LittleFS gifpack 分区空闲空间)
└── 最大文件数:32 个,每个 16KB
└── 命名:log_batch_001.jsonl ~ log_batch_032.jsonl
│
▼(满足上传条件时)
[domain/remote_log_format.c]
└── 封装为 JSON batch:
{
"device_id": "66c...",
"batch_id": "uuid",
"timestamp_ms": 1782552243000,
"logs": [
{"ts": 1782552243001, "level": "E", "tag": "cp_mm_session", "msg": "..."},
...
]
}
│
▼
[domain/remote_log_compress.c] ──→ 可选:miniz 压缩(压缩率 ~60%)
│
▼
[platform/remote_log_transport.c]
└── 复用 platform_https_post_json() → 服务端
POST https://gz.api.talkingq.com/log/batch
Content-Type: application/json
Authorization: Bearer <device_token>
```
---
## 三、核心模块设计
### 3.1 日志拦截层(platform/remote_log_hook.c)
**方案**:使用 ESP-IDF 的 `esp_log_set_vprintf()` 钩子,在保留原有串口输出的同时,将日志复制到远程日志缓冲区。
```c
// 接口设计
void platform_remote_log_hook_install(void);
void platform_remote_log_hook_uninstall(void);
// 内部实现
static int remote_log_vprintf(const char *fmt, va_list args)
{
// 1. 调用原始 vprintf,保持串口输出
int ret = s_original_vprintf(fmt, args);
// 2. 格式化为结构化记录,写入 ring buffer
// 使用非阻塞写入,buffer 满时丢弃最旧记录
platform_remote_log_buffer_write(fmt, args);
return ret;
}
```
**关键约束**:
- `remote_log_vprintf` 可能在 ISR 上下文被调用(如 `ESP_EARLY_LOG`),因此 `platform_remote_log_buffer_write` 内部必须:
- 使用 `xRingbufferSendFromISR` 或自旋锁保护的环形缓冲区
- 禁止任何可能阻塞的操作(malloc、semaphore take 等)
- 如果检测到 ISR 上下文,只做**原子计数 + 标记**,由后台任务在退出 ISR 后批量拷贝
**日志级别过滤**:在 hook 层不做级别过滤(避免重复判断),过滤由 control_plane 策略层通过 Kconfig 宏控制编译期开关。
---
### 3.2 双缓冲层(platform/remote_log_buffer.c)
#### 3.2.1 内存环形缓冲区(热路径)
```c
#define REMOTE_LOG_RING_BUFFER_SIZE (64 * 1024) // 64KB
#define REMOTE_LOG_RING_BLOCK_SIZE 256 // 每条日志最大 256 字节(超长截断)
static uint8_t s_ring_buffer[REMOTE_LOG_RING_BUFFER_SIZE]; // PSRAM 优先分配
static volatile size_t s_ring_write_idx;
static volatile size_t s_ring_read_idx;
```
**设计**:
- 使用简单的无锁环形缓冲区(单生产者:hook ISR/任务;单消费者:后台 flush 任务)
- 写入:原子递增 write_idx,memcpy 数据
- 读取:后台任务定期(100ms)将数据从 ring buffer 搬到文件队列
- 满策略:丢弃最旧记录(覆盖 read_idx 指向的数据),避免阻塞生产者
#### 3.2.2 文件队列(冷路径 / 持久化)
```c
#define REMOTE_LOG_FILE_MAX_BATCHES 32
#define REMOTE_LOG_FILE_BATCH_SIZE (16 * 1024) // 16KB / 文件
```
**设计**:
- 使用 LittleFS 的 `gifpack` 分区空闲空间(当前分区 8MB,OTA 后仍有富余)
- 文件名:`/littlefs/log/batch_001.jsonl` ~ `/littlefs/log/batch_032.jsonl`
- 写入格式:JSON Lines(每行一条 JSON,便于追加和断点续传)
- 循环覆盖:当 32 个文件都写满后,覆盖最早的文件
**掉电保护**:
- 每个 batch 文件写完后调用 `fsync`,确保数据落盘
- 启动时扫描 `/littlefs/log/` 目录,恢复未上传的 batch
---
### 3.3 格式化层(domain/remote_log_format.c)
**输入**:原始 ESP-IDF 日志字符串(如 `I (1934379) cp_mm_session: Transport unavailable`)
**输出**:结构化 JSON batch
```c
typedef struct {
int64_t timestamp_ms; // SNTP 同步后的毫秒时间戳
char level; // 'V'/'D'/'I'/'W'/'E'
char tag[32]; // 模块 TAG
char message[192]; // 日志内容(超长截断)
} remote_log_entry_t;
```
**批处理策略**:
- 每批最多 50 条日志 或 每批最多 8KB 数据(先到为准)
- 超时机制:即使不满 50 条,最多等待 5 秒后强制上传
- 压缩:使用 miniz(zlib 兼容,纯 C,~8KB 代码)压缩 JSON batch,减少流量 50-70%
**脱敏规则**:
- 自动匹配并替换:`password=xxx`、`Bearer xxx`、`token=xxx`
- 使用正则或简单字符串扫描:`s/([Pp]assword|[Tt]oken|[Bb]earer)[=:]\S+/\1=***REDACTED***/g`
---
### 3.4 传输层(platform/remote_log_transport.c)
**复用现有能力**:
- HTTP 客户端:复用 `platform_https_post_json()`(已有 TLS、DNS、超时处理)
- 认证:复用 device JWT token(已有 `control_plane_multimodal_auth_service`)
**上传策略**:
```c
typedef struct {
const char *endpoint_url; // 如 "https://gz.api.talkingq.com/log/batch"
uint32_t batch_max_count; // 50
uint32_t batch_max_size_bytes; // 8192
uint32_t flush_interval_ms; // 5000
uint32_t upload_timeout_ms; // 10000
uint32_t max_retry_count; // 3
uint32_t retry_backoff_ms; // 2000
} remote_log_transport_config_t;
```
**上传流程**:
1. 检查 Wi-Fi 是否连接(`control_plane_is_sta_connected()`)
2. 检查 device token 是否有效(复用 auth service)
3. 读取一批日志(优先内存 buffer,其次文件队列)
4. 封装 JSON batch + device_id + batch_id
5. 调用 `platform_https_post_json()` 上传
6. 上传成功:删除已上传的文件 batch / 释放内存 buffer
7. 上传失败(非 401):按指数退避重试(2s / 4s / 8s),失败后回写文件队列
8. 上传失败(401):触发 device token 刷新,刷新成功后重试
**网络降级**:
- WS 连接活跃时(用户正在对话):暂停日志上传,避免干扰实时音频/WS 流量
- Wi-Fi 断开时:完全停止上传,仅写入文件队列
- 内存 buffer 满且文件队列满时:丢弃最旧记录(采样策略:优先保留 Error/Warning)
---
### 3.5 策略层(control_plane/remote_log_policy.c)
**职责**:控制"上传什么、何时上传、上传多少"
```c
typedef enum {
REMOTE_LOG_LEVEL_NONE = 0,
REMOTE_LOG_LEVEL_ERROR = 1, // 仅 Error
REMOTE_LOG_LEVEL_WARN = 2, // Error + Warning
REMOTE_LOG_LEVEL_INFO = 3, // Error + Warning + Info
REMOTE_LOG_LEVEL_DEBUG = 4, // 全部
} remote_log_level_t;
// 模块级别开关(与现有 TAG 对齐)
typedef struct {
bool cp_mm_session; // control_plane multimodal session
bool cp_mm_runtime; // control_plane multimodal runtime
bool cp_workers; // control_plane runtime workers
bool cp_connectivity; // control_plane connectivity
bool transport_ws; // platform transport ws
bool audio_pipeline; // platform audio pipeline
bool direction; // domain direction
bool fortune; // domain fortune
// ... 其他模块
} remote_log_module_mask_t;
```
**策略规则**:
- 生产环境默认:`LEVEL_ERROR`,仅关键模块开启
- 调试环境默认:`LEVEL_DEBUG`,全部模块开启
- 动态调整:通过 LAN HTTP 配置页面(复用 `domain_user_config_server`)下发策略变更
---
## 四、Kconfig 配置项
建议在 `main/Kconfig.projbuild` 中新增菜单:
```kconfig
menu "Remote Logging"
config TQ_REMOTE_LOG_ENABLED
bool "Enable remote logging"
default n
help
Enable log collection and remote upload. Increases flash
usage by ~24KB and RAM usage by ~80KB.
config TQ_REMOTE_LOG_LEVEL
int "Default log level for remote upload"
default 1
range 0 4
help
0=NONE, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG
config TQ_REMOTE_LOG_RING_BUFFER_SIZE
int "Memory ring buffer size (KB)"
default 64
range 8 256
config TQ_REMOTE_LOG_FILE_BATCHES
int "Max file batches on LittleFS"
default 32
range 8 64
config TQ_REMOTE_LOG_BATCH_MAX_COUNT
int "Max log entries per batch"
default 50
range 10 200
config TQ_REMOTE_LOG_FLUSH_INTERVAL_MS
int "Flush interval (ms)"
default 5000
range 1000 30000
config TQ_REMOTE_LOG_UPLOAD_TIMEOUT_MS
int "Upload HTTP timeout (ms)"
default 10000
range 3000 30000
config TQ_REMOTE_LOG_COMPRESS
bool "Enable miniz compression"
default y
help
Compress log batches before upload. Reduces data usage
by ~60% at the cost of ~8KB flash and ~2KB RAM.
config TQ_REMOTE_LOG_ENDPOINT
string "Log upload endpoint URL"
default "https://gz.api.talkingq.com/log/batch"
help
HTTPS endpoint for log batch upload.
endmenu
```
---
## 五、内存和性能预算
### 5.1 Flash 占用
| 组件 | 代码+数据 | 说明 |
|------|----------|------|
| remote_log_hook.c | ~4KB | 钩子 + ring buffer 管理 |
| remote_log_buffer.c | ~6KB | 双缓冲 + LittleFS 文件操作 |
| remote_log_transport.c | ~4KB | HTTP 上传 + 重试逻辑 |
| remote_log_format.c | ~6KB | JSON 序列化 + 脱敏 |
| remote_log_compress.c | ~8KB | miniz 压缩库 |
| remote_log_policy.c | ~2KB | 策略判断 + 模块过滤 |
| **合计** | **~30KB** | 可配置关闭压缩节省 8KB |
### 5.2 RAM 占用(运行时)
| 组件 | 内存 | 位置 |
|------|------|------|
| Ring buffer | 64KB | PSRAM(配置为 PSRAM 优先) |
| Upload working buffer | 8KB | PSRAM |
| HTTP client(复用现有) | ~4KB | 内部 RAM(复用已有实例) |
| 格式化/压缩临时区 | ~4KB | PSRAM |
| **合计** | **~80KB** | 其中内部 RAM < 10KB |
### 5.3 CPU 占用
| 场景 | CPU 占用 |
|------|----------|
| 日志写入(hook) | < 1%(纯内存拷贝) |
| 定时 flush(100ms) | < 2%(文件写入) |
| 上传(每 5s 一次) | < 5%(JSON 格式化 + HTTP POST) |
| 用户对话期间(WS 活跃) | 0%(暂停上传) |
---
## 六、启动时序
在 `app_composition_default_runtime.c` 的 boot sequence 中,在 `control_plane_init()` 之后、`wifi_bootstrap` 之前插入:
```c
// 步骤 7.5:初始化远程日志系统
esp_err_t log_err = control_plane_init_remote_logging();
if (log_err != ESP_OK) {
ESP_LOGW(TAG, "Remote logging init failed: %s", esp_err_to_name(log_err));
// 非致命错误,继续启动
}
```
**理由**:
- 在 `control_plane_init()` 之后:确保 device auth 已加载(用于上传认证)
- 在 `wifi_bootstrap` 之前:确保 Wi-Fi 连接建立后日志系统已就绪,可以立即开始收集
---
## 七、服务端接口契约
### 7.1 上传接口
```http
POST /log/batch
Content-Type: application/json
Authorization: Bearer <device_jwt_token>
{
"device_id": "66c7f8a2e4b0d1e2f3a4b5c6",
"batch_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"timestamp_ms": 1782552243000,
"firmware_version": "1.2.3",
"logs": [
{
"ts": 1782552243001,
"level": "E",
"tag": "transport_ws",
"msg": "Sec-WebSocket-Accept not found"
},
{
"ts": 1782552243010,
"level": "W",
"tag": "cp_mm_session",
"msg": "Transport unavailable: reason=ws_error"
}
]
}
```
### 7.2 服务端响应
```http
HTTP/1.1 200 OK
Content-Type: application/json
{
"code": 200,
"message": "OK",
"data": {
"accepted": 2,
"dropped": 0
}
}
```
### 7.3 错误码
| HTTP 状态码 | 含义 | 客户端行为 |
|------------|------|-----------|
| 200 | 成功 | 删除已上传的 batch |
| 400 | 格式错误 | 丢弃当前 batch(避免无限重试) |
| 401 | 认证失败 | 刷新 device JWT 后重试 |
| 413 | Payload 过大 | 减小 batch size 后重试 |
| 429 | 频率限制 | 指数退避后重试 |
| 500+ | 服务端错误 | 指数退避后重试 |
---
## 八、安全与隐私
### 8.1 认证
- 复用现有 device JWT 认证体系,不引入新密钥
- token 过期时自动刷新(复用 `control_plane_multimodal_auth_service`)
### 8.2 传输
- 强制 HTTPS,复用现有 TLS 证书校验(`esp-x509-crt-bundle`)
- 禁止 HTTP 明文传输
### 8.3 脱敏
- 自动脱敏:password、token、Bearer、secret、api_key 等前缀后的值
- 用户配置:支持通过 LAN HTTP 配置页面自定义脱敏关键词
### 8.4 数据保留
- 本地:最多保留 32 个 batch 文件(~512KB),循环覆盖
- 服务端:建议保留 30 天,过期自动清理
---
## 九、故障排查与调试
### 9.1 本地诊断命令
通过现有的 factory CLI 或 LAN HTTP 配置页面,增加以下诊断接口:
```
GET /api/diag/log/status
→ {
"enabled": true,
"level": 2,
"ring_buffer_used": 32768,
"ring_buffer_total": 65536,
"file_batches_pending": 5,
"last_upload_status": "success",
"last_upload_time": "2026-06-27T10:30:00Z"
}
```
### 9.2 本地日志导出
```
GET /api/diag/log/download?batch=latest
→ 返回最近一个 batch 文件的内容(纯文本)
```
---
## 十、实施建议
### Phase 1(最小可用)
1. `platform/remote_log_hook.c` — 拦截 ESP_LOG 到 ring buffer
2. `platform/remote_log_buffer.c` — 内存缓冲 + 文件持久化
3. `platform/remote_log_transport.c` — 复用 HTTPS 上传
4. `control_plane/remote_log_policy.c` — 级别过滤 + 模块开关
5. Kconfig 配置项 + boot sequence 初始化
### Phase 2(增强)
6. `domain/remote_log_format.c` — JSON 格式化 + 脱敏
7. `domain/remote_log_compress.c` — miniz 压缩
8. LAN HTTP 配置页面集成(动态调整级别)
9. 诊断接口(`/api/diag/log/status`)
### Phase 3(优化)
10. 服务端实时告警(Error 级别日志触发服务端告警)
11. 日志采样(高频 Debug 日志按概率采样)
12. 日志关联(与 device session_id 关联,便于追踪单设备全链路)
---
## 十一、风险评估
| 风险 | 缓解措施 |
|------|----------|
| 日志写入拖慢 ISR | hook 层使用纯内存拷贝 + 无锁 ring buffer,禁止 malloc |
| 文件写入磨损 Flash | LittleFS 磨损均衡,日志 batch 文件大小控制在 16KB |
| 上传流量过大 | 压缩(-60%)+ 级别过滤(仅 ERROR/WARN)+ 批量上传(5s 间隔) |
| 上传干扰 WS 音频 | WS 活跃时暂停上传,仅写入本地 buffer |
| 敏感信息泄露 | 自动脱敏 + HTTPS + 服务端 ACL |
| 内存不足 | PSRAM 优先分配,内部 RAM 占用 < 10KB |
---
## 十二、总结
本方案基于现有四层架构,以 **"零侵入、低开销、高可靠"** 为核心原则:
- **platform 层**:提供日志拦截、双缓冲、网络传输的底层能力
- **domain 层**:提供 JSON 格式化、压缩、脱敏的业务处理
- **control_plane 层**:提供级别控制、模块过滤、上传策略的策略决策
- **app_composition 层**:负责启动编排
关键设计决策:
1. 复用 `platform_https_post_json()`,不引入新 HTTP 客户端
2. 复用 device JWT,不引入新认证体系
3. 使用 `esp_log_set_vprintf()` 拦截,不修改现有 ESP_LOGx 调用
4. 使用 LittleFS 持久化,不引入新文件系统
5. 用户对话期间暂停上传,保证实时语音不受干扰
-14
View File
@@ -63,25 +63,11 @@ CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE="standby_5min.opus"
CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT=y CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT=y
CONFIG_TQ_LIGHT_STANDBY_RELEASE_MOTOR_HOLD=y CONFIG_TQ_LIGHT_STANDBY_RELEASE_MOTOR_HOLD=y
CONFIG_TQ_LIGHT_STANDBY_WIFI_POWER_SAVE=y CONFIG_TQ_LIGHT_STANDBY_WIFI_POWER_SAVE=y
CONFIG_TQ_LIGHT_STANDBY_PAUSE_REMOTE_LOG_UPLOAD=y
CONFIG_TQ_LIGHT_STANDBY_REMOTE_LOG_POLL_MS=5000
CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC=300 CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC=300
CONFIG_TQ_LIGHT_STANDBY_MEMORY_SAMPLE_INTERVAL_MS=10000 CONFIG_TQ_LIGHT_STANDBY_MEMORY_SAMPLE_INTERVAL_MS=10000
CONFIG_TQ_LIGHT_STANDBY_MEMORY_REPORT_INTERVAL_MS=300000 CONFIG_TQ_LIGHT_STANDBY_MEMORY_REPORT_INTERVAL_MS=300000
CONFIG_TQ_LIGHT_STANDBY_TASK_STACK_BYTES=4096 CONFIG_TQ_LIGHT_STANDBY_TASK_STACK_BYTES=4096
CONFIG_TQ_LIGHT_STANDBY_TASK_PRIORITY=3 CONFIG_TQ_LIGHT_STANDBY_TASK_PRIORITY=3
CONFIG_TQ_REMOTE_LOG_ENABLE=y
CONFIG_TQ_REMOTE_LOG_ENDPOINT="http://47.98.175.183:8090/log/batch"
CONFIG_TQ_REMOTE_LOG_AUTH_TOKEN=""
CONFIG_TQ_REMOTE_LOG_MIN_LEVEL=2
CONFIG_TQ_REMOTE_LOG_PRESERVE_LOCAL_OUTPUT=y
CONFIG_TQ_REMOTE_LOG_QUEUE_LEN=128
CONFIG_TQ_REMOTE_LOG_ENTRY_MAX_BYTES=256
CONFIG_TQ_REMOTE_LOG_BATCH_MAX_ENTRIES=30
CONFIG_TQ_REMOTE_LOG_FLUSH_INTERVAL_MS=5000
CONFIG_TQ_REMOTE_LOG_UPLOAD_TIMEOUT_MS=8000
CONFIG_TQ_REMOTE_LOG_MAX_FILES=12
CONFIG_TQ_REMOTE_LOG_FILE_MAX_BYTES=16384
CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y
# CONFIG_ESP_COREDUMP_ENABLE_TO_UART is not set # CONFIG_ESP_COREDUMP_ENABLE_TO_UART is not set
# CONFIG_ESP_COREDUMP_ENABLE_TO_NONE is not set # CONFIG_ESP_COREDUMP_ENABLE_TO_NONE is not set
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""Minimal HTTP receiver for TQ remote log batches.
Usage:
python tools/remote_log_receiver.py --host 0.0.0.0 --port 8080 --dir ./remote_logs
Configure the device endpoint as:
http://<server-ip>:8080/log/batch
For production, put this behind HTTPS or a reverse proxy that terminates TLS.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
class LogReceiver(BaseHTTPRequestHandler):
server_version = "TQRemoteLogReceiver/1.0"
def do_POST(self) -> None:
if self.path != "/log/batch":
self._send_json(404, {"code": 404, "message": "not found"})
return
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > self.server.max_body_bytes:
self._send_json(413, {"code": 413, "message": "payload too large"})
return
raw = self.rfile.read(length)
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
self._send_json(400, {"code": 400, "message": f"invalid json: {exc}"})
return
now = dt.datetime.now(dt.timezone.utc)
day_dir = self.server.output_dir / now.strftime("%Y%m%d")
day_dir.mkdir(parents=True, exist_ok=True)
device_id = str(payload.get("device_id") or "unknown").replace("/", "_")
batch_id = str(payload.get("batch_id") or now.strftime("%H%M%S%f")).replace("/", "_")
json_path = day_dir / f"{device_id}_{batch_id}.json"
jsonl_path = day_dir / f"{device_id}.jsonl"
with json_path.open("w", encoding="utf-8") as file:
json.dump(payload, file, ensure_ascii=False, indent=2)
file.write("\n")
entries = payload.get("entries") or []
with jsonl_path.open("a", encoding="utf-8") as file:
for entry in entries:
row = {
"received_at": now.isoformat(),
"device_id": payload.get("device_id"),
"serial_number": payload.get("serial_number"),
"batch_id": payload.get("batch_id"),
**entry,
}
file.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
file.write("\n")
self._send_json(
200,
{
"code": 200,
"message": "OK",
"data": {
"accepted": len(entries),
"stored": str(json_path),
},
},
)
def log_message(self, fmt: str, *args: object) -> None:
print(f"{self.address_string()} - {fmt % args}")
def _send_json(self, status: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
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)
class LogServer(ThreadingHTTPServer):
def __init__(self, server_address: tuple[str, int], handler_class, output_dir: Path, max_body_bytes: int):
super().__init__(server_address, handler_class)
self.output_dir = output_dir
self.max_body_bytes = max_body_bytes
def main() -> None:
parser = argparse.ArgumentParser(description="Receive TQ device remote log batches.")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--dir", type=Path, default=Path("remote_logs"))
parser.add_argument("--max-body-bytes", type=int, default=256 * 1024)
args = parser.parse_args()
args.dir.mkdir(parents=True, exist_ok=True)
server = LogServer((args.host, args.port), LogReceiver, args.dir, args.max_body_bytes)
print(f"listening on http://{args.host}:{args.port}/log/batch")
print(f"writing logs under {args.dir.resolve()}")
server.serve_forever()
if __name__ == "__main__":
main()
-198
View File
@@ -1,198 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
LOGFS_OFFSET="0x210000"
LOGFS_SIZE="0x40000"
LOGFS_BLOCK_SIZE="4096"
pause_before_exit() {
if [[ -t 0 ]]; then
printf '\n按回车键关闭窗口...'
read -r _
fi
}
fail() {
printf '\n错误:%s\n' "$1" >&2
pause_before_exit
exit 1
}
find_serial_port() {
local requested_port="${1:-}"
if [[ -n "$requested_port" ]]; then
[[ -e "$requested_port" ]] || fail "串口不存在:$requested_port"
printf '%s' "$requested_port"
return
fi
local ports=()
local candidate
for candidate in /dev/cu.usbmodem* /dev/cu.usbserial* \
/dev/cu.SLAB_USBtoUART* /dev/cu.wchusbserial*; do
[[ -e "$candidate" ]] && ports+=("$candidate")
done
if [[ ${#ports[@]} -eq 0 ]]; then
fail "没有检测到设备串口。请连接设备后重试。"
fi
if [[ ${#ports[@]} -eq 1 ]]; then
printf '%s' "${ports[0]}"
return
fi
printf '检测到多个串口:\n' >&2
local index
for ((index = 0; index < ${#ports[@]}; index++)); do
printf ' %d) %s\n' "$((index + 1))" "${ports[index]}" >&2
done
printf '请选择序号:' >&2
local selection
read -r selection
[[ "$selection" =~ ^[0-9]+$ ]] || fail "输入的序号无效。"
((selection >= 1 && selection <= ${#ports[@]})) || fail "输入的序号超出范围。"
printf '%s' "${ports[selection - 1]}"
}
find_build_tool() {
local relative_path="$1"
local candidate="$PROJECT_DIR/$relative_path"
if [[ -x "$candidate" ]]; then
printf '%s' "$candidate"
return
fi
return 1
}
find_idf_value() {
local key="$1"
local cache="$PROJECT_DIR/build/CMakeCache.txt"
[[ -f "$cache" ]] || return 1
sed -n "s|^${key}:[^=]*=||p" "$cache" | head -n 1
}
find_idf_python() {
local candidate
candidate="$(find_idf_value PYTHON || true)"
if [[ -n "$candidate" && -x "$candidate" ]]; then
printf '%s' "$candidate"
return
fi
for candidate in "$HOME"/.espressif/tools/python/*/venv/bin/python; do
if [[ -x "$candidate" ]]; then
printf '%s' "$candidate"
return
fi
done
return 1
}
find_esptool() {
local idf_path
idf_path="$(find_idf_value esp-idf_SOURCE_DIR || true)"
local candidate="$idf_path/components/esptool_py/esptool/esptool.py"
if [[ -n "$idf_path" && -f "$candidate" ]]; then
printf '%s' "$candidate"
return
fi
for candidate in "$HOME"/.espressif/v*/esp-idf/components/esptool_py/esptool/esptool.py; do
if [[ -f "$candidate" ]]; then
printf '%s' "$candidate"
return
fi
done
return 1
}
PORT="$(find_serial_port "${1:-}")"
IDF_PYTHON="$(find_idf_python || true)"
ESPTOOL="$(find_esptool || true)"
LITTLEFS_TOOL="$(find_build_tool build/littlefs_py_venv/bin/littlefs-python || true)"
[[ -n "$IDF_PYTHON" ]] || fail "找不到 ESP-IDF Python 环境,请先成功构建一次工程。"
[[ -n "$ESPTOOL" ]] || fail "找不到 esptool.py,请检查 ESP-IDF 是否已安装。"
[[ -n "$LITTLEFS_TOOL" ]] || fail "找不到 LittleFS 解包工具,请先成功构建一次工程。"
TIMESTAMP="$(date '+%Y%m%d-%H%M%S')"
OUTPUT_DIR="$PROJECT_DIR/flash_log_dump/$TIMESTAMP"
IMAGE_PATH="$OUTPUT_DIR/logfs.bin"
EXTRACT_DIR="$OUTPUT_DIR/extracted"
REPORT_PATH="$OUTPUT_DIR/remote_logs.txt"
mkdir -p "$EXTRACT_DIR"
printf '设备串口:%s\n' "$PORT"
printf '输出目录:%s\n' "$OUTPUT_DIR"
printf '\n注意:请先关闭正在使用该串口的 monitor。\n'
printf '读取 Flash 会让设备进入下载模式,完成后设备会自动重启。\n'
printf '正在读取 logfs 分区,请勿拔线...\n\n'
if ! "$IDF_PYTHON" "$ESPTOOL" \
--chip esp32s3 \
--port "$PORT" \
--baud 460800 \
--before default_reset \
--after hard_reset \
read_flash "$LOGFS_OFFSET" "$LOGFS_SIZE" "$IMAGE_PATH"; then
fail "读取 Flash 失败。请关闭 monitor、检查 USB 连接后重试。"
fi
printf '\n正在解包 LittleFS...\n'
if ! "$LITTLEFS_TOOL" extract \
--block-size "$LOGFS_BLOCK_SIZE" \
"$IMAGE_PATH" "$EXTRACT_DIR"; then
fail "LittleFS 解包失败,请确认设备使用的是当前 16m.csv 分区表。"
fi
if ! "$IDF_PYTHON" - "$EXTRACT_DIR" "$REPORT_PATH" <<'PY'
import json
import sys
from pathlib import Path
extract_dir = Path(sys.argv[1])
report_path = Path(sys.argv[2])
log_dir = extract_dir / "remote_logs"
log_files = sorted(log_dir.glob("log_*.jsonl")) if log_dir.is_dir() else []
with report_path.open("w", encoding="utf-8") as report:
report.write("TalkingQ cached remote logs\n")
report.write(f"Source: {log_dir}\n")
report.write(f"Files: {len(log_files)}\n\n")
if not log_files:
report.write("No pending remote log files were found.\n")
for log_file in log_files:
report.write(f"===== {log_file.name} =====\n")
with log_file.open("r", encoding="utf-8", errors="replace") as source:
for raw_line in source:
line = raw_line.strip()
if not line:
continue
try:
entry = json.loads(line)
timestamp = entry.get("ts_ms", "?")
level = entry.get("level", "?")
truncated = " [truncated]" if entry.get("truncated") else ""
message = entry.get("message", "")
report.write(f"[{timestamp:>10} ms] {level}{truncated} {message}\n")
except (json.JSONDecodeError, TypeError, ValueError):
report.write(f"[invalid JSON] {line}\n")
report.write("\n")
PY
then
fail "生成可读日志文件失败。"
fi
printf '\n读取完成:%s\n' "$REPORT_PATH"
if command -v open >/dev/null 2>&1; then
open "$REPORT_PATH"
else
printf '当前系统无法自动打开文件,请手动查看上述路径。\n'
fi
pause_before_exit