diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index b41239e..061ab18 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -653,20 +653,12 @@ elseif(CONFIG_BOARD_TYPE_ZHENGCHEN_CAM) set(BOARD_TYPE "zhengchen-cam") set(BUILTIN_TEXT_FONT font_puhui_20_4) set(BUILTIN_ICON_FONT font_awesome_20_4) - if(CONFIG_ZHENGCHEN_CAM_USE_GIF_EMOJI) - set(DEFAULT_EMOJI_COLLECTION main/boards/zhengchen-cam/custom-emojis/gif) - else() - set(DEFAULT_EMOJI_COLLECTION main/boards/zhengchen-cam/custom-emojis/png) - endif() + set(DEFAULT_EMOJI_COLLECTION main/boards/zhengchen-cam/custom-emojis) elseif(CONFIG_BOARD_TYPE_ZHENGCHEN_CAM_ML307) set(BOARD_TYPE "zhengchen-cam-ml307") set(BUILTIN_TEXT_FONT font_puhui_20_4) set(BUILTIN_ICON_FONT font_awesome_20_4) - if(CONFIG_ZHENGCHEN_CAM_USE_GIF_EMOJI) - set(DEFAULT_EMOJI_COLLECTION main/boards/zhengchen-cam/custom-emojis/gif) - else() - set(DEFAULT_EMOJI_COLLECTION main/boards/zhengchen-cam/custom-emojis/png) - endif() + set(DEFAULT_EMOJI_COLLECTION main/boards/zhengchen-cam/custom-emojis) elseif(CONFIG_BOARD_TYPE_SPOTPEAR_ESP32_S3_1_54_MUMA) set(BOARD_TYPE "sp-esp32-s3-1.54-muma") set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) diff --git a/main/application.cc b/main/application.cc index 8221ddb..7b65794 100644 --- a/main/application.cc +++ b/main/application.cc @@ -26,6 +26,12 @@ namespace { constexpr const char* kDirectWebsocketUrl = "ws://172.19.0.240:8080"; constexpr int kDirectWebsocketVersion = 3; constexpr bool kUseDirectWebsocketWithoutOta = true; +constexpr int kDefaultFatigueListeningTimeoutSec = 12; +constexpr int kDefaultFatigueIdleTimeoutSec = 12; +constexpr int kDefaultFatigueCooldownSec = 60; +constexpr const char* kDefaultFatigueEmotion = "wakeup"; +constexpr const char* kDefaultFatigueMessage = "你是不是又要睡着啦?快醒醒,我还要给你跳舞呢~"; +constexpr const char* kDefaultFatigueStatus = "打起精神"; void StartDirectTimeSync() { setenv("TZ", "CST-8", 1); @@ -283,6 +289,7 @@ void Application::Run() { clock_ticks_++; auto display = Board::GetInstance().GetDisplay(); display->UpdateStatusBar(); + CheckFatigueReminder(); // Print debug info every 10 seconds if (clock_ticks_ % 10 == 0) { @@ -706,6 +713,108 @@ void Application::DismissAlert() { } } +void Application::CheckFatigueReminder() { + auto state = GetDeviceState(); + if (state != kDeviceStateListening) { + fatigue_silence_seconds_ = 0; + fatigue_reminder_triggered_in_listening_ = false; + } + if (state != kDeviceStateIdle) { + fatigue_idle_seconds_ = 0; + } + + Settings settings("fatigue", false); + if (!settings.GetBool("enabled", true)) { + return; + } + + int64_t now_us = esp_timer_get_time(); + int cooldown_sec = settings.GetInt("cooldown_sec", kDefaultFatigueCooldownSec); + if (cooldown_sec < 10) { + cooldown_sec = 10; + } else if (cooldown_sec > 3600) { + cooldown_sec = 3600; + } + if (last_fatigue_reminder_time_us_ != 0 && + now_us - last_fatigue_reminder_time_us_ < static_cast(cooldown_sec) * 1000000) { + return; + } + + if (state == kDeviceStateIdle) { + fatigue_idle_seconds_++; + + int idle_timeout_sec = settings.GetInt("idle_timeout_sec", kDefaultFatigueIdleTimeoutSec); + if (idle_timeout_sec < 3) { + idle_timeout_sec = 3; + } else if (idle_timeout_sec > 3600) { + idle_timeout_sec = 3600; + } + if (fatigue_idle_seconds_ >= idle_timeout_sec) { + fatigue_idle_seconds_ = 0; + last_fatigue_reminder_time_us_ = now_us; + TriggerFatigueReminder(); + } + return; + } + + if (state != kDeviceStateListening) { + return; + } + + if (audio_service_.IsVoiceDetected()) { + fatigue_silence_seconds_ = 0; + fatigue_reminder_triggered_in_listening_ = false; + return; + } + + fatigue_silence_seconds_++; + if (fatigue_reminder_triggered_in_listening_) { + return; + } + + int timeout_sec = settings.GetInt("listening_timeout_sec", kDefaultFatigueListeningTimeoutSec); + if (timeout_sec < 3) { + timeout_sec = 3; + } else if (timeout_sec > 300) { + timeout_sec = 300; + } + if (fatigue_silence_seconds_ < timeout_sec) { + return; + } + + fatigue_reminder_triggered_in_listening_ = true; + last_fatigue_reminder_time_us_ = now_us; + TriggerFatigueReminder(); +} + +void Application::TriggerFatigueReminder() { + Settings settings("fatigue", false); + std::string emotion = settings.GetString("emotion", kDefaultFatigueEmotion); + std::string message = settings.GetString("message", kDefaultFatigueMessage); + std::string sound_asset = settings.GetString("sound_asset"); + + ESP_LOGW(TAG, "Fatigue reminder triggered: silence=%ds, emotion=%s", + fatigue_silence_seconds_, emotion.c_str()); + + auto display = Board::GetInstance().GetDisplay(); + display->SetStatus(kDefaultFatigueStatus); + display->SetEmotion(emotion.c_str()); + display->SetChatMessage("assistant", message.c_str()); + + if (!sound_asset.empty()) { + void* ptr = nullptr; + size_t size = 0; + auto& assets = Assets::GetInstance(); + if (assets.partition_valid() && assets.GetAssetData(sound_asset, ptr, size)) { + audio_service_.PlaySound(std::string_view(static_cast(ptr), size)); + return; + } + ESP_LOGW(TAG, "Fatigue sound asset not found: %s", sound_asset.c_str()); + } + + audio_service_.PlaySound(Lang::Sounds::OGG_POPUP); +} + void Application::ToggleChatState() { xEventGroupSetBits(event_group_, MAIN_EVENT_TOGGLE_CHAT); } @@ -919,11 +1028,12 @@ void Application::HandleStateChangedEvent() { break; case kDeviceStateConnecting: display->SetStatus(Lang::Strings::CONNECTING); - display->SetEmotion("neutral"); display->SetChatMessage("system", ""); + display->SetEmotion("neutral"); break; case kDeviceStateListening: display->SetStatus(Lang::Strings::LISTENING); + display->ClearChatMessages(); display->SetEmotion("neutral"); // Make sure the audio processor is running diff --git a/main/application.h b/main/application.h index 7ca7af4..1cea287 100644 --- a/main/application.h +++ b/main/application.h @@ -140,6 +140,10 @@ private: bool aborted_ = false; bool assets_version_checked_ = false; bool play_popup_on_listening_ = false; // Flag to play popup sound after state changes to listening + bool fatigue_reminder_triggered_in_listening_ = false; + int fatigue_silence_seconds_ = 0; + int fatigue_idle_seconds_ = 0; + int64_t last_fatigue_reminder_time_us_ = 0; int clock_ticks_ = 0; TaskHandle_t activation_task_handle_ = nullptr; @@ -155,6 +159,8 @@ private: void HandleWakeWordDetectedEvent(); void ContinueOpenAudioChannel(ListeningMode mode); void ContinueWakeWordInvoke(const std::string& wake_word); + void CheckFatigueReminder(); + void TriggerFatigueReminder(); // Activation task (runs in background) void ActivationTask(); diff --git a/main/boards/zhengchen-cam/custom-emojis/README.md b/main/boards/zhengchen-cam/custom-emojis/README.md index 8a64a71..d9e1c13 100644 --- a/main/boards/zhengchen-cam/custom-emojis/README.md +++ b/main/boards/zhengchen-cam/custom-emojis/README.md @@ -26,13 +26,28 @@ Recommended minimum set: - `sad` - `angry` +Fatigue reminder: + +- Add `wakeup.gif` or `wakeup.png` to make the idle-fatigue reminder show a custom idol animation. +- The reminder defaults to `wakeup` after 12 seconds of idle time or listening silence, then waits 60 seconds before it can trigger again. +- Optional NVS settings in namespace `fatigue`: + - `enabled` (`bool`, default `true`) + - `idle_timeout_sec` (`int`, default `12`) + - `listening_timeout_sec` (`int`, default `12`) + - `cooldown_sec` (`int`, default `60`) + - `emotion` (`string`, default `wakeup`) + - `message` (`string`, default Chinese wake-up line) + - `sound_asset` (`string`, optional OGG filename in the assets partition) + If an emotion-specific image is missing, the firmware falls back to `neutral` before using the built-in icon. -For Zhengchen CAM boards, the build selects one subdirectory: +For Zhengchen CAM boards, the build packages both subdirectories: -- `CONFIG_ZHENGCHEN_CAM_USE_GIF_EMOJI=y`: use `gif/` -- unset `CONFIG_ZHENGCHEN_CAM_USE_GIF_EMOJI`: use `png/` +- Put static/default faces in `png/`, such as `png/neutral.png`. +- Put animated/special actions in `gif/`, such as `gif/wakeup.gif`. +- If both folders contain the same emotion name, PNG wins. For example, + `png/neutral.png` is used before `gif/neutral.gif`. After adding or replacing files, run a full flash so the assets partition is updated: diff --git a/main/boards/zhengchen-cam/custom-emojis/gif/flylove.gif b/main/boards/zhengchen-cam/custom-emojis/gif/flylove.gif new file mode 100644 index 0000000..3f784a6 Binary files /dev/null and b/main/boards/zhengchen-cam/custom-emojis/gif/flylove.gif differ diff --git a/main/boards/zhengchen-cam/custom-emojis/gif/neutral.gif b/main/boards/zhengchen-cam/custom-emojis/gif/neutral.gif index 3f784a6..9c96c56 100644 Binary files a/main/boards/zhengchen-cam/custom-emojis/gif/neutral.gif and b/main/boards/zhengchen-cam/custom-emojis/gif/neutral.gif differ diff --git a/main/boards/zhengchen-cam/custom-emojis/gif/neutralbak.gif b/main/boards/zhengchen-cam/custom-emojis/gif/neutralbak.gif new file mode 100644 index 0000000..3f784a6 Binary files /dev/null and b/main/boards/zhengchen-cam/custom-emojis/gif/neutralbak.gif differ diff --git a/main/boards/zhengchen-cam/custom-emojis/gif/wakeup.gif b/main/boards/zhengchen-cam/custom-emojis/gif/wakeup.gif new file mode 100644 index 0000000..0b03c49 Binary files /dev/null and b/main/boards/zhengchen-cam/custom-emojis/gif/wakeup.gif differ diff --git a/main/display/lcd_display.cc b/main/display/lcd_display.cc index fd4e838..1c57ad9 100644 --- a/main/display/lcd_display.cc +++ b/main/display/lcd_display.cc @@ -29,6 +29,9 @@ lv_coord_t ObjectHeight(lv_obj_t* obj) { if (obj == nullptr) { return 0; } + if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) { + return 0; + } lv_obj_update_layout(obj); return lv_obj_get_height(obj); } @@ -1202,8 +1205,9 @@ void LcdDisplay::SetEmotion(const char* emotion) { } DisplayLockGuard lock(this); - lv_coord_t top_reserved = ObjectHeight(top_bar_); - lv_coord_t bottom_reserved = ObjectHeight(status_bar_) + ObjectHeight(bottom_bar_); + bool use_full_screen_center = strcmp(emotion, "neutral") == 0; + lv_coord_t top_reserved = use_full_screen_center ? 0 : ObjectHeight(top_bar_); + lv_coord_t bottom_reserved = use_full_screen_center ? 0 : ObjectHeight(status_bar_) + ObjectHeight(bottom_bar_); auto apply_emoji_layout = [this, top_reserved, bottom_reserved](const lv_image_dsc_t* image_dsc) { ApplyEmojiImageScale(emoji_image_, emoji_box_, image_dsc, top_reserved, bottom_reserved); }; diff --git a/scripts/build_default_assets.py b/scripts/build_default_assets.py index d5f64ce..5fd0a83 100644 --- a/scripts/build_default_assets.py +++ b/scripts/build_default_assets.py @@ -235,8 +235,14 @@ def process_emoji_collection(emoji_collection_dir, assets_dir): "buxue": ["thinking", "confused", "embarrassed"] } - # Copy each image from input directory to build/assets directory + seen_emoji_names = set() + + # Copy each image from input directory to build/assets directory. Prefer PNG + # over GIF for duplicate emotion names so static defaults can coexist with + # animated special actions in sibling directories. for root, dirs, files in os.walk(emoji_collection_dir): + dirs.sort(key=lambda d: (0 if d == "png" else 1 if d == "gif" else 2, d)) + files.sort(key=lambda f: (0 if f.lower().endswith(".png") else 1, f)) for file in files: if file.lower().endswith(('.png', '.gif')): # Copy file @@ -245,6 +251,9 @@ def process_emoji_collection(emoji_collection_dir, assets_dir): if copy_file(src_file, dst_file): # Get filename without extension filename_without_ext = os.path.splitext(file)[0] + if filename_without_ext in seen_emoji_names: + continue + seen_emoji_names.add(filename_without_ext) # Add main emoji entry emoji_list.append({