diff --git a/changelog.md b/changelog.md index bf4a3ec..e6ff51c 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,14 @@ ### 新增 - 国内大模型厂商接入支持(硅基流动、火山方舟)— 计划中 +- **时区设置功能** + - 默认时区改为 `CST-8`(中国标准时间 UTC+8) + - 新增 `set_timezone` CLI 命令(支持 POSIX 格式和城市名) + - 新增 `timezone_show` CLI 命令 + - 新增 `set_timezone` LLM 工具(可通过对话设置时区) + - 时区通过 NVS 持久化存储(`system_config` namespace) + - 支持城市名映射(Asia/Shanghai → CST-8 等 18 个预设城市) + - `config_show` 中显示当前时区配置 ### 修复 - ESP-IDF v6.0 编译适配 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b8b399a..2c191ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -57,9 +57,17 @@ Telegram App (User) │ Anthropic Messages API (HTTPS) │ + Brave Search API (HTTPS) ▼ - ┌───────────┐ ┌──────────────┐ - │ Claude API │ │ Brave Search │ - └───────────┘ └──────────────┘ + ┌───────────┐ ┌──────────────┐ ┌──────────────┐ + │ Claude API │ │ Brave Search │ │ Tavily Search│ + └───────────┘ └──────────────┘ └──────────────┘ + │ + ┌───────────┐ ┌──────────────┐ + │ OpenAI API │ │ SiliconFlow │ + └───────────┘ └──────────────┘ + │ + ┌───────────┐ ┌──────────────┐ + │ Volcengine │ │ Feishu Bot │ + └───────────┘ └──────────────┘ ``` --- @@ -106,15 +114,21 @@ main/ │ ├── wifi/ │ ├── wifi_manager.h WiFi STA lifecycle API -│ └── wifi_manager.c Event handler, exponential backoff +│ └── wifi_manager.c Event handler, exponential backoff (timer-based retry) │ -├── telegram/ -│ ├── telegram_bot.h Bot init/start, send_message API -│ └── telegram_bot.c Long polling loop, JSON parsing, message splitting +├── channels/ +│ ├── telegram/ +│ │ ├── telegram_bot.h Bot init/start, send_message API +│ │ └── telegram_bot.c Long polling loop, JSON parsing, message splitting +│ └── feishu/ +│ ├── feishu_bot.h Feishu bot API +│ └── feishu_bot.c WebSocket event handling, message send/recv │ ├── llm/ │ ├── llm_proxy.h llm_chat() + llm_chat_tools() API, tool_use types -│ └── llm_proxy.c Anthropic Messages API (non-streaming), tool_use parsing +│ ├── llm_proxy.c Multi-provider LLM (Anthropic + OpenAI-compatible) +│ ├── llm_provider.h Provider registry + configuration API +│ └── llm_provider.c Provider configs: anthropic, openai, siliconflow, volcengine │ ├── agent/ │ ├── agent_loop.h Agent task init/start @@ -125,8 +139,17 @@ main/ ├── tools/ │ ├── tool_registry.h Tool definition struct, register/dispatch API │ ├── tool_registry.c Tool registration, JSON schema builder, dispatch by name -│ ├── tool_web_search.h Web search tool API -│ └── tool_web_search.c Brave Search API via HTTPS (direct + proxy) +│ ├── tool_web_search.h Web search tool API (Tavily + Brave) +│ ├── tool_web_search.c Brave/Tavily Search API via HTTPS +│ ├── tool_get_time.h Time tool API +│ ├── tool_get_time.c HTTP Date header parsing for time sync +│ ├── tool_cron.h Cron tool API +│ ├── tool_cron.c Cron job management +│ ├── tool_files.h File tool API +│ ├── tool_files.c read/write/edit/list files on SPIFFS +│ ├── tool_gpio.h GPIO tool API +│ ├── tool_gpio.c GPIO read/write +│ └── gpio_policy.c GPIO pin allowlist policy │ ├── memory/ │ ├── memory_store.h Long-term + daily memory API @@ -140,12 +163,29 @@ main/ │ ├── proxy/ │ ├── http_proxy.h Proxy connection API -│ └── http_proxy.c HTTP CONNECT tunnel + TLS via esp_tls +│ └── http_proxy.c HTTP CONNECT tunnel + SOCKS5 tunnel + TLS │ ├── cli/ │ ├── serial_cli.h CLI init API │ └── serial_cli.c esp_console REPL with debug/maintenance commands │ +├── cron/ +│ ├── cron_service.h Cron job API +│ └── cron_service.c Cron scheduler, job persistence, execution +│ +├── heartbeat/ +│ ├── heartbeat.h Heartbeat API +│ └── heartbeat.c Periodic heartbeat messages +│ +├── onboard/ +│ ├── wifi_onboard.h WiFi onboarding portal API +│ ├── wifi_onboard.c Captive portal + Soft AP + HTTP config page +│ └── onboard_html.h Embedded HTML/CSS/JS for setup page +│ +├── skills/ +│ ├── skill_loader.h Skill loader API +│ └── skill_loader.c Load skill files from SPIFFS +│ └── ota/ ├── ota_manager.h OTA update API └── ota_manager.c esp_https_ota wrapper @@ -158,9 +198,13 @@ main/ | Task | Core | Priority | Stack | Description | |--------------------|------|----------|--------|--------------------------------------| | `tg_poll` | 0 | 5 | 12 KB | Telegram long polling (30s timeout) | -| `agent_loop` | 1 | 6 | 12 KB | Message processing + Claude API call | -| `outbound` | 0 | 5 | 8 KB | Route responses to Telegram / WS | +| `feishu_ws` | 0 | 5 | 12 KB | Feishu WebSocket event handling | +| `agent_loop` | 1 | 6 | 24 KB | Message processing + LLM API call | +| `outbound` | 0 | 5 | 12 KB | Route responses to channels | | `serial_cli` | 0 | 3 | 4 KB | USB serial console REPL | +| `onboard_dns` | 0 | 5 | 4 KB | DNS hijack for captive portal | +| `cron_check` | 0 | 4 | 4 KB | Cron job scheduler | +| `heartbeat` | 0 | 4 | 4 KB | Periodic heartbeat | | httpd (internal) | 0 | 5 | — | WebSocket server (esp_http_server) | | wifi_event (IDF) | 0 | 8 | — | WiFi event handling (ESP-IDF) | @@ -225,20 +269,66 @@ Session files are JSONL (one JSON object per line): ## Configuration -All configuration is done exclusively through `mimi_secrets.h` at build time. There is no runtime configuration — changing any setting requires `idf.py fullclean && idf.py build`. +Configuration uses a multi-layer priority system: -| Define | Description | -|------------------------------|-----------------------------------------| -| `MIMI_SECRET_WIFI_SSID` | WiFi SSID | -| `MIMI_SECRET_WIFI_PASS` | WiFi password | -| `MIMI_SECRET_TG_TOKEN` | Telegram Bot API token | -| `MIMI_SECRET_API_KEY` | Anthropic API key | -| `MIMI_SECRET_MODEL` | Model ID (default: claude-opus-4-6) | -| `MIMI_SECRET_PROXY_HOST` | HTTP proxy hostname/IP (optional) | -| `MIMI_SECRET_PROXY_PORT` | HTTP proxy port (optional) | -| `MIMI_SECRET_SEARCH_KEY` | Brave Search API key (optional) | +### Build-time (`mimi_secrets.h`) +Highest priority. Set in `mimi_secrets.h` (copy from `mimi_secrets.h.example`). -NVS is still initialized (required by ESP-IDF WiFi internals) but is not used for application configuration. +| Define | Description | +|-------------------------------------|--------------------------------------------| +| `MIMI_SECRET_WIFI_SSID` | WiFi SSID | +| `MIMI_SECRET_WIFI_PASS` | WiFi password | +| `MIMI_SECRET_TG_TOKEN` | Telegram Bot API token | +| `MIMI_SECRET_FEISHU_APP_ID` | Feishu App ID | +| `MIMI_SECRET_FEISHU_APP_SECRET` | Feishu App Secret | +| `MIMI_SECRET_API_KEY` | Generic LLM API key (fallback) | +| `MIMI_SECRET_MODEL` | Model ID (default: claude-opus-4-5) | +| `MIMI_SECRET_MODEL_PROVIDER` | LLM provider: anthropic/openai/siliconflow/volcengine | +| `MIMI_SECRET_ANTHROPIC_API_KEY` | Anthropic-specific API key | +| `MIMI_SECRET_OPENAI_API_KEY` | OpenAI-specific API key | +| `MIMI_SECRET_SILICONFLOW_API_KEY` | SiliconFlow (硅基流动) API key | +| `MIMI_SECRET_SILICONFLOW_BASE_URL` | SiliconFlow Base URL | +| `MIMI_SECRET_VOLCENGINE_API_KEY` | Volcengine (火山引擎) API key | +| `MIMI_SECRET_VOLCENGINE_BASE_URL` | Volcengine Base URL | +| `MIMI_SECRET_PROXY_HOST` | HTTP proxy hostname/IP (optional) | +| `MIMI_SECRET_PROXY_PORT` | HTTP proxy port (optional) | +| `MIMI_SECRET_PROXY_TYPE` | Proxy type: http/socks5 | +| `MIMI_SECRET_SEARCH_KEY` | Brave Search API key (optional) | +| `MIMI_SECRET_TAVILY_KEY` | Tavily Search API key (optional) | + +### Runtime (NVS + Onboard Portal) +Set via serial CLI or the onboard configuration portal (192.168.4.1). + +| CLI Command | Description | +|------------------------------------|--------------------------------------| +| `wifi_set ` | Set WiFi credentials | +| `set_tg_token ` | Set Telegram Bot token | +| `set_api_key ` | Set generic LLM API key | +| `set_model_provider ` | Set provider: anthropic/openai/siliconflow/volcengine | +| `set_model ` | Set model name | +| `set_siliconflow_key ` | Set SiliconFlow-specific API key | +| `set_siliconflow_url ` | Set SiliconFlow Base URL | +| `set_volcengine_key ` | Set Volcengine-specific API key | +| `set_volcengine_url ` | Set Volcengine Base URL | +| `config_show` | Show current config (masked) | +| `config_reset` | Reset to build-time defaults | + +### Priority Order (highest → lowest) +1. NVS runtime config (CLI or onboard portal) +2. Provider-specific NVS key (e.g. `siliconflow_api_key`) +3. Provider-specific build-time config (e.g. `MIMI_SECRET_SILICONFLOW_API_KEY`) +4. Generic build-time config (`MIMI_SECRET_API_KEY`, `MIMI_SECRET_MODEL_PROVIDER`) + +## Supported LLM Providers + +| Provider | API Compatible | Default Endpoint | +|-------------|----------------|-------------------------------------------------------| +| anthropic | Anthropic | https://api.anthropic.com/v1/messages | +| openai | OpenAI | https://api.openai.com/v1/chat/completions | +| siliconflow | OpenAI | https://api.siliconflow.cn/v1/chat/completions | +| volcengine | OpenAI | https://ark.cn-beijing.volces.com/api/v3/chat/completions | + +All OpenAI-compatible providers use Bearer token authentication and the same message format. --- diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 493dec2..1e52783 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -20,6 +20,7 @@ idf_component_register( "tools/tool_cron.c" "tools/tool_web_search.c" "tools/tool_get_time.c" + "tools/tool_set_timezone.c" "tools/tool_files.c" "tools/tool_gpio.c" "tools/gpio_policy.c" diff --git a/main/agent/context_builder.c b/main/agent/context_builder.c index 870b2ac..5e83e2a 100644 --- a/main/agent/context_builder.c +++ b/main/agent/context_builder.c @@ -14,8 +14,12 @@ static size_t append_file(char *buf, size_t size, size_t offset, const char *pat FILE *f = fopen(path, "r"); if (!f) return offset; - if (header && offset < size - 1) { - offset += snprintf(buf + offset, size - offset, "\n## %s\n\n", header); + if (offset >= size) return offset; + + if (header) { + int ret = snprintf(buf + offset, size - offset, "\n## %s\n\n", header); + if (ret > 0) offset += (size_t)ret; + if (offset >= size) { offset = size - 1; buf[offset] = '\0'; fclose(f); return offset; } } size_t n = fread(buf + offset, 1, size - offset - 1, f); @@ -79,23 +83,35 @@ esp_err_t context_build_system_prompt(char *buf, size_t size) /* Long-term memory */ char mem_buf[4096]; if (memory_read_long_term(mem_buf, sizeof(mem_buf)) == ESP_OK && mem_buf[0]) { - off += snprintf(buf + off, size - off, "\n## Long-term Memory\n\n%s\n", mem_buf); + if (off < size) { + int ret = snprintf(buf + off, size - off, "\n## Long-term Memory\n\n%s\n", mem_buf); + if (ret > 0) off += (size_t)ret; + if (off >= size) off = size - 1; + } } /* Recent daily notes (last 3 days) */ char recent_buf[4096]; if (memory_read_recent(recent_buf, sizeof(recent_buf), 3) == ESP_OK && recent_buf[0]) { - off += snprintf(buf + off, size - off, "\n## Recent Notes\n\n%s\n", recent_buf); + if (off < size) { + int ret = snprintf(buf + off, size - off, "\n## Recent Notes\n\n%s\n", recent_buf); + if (ret > 0) off += (size_t)ret; + if (off >= size) off = size - 1; + } } /* Skills */ char skills_buf[2048]; size_t skills_len = skill_loader_build_summary(skills_buf, sizeof(skills_buf)); if (skills_len > 0) { - off += snprintf(buf + off, size - off, - "\n## Available Skills\n\n" - "Available skills (use read_file to load full instructions):\n%s\n", - skills_buf); + if (off < size) { + int ret = snprintf(buf + off, size - off, + "\n## Available Skills\n\n" + "Available skills (use read_file to load full instructions):\n%s\n", + skills_buf); + if (ret > 0) off += (size_t)ret; + if (off >= size) off = size - 1; + } } ESP_LOGI(TAG, "System prompt built: %d bytes", (int)off); diff --git a/main/channels/feishu/feishu_bot.c b/main/channels/feishu/feishu_bot.c index 3f3ae3a..1f02e42 100644 --- a/main/channels/feishu/feishu_bot.c +++ b/main/channels/feishu/feishu_bot.c @@ -583,6 +583,7 @@ static void feishu_ws_event_handler(void *arg, esp_event_base_t base, int32_t ev } else if (event_id == WEBSOCKET_EVENT_DISCONNECTED) { s_ws_connected = false; ESP_LOGW(TAG, "Feishu WS disconnected"); + if (rx_buf) { free(rx_buf); rx_buf = NULL; rx_cap = 0; } } else if (event_id == WEBSOCKET_EVENT_DATA) { if (e->op_code != WS_TRANSPORT_OPCODES_BINARY) return; size_t need = e->payload_offset + e->data_len; diff --git a/main/cli/serial_cli.c b/main/cli/serial_cli.c index 9c03b72..0a62b24 100644 --- a/main/cli/serial_cli.c +++ b/main/cli/serial_cli.c @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include "esp_log.h" #include "esp_console.h" @@ -654,6 +656,11 @@ static int cmd_config_show(int argc, char **argv) print_config_u16("Proxy Port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT, MIMI_SECRET_PROXY_PORT); print_config("Search Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_SEARCH_KEY, true); print_config("Tavily Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_TAVILY_KEY, MIMI_SECRET_TAVILY_KEY, true); + + /* System configs */ + printf(" --- System configs ---\n"); + print_config("Timezone", "system_config", MIMI_NVS_KEY_TIMEZONE, MIMI_TIMEZONE, false); + printf("=============================\n"); return 0; } @@ -676,6 +683,87 @@ static int cmd_config_reset(int argc, char **argv) return 0; } +/* --- set_timezone command --- */ +static struct { + struct arg_str *tz; + struct arg_end *end; +} timezone_args; + +static int cmd_set_timezone(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **)&timezone_args); + if (nerrors != 0) { + arg_print_errors(stderr, timezone_args.end, argv[0]); + return 1; + } + + const char *tz_str = timezone_args.tz->sval[0]; + nvs_handle_t nvs; + esp_err_t err = nvs_open("system_config", NVS_READWRITE, &nvs); + if (err != ESP_OK) { + printf("Failed to open NVS: %s\n", esp_err_to_name(err)); + return 1; + } + + err = nvs_set_str(nvs, MIMI_NVS_KEY_TIMEZONE, tz_str); + if (err != ESP_OK) { + printf("Failed to save timezone: %s\n", esp_err_to_name(err)); + nvs_close(nvs); + return 1; + } + + err = nvs_commit(nvs); + nvs_close(nvs); + + if (err != ESP_OK) { + printf("Failed to commit NVS: %s\n", esp_err_to_name(err)); + return 1; + } + + setenv("TZ", tz_str, 1); + tzset(); + + time_t now = time(NULL); + struct tm tm_now; + localtime_r(&now, &tm_now); + char time_str[64]; + strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S %Z", &tm_now); + + printf("Timezone set to '%s'. Current time: %s. Restart to apply permanently.\n", tz_str, time_str); + return 0; +} + +/* --- timezone_show command --- */ +static int cmd_timezone_show(int argc, char **argv) +{ + (void)argc; + (void)argv; + + char nvs_val[64] = {0}; + const char *source = "build"; + const char *display = MIMI_TIMEZONE; + + nvs_handle_t nvs; + if (nvs_open("system_config", NVS_READONLY, &nvs) == ESP_OK) { + size_t len = sizeof(nvs_val); + if (nvs_get_str(nvs, MIMI_NVS_KEY_TIMEZONE, nvs_val, &len) == ESP_OK && nvs_val[0]) { + source = "NVS"; + display = nvs_val; + } + nvs_close(nvs); + } + + printf("Current timezone: %s [%s]\n", display, source); + + time_t now = time(NULL); + struct tm tm_now; + localtime_r(&now, &tm_now); + char time_str[64]; + strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S %Z (%A)", &tm_now); + printf("Local time: %s\n", time_str); + return 0; +} + /* --- heartbeat_trigger command --- */ static int cmd_heartbeat_trigger(int argc, char **argv) { @@ -736,13 +824,22 @@ typedef struct { size_t output_size; esp_err_t err; SemaphoreHandle_t done; + bool timed_out; } web_search_task_ctx_t; static void web_search_task(void *arg) { web_search_task_ctx_t *task_ctx = (web_search_task_ctx_t *)arg; task_ctx->err = tool_web_search_execute(task_ctx->input_json, task_ctx->output, task_ctx->output_size); - xSemaphoreGive(task_ctx->done); + if (!task_ctx->timed_out) { + xSemaphoreGive(task_ctx->done); + } else { + /* Main thread timed out and freed ctx, so we must clean up ourselves */ + free((void *)task_ctx->input_json); + free(task_ctx->output); + vSemaphoreDelete(task_ctx->done); + free(task_ctx); + } vTaskDelete(NULL); } @@ -838,10 +935,8 @@ static int cmd_web_search(int argc, char **argv) if (xSemaphoreTake(ctx->done, pdMS_TO_TICKS(45000)) != pdTRUE) { printf("web_search status: timeout\n"); - vSemaphoreDelete(ctx->done); - free(input_copy); - free(ctx); - free(output); + ctx->timed_out = true; + /* Task will clean up ctx, input_copy, output, and done on its own */ return 1; } esp_err_t err = ctx->err; @@ -1163,6 +1258,25 @@ esp_err_t serial_cli_init(void) }; esp_console_cmd_register(&config_reset_cmd); + /* set_timezone */ + timezone_args.tz = arg_str1(NULL, NULL, "", "Timezone (e.g. CST-8, Asia/Shanghai)"); + timezone_args.end = arg_end(1); + esp_console_cmd_t set_timezone_cmd = { + .command = "set_timezone", + .help = "Set system timezone (e.g. set_timezone CST-8)", + .func = &cmd_set_timezone, + .argtable = &timezone_args, + }; + esp_console_cmd_register(&set_timezone_cmd); + + /* timezone_show */ + esp_console_cmd_t timezone_show_cmd = { + .command = "timezone_show", + .help = "Show current timezone and local time", + .func = &cmd_timezone_show, + }; + esp_console_cmd_register(&timezone_show_cmd); + /* heartbeat_trigger */ esp_console_cmd_t heartbeat_cmd = { .command = "heartbeat_trigger", diff --git a/main/cron/cron_service.c b/main/cron/cron_service.c index bf33195..3c3bf54 100644 --- a/main/cron/cron_service.c +++ b/main/cron/cron_service.c @@ -4,6 +4,7 @@ #include #include +#include #include #include #include "freertos/FreeRTOS.h" diff --git a/main/gateway/ws_server.c b/main/gateway/ws_server.c index 592e1a9..e133f48 100644 --- a/main/gateway/ws_server.c +++ b/main/gateway/ws_server.c @@ -4,7 +4,9 @@ #include #include +#include #include +#include #include "esp_log.h" #include "esp_http_server.h" #include "cJSON.h" diff --git a/main/llm/llm_provider.c b/main/llm/llm_provider.c index babc140..e9595a5 100644 --- a/main/llm/llm_provider.c +++ b/main/llm/llm_provider.c @@ -5,6 +5,7 @@ #include #include #include +#include #include "esp_log.h" #include "esp_err.h" #include "esp_http_client.h" diff --git a/main/llm/llm_proxy.c b/main/llm/llm_proxy.c index 09f73cc..44a85d7 100644 --- a/main/llm/llm_proxy.c +++ b/main/llm/llm_proxy.c @@ -5,6 +5,7 @@ #include #include +#include #include "esp_log.h" #include "esp_http_client.h" #include "esp_crt_bundle.h" @@ -246,12 +247,25 @@ esp_err_t llm_proxy_init(void) nvs_close(nvs); } - /* Load provider-specific API key if available */ + /* Load provider-specific API key from NVS */ const char *provider_api_key = llm_provider_get_api_key(s_provider); if (provider_api_key && provider_api_key[0]) { safe_copy(s_api_key, sizeof(s_api_key), provider_api_key); } + /* Fall back to build-time provider-specific API key if NVS key is empty */ + if (s_api_key[0] == '\0') { + if (strcmp(s_provider, "siliconflow") == 0 && MIMI_SECRET_SILICONFLOW_API_KEY[0] != '\0') { + safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_SILICONFLOW_API_KEY); + } else if (strcmp(s_provider, "volcengine") == 0 && MIMI_SECRET_VOLCENGINE_API_KEY[0] != '\0') { + safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_VOLCENGINE_API_KEY); + } else if (strcmp(s_provider, "openai") == 0 && MIMI_SECRET_OPENAI_API_KEY[0] != '\0') { + safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_OPENAI_API_KEY); + } else if (strcmp(s_provider, "anthropic") == 0 && MIMI_SECRET_ANTHROPIC_API_KEY[0] != '\0') { + safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_ANTHROPIC_API_KEY); + } + } + if (s_api_key[0]) { ESP_LOGI(TAG, "LLM proxy initialized (provider: %s, model: %s)", s_provider, s_model); } else { diff --git a/main/memory/session_mgr.c b/main/memory/session_mgr.c index 5579934..5cfd8fc 100644 --- a/main/memory/session_mgr.c +++ b/main/memory/session_mgr.c @@ -24,7 +24,7 @@ esp_err_t session_mgr_init(void) esp_err_t session_append(const char *chat_id, const char *role, const char *content) { - char path[64]; + char path[128]; session_path(chat_id, path, sizeof(path)); FILE *f = fopen(path, "a"); @@ -52,7 +52,7 @@ esp_err_t session_append(const char *chat_id, const char *role, const char *cont esp_err_t session_get_history_json(const char *chat_id, char *buf, size_t size, int max_msgs) { - char path[64]; + char path[128]; session_path(chat_id, path, sizeof(path)); FILE *f = fopen(path, "r"); @@ -127,7 +127,7 @@ esp_err_t session_get_history_json(const char *chat_id, char *buf, size_t size, esp_err_t session_clear(const char *chat_id) { - char path[64]; + char path[128]; session_path(chat_id, path, sizeof(path)); if (remove(path) == 0) { diff --git a/main/mimi.c b/main/mimi.c index b949970..eb589b2 100644 --- a/main/mimi.c +++ b/main/mimi.c @@ -1,5 +1,6 @@ #include #include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" diff --git a/main/mimi_config.h b/main/mimi_config.h index 55eb7d0..3055831 100644 --- a/main/mimi_config.h +++ b/main/mimi_config.h @@ -47,6 +47,26 @@ #define MIMI_SECRET_TAVILY_KEY "" #endif +/* Provider-specific API keys (fallback when generic MIMI_SECRET_API_KEY is empty) */ +#ifndef MIMI_SECRET_ANTHROPIC_API_KEY +#define MIMI_SECRET_ANTHROPIC_API_KEY "" +#endif +#ifndef MIMI_SECRET_OPENAI_API_KEY +#define MIMI_SECRET_OPENAI_API_KEY "" +#endif +#ifndef MIMI_SECRET_SILICONFLOW_API_KEY +#define MIMI_SECRET_SILICONFLOW_API_KEY "" +#endif +#ifndef MIMI_SECRET_SILICONFLOW_BASE_URL +#define MIMI_SECRET_SILICONFLOW_BASE_URL "https://api.siliconflow.cn/v1" +#endif +#ifndef MIMI_SECRET_VOLCENGINE_API_KEY +#define MIMI_SECRET_VOLCENGINE_API_KEY "" +#endif +#ifndef MIMI_SECRET_VOLCENGINE_BASE_URL +#define MIMI_SECRET_VOLCENGINE_BASE_URL "https://ark.cn-beijing.volces.com/api/v3" +#endif + /* WiFi */ #define MIMI_WIFI_MAX_RETRY 10 #define MIMI_WIFI_RETRY_BASE_MS 1000 @@ -80,7 +100,7 @@ #define MIMI_AGENT_SEND_WORKING_STATUS 1 /* Timezone (POSIX TZ format) */ -#define MIMI_TIMEZONE "PST8PDT,M3.2.0,M11.1.0" +#define MIMI_TIMEZONE "CST-8" /* LLM */ #define MIMI_LLM_DEFAULT_MODEL "claude-opus-4-5" @@ -166,6 +186,9 @@ #define MIMI_NVS_KEY_VOLCENGINE_API_KEY "volcengine_api_key" #define MIMI_NVS_KEY_VOLCENGINE_BASE_URL "volcengine_base_url" +/* System NVS Keys */ +#define MIMI_NVS_KEY_TIMEZONE "timezone" + /* WiFi Onboarding (Captive Portal) */ #define MIMI_ONBOARD_AP_PREFIX "MimiClaw-" #define MIMI_ONBOARD_AP_PASS "" /* open network */ diff --git a/main/mimi_secrets.h.example b/main/mimi_secrets.h.example index c5050f4..0424fc6 100644 --- a/main/mimi_secrets.h.example +++ b/main/mimi_secrets.h.example @@ -21,16 +21,32 @@ #define MIMI_SECRET_FEISHU_APP_ID "" #define MIMI_SECRET_FEISHU_APP_SECRET "" -/* Anthropic API */ +/* LLM Configuration + * + * Method 1: Generic API key (works for any provider) + * Set MIMI_SECRET_API_KEY + MIMI_SECRET_MODEL_PROVIDER + MIMI_SECRET_MODEL + * + * Method 2: Provider-specific API keys (recommended for multi-provider setups) + * Set MIMI_SECRET__API_KEY for each provider you want to use + * Switch providers via onboard portal or CLI: set_model_provider + * + * Priority: NVS (runtime config) > provider-specific key > generic key + */ #define MIMI_SECRET_API_KEY "" -#define MIMI_SECRET_MODEL "" +#define MIMI_SECRET_MODEL "claude-opus-4-5" #define MIMI_SECRET_MODEL_PROVIDER "anthropic" -/* SiliconFlow API (OpenAI-compatible) */ +/* Anthropic (Claude) */ +#define MIMI_SECRET_ANTHROPIC_API_KEY "" + +/* OpenAI (GPT) */ +#define MIMI_SECRET_OPENAI_API_KEY "" + +/* SiliconFlow (硅基流动, OpenAI-compatible) */ #define MIMI_SECRET_SILICONFLOW_API_KEY "" #define MIMI_SECRET_SILICONFLOW_BASE_URL "https://api.siliconflow.cn/v1" -/* Volcengine API (OpenAI-compatible, ByteDance Doubao models) */ +/* Volcengine (火山引擎/豆包, OpenAI-compatible) */ #define MIMI_SECRET_VOLCENGINE_API_KEY "" #define MIMI_SECRET_VOLCENGINE_BASE_URL "https://ark.cn-beijing.volces.com/api/v3" diff --git a/main/onboard/onboard_html.h b/main/onboard/onboard_html.h index ac4fb02..f4a56e6 100644 --- a/main/onboard/onboard_html.h +++ b/main/onboard/onboard_html.h @@ -56,10 +56,14 @@ static const char ONBOARD_HTML[] = "" "" "" -"" "" "" +"" +"" "" +"" +"" "" /* Telegram section */ @@ -133,8 +137,15 @@ static const char ONBOARD_HTML[] = "btn.textContent='Scan WiFi Networks';btn.disabled=false;" "}).catch(()=>{btn.textContent='Scan WiFi Networks';btn.disabled=false})}" +"function onProviderChange(){" +"var p=document.getElementById('provider').value;" +"var u=document.getElementById('base_url');" +"if(p==='siliconflow'){u.value='https://api.siliconflow.cn/v1'}" +"else if(p==='volcengine'){u.value='https://ark.cn-beijing.volces.com/api/v3'}" +"else{u.value=''}}" + "function save(){" -"var fields=['ssid','password','api_key','model','provider','tg_token'," +"var fields=['ssid','password','api_key','model','provider','base_url','tg_token'," "'feishu_app_id','feishu_app_secret','proxy_host','proxy_port','proxy_type','search_key','tavily_key'];" "var data={};" "fields.forEach(f=>{data[f]=document.getElementById(f).value.trim()});" diff --git a/main/onboard/wifi_onboard.c b/main/onboard/wifi_onboard.c index 6b00429..150d273 100644 --- a/main/onboard/wifi_onboard.c +++ b/main/onboard/wifi_onboard.c @@ -220,6 +220,40 @@ static esp_err_t http_get_config(httpd_req_t *req) json_add_effective_config(root, "api_key", MIMI_NVS_LLM, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_API_KEY); json_add_effective_config(root, "model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL, MIMI_SECRET_MODEL); json_add_effective_config(root, "provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER, MIMI_SECRET_MODEL_PROVIDER); + + /* Provider-specific Base URL (load from current provider's NVS key) */ + { + char base_url[256] = {0}; + bool found = false; + char provider[32] = {0}; + nvs_handle_t nvs; + if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) { + size_t len = sizeof(provider); + if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, provider, &len) != ESP_OK || !provider[0]) { + /* Fall back to build-time provider if NVS doesn't have one */ + strlcpy(provider, MIMI_SECRET_MODEL_PROVIDER, sizeof(provider)); + } + /* Try to load provider-specific base URL from NVS */ + const char *url_key = NULL; + if (strcmp(provider, "anthropic") == 0) url_key = MIMI_NVS_KEY_ANTHROPIC_BASE_URL; + else if (strcmp(provider, "openai") == 0) url_key = MIMI_NVS_KEY_OPENAI_BASE_URL; + else if (strcmp(provider, "siliconflow") == 0) url_key = MIMI_NVS_KEY_SILICONFLOW_BASE_URL; + else if (strcmp(provider, "volcengine") == 0) url_key = MIMI_NVS_KEY_VOLCENGINE_BASE_URL; + if (url_key) { + len = sizeof(base_url); + if (nvs_get_str(nvs, url_key, base_url, &len) == ESP_OK && base_url[0]) { + found = true; + } + } + nvs_close(nvs); + } + if (!found) { + /* Fall back to build-time defaults */ + if (strcmp(provider, "siliconflow") == 0) strlcpy(base_url, MIMI_SECRET_SILICONFLOW_BASE_URL, sizeof(base_url)); + else if (strcmp(provider, "volcengine") == 0) strlcpy(base_url, MIMI_SECRET_VOLCENGINE_BASE_URL, sizeof(base_url)); + } + cJSON_AddStringToObject(root, "base_url", base_url); + } json_add_effective_config(root, "tg_token", MIMI_NVS_TG, MIMI_NVS_KEY_TG_TOKEN, MIMI_SECRET_TG_TOKEN); json_add_effective_config(root, "feishu_app_id", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_ID, MIMI_SECRET_FEISHU_APP_ID); json_add_effective_config(root, "feishu_app_secret", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_SECRET, MIMI_SECRET_FEISHU_APP_SECRET); @@ -344,6 +378,37 @@ static esp_err_t http_post_save(httpd_req_t *req) nvs_sync_field(root, "model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL); nvs_sync_field(root, "provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER); + /* Save API key and base URL to provider-specific NVS keys */ + { + cJSON *provider_item = cJSON_GetObjectItem(root, "provider"); + cJSON *api_key_item = cJSON_GetObjectItem(root, "api_key"); + cJSON *base_url_item = cJSON_GetObjectItem(root, "base_url"); + if (provider_item && cJSON_IsString(provider_item) && provider_item->valuestring[0]) { + const char *provider = provider_item->valuestring; + const char *api_key_nvs = NULL; + const char *base_url_nvs = NULL; + if (strcmp(provider, "anthropic") == 0) { + api_key_nvs = MIMI_NVS_KEY_ANTHROPIC_API_KEY; + base_url_nvs = MIMI_NVS_KEY_ANTHROPIC_BASE_URL; + } else if (strcmp(provider, "openai") == 0) { + api_key_nvs = MIMI_NVS_KEY_OPENAI_API_KEY; + base_url_nvs = MIMI_NVS_KEY_OPENAI_BASE_URL; + } else if (strcmp(provider, "siliconflow") == 0) { + api_key_nvs = MIMI_NVS_KEY_SILICONFLOW_API_KEY; + base_url_nvs = MIMI_NVS_KEY_SILICONFLOW_BASE_URL; + } else if (strcmp(provider, "volcengine") == 0) { + api_key_nvs = MIMI_NVS_KEY_VOLCENGINE_API_KEY; + base_url_nvs = MIMI_NVS_KEY_VOLCENGINE_BASE_URL; + } + if (api_key_nvs && api_key_item && cJSON_IsString(api_key_item)) { + nvs_sync_field(root, "api_key", MIMI_NVS_LLM, api_key_nvs); + } + if (base_url_nvs && base_url_item && cJSON_IsString(base_url_item)) { + nvs_sync_field(root, "base_url", MIMI_NVS_LLM, base_url_nvs); + } + } + } + /* Telegram */ nvs_sync_field(root, "tg_token", MIMI_NVS_TG, MIMI_NVS_KEY_TG_TOKEN); diff --git a/main/proxy/http_proxy.c b/main/proxy/http_proxy.c index 0ae2e87..33016e7 100644 --- a/main/proxy/http_proxy.c +++ b/main/proxy/http_proxy.c @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -246,7 +247,7 @@ static int open_socks5_tunnel(const char *host, int port, int timeout_ms) free(req); /* Receive connect response */ - unsigned char resp[256]; + unsigned char resp[512]; int resp_len = recv(sock, resp, sizeof(resp), 0); if (resp_len < 10) { ESP_LOGE(TAG, "No response from SOCKS5 proxy"); close(sock); return -1; diff --git a/main/tools/gpio_policy.c b/main/tools/gpio_policy.c index 5c12d30..d013d1d 100644 --- a/main/tools/gpio_policy.c +++ b/main/tools/gpio_policy.c @@ -4,6 +4,7 @@ #include #include +#include #include #ifndef GPIO_IS_VALID_GPIO diff --git a/main/tools/tool_get_time.c b/main/tools/tool_get_time.c index d1c46b8..02f129a 100644 --- a/main/tools/tool_get_time.c +++ b/main/tools/tool_get_time.c @@ -4,6 +4,7 @@ #include #include +#include #include #include #include "esp_log.h" diff --git a/main/tools/tool_registry.c b/main/tools/tool_registry.c index b4c6880..e57dcbe 100644 --- a/main/tools/tool_registry.c +++ b/main/tools/tool_registry.c @@ -2,6 +2,7 @@ #include "mimi_config.h" #include "tools/tool_web_search.h" #include "tools/tool_get_time.h" +#include "tools/tool_set_timezone.h" #include "tools/tool_files.h" #include "tools/tool_cron.h" #include "tools/tool_gpio.h" @@ -83,6 +84,18 @@ esp_err_t tool_registry_init(void) }; register_tool(>); + /* Register set_timezone */ + mimi_tool_t stz = { + .name = "set_timezone", + .description = "Set the system timezone. Accepts POSIX format (e.g. CST-8, EST5EDT,M3.2.0,M11.1.0) or city name (e.g. Asia/Shanghai, America/New_York).", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{\"timezone\":{\"type\":\"string\",\"description\":\"Timezone in POSIX format or city name (e.g. CST-8, Asia/Shanghai)\"}}," + "\"required\":[\"timezone\"]}", + .execute = tool_set_timezone_execute, + }; + register_tool(&stz); + /* Register read_file */ mimi_tool_t rf = { .name = "read_file", diff --git a/main/tools/tool_set_timezone.c b/main/tools/tool_set_timezone.c new file mode 100644 index 0000000..efae700 --- /dev/null +++ b/main/tools/tool_set_timezone.c @@ -0,0 +1,145 @@ +#include "tool_set_timezone.h" +#include "mimi_config.h" + +#include +#include +#include +#include +#include "esp_log.h" +#include "nvs.h" +#include "cJSON.h" + +static const char *TAG = "tool_timezone"; + +/* Common timezone mappings for user-friendly names */ +typedef struct { + const char *name; + const char *posix_tz; +} tz_mapping_t; + +static const tz_mapping_t tz_mappings[] = { + { "Asia/Shanghai", "CST-8" }, + { "Asia/Beijing", "CST-8" }, + { "Asia/Hong_Kong", "HKT-8" }, + { "Asia/Tokyo", "JST-9" }, + { "Asia/Seoul", "KST-9" }, + { "Asia/Singapore", "SGT-8" }, + { "Asia/Kolkata", "IST-5:30" }, + { "Asia/Dubai", "GST-4" }, + { "Europe/London", "GMT0BST,M3.5.0/1,M10.5.0" }, + { "Europe/Paris", "CET-1CEST,M3.5.0,M10.5.0/3" }, + { "Europe/Berlin", "CET-1CEST,M3.5.0,M10.5.0/3" }, + { "America/New_York", "EST5EDT,M3.2.0,M11.1.0" }, + { "America/Chicago", "CST6CDT,M3.2.0,M11.1.0" }, + { "America/Denver", "MST7MDT,M3.2.0,M11.1.0" }, + { "America/Los_Angeles", "PST8PDT,M3.2.0,M11.1.0" }, + { "Australia/Sydney", "AEST-10AEDT,M10.1.0,M4.1.0/3" }, + { "UTC", "UTC0" }, + { "GMT", "GMT0" }, +}; + +static const char *resolve_timezone(const char *tz_str) +{ + if (!tz_str || !tz_str[0]) return NULL; + + for (size_t i = 0; i < sizeof(tz_mappings) / sizeof(tz_mappings[0]); i++) { + if (strcmp(tz_str, tz_mappings[i].name) == 0) { + return tz_mappings[i].posix_tz; + } + } + + return tz_str; +} + +static bool validate_timezone(const char *tz_str) +{ + if (!tz_str || !tz_str[0]) return false; + if (strlen(tz_str) > 64) return false; + + for (size_t i = 0; i < sizeof(tz_mappings) / sizeof(tz_mappings[0]); i++) { + if (strcmp(tz_str, tz_mappings[i].name) == 0) return true; + } + + if (strchr(tz_str, '+') || strchr(tz_str, '-') || + strcmp(tz_str, "UTC0") == 0 || strcmp(tz_str, "GMT0") == 0) { + return true; + } + + return false; +} + +static esp_err_t save_timezone_nvs(const char *tz_str) +{ + nvs_handle_t nvs; + esp_err_t err = nvs_open("system_config", NVS_READWRITE, &nvs); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to open NVS: %s", esp_err_to_name(err)); + return err; + } + + err = nvs_set_str(nvs, MIMI_NVS_KEY_TIMEZONE, tz_str); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to save timezone: %s", esp_err_to_name(err)); + nvs_close(nvs); + return err; + } + + err = nvs_commit(nvs); + nvs_close(nvs); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to commit NVS: %s", esp_err_to_name(err)); + return err; + } + + return ESP_OK; +} + +esp_err_t tool_set_timezone_execute(const char *input_json, char *output, size_t output_size) +{ + ESP_LOGI(TAG, "Setting timezone..."); + + cJSON *root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + cJSON *tz_item = cJSON_GetObjectItem(root, "timezone"); + if (!tz_item || !cJSON_IsString(tz_item)) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: 'timezone' field required (string)"); + return ESP_ERR_INVALID_ARG; + } + + const char *input_tz = tz_item->valuestring; + const char *resolved_tz = resolve_timezone(input_tz); + + if (!resolved_tz || !validate_timezone(resolved_tz)) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: invalid timezone format '%s'. Use POSIX format (e.g. CST-8) or city name (e.g. Asia/Shanghai)", input_tz); + return ESP_ERR_INVALID_ARG; + } + + esp_err_t err = save_timezone_nvs(resolved_tz); + if (err != ESP_OK) { + cJSON_Delete(root); + snprintf(output, output_size, "Error: failed to save timezone (%s)", esp_err_to_name(err)); + return err; + } + + setenv("TZ", resolved_tz, 1); + tzset(); + + time_t now = time(NULL); + struct tm tm_now; + localtime_r(&now, &tm_now); + char time_str[64]; + strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S %Z", &tm_now); + + cJSON_Delete(root); + snprintf(output, output_size, "Timezone set to '%s'. Current time: %s", resolved_tz, time_str); + ESP_LOGI(TAG, "Timezone set to: %s, current time: %s", resolved_tz, time_str); + + return ESP_OK; +} diff --git a/main/tools/tool_set_timezone.h b/main/tools/tool_set_timezone.h new file mode 100644 index 0000000..3721244 --- /dev/null +++ b/main/tools/tool_set_timezone.h @@ -0,0 +1,11 @@ +#pragma once + +#include "esp_err.h" +#include + +/** + * Execute set_timezone tool. + * Sets the system timezone via NVS and updates the TZ environment variable. + * Input JSON: {"timezone": "CST-8"} or {"timezone": "Asia/Shanghai"} + */ +esp_err_t tool_set_timezone_execute(const char *input_json, char *output, size_t output_size); diff --git a/main/wifi/wifi_manager.c b/main/wifi/wifi_manager.c index a329af4..9a03a04 100644 --- a/main/wifi/wifi_manager.c +++ b/main/wifi/wifi_manager.c @@ -2,6 +2,7 @@ #include "mimi_config.h" #include +#include #include #include "esp_log.h" #include "esp_wifi.h" @@ -12,6 +13,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/event_groups.h" +#include "freertos/timers.h" static const char *TAG = "wifi"; @@ -20,6 +22,15 @@ static int s_retry_count = 0; static char s_ip_str[16] = "0.0.0.0"; static bool s_connected = false; static bool s_reconnect_enabled = true; +static TimerHandle_t s_retry_timer = NULL; + +static void retry_timer_callback(TimerHandle_t xTimer) +{ + (void)xTimer; + if (s_reconnect_enabled && !s_connected) { + esp_wifi_connect(); + } +} static const char *wifi_reason_to_str(wifi_err_reason_t reason) { @@ -77,9 +88,10 @@ static void event_handler(void *arg, esp_event_base_t event_base, } ESP_LOGW(TAG, "Disconnected, retry %d/%d in %" PRIu32 "ms", s_retry_count + 1, MIMI_WIFI_MAX_RETRY, delay_ms); - vTaskDelay(pdMS_TO_TICKS(delay_ms)); - esp_wifi_connect(); s_retry_count++; + /* Use timer instead of blocking vTaskDelay in event handler */ + if (s_retry_timer) xTimerStop(s_retry_timer, 0); + xTimerChangePeriod(s_retry_timer, pdMS_TO_TICKS(delay_ms), 0); } else { ESP_LOGE(TAG, "Failed to connect after %d retries", MIMI_WIFI_MAX_RETRY); xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); @@ -98,6 +110,7 @@ static void event_handler(void *arg, esp_event_base_t event_base, esp_err_t wifi_manager_init(void) { s_wifi_event_group = xEventGroupCreate(); + s_retry_timer = xTimerCreate("wifi_retry", pdMS_TO_TICKS(1000), pdFALSE, NULL, retry_timer_callback); ESP_ERROR_CHECK(esp_netif_init()); esp_netif_create_default_wifi_sta(); diff --git a/taolun.md b/taolun.md index a65beaf..5b46e8f 100644 --- a/taolun.md +++ b/taolun.md @@ -80,3 +80,47 @@ idf.py -p COMx flash monitor 1. 认证方式差异确认 2. 模型名称规范 3. 工具调用格式兼容性验证 + +--- + +## 讨论:时区设置功能 + +**日期**:2026-04-01 +**目标**:为 MimiClaw 添加可配置的时区支持,默认改为中国时区 + +### 背景 +- 原默认时区为 `PST8PDT,M3.2.0,M11.1.0`(太平洋时间) +- 需要支持用户自定义时区,特别是中国用户(UTC+8) +- 交互方式从 Telegram 改为飞书 + +### 实现方案 + +#### 存储方式 +- **NVS 存储**:使用 `system_config` namespace,key 为 `timezone` +- **Build-time 默认值**:`MIMI_TIMEZONE` 改为 `"CST-8"` +- **优先级**:NVS 值 > Build-time 值 + +#### CLI 命令 +``` +set_timezone # 例如: set_timezone CST-8 或 set_timezone Asia/Shanghai +timezone_show # 显示当前时区配置和本地时间 +``` + +#### LLM 工具 +- 新增 `set_timezone` 工具,LLM 可通过对话设置时区 +- 支持 POSIX 格式(`CST-8`)和城市名(`Asia/Shanghai`) +- 内置 18 个城市名映射表 + +### 改动文件 +| 文件 | 操作 | +|------|------| +| `main/mimi_config.h` | 默认时区改为 `CST-8`,添加 `MIMI_NVS_KEY_TIMEZONE` | +| `main/tools/tool_set_timezone.h` | **新建** | +| `main/tools/tool_set_timezone.c` | **新建** | +| `main/tools/tool_registry.c` | include 新头文件 + 注册工具 | +| `main/cli/serial_cli.c` | 添加 `set_timezone` / `timezone_show` 命令 | +| `main/CMakeLists.txt` | 添加 `tool_set_timezone.c` 到 SRCS | + +### 支持的时区格式 +- POSIX: `CST-8`, `JST-9`, `EST5EDT,M3.2.0,M11.1.0`, `UTC0` +- 城市名: Asia/Shanghai, Asia/Tokyo, America/New_York 等 18 个预设