Thiết kế hệ thống trạng thái cảm xúc cho robot Mochi
Xây dựng state machine cảm xúc cho Robot Mochi: idle, listening, talking, happy, sad, thinking, error và animation dễ thay thế.
Chia sẻ

Một robot có khuôn mặt không chỉ cần nhiều ảnh đẹp. Nó còn phải chọn đúng biểu cảm, đúng thời điểm và biết khi nào nên dừng. Nếu microphone vừa phát hiện giọng nói, dịch vụ mạng đang xử lý câu hỏi, loa bắt đầu phát câu trả lời và pin đồng thời báo lỗi, màn hình nên thể hiện trạng thái nào?
Cách nhanh nhất ở prototype là để mỗi module gọi thẳng hàm đổi GIF. Mình cũng có thể bắt đầu như vậy, nhưng chỉ sau vài trạng thái sẽ xuất hiện hàng loạt nhánh if, animation giẫm lên nhau và logic phụ thuộc vào tên file. Một state machine nhỏ giải quyết phần lớn vấn đề đó, miễn là giữ một ranh giới rõ ràng: state machine quyết định Mochi đang ở trạng thái nào; lớp trình bày quyết định trạng thái đó trông ra sao.
Kiến trúc bốn lớp
Kiến trúc đề xuất giữ trạng thái ứng dụng độc lập với asset và cách chạy animation.
Hệ thống có thể chia thành bốn lớp:
- Nguồn sự kiện nhận tín hiệu từ nút chạm, microphone, audio player, dịch vụ hội thoại, timer và bộ giám sát lỗi.
- Emotion controller là state machine duy nhất được phép thay đổi trạng thái cảm xúc hiện tại.
- Presentation map ánh xạ trạng thái logic sang một
emotion_scene_t, chẳng hạn asset khuôn mặt, tốc độ chớp mắt, màu nhấn và âm thanh phụ. - Renderer chạy trên ngữ cảnh UI, cập nhật widget LVGL, GIF, frame sequence hoặc animation timeline.
Điểm quan trọng là event producer không biết Mochi đang dùng GIF nào. Tương tự, renderer không cần biết vì sao robot chuyển sang THINKING. Nhờ đó bạn có thể thay một bộ Emoji bằng bộ khác, chuyển từ GIF sang sprite sheet hoặc thêm giao diện tiết kiệm pin mà không sửa code nhận giọng nói.
Bảy trạng thái cốt lõi
Nên bắt đầu với một enum nhỏ và ổn định:
typedef enum {
MOCHI_IDLE,
MOCHI_LISTENING,
MOCHI_THINKING,
MOCHI_TALKING,
MOCHI_HAPPY,
MOCHI_SAD,
MOCHI_ERROR,
} mochi_state_t;
Mỗi trạng thái trả lời một ý nghĩa ứng dụng, không mô tả animation cụ thể:
| Trạng thái | Ý nghĩa | Cách thoát thường gặp |
|---|---|---|
IDLE | Sẵn sàng, không có tác vụ chính | Có giọng nói, chạm hoặc sự kiện mới |
LISTENING | Đang thu hoặc chờ câu nói kết thúc | Hết giọng nói, hủy hoặc timeout |
THINKING | Đang xử lý yêu cầu | Có kết quả, lỗi hoặc timeout |
TALKING | Đang phát lời đáp | Audio kết thúc hoặc bị ngắt |
HAPPY | Phản hồi tích cực ngắn | Hết thời lượng biểu cảm |
SAD | Phản hồi tiêu cực nhưng không phải lỗi hệ thống | Hết thời lượng biểu cảm |
ERROR | Chức năng quan trọng không thể tiếp tục | Lỗi được xử lý hoặc người dùng reset |
HAPPY và SAD thường là trạng thái tạm thời. Chúng có thể được chèn sau một hành động rồi quay lại IDLE, hoặc quay về trạng thái trước đó nếu sản phẩm cần phản hồi tinh tế hơn. Với phiên bản đầu, quay về IDLE dễ kiểm soát và dễ test hơn.
Event khác với state
Một lỗi phổ biến là biến mọi tín hiệu thành state: TOUCHING, WIFI_CONNECTED, AUDIO_FINISHED. Phần lớn các tên này thực ra là event. Event xảy ra tại một thời điểm; state tồn tại trong một khoảng thời gian.
typedef enum {
EVT_WAKE_WORD,
EVT_SPEECH_END,
EVT_REPLY_READY,
EVT_AUDIO_FINISHED,
EVT_POSITIVE_FEEDBACK,
EVT_NEGATIVE_FEEDBACK,
EVT_TIMEOUT,
EVT_FAULT,
EVT_RECOVERED,
EVT_CANCEL,
} mochi_event_type_t;
typedef struct {
mochi_event_type_t type;
uint32_t timestamp_ms;
int32_t detail;
} mochi_event_t;
Trường detail có thể chứa mã lỗi hoặc mức độ phản hồi. Nếu payload lớn hơn, nên truyền một cấu trúc riêng hoặc handle có vòng đời rõ ràng thay vì nhét pointer tạm vào queue.
Luật chuyển trạng thái và độ ưu tiên
Các trạng thái ngắn quay về idle theo timeout; error có ưu tiên cao và chỉ thoát khi lỗi đã được xử lý.
Không phải transition nào cũng có giá trị như nhau. Một bộ luật thực dụng cho Mochi là:
EVT_FAULTcó ưu tiên cao nhất và có thể đưa mọi state sangERROR.EVT_CANCELdừng listening, thinking hoặc talking và đưa robot vềIDLE.- Event hoàn thành chỉ hợp lệ trong state tương ứng. Ví dụ
EVT_AUDIO_FINISHEDkhông được đổi state nếu Mochi không còn ởTALKING. HAPPYvàSADcó deadline; khi hết hạn, controller phát sinhEVT_TIMEOUT.- Event cũ phải được bỏ qua nếu nó thuộc một phiên tương tác đã bị thay thế.
Quy tắc thứ năm đặc biệt hữu ích. Giả sử request A bị hủy, request B đã bắt đầu, nhưng callback mạng của A về muộn. Nếu event chỉ chứa EVT_REPLY_READY, Mochi có thể nói nhầm câu trả lời. Thêm session_id vào event hoặc controller context giúp loại bỏ callback đã lỗi thời.
Một reducer dễ kiểm thử
Phần quyết định state nên là hàm thuần: nhận state hiện tại và event, trả về state kế tiếp. Nó không gọi LVGL, không phát audio và không tạo timer.
static mochi_state_t reduce_state(mochi_state_t current,
const mochi_event_t *event)
{
if (event->type == EVT_FAULT) return MOCHI_ERROR;
if (event->type == EVT_CANCEL) return MOCHI_IDLE;
switch (current) {
case MOCHI_IDLE:
if (event->type == EVT_WAKE_WORD) return MOCHI_LISTENING;
if (event->type == EVT_POSITIVE_FEEDBACK) return MOCHI_HAPPY;
if (event->type == EVT_NEGATIVE_FEEDBACK) return MOCHI_SAD;
break;
case MOCHI_LISTENING:
if (event->type == EVT_SPEECH_END) return MOCHI_THINKING;
if (event->type == EVT_TIMEOUT) return MOCHI_IDLE;
break;
case MOCHI_THINKING:
if (event->type == EVT_REPLY_READY) return MOCHI_TALKING;
if (event->type == EVT_TIMEOUT) return MOCHI_SAD;
break;
case MOCHI_TALKING:
if (event->type == EVT_AUDIO_FINISHED) return MOCHI_IDLE;
break;
case MOCHI_HAPPY:
case MOCHI_SAD:
if (event->type == EVT_TIMEOUT) return MOCHI_IDLE;
break;
case MOCHI_ERROR:
if (event->type == EVT_RECOVERED) return MOCHI_IDLE;
break;
}
return current;
}
Khi reducer trả về state mới, controller mới thực hiện side effect: ghi log, cập nhật deadline, gửi lệnh sang UI và thông báo cho audio task. Cấu trúc này cho phép viết test dạng bảng cho từng cặp current + event, kể cả các event cần bị bỏ qua.
Tách state khỏi bộ animation
Không nên đặt tên asset trực tiếp trong reducer. Thay vào đó, tạo một bảng trình bày có thể thay thế:
typedef struct {
const void *face_asset;
uint16_t transition_ms;
uint16_t minimum_hold_ms;
bool loop;
} emotion_scene_t;
static const emotion_scene_t default_theme[] = {
[MOCHI_IDLE] = { &face_idle, 180, 0, true },
[MOCHI_LISTENING] = { &face_listening, 120, 0, true },
[MOCHI_THINKING] = { &face_thinking, 160, 0, true },
[MOCHI_TALKING] = { &face_talking, 100, 0, true },
[MOCHI_HAPPY] = { &face_happy, 140, 1200, false },
[MOCHI_SAD] = { &face_sad, 180, 1400, false },
[MOCHI_ERROR] = { &face_error, 80, 0, true },
};
Một theme khác chỉ cần cung cấp bảng mới có cùng số state. Nếu phần cứng ít flash, face_asset có thể trỏ tới icon tĩnh; nếu có PSRAM, nó có thể trỏ tới GIF hoặc frame sequence. Logic hội thoại không thay đổi.
Dùng LVGL đúng vai trò
Animation của LVGL thay đổi một giá trị từ điểm đầu đến điểm cuối theo thời gian thông qua callback. Nó phù hợp cho fade, scale, dịch chuyển, độ sáng hoặc chuyển màu. Animation timeline hữu ích khi một biểu cảm gồm nhiều chuyển động phối hợp, ví dụ mắt nheo trước rồi miệng cười xuất hiện sau.
State controller chỉ nên yêu cầu ui_show_state(next_state). Hàm UI tra presentation map, dừng animation xung đột trên cùng target rồi tạo animation mới. Không nên chờ animation hoàn tất bằng vòng lặp block; dùng completed callback để gửi một event nếu state machine thực sự cần biết.
Một nguyên tắc thực tế là chỉ task UI được gọi API LVGL. Callback từ microphone, network hoặc timer gửi event vào queue, thay vì sửa widget trực tiếp. Điều này vừa giảm race condition, vừa giữ thứ tự render dễ dự đoán.
Kết nối với FreeRTOS
FreeRTOS queue là lựa chọn phù hợp để chuyển các event nhỏ giữa task và từ interrupt sang task. Queue sao chép item, vì vậy một mochi_event_t nhỏ có vòng đời đơn giản và không phụ thuộc stack của producer.
static QueueHandle_t emotion_queue;
void emotion_task(void *arg)
{
mochi_event_t event;
mochi_state_t state = MOCHI_IDLE;
for (;;) {
if (xQueueReceive(emotion_queue, &event, portMAX_DELAY) != pdTRUE) {
continue;
}
mochi_state_t next = reduce_state(state, &event);
if (next != state) {
state = next;
ui_command_t command = { .state = next };
xQueueSend(ui_queue, &command, 0);
update_state_deadline(next);
}
}
}
Không cần tạo một task cho mỗi state. Một controller task và một UI task thường đã đủ cho robot nhỏ. Nếu event chỉ là cờ đánh thức đơn giản, task notification nhẹ hơn queue; nhưng queue dễ mở rộng khi cần type, timestamp, mã lỗi và session ID.
Timeout và chống nhấp nháy trạng thái
Mochi sẽ trông thiếu tự nhiên nếu state đổi quá nhanh. Có ba cơ chế nên thêm từ đầu:
- Minimum hold giữ một biểu cảm ít nhất vài trăm mili giây, trừ khi có
ERRORhoặcCANCEL. - Deadline giới hạn
LISTENING,THINKING,HAPPYvàSADđể robot không bị kẹt vô hạn. - Coalescing gộp các event lặp như mức âm thanh hoặc touch move, tránh làm đầy queue.
Timer callback không nên tự đổi state. Nó gửi EVT_TIMEOUT kèm generation hoặc session ID. Controller kiểm tra timeout đó còn thuộc state hiện tại hay không trước khi chấp nhận.
Checklist kiểm thử
Trước khi nối với GIF thật, có thể dùng màn hình màu hoặc text label để test logic:
- Mọi state có đường thoát hợp lệ, đặc biệt là
THINKINGvàERROR. - Event đến sai state không gây chuyển trạng thái ngoài ý muốn.
- Callback cũ của phiên trước bị bỏ qua.
EVT_FAULTluôn thắng event thường đang chờ trong queue.- Animation hoàn tất không kéo UI về state cũ.
- Thay toàn bộ presentation map không làm thay đổi test của reducer.
- Queue đầy có log hoặc metric, không âm thầm mất lỗi nghiêm trọng.
Sau khi state machine ổn định, mới gắn bộ Emoji hoàn chỉnh và tinh chỉnh thời lượng. Cách làm này giúp phân biệt lỗi logic với lỗi asset hoặc render, vốn rất dễ bị trộn lẫn nếu phát triển cả hai cùng lúc.
Kết luận
Hệ thống cảm xúc của Mochi nên được xem là một phần của kiến trúc firmware, không chỉ là tập hợp GIF. State machine quản lý ý nghĩa và luật chuyển; presentation map chọn cách thể hiện; LVGL thực hiện chuyển động; FreeRTOS queue đưa event về đúng nơi xử lý. Khi các lớp này độc lập, robot dễ mở rộng hơn và một bộ giao diện mới thực sự có thể được thay mà không phải viết lại luồng hội thoại.
Tham khảo
Đọc thêm liên quan
Chia sẻ
Tiếp tục khám phá
Đọc tiếp
Bài viết liên quan
Tối ưu thời lượng pin cho robot ESP32 có màn hình
Phân tích thời lượng pin cho robot ESP32 có màn hình: backlight, Wi-Fi, audio, ngoại vi, sleep mode, wake-up và runtime.
Ghi chú đầu tiên về firmware cho sản phẩm nhúng
Nguyên tắc ban đầu khi viết firmware cho một sản phẩm nhúng nhỏ: phạm vi, ranh giới module, logging, và xử lý lỗi mà không đoán mò.
Chạy Linux trên ESP32-S31: MMU thay đổi điều gì?
ESP32-S31 đã có Linux BSP chính thức ở dạng Developer Preview. Mình cùng xem MMU, Buildroot, U-Boot, Linux 6.18 và những giới hạn thực tế trước khi chọn board để thử.