Bỏ qua, đến nội dung chính
Nastrotek
MochiFirmware

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ẻ

LinkedInFacebookX
Original OLED prototype displaying a simple face during firmware experiments

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

Sơ đồ bốn lớp tách nguồn sự kiện, state machine, lớp trình bày cảm xúc và LVGL renderer của robot Mochi.

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ĩaCách thoát thường gặp
IDLESẵn sàng, không có tác vụ chínhCó 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úcHết giọng nói, hủy hoặc timeout
THINKINGĐang xử lý yêu cầuCó kết quả, lỗi hoặc timeout
TALKINGĐang phát lời đápAudio kết thúc hoặc bị ngắt
HAPPYPhản hồi tích cực ngắnHết thời lượng biểu cảm
SADPhản hồi tiêu cực nhưng không phải lỗi hệ thốngHết thời lượng biểu cảm
ERRORChức năng quan trọng không thể tiếp tụcLỗi được xử lý hoặc người dùng reset

HAPPYSAD 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

Sơ đồ chuyển giữa idle, listening, thinking, talking, happy, sad và error với quy tắc ư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à:

  1. EVT_FAULT có ưu tiên cao nhất và có thể đưa mọi state sang ERROR.
  2. EVT_CANCEL dừng listening, thinking hoặc talking và đưa robot về IDLE.
  3. Event hoàn thành chỉ hợp lệ trong state tương ứng. Ví dụ EVT_AUDIO_FINISHED không được đổi state nếu Mochi không còn ở TALKING.
  4. HAPPYSAD có deadline; khi hết hạn, controller phát sinh EVT_TIMEOUT.
  5. 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ó ERROR hoặc CANCEL.
  • Deadline giới hạn LISTENING, THINKING, HAPPYSAD để 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à THINKINGERROR.
  • 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_FAULT luô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ẻ

LinkedInFacebookX

Tiếp tục khám phá

Đọc tiếp

Bài viết liên quan

Xem thêm trong Mochi

Nastrotek sử dụng cookie để phân tích truy cập và cá nhân hóa quảng cáo, giúp hiểu cách website được sử dụng. Bạn có thể chấp nhận hoặc từ chối các cookie không thiết yếu.