From b03b6d2c31b1709d51058dc6b2f4a7db3bffc000 Mon Sep 17 00:00:00 2001 From: bongkj Date: Tue, 24 Mar 2026 14:42:28 +0900 Subject: [PATCH 1/2] Add robot companion controller foundation --- .../atom_echo_m5stack_esp32_ino.ino | 3 + arduino/atom_echo_m5stack_esp32_ino/config.h | 19 +- .../atom_echo_m5stack_esp32_ino/protocol.cpp | 22 ++ .../robot_bridge.cpp | 55 +++ .../robot_bridge.h | 11 + .../servo_control.cpp | 19 +- .../robot_companion_controller.ino | 40 ++ docs/AGENT_FEATURE_PLANNING.md | 21 ++ docs/PRD.md | 188 ++-------- docs/ROBOT_MODE_WAVESHARE_PRD.md | 351 ++++++++++++++++++ server/config_loader.py | 89 +++++ server/server.py | 9 +- server/src/robot_mode.py | 117 +++++- server/tests/test_config_loader.py | 26 ++ server/tests/test_robot_mode_extended.py | 30 ++ 15 files changed, 827 insertions(+), 173 deletions(-) create mode 100644 arduino/atom_echo_m5stack_esp32_ino/robot_bridge.cpp create mode 100644 arduino/atom_echo_m5stack_esp32_ino/robot_bridge.h create mode 100644 arduino/robot_companion_controller/robot_companion_controller.ino create mode 100644 docs/ROBOT_MODE_WAVESHARE_PRD.md diff --git a/arduino/atom_echo_m5stack_esp32_ino/atom_echo_m5stack_esp32_ino.ino b/arduino/atom_echo_m5stack_esp32_ino/atom_echo_m5stack_esp32_ino.ino index 881bca8..7f07464 100755 --- a/arduino/atom_echo_m5stack_esp32_ino/atom_echo_m5stack_esp32_ino.ino +++ b/arduino/atom_echo_m5stack_esp32_ino/atom_echo_m5stack_esp32_ino.ino @@ -26,6 +26,7 @@ #include "display_control.h" #include "led_control.h" #include "protocol.h" +#include "robot_bridge.h" #include "servo_control.h" #include "vad.h" @@ -124,6 +125,7 @@ void setup() { Serial.begin(SERIAL_BAUD_RATE); Serial.setTimeout(0); delay(500); // Wait for serial stabilization + robot_bridge_init(); // Initialize LED -> indicate connecting (red) led_init(); @@ -272,5 +274,6 @@ void loop() { led_update_pattern(); // LED animation pattern display_update(); // OLED face animation + blink (currently placeholder) servo_update(); // Servo async action (rotate/wiggle) step processing + robot_bridge_update(); delay(1); // Feed watchdog + yield CPU } diff --git a/arduino/atom_echo_m5stack_esp32_ino/config.h b/arduino/atom_echo_m5stack_esp32_ino/config.h index 5abdf37..efa2d12 100644 --- a/arduino/atom_echo_m5stack_esp32_ino/config.h +++ b/arduino/atom_echo_m5stack_esp32_ino/config.h @@ -34,9 +34,24 @@ extern const uint16_t SERVER_PORT; #define DISPLAY_HEIGHT 64 #define DISPLAY_I2C_ADDR 0x3C +// Companion robot bridge (Phase 1 scaffold) +// Disabled by default so the legacy direct-servo path keeps working. +// When enabled, the Grove pins are reassigned as UART TX/RX for a companion +// controller and local servo outputs are disabled. +#define ROBOT_BRIDGE_ENABLED 0 +#define ROBOT_BRIDGE_UART_PORT 1 +#define ROBOT_BRIDGE_BAUD 115200 +#define ROBOT_BRIDGE_TX_PIN 26 +#define ROBOT_BRIDGE_RX_PIN 32 + // Servo motors — Grove HY2.0-4P port -#define SERVO_PIN_PITCH 26 // Servo #1: nod (up/down) -#define SERVO_PIN_TILT 32 // Servo #2: tilt (left/right) +#if ROBOT_BRIDGE_ENABLED + #define SERVO_PIN_PITCH -1 + #define SERVO_PIN_TILT -1 +#else + #define SERVO_PIN_PITCH 26 // Servo #1: nod (up/down) + #define SERVO_PIN_TILT 32 // Servo #2: tilt (left/right) +#endif #define SERVO_MIN_ANGLE 0 #define SERVO_MAX_ANGLE 180 #define SERVO_CENTER_ANGLE 90 diff --git a/arduino/atom_echo_m5stack_esp32_ino/protocol.cpp b/arduino/atom_echo_m5stack_esp32_ino/protocol.cpp index 89a9350..c26d21a 100644 --- a/arduino/atom_echo_m5stack_esp32_ino/protocol.cpp +++ b/arduino/atom_echo_m5stack_esp32_ino/protocol.cpp @@ -15,6 +15,7 @@ #include "connection.h" #include "config.h" #include "led_control.h" +#include "robot_bridge.h" #include "servo_control.h" #include "display_control.h" #include @@ -365,6 +366,27 @@ static void handleCmdJson(const uint8_t* payload, uint16_t len) { return; } + // ── ROBOT_STATE: companion-forward first, local fallback second ── + if (has_action && strcmp(action, "ROBOT_STATE") == 0) { + if (robot_bridge_ready()) { + robot_bridge_forward_json(json); + } + + char face[32] = {0}; + char display_text[32] = {0}; + json_get_string(json, "face", face, sizeof(face)); + json_get_string(json, "display_text", display_text, sizeof(display_text)); + + display_show_face(face_from_string(face)); + if (display_text[0]) display_set_status_text(display_text); + + ServoAction sa = action_from_string(servo_action); + if (sa != ACTION_NONE) servo_play_action(sa); + + led_show_emotion(emotion); + return; + } + // ── ROBOT_EMOTION: display face + servo action + LED ── if (has_action && strcmp(action, "ROBOT_EMOTION") == 0) { char face[32] = {0}; diff --git a/arduino/atom_echo_m5stack_esp32_ino/robot_bridge.cpp b/arduino/atom_echo_m5stack_esp32_ino/robot_bridge.cpp new file mode 100644 index 0000000..68ce013 --- /dev/null +++ b/arduino/atom_echo_m5stack_esp32_ino/robot_bridge.cpp @@ -0,0 +1,55 @@ +#include "robot_bridge.h" +#include "config.h" + +#if ROBOT_BRIDGE_ENABLED + +#include +#include + +static HardwareSerial robot_bridge_serial(ROBOT_BRIDGE_UART_PORT); +static bool robot_bridge_started = false; + +void robot_bridge_init() { + robot_bridge_serial.begin( + ROBOT_BRIDGE_BAUD, + SERIAL_8N1, + ROBOT_BRIDGE_RX_PIN, + ROBOT_BRIDGE_TX_PIN); + robot_bridge_started = true; +} + +void robot_bridge_update() { + while (robot_bridge_serial.available() > 0) { + (void)robot_bridge_serial.read(); + } +} + +bool robot_bridge_ready() { + return robot_bridge_started; +} + +bool robot_bridge_forward_json(const char* json) { + if (!robot_bridge_started || !json || !*json) return false; + + size_t wanted = strlen(json); + size_t written = robot_bridge_serial.write((const uint8_t*)json, wanted); + if (written != wanted) return false; + return robot_bridge_serial.write('\n') == 1; +} + +#else + +void robot_bridge_init() {} + +void robot_bridge_update() {} + +bool robot_bridge_ready() { + return false; +} + +bool robot_bridge_forward_json(const char* json) { + (void)json; + return false; +} + +#endif diff --git a/arduino/atom_echo_m5stack_esp32_ino/robot_bridge.h b/arduino/atom_echo_m5stack_esp32_ino/robot_bridge.h new file mode 100644 index 0000000..7242162 --- /dev/null +++ b/arduino/atom_echo_m5stack_esp32_ino/robot_bridge.h @@ -0,0 +1,11 @@ +#ifndef ROBOT_BRIDGE_H +#define ROBOT_BRIDGE_H + +#include + +void robot_bridge_init(); +void robot_bridge_update(); +bool robot_bridge_ready(); +bool robot_bridge_forward_json(const char* json); + +#endif diff --git a/arduino/atom_echo_m5stack_esp32_ino/servo_control.cpp b/arduino/atom_echo_m5stack_esp32_ino/servo_control.cpp index 67d1276..8506533 100644 --- a/arduino/atom_echo_m5stack_esp32_ino/servo_control.cpp +++ b/arduino/atom_echo_m5stack_esp32_ino/servo_control.cpp @@ -18,6 +18,10 @@ struct ServoState { static Servo servos[2]; static ServoState servo_states[2]; +static bool servo_hw_enabled() { + return SERVO_PIN_PITCH >= 0 && SERVO_PIN_TILT >= 0; +} + static int clamp_angle(int angle) { if (angle < SERVO_MIN_ANGLE) return SERVO_MIN_ANGLE; if (angle > SERVO_MAX_ANGLE) return SERVO_MAX_ANGLE; @@ -72,19 +76,22 @@ static const ActionStep action_wiggle[] = { }; void servo_init() { + servo_states[0] = {SERVO_CENTER_ANGLE, 0, 0, nullptr, false}; + servo_states[1] = {SERVO_CENTER_ANGLE, 0, 0, nullptr, false}; + + if (!servo_hw_enabled()) return; + servos[0].setPeriodHertz(50); servos[0].attach(SERVO_PIN_PITCH, 500, 2400); servos[1].setPeriodHertz(50); servos[1].attach(SERVO_PIN_TILT, 500, 2400); - - servo_states[0] = {SERVO_CENTER_ANGLE, 0, 0, nullptr, false}; - servo_states[1] = {SERVO_CENTER_ANGLE, 0, 0, nullptr, false}; - + servos[0].write(SERVO_CENTER_ANGLE); servos[1].write(SERVO_CENTER_ANGLE); } void servo_set_angle(int servo_idx, int angle) { + if (!servo_hw_enabled()) return; if (servo_idx < 0 || servo_idx > 1) return; angle = clamp_angle(angle); servos[servo_idx].write(angle); @@ -96,6 +103,7 @@ void servo_set_angle(int angle) { } void servo_play_action(ServoAction action) { + if (!servo_hw_enabled()) return; const ActionStep* steps = nullptr; switch (action) { @@ -120,6 +128,7 @@ void servo_play_action(ServoAction action) { } void servo_stop() { + if (!servo_hw_enabled()) return; servo_states[0].busy = false; servo_states[1].busy = false; servo_states[0].current_action = nullptr; @@ -129,6 +138,7 @@ void servo_stop() { } void servo_update() { + if (!servo_hw_enabled()) return; if (!servo_states[0].busy || !servo_states[0].current_action) return; unsigned long now = millis(); @@ -149,6 +159,7 @@ void servo_update() { } bool servo_is_busy() { + if (!servo_hw_enabled()) return false; return servo_states[0].busy || servo_states[1].busy; } diff --git a/arduino/robot_companion_controller/robot_companion_controller.ino b/arduino/robot_companion_controller/robot_companion_controller.ino new file mode 100644 index 0000000..2cd3cf4 --- /dev/null +++ b/arduino/robot_companion_controller/robot_companion_controller.ino @@ -0,0 +1,40 @@ +#include + +#if defined(ESP32) +HardwareSerial BridgeSerial(1); +static constexpr int BRIDGE_RX_PIN = 16; +static constexpr int BRIDGE_TX_PIN = 17; +static constexpr uint32_t BRIDGE_BAUD = 115200; +#else +#error "robot_companion_controller currently targets ESP32-class companion boards." +#endif + +static String incoming_line; +static String last_robot_state; + +void setup() { + Serial.begin(115200); + BridgeSerial.begin(BRIDGE_BAUD, SERIAL_8N1, BRIDGE_RX_PIN, BRIDGE_TX_PIN); + Serial.println("[robot-companion] bridge ready"); +} + +static void handle_robot_line(const String& line) { + last_robot_state = line; + Serial.print("[robot-companion] state: "); + Serial.println(last_robot_state); +} + +void loop() { + while (BridgeSerial.available() > 0) { + char c = static_cast(BridgeSerial.read()); + if (c == '\r') continue; + if (c == '\n') { + if (incoming_line.length() > 0) { + handle_robot_line(incoming_line); + incoming_line = ""; + } + continue; + } + incoming_line += c; + } +} diff --git a/docs/AGENT_FEATURE_PLANNING.md b/docs/AGENT_FEATURE_PLANNING.md index f9caf35..a16fcd4 100644 --- a/docs/AGENT_FEATURE_PLANNING.md +++ b/docs/AGENT_FEATURE_PLANNING.md @@ -30,6 +30,7 @@ | SA-5 Voice ID | 화자 등록/식별/게이트 | Voice ID 기능 안정화 + 테스트 | | SA-6 Channel Expansion | Telegram 기반 iOS 채널 | Bot 연동 MVP + 운영 가이드 | | SA-7 Dev Productivity | 도구 PoC/자동화 | Ralph PoC 리포트 | +| SA-8 Robot Mode | Companion 기반 로봇 모드 | 로봇 PRD, bridge protocol, companion firmware, 하드웨어 가이드 | --- @@ -123,6 +124,18 @@ - `docker compose -f docker/docker-compose.test.yml run --rm server-test pytest server/tests/test_runtime_controller.py server/tests/test_runtime_preferences.py` - `docker compose -f docker/docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from server-test` +### EPIC-J: Robot Mode Re-Architecture (PRD 5.6) +- [ ] (SA-0, TODO) Atom Echo/SG90/Waveshare 공식 문서 기반 제약과 권장 아키텍처를 `docs/PRD.md` 및 `docs/ROBOT_MODE_WAVESHARE_PRD.md`에 고정 +- [ ] (SA-2/SA-8, TODO) `server -> Atom Echo -> Companion` 로봇 상태 페이로드와 회귀 시나리오를 정의하고 protocol test에 편입 +- [ ] (SA-8, TODO) `arduino/robot_companion_controller/` 신규 펌웨어 스켈레톤과 ST7789V2 + SG90(1~4채널) 드라이버 구조를 확정 +- [ ] (SA-5/SA-8, TODO) `SOUL + RELATION + MOOD + AFFECT + BODY` 기반 감정 지속 모델과 idle action policy를 설계/테스트 +- [ ] (SA-8, TODO) farewell/sleep/idle gesture preset과 display state machine(blink/gaze/yawn/talk overlay)을 구현 +- [ ] (SA-1/SA-2/SA-8, TODO) Docker 검증 명령과 실기 하드웨어 smoke checklist를 단일 실행 계획으로 문서화 +- 검증: + - `docker compose -f docker/docker-compose.test.yml run --rm server-test pytest server/tests/test_emotion_system.py server/tests/test_robot_mode_extended.py` + - `docker compose -f docker/docker-compose.test.yml run --rm server-test pytest server/tests/test_protocol.py` + - `docker compose -f docker/docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from server-test` + --- ## 4) 실행 순서 (권장 스프린트) @@ -151,6 +164,13 @@ - Telegram 채널 E2E smoke 통과 - 도구 도입 의사결정 리포트 완료 +### Sprint 5 (Robot Mode) +- SA-0, SA-2, SA-5, SA-8 수행: Companion 구조 확정 + 감정 지속 엔진 + 디스플레이/서보 제어 +- Exit Criteria: + - Atom Echo 단독 직결 대신 Companion 기반 배선/프로토콜이 문서와 설정에 반영됨 + - insult -> apology lingering mood 시나리오가 단위 테스트로 재현됨 + - ST7789V2 display state machine과 1~4채널 servo profile이 하드웨어 smoke checklist를 통과함 + --- ## 5) 공통 Definition of Ready / Done @@ -172,3 +192,4 @@ - 외부 API 변동: mock-services와 표준 오류코드로 회귀 안정성 확보 - 채널 확장 복잡도: Telegram MVP로 범위 제한 후 iOS 앱은 인터페이스 추상화 우선 - 도구 도입 리스크: PoC 결과 기반 점진 적용, 런타임 경로 직접 치환 금지 +- 로봇 하드웨어 제약: Atom Echo 단독 핀/전원 한계를 전제로 Companion 구조를 조기에 확정하고, direct path는 레거시 fallback으로만 유지 diff --git a/docs/PRD.md b/docs/PRD.md index 27896ce..49049b2 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -112,165 +112,27 @@ ## 5.6 기능 트랙 F — Robot 모드 (서보 + 디스플레이 기반 로봇 펫) -> 목표: Atom Echo에 SG90 서보모터와 SSD1306 OLED 디스플레이를 연결하여, LLM 응답에 따른 감정 표현(얼굴 애니메이션)과 물리적 액션(서보 동작)을 수행하는 로봇 펫 모드를 구현한다. - -### F-1. 하드웨어 제약 및 핀 배분 - -#### Atom Echo 사용 가능 GPIO (공식 문서 기준) -- **재사용 금지 핀**: G19, G22 (NS4168 I2S 스피커), G23, G33 (SPM1423 PDM 마이크) -- **내부 전용**: G27 (SK6812 RGB LED), G39 (버튼), G12 (IR TX) -- **사용 가능 핀**: G21, G25 (우측 헤더), G26, G32 (Grove HY2.0-4P 포트) -- **전원**: Grove 포트에서 5V/GND 공급 가능, GPIO는 모두 3.3V 레벨 - -> 출처: [M5Stack Atom Echo 공식 문서](https://docs.m5stack.com/en/atom/atomecho), [M5Stack 커뮤니티 핀 확인](https://community.m5stack.com/topic/4828/) - -#### 핀 배분 계획 - -| 기능 | 핀 | 비고 | -|---|---|---| -| OLED SCL | G21 | I2C 클럭 (우측 헤더) | -| OLED SDA | G25 | I2C 데이터 (우측 헤더) | -| 서보 #1 PWM | G26 | Grove 포트, 고개 끄덕임(pitch) | -| 서보 #2 PWM | G32 | Grove 포트, 고개 갸웃(roll/tilt) | -| 서보 VCC | Grove 5V | SG90 동작 전압 4.8-6V | -| OLED VCC | 3V3 헤더 | SSD1306 동작 전압 3.3-5V | -| 공통 GND | Grove GND | 모든 외부 장치 공유 | - -#### 부품 스펙 요약 - -| 부품 | 모델 | 주요 스펙 | -|---|---|---| -| 서보모터 | SG90 | 4.8-6V, PWM 50Hz, 180° 회전, 3.3V 신호 호환 | -| OLED 디스플레이 | SSD1306 0.96" | 128×64px, I2C, 3.3-5V, 주소 0x3C | - -> 출처: [SG90 스펙](https://www.espboards.dev/sensors/sg90/), [SSD1306 + ESP32 연결](https://randomnerdtutorials.com/esp32-ssd1306-oled-display-arduino-ide/) - -### F-2. 감정 표현 시스템 (Emotion Expression) - -LLM 응답에서 감정 태그를 추출하고, 디스플레이 + 서보를 조합하여 로봇 펫의 감정을 표현한다. - -#### 감정 정의 테이블 - -| 감정 ID | 이름 | OLED 표정 | 서보 #1 (pitch) | 서보 #2 (tilt) | RGB LED | -|---|---|---|---|---|---| -| `neutral` | 기본 | ●‿● 기본 눈 | 정면 90° | 정면 90° | 흰색 | -| `happy` | 기쁨 | ◠‿◠ 웃는 눈 | 위아래 끄덕 ×2 | 좌우 흔들 ×2 | 노란색 | -| `sad` | 슬픔 | ●︵● 처진 눈 | 아래로 천천히 | 정면 유지 | 파란색 | -| `angry` | 화남 | ▼_▼ 찡그린 눈 | 빠른 떨림 ×3 | 정면 유지 | 빨간색 | -| `surprised` | 놀람 | ◎◎ 큰 눈 | 위로 빠르게 | 정면 유지 | 흰색 깜빡 | -| `sleepy` | 졸림 | ─‿─ 감긴 눈 | 천천히 아래로 | 한쪽으로 기울임 | 어두운 보라 | -| `love` | 애정 | ♥‿♥ 하트 눈 | 위아래 끄덕 ×1 | 좌우 흔들 ×1 | 분홍색 | -| `curious` | 호기심 | ●_◉ 한쪽 큰 눈 | 정면 유지 | 한쪽으로 갸웃 | 초록색 | -| `excited` | 신남 | ★‿★ 별 눈 | 빠른 끄덕 ×4 | 빠른 흔들 ×4 | 무지개 순환 | -| `confused` | 혼란 | ●_●? 물음표 | 좌우 느린 회전 | 좌우 번갈아 기울임 | 주황색 깜빡 | - -#### OLED 애니메이션 기본 요소 -- **눈 깜빡임**: 3-5초 간격 랜덤 블링크 (모든 감정 공통) -- **시선 이동**: 눈동자 좌/우/위/아래 이동 (idle 상태에서 랜덤) -- **전환 애니메이션**: 감정 변경 시 200ms 페이드 전환 -- **상태 텍스트**: 하단 영역에 짧은 상태 메시지 표시 가능 (예: "듣는 중...", "생각 중...") - -### F-3. 물리 액션 프리셋 (Servo Action Presets) - -서보 2개를 조합한 물리적 동작 프리셋을 정의한다. - -| 액션 ID | 이름 | 서보 #1 동작 | 서보 #2 동작 | 소요 시간 | -|---|---|---|---|---| -| `nod_yes` | 끄덕끄덕 | 90°→70°→90° ×2 | 유지 | 800ms | -| `nod_no` | 도리도리 | 유지 | 90°→60°→120°→90° | 800ms | -| `tilt_curious` | 갸웃 | 유지 | 90°→65° (유지 1s) →90° | 1500ms | -| `bounce_happy` | 통통 | 90°→75°→90° ×3 (빠르게) | 90°→80°→100°→90° ×2 | 1200ms | -| `droop_sad` | 축 처짐 | 90°→110° (유지 2s) →90° | 유지 | 2500ms | -| `shake_angry` | 부르르 | 85°→95° ×5 (빠른 떨림) | 유지 | 600ms | -| `startle` | 깜짝 | 90°→60° (빠르게) →90° | 유지 | 400ms | -| `dance` | 춤 | 70°→110° 반복 ×4 | 60°→120° 반복 ×4 (역위상) | 2000ms | -| `sleep_drift` | 꾸벅꾸벅 | 90°→105°→90° (느리게) | 90°→80° (느리게) | 3000ms | -| `wiggle` | 꼬리흔들기 | 유지 | 75°→105° ×3 (빠르게) | 900ms | - -### F-4. 서버-디바이스 통신 프로토콜 확장 - -기존 TTS 응답 파이프라인에 Robot 제어 페이로드를 추가한다. - -#### Robot Control Payload 구조 (서버 → ESP32) -```json -{ - "type": "robot_action", - "emotion": "happy", - "action": "bounce_happy", - "display": { - "face": "happy", - "text": "안녕!" - }, - "led_color": [255, 200, 0] -} -``` - -#### 동작 우선순위 -1. 음성 재생 (TTS) — 최우선, 서보/디스플레이와 동시 실행 가능 -2. 감정 표현 (디스플레이 + 서보 + LED) — TTS와 병렬 실행 -3. Idle 애니메이션 — 명시적 명령이 없을 때 기본 눈 깜빡임 + 랜덤 시선 - -### F-5. LLM 감정 추출 연동 - -#### 서버 측 처리 흐름 -1. LLM 응답에서 감정 태그 추출 (프롬프트 엔지니어링 또는 후처리) -2. 감정 → (표정 + 액션 + LED 색상) 매핑 -3. Robot Control Payload 생성 -4. TTS 오디오와 함께 ESP32로 전송 - -#### 감정 추출 방식 -- **방식 A (프롬프트)**: LLM 시스템 프롬프트에 `[emotion:happy]` 형식 태그 삽입 요청 -- **방식 B (후처리)**: 응답 텍스트의 감성 분석으로 감정 자동 분류 -- 초기 구현은 방식 A를 우선 적용, 방식 B는 고도화 단계에서 검토 - -### F-6. 기능 플래그 및 설정 - -```yaml -# server/config.yaml -features: - robot_mode_enabled: true - -robot: - servo: - pin_pitch: 26 # 서보 #1 (G26) - pin_tilt: 32 # 서보 #2 (G32) - angle_min: 0 - angle_max: 180 - default_angle: 90 - display: - type: "ssd1306" - width: 128 - height: 64 - i2c_scl: 21 # G21 - i2c_sda: 25 # G25 - i2c_addr: "0x3C" - idle: - blink_interval_min: 3000 # ms - blink_interval_max: 5000 - gaze_interval: 8000 - emotion: - default: "neutral" - decay_to_neutral: true - decay_interval: 30 # seconds -``` - -### F-7. 구현 단계 (Phased Rollout) - -| Phase | 범위 | 산출물 | -|---|---|---| -| F-Phase 1 | OLED 기본 표정 표시 + 눈 깜빡임 idle | 펌웨어 디스플레이 드라이버, 표정 비트맵 | -| F-Phase 2 | 서보 단일 동작 (끄덕/도리) | 서보 PWM 드라이버, 액션 프리셋 | -| F-Phase 3 | 감정 ↔ 표정+액션 매핑 통합 | 서버 감정 추출, 제어 페이로드 전송 | -| F-Phase 4 | LLM 프롬프트 감정 태그 + 복합 애니메이션 | 프롬프트 템플릿, 동시 실행 스케줄러 | -| F-Phase 5 | Idle 행동 패턴 + 감정 감쇠 | 자율 idle 루프, neutral 복귀 로직 | - -### F-8. 하드웨어 주의사항 - -- **접지 공유 필수**: 서보, OLED, Atom Echo는 반드시 공통 GND로 연결. 접지 분리 시 신호 불안정/오동작 발생 -- **서보 전원 분리 권장**: SG90 stall 시 최대 ~750mA 소모. USB-C 500mA 한계 초과 가능. 서보 2개 동시 구동 시 외부 5V 전원 어댑터 권장 -- **I2C 풀업**: ESP32 내부 풀업 사용 가능하나, 배선 길이 10cm 초과 시 외부 4.7kΩ 풀업 저항 추가 권장 -- **PWM 채널 충돌**: ESP32 LEDC PWM 채널은 I2S와 공유됨. 서보 PWM은 채널 0-1, 스피커 I2S는 별도 하드웨어이므로 충돌 없음을 확인할 것 -- **핀 재사용 금지**: G19/G22/G23/G33은 절대 외부 장치에 연결하지 않을 것 (Atom Echo 손상 위험) +> 목표: Atom Echo 기반 음성 에이전트를 유지하면서, `SG90 최대 4개`와 `Waveshare 1.69inch LCD Module (ST7789V2)`를 이용해 자연스러운 감정 표현과 물리 액션을 수행하는 로봇 펫 모드를 구현한다. + +### F-1. 공식 문서 기반 하드웨어 결론 +- Atom Echo 공식 문서는 `G19/G22/G23/G33` 재사용 금지, 외부 확장용으로 `G21/G25`와 Grove `G26/G32`를 제공한다. +- SG90 공식 문서는 `4.8V` 동작과 외부 전원 사용을 전제로 한다. +- Waveshare 공식 문서는 `4-wire SPI`, `DIN/CLK/CS/DC/RST/BL`, `3.3V/5V` 및 전원/로직 전압 일치를 요구한다. +- 따라서 `Atom Echo 단독 + 공식 노출 핀만 사용 + 음성 기능 유지` 조건에서는 디스플레이와 최대 4서보를 동시에 직결하는 구조를 목표 아키텍처로 삼지 않는다. + +### F-2. 권장 아키텍처 +- `Atom Echo`는 오디오, 서버 통신, 모드 전환, 로봇 상태 브리지를 담당한다. +- `Companion Controller`는 ST7789V2 디스플레이 렌더링과 1~4채널 SG90 제어를 담당한다. +- 두 보드는 UART 우선 구조로 연결하고, 서보는 외부 5V 전원 레일을 사용한다. +- 디스플레이는 ESP32 3.3V 로직과 맞추기 위해 3.3V 구동을 기본값으로 한다. + +### F-3. 감정/행동 요구사항 +- 액션은 답변 직후에만 실행하지 않고, awake 상태에서는 idle micro-action도 수행해야 한다. +- 표정은 한 턴마다 즉시 갈아끼우지 않고, blink/gaze/yawn/talk overlay를 포함한 지속형 상태 머신으로 움직여야 한다. +- 감정은 `SOUL + RELATION + MOOD + AFFECT + BODY` 계층으로 관리해, 사과 직후에도 감정 잔상이 남는 인간형 반응을 목표로 한다. + +### F-4. 구현 기준 +- 세부 요구사항, 배선, 프로토콜, phased rollout은 [docs/ROBOT_MODE_WAVESHARE_PRD.md](/Users/b__ono__ng/Main/Projects/LLM_Adruino/docs/ROBOT_MODE_WAVESHARE_PRD.md)를 기준으로 한다. --- @@ -328,9 +190,9 @@ robot: - Ralph PoC: 문서/테스트 자동화 중심 적용 평가 ### Phase 5 (Robot 모드) -- F-Phase 1~2: OLED 디스플레이 표정 + 서보 기본 동작 -- F-Phase 3~4: LLM 감정 추출 → 표정+액션 통합, 프로토콜 확장 -- F-Phase 5: Idle 행동 패턴 + 감정 감쇠 자율 루프 +- Companion 기반 ST7789V2 디스플레이 + SG90 1~4채널 제어 구조 확정 +- `SOUL + RELATION + MOOD + AFFECT + BODY` 감정 상태 기반 표정/제스처 통합 +- 대화 맥락 기반 액션 + sleep/idle 상태 머신 고도화 --- @@ -338,7 +200,7 @@ robot: - 신규 기여자가 문서 기준으로 1일 내 로컬 셋업/테스트 재현 가능 - 주요 회귀 버그가 Docker 통합 테스트로 포착됨 - 사용자 체감 실패율(연결/인증/타임아웃) 감소 -- Robot 모드: 감정 표현 10종이 LLM 응답과 연동되어 디스플레이+서보로 출력됨 +- Robot 모드: 감정 상태가 즉시 리셋되지 않고 디스플레이+서보에 지속형으로 반영됨 --- diff --git a/docs/ROBOT_MODE_WAVESHARE_PRD.md b/docs/ROBOT_MODE_WAVESHARE_PRD.md new file mode 100644 index 0000000..fa07c48 --- /dev/null +++ b/docs/ROBOT_MODE_WAVESHARE_PRD.md @@ -0,0 +1,351 @@ +# Robot Mode Plan — Atom Echo + SG90 + Waveshare 1.69inch LCD + +## 배경/문제 +- 현재 로봇 모드 초안은 `SSD1306 I2C OLED + 서보 2개 직결` 전제를 갖고 있다. +- 새 목표 하드웨어는 `SG90 Servo Motor 최대 4개`와 `Waveshare 1.69inch LCD Module (IPS, SPI, ST7789V2)`다. +- 요구사항도 단순 감정 태그 표시가 아니라, 대화 맥락에 따라 제스처를 수행하고, 감정이 한 번의 답변마다 즉시 리셋되지 않는 지속형 로봇 펫 동작이다. +- 이 문서는 공식 문서를 기준으로 현재 Atom Echo 기반 시스템에서 구현 가능한 권장 구조와 실행 계획을 정리한다. + +## 범위 +- 메인 음성 보드는 계속 `M5Stack Atom Echo (ESP32-PICO-D4)`를 사용한다. +- 로봇 모드는 최대 4개의 SG90와 Waveshare 1.69인치 LCD를 지원한다. +- 동작 범위: + - 응답과 연결된 의미 있는 제스처 + - 슬립 모드가 아닐 때의 idle 액션 + - 눈 깜빡임, 시선 이동, 하품, 졸림 등 지속형 표정 애니메이션 + - `SOUL + RELATION + MOOD + AFFECT + BODY` 기반 감정 지속 모델 +- 비범위: + - Atom Echo의 마이크/스피커를 제거하는 보드 교체 + - 1차 릴리즈에서의 터치 입력 사용 + - 서보 4개를 Atom Echo에 직접 배선하는 단일 MCU 구조 + +## 공식 문서 검토 요약 + +| 대상 | 공식 문서 핵심 내용 | 계획상 의미 | +|---|---|---| +| M5Stack Atom Echo | 공식 문서에 `PinOut G21/G25/5V/GND, 3V3/G22/G19/G23/G33`, Grove는 `G26/G32`, 그리고 `G19/G22/G23/G33` 재사용 금지가 명시됨 | 음성 기능을 유지하면 로봇용으로 실질적으로 쓸 수 있는 외부 GPIO가 매우 제한적이다 | +| ESP32 LEDC | Espressif LEDC 문서는 ESP32가 PWM 신호를 GPIO로 라우팅할 수 있고, 주파수/분해능 조정이 가능하다고 설명함 | SG90 구동 자체는 가능하지만, 병목은 PWM 기능이 아니라 Atom Echo에서 외부로 꺼낼 수 있는 핀 수다 | +| TowerPro SG90 Analog | 공식 페이지에 `Operating voltage 4.8V`, `Dead band width 1us`, `Power Supply: Through External Adapter`가 명시됨 | 서보는 외부 5V 전원 레일을 전제로 계획해야 하며, Atom Echo USB 전원만으로 4개 동시 구동을 목표로 잡으면 안 된다 | +| Waveshare 1.69inch LCD Module | 공식 위키에 `3.3V/5V`, `logic voltage and supply voltage must be the same`, `4-wire SPI`, `DIN/CLK/CS/DC/RST/BL`, `240x280`, `ST7789V2`, `3.3V 90mA`가 명시됨 | ESP32 3.3V 로직에 맞추려면 LCD도 3.3V 구동이 가장 안전하며, 디스플레이는 전용 SPI 제어 핀을 여러 개 요구한다 | + +## 결론: Atom Echo 단독 직결은 권장 아키텍처가 아니다 +- Atom Echo 공식 문서 기준으로 로봇용으로 안전하게 활용 가능한 외부 GPIO는 사실상 `G21/G25/G26/G32` 중심이다. +- Waveshare LCD는 공식 배선 기준으로 `DIN/CLK/CS/DC/RST/BL`를 요구하고, SG90 4개는 PWM 4채널과 외부 전원을 요구한다. +- 따라서 `Atom Echo 1개 + 공식 노출 핀만 사용 + 음성 기능 유지`라는 조건에서는 유지보수 가능한 단일 MCU 직결 구성이 현실적이지 않다. +- 이 계획의 권장안은 `Atom Echo + 보조 컨트롤러(Companion Controller)` 구조다. + +## 권장 하드웨어 아키텍처 + +### 권장안 A — 듀얼 컨트롤러 구조 (추천) +- `Atom Echo` + - 마이크/스피커 + - 서버와의 USB/Wi-Fi 통신 + - 음성 인식 결과/LLM 응답 수신 + - 로봇 상태 프레임을 보조 컨트롤러로 전달 +- `Companion Controller` + - SPI 기반 Waveshare LCD 구동 + - SG90 최대 4개 PWM 제어 + - 표정 프레임 루프와 서보 스케줄러를 로컬에서 유지 +- `Breadboard + 외부 전원 분배` + - 서보용 5V 전원 레일 + - 공통 GND + - LCD용 3.3V 전원 레일 + +### 비권장안 B — Atom Echo 단일 MCU + 확장 칩 체인 +- GPIO expander, PWM expander, 레벨 설계까지 모두 얹으면 이론상 가능성은 있다. +- 그러나 배선 복잡도와 디버깅 비용이 커지고, 현재 저장소의 펌웨어 구조도 크게 흔들린다. +- PoC 실험용으로는 가능하지만 1차 구현 목표로는 비추천한다. + +### 대안 C — 메인 보드 교체 +- GPIO가 더 많은 ESP32/ESP32-S3 보드로 주 제어 보드를 교체하면 단일 MCU 구성이 쉬워진다. +- 하지만 현재 제품의 Atom Echo 음성 입출력 경로를 크게 바꾸므로 이번 계획에서는 제외한다. + +## 권장 배선 계획 + +### 1) Atom Echo ↔ Companion Controller +- `G26` -> Companion `RX` +- `G32` -> Companion `TX` +- `GND` -> Companion `GND` +- 이유: + - Grove 포트 2핀으로 UART 브리지를 만들 수 있다 + - Atom Echo의 오디오 관련 금지 핀을 건드리지 않는다 + - 디스플레이/서보의 타이밍 루프를 Companion 쪽으로 완전히 분리할 수 있다 + +### 2) Companion Controller ↔ Waveshare 1.69 LCD +- `3.3V` -> `VCC` +- `GND` -> `GND` +- SPI GPIO -> `DIN`, `CLK`, `CS`, `DC`, `RST`, `BL` +- 원칙: + - Waveshare 공식 문서상 전원과 로직 전압을 같게 맞춰야 하므로 3.3V 구동을 기본값으로 한다 + - 5V 구동을 선택할 경우 별도 레벨 시프터 계획이 필요하다 + +### 3) Companion Controller ↔ SG90 x 1~4 +- 각 SG90 signal -> PWM 가능한 GPIO 또는 Companion에 연결된 전용 서보 드라이버 +- `V+` -> 외부 5V 전원 +- `GND` -> 공통 GND +- 권장: + - 5V 2A 이상, 가능하면 3A급 외부 전원 + - 서보 전원 입력 근처에 벌크 캐패시터 추가 + - USB 전원 하나에 모든 서보를 직접 매달지 않는다 + +### 4) 기구/역할 추천 +- Servo 0: head pan +- Servo 1: head tilt/pitch +- Servo 2: left arm +- Servo 3: right arm +- 서보가 2개만 장착된 빌드에서는 `head pan + head tilt`만 활성화하고, 팔 동작은 head gesture로 대체한다. + +## 감정 모델 설계 + +### 계층형 Emotion State + +| 계층 | 지속 시간 | 저장 위치 | 역할 | +|---|---|---|---| +| `SOUL` | 매우 김 | `server/memory/Soul.md` | 기본 성격. 장난기, 예민함, 회복 속도, 수면 성향 | +| `RELATION` | 김 | `server/memory/Relation.md` | 사용자별 호감도, 신뢰도, 서운함, 친밀감 | +| `MOOD` | 중간 | 런타임 상태 | 최근 사건이 남긴 감정의 잔상 | +| `AFFECT` | 짧음 | 턴별 계산 | 방금 들어온 말에 대한 즉각 반응 | +| `BODY` | 중간 | 런타임 상태 | 졸림, 휴식, 슬립 모드, idle 에너지 | + +### 감정 결정 원칙 +- 최종 표정/제스처는 `AFFECT`만 보지 않고 `SOUL + RELATION + MOOD + AFFECT + BODY`를 함께 반영해 결정한다. +- 예시: + - 사용자가 심한 말을 하면 `AFFECT=angry/hurt`, `MOOD`가 음수 방향으로 크게 이동하고, `RELATION.grudge`가 소폭 증가한다. + - 이후 사용자가 사과하면 즉시 `AFFECT=softened`는 되지만, `MOOD`와 `RELATION.grudge`가 남아 한동안 삐진 표정과 소극적 액션을 유지한다. + - 반복적으로 다정한 상호작용이 쌓이면 `RELATION.affection`이 올라가고, 같은 중립 문장에도 더 장난스럽고 친근한 idle 액션을 선택한다. + +### 저장 전략 +- `Soul.md` + - 성격 파라미터 섹션 추가: `playfulness`, `sensitivity`, `forgiveness`, `curiosity`, `sleep_rhythm` +- `Relation.md` + - 사용자별 감정 파라미터 섹션 추가: `affection`, `trust`, `grudge`, `comfort` +- 런타임 + - `EmotionSystem` 안에 `mood_vector`, `body_state`, `last_major_event_at`를 둔다 + - `MOOD`는 즉시 neutral로 리셋하지 않고, 회복 곡선과 이벤트 강도에 따라 서서히 이동한다 + +## 디스플레이 동작 설계 + +### 목표 +- 한 번의 응답마다 얼굴이 딱 바뀌는 구조를 피한다. +- 기본 표정 위에 작은 생체적 움직임을 계속 얹는다. + +### 필수 애니메이션 레이어 +- `Base Face` + - happy, sad, angry, sulky, sleepy, curious, surprised, neutral +- `Micro Motion` + - blink + - microsaccade + - eyelid droop + - subtle breathing/bob +- `Context Overlay` + - yawn + - talking pulse + - sparkle / tear / annoyance mark +- `Transition` + - 150~300ms easing 기반 전환 + - 표정이 바뀔 때 즉시 스냅 전환 금지 + +### 타이밍 정책 +- blink: 3~7초 랜덤 +- gaze shift: 5~12초 랜덤 +- yawn: sleepy/bored 상태에서만 확률적으로 실행 +- talk overlay: TTS 재생 중 mouth 또는 cheek pulse 활성화 +- sleep mode: 눈 감김 + 저빈도 breathing만 유지, 과한 idle 액션 금지 + +## 액션/서보 동작 설계 + +### 제스처 계층 +- `Semantic Gesture` + - 대화 의미에 직접 대응하는 동작 + - 예: `"나 나갔다올게"` -> `farewell_wave` + - 예: `"잔다"` -> `sleep_settle` 또는 `goodnight_wave` +- `Reply Gesture` + - 응답을 말할 때 보조적으로 붙는 작은 끄덕임/손동작 +- `Idle Gesture` + - 슬립 모드가 아닐 때 확률적으로 나오는 자발 행동 + +### 우선 제스처 세트 + +| gesture_id | 트리거 | 2서보 폴백 | 4서보 권장 동작 | +|---|---|---|---| +| `farewell_wave` | 외출/작별 인사 | head tilt + nod | 오른팔 wave + head tilt | +| `goodnight_settle` | 수면/휴식 진입 | slow droop | 양팔 내림 + 눈 감김 + 고개 숙임 | +| `curious_tilt` | 질문/새 정보 | tilt | head tilt + 작은 팔 lift | +| `happy_bounce` | 칭찬/반가움 | nod + wiggle | 양팔 bounce + head bob | +| `hurt_turnaway` | 비난/무례 발화 | head turn away | 팔 고정 + 고개 회피 | +| `idle_stretch` | 장시간 대기 후 깨어있음 | 작은 sway | 팔 스트레치 + 고개 들기 | + +### Idle 정책 +- awake 상태에서만 활성화 +- 과도한 소음/진동을 막기 위해 idle gesture는 쿨다운 기반으로 실행 +- 권장 기본값: + - micro motion: 수초 단위 + - 작은 servo idle: 15~45초 랜덤 + - 큰 idle gesture: 1~3분 랜덤, 최근 사용자 상호작용이 없을 때만 + +## 서버/펌웨어 역할 분리 + +### 서버 +- 대화 맥락 분석 +- `SOUL/RELATION/MOOD/AFFECT/BODY` 계산 +- 의미 기반 gesture 추천 +- 로봇 상태 페이로드 생성 + +### Atom Echo 펌웨어 +- 서버와 기존 프로토콜 유지 +- 로봇 페이로드를 Companion으로 브리지 +- 실패 시 로봇 제어를 끊고 음성 경로만 유지 + +### Companion 펌웨어 +- LCD 렌더 루프 +- SG90 동작 스케줄링 +- idle animation 자율 루프 +- 안전 제한: + - 각도 clamp + - 서보 동작 쿨다운 + - long hold 최소화 + +## 제안 프로토콜 + +### 서버 -> Atom Echo +```json +{ + "action": "ROBOT_STATE", + "mode": "awake", + "emotion": { + "dominant": "sulky", + "valence": -0.45, + "arousal": 0.18, + "persist_sec": 900 + }, + "gesture": { + "id": "farewell_wave", + "intensity": 0.72 + }, + "speech": { + "tts_active": true, + "text": "다녀와. 조심하고." + }, + "profile": { + "servo_count": 4, + "display": "st7789v2_240x280" + } +} +``` + +### Atom Echo -> Companion +- 1차 구현은 디버깅이 쉬운 line-delimited JSON을 사용한다. +- 2차 최적화에서 CBOR 또는 바이너리 프레임을 검토한다. + +## 구현 단계 + +### Phase 0 — 문서/하드웨어 PoC 정렬 +- 산출물: + - `docs/PRD.md` + - `docs/AGENT_FEATURE_PLANNING.md` + - `docs/ROBOT_MODE_WAVESHARE_PRD.md` +- 결정 항목: + - Companion 컨트롤러 종류 + - Atom Echo ↔ Companion transport(UART 우선) + - 2서보/4서보 프로파일 분리 여부 +- 완료 기준: + - 직결 대신 Companion 구조를 공식 계획으로 확정 + - 배선도 초안과 BOM 초안 작성 + +### Phase 1 — 프로토콜/설정 스키마 확장 +- 대상 파일: + - `server/config.yaml` + - `server/src/robot_mode.py` + - `server/server.py` + - `arduino/atom_echo_m5stack_esp32_ino/atom_echo_m5stack_esp32_ino.ino` + - 신규 `arduino/atom_echo_m5stack_esp32_ino/robot_bridge.*` +- 핵심 작업: + - `robot.controller = legacy_direct | companion_uart` + - `servo.count = 1..4` + - `display.type = st7789v2_240x280` + - 로봇 상태 페이로드 버전 정의 +- 완료 기준: + - 서버 단위 테스트에서 새 payload schema 검증 + - Atom Echo가 Companion으로 payload를 중계할 수 있음 + +### Phase 2 — Emotion Persistence 엔진 +- 대상 파일: + - `server/emotion_system.py` + - `server/src/agent_mode.py` + - `server/src/memory_manager.py` + - `server/memory/Soul.md` + - `server/memory/Relation.md` +- 핵심 작업: + - `SOUL/RELATION/MOOD/AFFECT/BODY` 상태 모델 추가 + - 사과 후 즉시 neutral로 복귀하지 않는 회복 곡선 구현 + - idle 행동 확률에 감정 상태 반영 +- 완료 기준: + - insult -> apology -> lingering sulk 시나리오 테스트 통과 + - sleep mode에서 감정 표현이 억제되는 테스트 통과 + +### Phase 3 — Companion Display 엔진 +- 대상 파일: + - 신규 `arduino/robot_companion_controller/` +- 핵심 작업: + - ST7789V2 초기화 + - face sprite/state machine + - blink, gaze, yawn, talk overlay +- 완료 기준: + - 부팅 시 neutral face + - mood 전환 시 부드러운 face transition + - idle loop 중 blink/gaze/yawn 동작 확인 + +### Phase 4 — Companion Servo 엔진 +- 대상 파일: + - 신규 `arduino/robot_companion_controller/` +- 핵심 작업: + - 1~4채널 servo profile + - gesture preset library + - idle scheduler와 safety limiter +- 완료 기준: + - 4서보 각 채널 독립 구동 + - semantic gesture와 idle gesture가 충돌 없이 실행 + +### Phase 5 — E2E 통합/튜닝 +- 대상 파일: + - `server/tests/*` + - `docs/*` + - `arduino/*` +- 핵심 작업: + - Docker 기반 protocol regression + - 하드웨어 smoke 시나리오 + - legacy direct path와 fallback 정리 +- 완료 기준: + - 대화 -> emotion -> gesture -> display 전체 흐름 재현 + - 하드웨어 연결 실패 시 음성 모드는 계속 동작 + +## 테스트 전략 + +### Docker 검증 +- `docker compose -f docker/docker-compose.test.yml run --rm server-test pytest server/tests/test_emotion_system.py server/tests/test_robot_mode_extended.py` +- `docker compose -f docker/docker-compose.test.yml run --rm server-test pytest server/tests/test_protocol.py` +- `docker compose -f docker/docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from server-test` + +### 추가 예정 테스트 +- `server/tests/test_robot_emotion_persistence.py` +- `server/tests/test_robot_protocol_companion.py` +- `server/tests/test_robot_idle_behavior.py` + +### 실기 하드웨어 스모크 체크리스트 +1. 전원 투입 후 디스플레이가 neutral face로 부팅된다. +2. SG90 1~4 채널이 개별 sweep 테스트를 통과한다. +3. `"나 나갔다올게"` 발화 시 farewell 계열 동작이 나온다. +4. 부정적 발화 후 사과를 해도 표정이 곧바로 neutral로 돌아가지 않는다. +5. `"잘게"` 또는 sleep intent 후 과한 idle action이 멈춘다. + +## 롤백 전략 +- `features.robot_mode_enabled = false`로 전체 로봇 모드를 즉시 끌 수 있어야 한다. +- `robot.controller = legacy_direct`를 유지해 기존 SSD1306 실험 경로를 임시 보존할 수 있게 한다. +- Companion 경로가 불안정하면 Atom Echo는 로봇 제어를 생략하고 음성 에이전트 기능만 유지한다. +- 새 감정 상태 계산이 실패하면 `neutral + no gesture + safe display`로 폴백한다. + +## 공식 문서 링크 +- M5Stack Atom Echo: +- Espressif ESP32 LEDC: +- TowerPro SG90 Analog: +- Waveshare 1.69inch LCD Module: diff --git a/server/config_loader.py b/server/config_loader.py index 91afafa..f7e5707 100644 --- a/server/config_loader.py +++ b/server/config_loader.py @@ -104,6 +104,46 @@ class Config: "proactive": True, "proactive_interval": 1800, }, + "features": { + "robot_mode_enabled": False, + }, + "robot": { + "controller": "legacy_direct", + "transport": "local", + "companion": { + "transport": "uart", + "baudrate": 115200, + "tx_pin": 26, + "rx_pin": 32, + }, + "servo": { + "count": 2, + "pin_pitch": 26, + "pin_tilt": 32, + "angle_min": 0, + "angle_max": 180, + "default_angle": 90, + }, + "display": { + "type": "ssd1306", + "width": 128, + "height": 64, + "i2c_scl": 21, + "i2c_sda": 25, + "i2c_addr": "0x3C", + }, + "idle": { + "blink_interval_min": 3000, + "blink_interval_max": 5000, + "gaze_interval": 8000, + }, + "emotion": { + "default": "neutral", + "decay_to_neutral": True, + "decay_interval": 30, + "persist_sec": 900, + }, + }, "weather": { "api_key": "", "lat": 37.5665, @@ -348,6 +388,49 @@ def _apply_runtime_defaults(self): tts_cfg = self.config.setdefault("tts", {}) tts_cfg["backend"] = (tts_cfg.get("backend") or "edge_tts").strip().lower() + features_cfg = self.config.setdefault("features", {}) + features_cfg["robot_mode_enabled"] = _coerce_bool(features_cfg.get("robot_mode_enabled"), False) + + robot_cfg = self.config.setdefault("robot", {}) + controller = str(robot_cfg.get("controller", "legacy_direct") or "legacy_direct").strip().lower() + if controller not in {"legacy_direct", "companion_uart"}: + controller = "legacy_direct" + robot_cfg["controller"] = controller + robot_cfg["transport"] = str( + robot_cfg.get("transport", "companion" if controller == "companion_uart" else "local") or "" + ).strip().lower() or ("companion" if controller == "companion_uart" else "local") + + companion_cfg = robot_cfg.setdefault("companion", {}) + companion_cfg["transport"] = "uart" + try: + companion_cfg["baudrate"] = int(companion_cfg.get("baudrate", 115200) or 115200) + except (TypeError, ValueError): + companion_cfg["baudrate"] = 115200 + for pin_key, default_pin in (("tx_pin", 26), ("rx_pin", 32)): + try: + companion_cfg[pin_key] = int(companion_cfg.get(pin_key, default_pin)) + except (TypeError, ValueError): + companion_cfg[pin_key] = default_pin + + servo_cfg = robot_cfg.setdefault("servo", {}) + try: + servo_count = int(servo_cfg.get("count", 2) or 2) + except (TypeError, ValueError): + servo_count = 2 + servo_cfg["count"] = min(4, max(1, servo_count)) + + display_cfg = robot_cfg.setdefault("display", {}) + display_type = str(display_cfg.get("type") or "").strip().lower() + if not display_type: + display_type = "st7789v2_240x280" if controller == "companion_uart" else "ssd1306" + display_cfg["type"] = display_type + + robot_emotion_cfg = robot_cfg.setdefault("emotion", {}) + try: + robot_emotion_cfg["persist_sec"] = int(robot_emotion_cfg.get("persist_sec", 900) or 900) + except (TypeError, ValueError): + robot_emotion_cfg["persist_sec"] = 900 + connection_cfg = self.config.setdefault("connection", {}) connection_cfg["priority"] = normalize_priority_list( connection_cfg.get("priority"), @@ -406,6 +489,12 @@ def get_tts_config(self) -> Dict: def get_assistant_config(self) -> Dict: return self.config.get("assistant", {}) + def get_features_config(self) -> Dict: + return self.config.get("features", {}) + + def get_robot_config(self) -> Dict: + return self.config.get("robot", {}) + def get_weather_config(self) -> Dict: return self.config.get("weather", {}) diff --git a/server/server.py b/server/server.py index e44ef48..8924e58 100644 --- a/server/server.py +++ b/server/server.py @@ -1025,6 +1025,7 @@ def main(): weather_config = config.get_weather_config() assistant_config = config.get_assistant_config() tts_config = config.get_tts_config() + robot_config = config.get_robot_config() memory_dir = config.get("memory", "memory_dir", default="memory") memory_refresh_interval = int(config.get("memory", "refresh_interval", default=5)) @@ -1046,7 +1047,7 @@ def main(): log.info("Runtime note: %s", note) # Initialize mode handlers - robot_handler = RobotMode(ACTIONS_CONFIG, llm_client) + robot_handler = RobotMode(ACTIONS_CONFIG, llm_client, robot_config=robot_config) agent_handler = AgentMode( llm_client, weather_config.get("api_key"), @@ -1064,6 +1065,12 @@ def main(): assistant_config.get("name", "ccoli"), assistant_config.get("personality", "witty"), ) + log.info( + "Robot controller: %s (display=%s servo_count=%s)", + robot_config.get("controller", "legacy_direct"), + robot_config.get("display", {}).get("type", "ssd1306"), + robot_config.get("servo", {}).get("count", 2), + ) stt_engine = STTEngine( diff --git a/server/src/robot_mode.py b/server/src/robot_mode.py index ba6920b..e8481a8 100644 --- a/server/src/robot_mode.py +++ b/server/src/robot_mode.py @@ -3,6 +3,7 @@ - 음성 명령을 서보 모터 제어 명령으로 변환 - LLM 기반 명령 해석 및 모드 전환 의도 분류 """ +import copy import json import logging import re @@ -14,6 +15,8 @@ SERVO_MIN = 0 SERVO_MAX = 180 DEFAULT_ANGLE_CENTER = 90 +ROBOT_CONTROLLER_LEGACY_DIRECT = "legacy_direct" +ROBOT_CONTROLLER_COMPANION_UART = "companion_uart" EMOTION_MAP = { "neutral": {"face": "neutral", "action": "none", "led": [255, 255, 255]}, @@ -28,12 +31,86 @@ "confused": {"face": "confused", "action": "wiggle", "led": [255, 150, 0]}, } +DEFAULT_ROBOT_CONFIG = { + "controller": ROBOT_CONTROLLER_LEGACY_DIRECT, + "transport": "local", + "companion": { + "transport": "uart", + "baudrate": 115200, + "tx_pin": 26, + "rx_pin": 32, + }, + "servo": { + "count": 2, + }, + "display": { + "type": "ssd1306", + }, + "emotion": { + "persist_sec": 900, + }, +} + + +def _deep_merge(base: dict, override: dict | None) -> dict: + if not override: + return base + + for key, value in override.items(): + if isinstance(base.get(key), dict) and isinstance(value, dict): + _deep_merge(base[key], value) + else: + base[key] = value + return base + + +def _normalize_robot_config(robot_config: dict | None) -> dict: + merged = copy.deepcopy(DEFAULT_ROBOT_CONFIG) + _deep_merge(merged, robot_config) + + controller = str(merged.get("controller") or ROBOT_CONTROLLER_LEGACY_DIRECT).strip().lower() + if controller not in {ROBOT_CONTROLLER_LEGACY_DIRECT, ROBOT_CONTROLLER_COMPANION_UART}: + controller = ROBOT_CONTROLLER_LEGACY_DIRECT + merged["controller"] = controller + merged["transport"] = "companion" if controller == ROBOT_CONTROLLER_COMPANION_UART else "local" + + servo_cfg = merged.setdefault("servo", {}) + try: + servo_count = int(servo_cfg.get("count", 2) or 2) + except (TypeError, ValueError): + servo_count = 2 + servo_cfg["count"] = min(4, max(1, servo_count)) + + display_cfg = merged.setdefault("display", {}) + display_type = str(display_cfg.get("type") or "").strip().lower() + if not display_type: + display_type = "st7789v2_240x280" if controller == ROBOT_CONTROLLER_COMPANION_UART else "ssd1306" + display_cfg["type"] = display_type + + emotion_cfg = merged.setdefault("emotion", {}) + try: + emotion_cfg["persist_sec"] = int(emotion_cfg.get("persist_sec", 900) or 900) + except (TypeError, ValueError): + emotion_cfg["persist_sec"] = 900 + + companion_cfg = merged.setdefault("companion", {}) + companion_cfg["transport"] = "uart" + return merged + class RobotMode: """로봇 모드 메인 클래스 - 음성 명령을 로봇 동작으로 변환""" - def __init__(self, actions_config, llm_client=None): + def __init__(self, actions_config, llm_client=None, robot_config: dict | None = None): self.actions_config = actions_config self.llm = llm_client + self.robot_config = _normalize_robot_config(robot_config) + + @property + def controller_type(self) -> str: + return self.robot_config.get("controller", ROBOT_CONTROLLER_LEGACY_DIRECT) + + def uses_companion_controller(self) -> bool: + return self.controller_type == ROBOT_CONTROLLER_COMPANION_UART def process_with_llm(self, text: str, current_angle: int) -> tuple[str, dict]: """LLM 기반 명령 처리. Returns (refined_text, action_dict). @@ -117,13 +194,47 @@ def _determine_action(self, text: str, current_angle: int) -> dict: def build_robot_payload(self, emotion: str, text: str = "") -> dict: mapping = EMOTION_MAP.get(emotion, EMOTION_MAP["neutral"]) + display_text = text[:20] if text else "" + if not self.uses_companion_controller(): + return { + "action": "ROBOT_EMOTION", + "emotion": emotion, + "face": mapping["face"], + "servo_action": mapping["action"], + "led_color": mapping["led"], + "display_text": display_text, + "meaningful": True, + "transport": "local", + } + + speech_text = text[:80] if text else "" return { - "action": "ROBOT_EMOTION", + "action": "ROBOT_STATE", + "controller": self.controller_type, + "transport": self.robot_config.get("companion", {}).get("transport", "uart"), + "mode": "awake", "emotion": emotion, + "emotion_state": { + "dominant": emotion, + "persist_sec": self.robot_config.get("emotion", {}).get("persist_sec", 900), + }, + "gesture": { + "id": mapping["action"], + "intensity": 0.72 if text else 0.35, + }, + "speech": { + "tts_active": bool(text), + "text": speech_text, + }, + "profile": { + "servo_count": self.robot_config.get("servo", {}).get("count", 2), + "display": self.robot_config.get("display", {}).get("type", "st7789v2_240x280"), + }, + # Keep these fields so legacy firmware can still do a safe local fallback. "face": mapping["face"], "servo_action": mapping["action"], "led_color": mapping["led"], - "display_text": text[:20] if text else "", + "display_text": speech_text[:32], "meaningful": True, } diff --git a/server/tests/test_config_loader.py b/server/tests/test_config_loader.py index 75c9d6f..2c166d7 100644 --- a/server/tests/test_config_loader.py +++ b/server/tests/test_config_loader.py @@ -17,6 +17,9 @@ def test_defaults_when_no_yaml_exists(tmp_path): cfg = Config(config_file=str(tmp_path / "missing.yaml")) assert cfg.get("server", "port") == 5001 assert cfg.get("stt", "language") == "ko" + assert cfg.get("features", "robot_mode_enabled") is False + assert cfg.get("robot", "controller") == "legacy_direct" + assert cfg.get("robot", "servo", "count") == 2 assert cfg.get("connection", "mode") == "auto" assert cfg.get("connection", "serial_baudrate") == 115200 assert cfg.get("llm", "priority") == ["ollama", "api", "ollama_cpu", "other"] @@ -69,6 +72,8 @@ def test_section_getters(tmp_path): assert "provider" in cfg.get_llm_config() assert "voice" in cfg.get_tts_config() assert "name" in cfg.get_assistant_config() + assert "robot_mode_enabled" in cfg.get_features_config() + assert "controller" in cfg.get_robot_config() assert "api_key" in cfg.get_weather_config() assert "max_history" in cfg.get_context_config() assert "enabled" in cfg.get_emotion_config() @@ -96,3 +101,24 @@ def test_env_voice_id_enabled(tmp_path, monkeypatch): monkeypatch.setenv("VOICE_ID_ENABLED", "true") cfg = Config(config_file=str(tmp_path / "missing.yaml")) assert cfg.get("voice_id", "enabled") is True + + +def test_robot_controller_normalized_from_yaml(tmp_path): + yaml_path = tmp_path / "config.yaml" + _write_yaml( + yaml_path, + { + "features": {"robot_mode_enabled": "yes"}, + "robot": { + "controller": "COMPANION_UART", + "servo": {"count": 9}, + "emotion": {"persist_sec": "1200"}, + }, + }, + ) + cfg = Config(config_file=str(yaml_path)) + assert cfg.get("features", "robot_mode_enabled") is True + assert cfg.get("robot", "controller") == "companion_uart" + assert cfg.get("robot", "transport") == "companion" + assert cfg.get("robot", "servo", "count") == 4 + assert cfg.get("robot", "emotion", "persist_sec") == 1200 diff --git a/server/tests/test_robot_mode_extended.py b/server/tests/test_robot_mode_extended.py index 8c889b4..8d82fa2 100644 --- a/server/tests/test_robot_mode_extended.py +++ b/server/tests/test_robot_mode_extended.py @@ -68,3 +68,33 @@ def test_switch_mode_action(): _, action = robot.process_with_llm("모드 전환", current_angle=90) assert action["action"] == "SWITCH_MODE" assert action["mode"] == "agent" + + +def test_build_robot_payload_legacy_direct_default(): + robot = RobotMode([], None) + payload = robot.build_robot_payload("happy", "다녀와") + assert payload["action"] == "ROBOT_EMOTION" + assert payload["transport"] == "local" + assert payload["servo_action"] == "bounce_happy" + + +def test_build_robot_payload_companion_uart_profile(): + robot = RobotMode( + [], + None, + robot_config={ + "controller": "companion_uart", + "servo": {"count": 4}, + "display": {"type": "st7789v2_240x280"}, + "emotion": {"persist_sec": 1200}, + }, + ) + payload = robot.build_robot_payload("sad", "미안해도 아직 조금 삐졌어") + assert payload["action"] == "ROBOT_STATE" + assert payload["controller"] == "companion_uart" + assert payload["transport"] == "uart" + assert payload["profile"]["servo_count"] == 4 + assert payload["profile"]["display"] == "st7789v2_240x280" + assert payload["emotion"] == "sad" + assert payload["emotion_state"]["persist_sec"] == 1200 + assert payload["face"] == "sad" From bf511be8b036d89363b43d3d4e7326628b7f8eab Mon Sep 17 00:00:00 2001 From: bongkj Date: Tue, 24 Mar 2026 14:48:57 +0900 Subject: [PATCH 2/2] Add lingering robot emotion state --- server/emotion_system.py | 401 +++++++++++++++++------ server/memory/Relation.md | 6 + server/memory/Soul.md | 7 + server/server.py | 10 +- server/src/agent_mode.py | 9 +- server/src/robot_mode.py | 17 +- server/tests/test_emotion_system.py | 29 ++ server/tests/test_robot_mode_extended.py | 14 + 8 files changed, 388 insertions(+), 105 deletions(-) diff --git a/server/emotion_system.py b/server/emotion_system.py index 4331027..44357c3 100644 --- a/server/emotion_system.py +++ b/server/emotion_system.py @@ -1,172 +1,379 @@ # ============================================================ # emotion_system.py — 감정 상태 분석 및 표현 시스템 # ============================================================ -# 역할: 대화 텍스트에서 키워드 기반으로 감정을 분석하고, -# 감정에 대응하는 LED 색상/패턴 및 서보 동작을 결정. -# ESP32에 전송할 EMOTION 명령 JSON을 생성. -# -# 지원 감정: happy, sad, excited, sleepy, angry, neutral -# 감정 전이: 새 감정 감지 시 자동 전환, 시간 경과 시 neutral로 회귀 +# 역할: +# - 대화 텍스트에서 즉각 반응(AFFECT)을 추출 +# - 관계(RELATION), 기분 잔상(MOOD), 바디 상태(BODY)를 함께 관리 +# - 감정이 한 턴마다 바로 neutral로 리셋되지 않도록 지속형 상태를 유지 # ============================================================ +import copy import logging import random from typing import Dict, Tuple log = logging.getLogger("emotion_system") + +def _clamp01(value: float) -> float: + return max(0.0, min(1.0, float(value))) + + class EmotionSystem: """ 감정 상태 시스템 - 대화 내용을 분석하여 감정 상태를 결정하고, - 적절한 LED 색상과 서보 동작을 반환합니다. + - 즉각 반응보다 lingering mood를 우선 보존합니다. + - 기본 사용자 기준 RELATION 상태를 유지합니다. + - sleep/fatigue 같은 BODY 상태가 idle 감정에도 영향을 줍니다. """ - + EMOTIONS = ["happy", "sad", "excited", "sleepy", "angry", "neutral"] - - # 감정별 LED 색상 (RGB 0-255) + NON_NEUTRAL_EMOTIONS = [emotion for emotion in EMOTIONS if emotion != "neutral"] + DOMINANT_THRESHOLD = 0.18 + EMOTION_COLORS = { - "happy": (255, 200, 0), # 밝은 노란색 - "sad": (0, 100, 255), # 파란색 - "excited": (255, 50, 200), # 핑크/마젠타 - "sleepy": (100, 100, 150), # 은은한 보라 - "angry": (255, 0, 0), # 빨간색 - "neutral": (100, 255, 100), # 연두색 + "happy": (255, 200, 0), + "sad": (0, 100, 255), + "excited": (255, 50, 200), + "sleepy": (100, 100, 150), + "angry": (255, 0, 0), + "neutral": (100, 255, 100), } - - # 감정별 LED 패턴 (pattern_type, speed) + EMOTION_PATTERNS = { - "happy": ("pulse", "medium"), # 부드러운 펄스 - "sad": ("slow_fade", "slow"), # 천천히 페이드 - "excited": ("rainbow", "fast"), # 빠른 무지개 - "sleepy": ("breathing", "slow"), # 느린 호흡 - "angry": ("blink", "fast"), # 빠른 깜빡임 - "neutral": ("solid", "none"), # 고정 + "happy": ("pulse", "medium"), + "sad": ("slow_fade", "slow"), + "excited": ("rainbow", "fast"), + "sleepy": ("breathing", "slow"), + "angry": ("blink", "fast"), + "neutral": ("solid", "none"), } - - # 감정별 서보 동작 + EMOTION_SERVO_ACTIONS = { - "happy": "NOD", # 끄덕이기 - "sad": "SHAKE_SLOW", # 천천히 좌우 - "excited": "WIGGLE_FAST", # 빠르게 흔들기 - "sleepy": "DRIFT", # 천천히 내려가기 - "angry": "SHAKE_SHARP", # 빠르게 좌우 - "neutral": "CENTER", # 중앙 + "happy": "NOD", + "sad": "SHAKE_SLOW", + "excited": "WIGGLE_FAST", + "sleepy": "DRIFT", + "angry": "SHAKE_SHARP", + "neutral": "CENTER", } - - # 감정 키워드 + EMOTION_KEYWORDS = { - "happy": ["행복", "기쁘", "좋아", "웃", "즐거", "신나", "재밌", "굿", "최고", "좋다"], + "happy": ["행복", "기쁘", "좋아", "웃", "즐거", "신나", "재밌", "굿", "최고", "좋다", "고마", "반갑"], "sad": ["슬프", "우울", "힘들", "아프", "외로", "쓸쓸", "답답", "안타깝", "아쉽"], "excited": ["와", "대박", "짱", "신난다", "흥분", "놀라", "멋지", "환상", "완전"], - "sleepy": ["피곤", "졸려", "자고", "잠", "쉬고", "휴식", "지쳐"], - "angry": ["화", "짜증", "싫", "귀찮", "답답", "속상", "빡", "열받"], - "neutral": [] # 기본값 + "sleepy": ["피곤", "졸려", "자고", "잠", "쉬고", "휴식", "지쳐", "잔다", "잘게"], + "angry": ["화", "짜증", "싫", "귀찮", "답답", "속상", "빡", "열받", "미워", "별로"], + "neutral": [], + } + + APOLOGY_KEYWORDS = ["미안", "죄송", "사과", "잘못했", "용서"] + WAKE_KEYWORDS = ["일어", "깨워", "굿모닝", "아침", "wake"] + + DEFAULT_SOUL = { + "playfulness": 0.60, + "sensitivity": 0.68, + "forgiveness": 0.34, + "curiosity": 0.55, + "sleep_rhythm": 0.50, } - - def __init__(self): + + DEFAULT_RELATION = { + "affection": 0.50, + "trust": 0.50, + "grudge": 0.00, + "comfort": 0.50, + } + + DEFAULT_BODY = { + "sleep_mode": False, + "fatigue": 0.15, + } + + def __init__(self, soul_profile: Dict | None = None): self.current_emotion = "neutral" - self.emotion_history = [] # 최근 감정 기록 + self.emotion_history = [] self.max_history = 10 - - def analyze_emotion(self, text: str) -> str: + + self.soul_profile = copy.deepcopy(self.DEFAULT_SOUL) + if soul_profile: + for key, value in soul_profile.items(): + if key in self.soul_profile: + self.soul_profile[key] = _clamp01(value) + + self.relations: dict[str, dict[str, float]] = {"default": copy.deepcopy(self.DEFAULT_RELATION)} + self.body_state = copy.deepcopy(self.DEFAULT_BODY) + self.mood_weights = {emotion: 0.0 for emotion in self.NON_NEUTRAL_EMOTIONS} + + def analyze_emotion(self, text: str, speaker_id: str = "default") -> str: """ - 텍스트에서 감정을 분석 - Returns: emotion string + 텍스트에서 감정을 분석하고 내부 lingering mood를 갱신합니다. + Returns: dominant emotion string """ + relation = self._relation_for(speaker_id) if not text: - return self.current_emotion - + return self._recompute_current_emotion() + text_lower = text.lower() - - # 각 감정별로 키워드 매칭 점수 계산 + self._passive_decay() + self._update_body_bias(text_lower) + scores = {emotion: 0 for emotion in self.EMOTIONS} - for emotion, keywords in self.EMOTION_KEYWORDS.items(): for keyword in keywords: if keyword in text_lower: scores[emotion] += 1 - - # 가장 높은 점수의 감정 선택 + + apology_detected = any(keyword in text_lower for keyword in self.APOLOGY_KEYWORDS) max_score = max(scores.values()) + if max_score > 0: detected_emotion = max(scores, key=scores.get) - self._update_emotion(detected_emotion) - log.info(f"Emotion detected: {detected_emotion} (from text: {text[:30]}...)") - return detected_emotion - - # 키워드 없으면 현재 감정 유지 (점진적 변화) + self._apply_detected_emotion(detected_emotion, max_score, relation) + elif apology_detected: + self._apply_apology(relation) + else: + self._apply_idle_bias(relation) + + dominant = self._recompute_current_emotion() + log.info("Emotion detected: %s (from text: %s...)", dominant, text[:30]) + return dominant + + def _relation_for(self, speaker_id: str) -> dict[str, float]: + relation = self.relations.get(speaker_id) + if relation is None: + relation = copy.deepcopy(self.DEFAULT_RELATION) + self.relations[speaker_id] = relation + return relation + + def _apply_emotion_delta(self, emotion: str, delta: float): + if emotion not in self.mood_weights: + return + self.mood_weights[emotion] = _clamp01(self.mood_weights[emotion] + delta) + + def _soften_emotion(self, emotion: str, amount: float): + if emotion not in self.mood_weights: + return + self.mood_weights[emotion] = _clamp01(self.mood_weights[emotion] - amount) + + def _apply_detected_emotion(self, emotion: str, score: int, relation: dict[str, float]): + sensitivity = self.soul_profile["sensitivity"] + forgiveness = self.soul_profile["forgiveness"] + playfulness = self.soul_profile["playfulness"] + + if emotion == "angry": + self._apply_emotion_delta("angry", 0.42 + (score * 0.10) + (relation["grudge"] * 0.18)) + self._apply_emotion_delta("sad", 0.10 * sensitivity) + relation["grudge"] = _clamp01(relation["grudge"] + (0.18 * sensitivity)) + relation["trust"] = _clamp01(relation["trust"] - (0.06 * sensitivity)) + relation["comfort"] = _clamp01(relation["comfort"] - 0.04) + return + + if emotion == "sad": + self._apply_emotion_delta("sad", 0.34 + (score * 0.08)) + self._soften_emotion("angry", 0.05 + (forgiveness * 0.03)) + relation["comfort"] = _clamp01(relation["comfort"] - 0.03) + return + + if emotion == "happy": + self._apply_emotion_delta("happy", 0.30 + (score * 0.08) + (playfulness * 0.04)) + self._soften_emotion("sad", 0.10 + (forgiveness * 0.05)) + self._soften_emotion("angry", 0.09 + (forgiveness * 0.05)) + relation["affection"] = _clamp01(relation["affection"] + 0.06) + relation["trust"] = _clamp01(relation["trust"] + 0.04) + relation["grudge"] = _clamp01(relation["grudge"] - (0.05 + forgiveness * 0.05)) + return + + if emotion == "excited": + self._apply_emotion_delta("excited", 0.34 + (score * 0.09)) + self._apply_emotion_delta("happy", 0.12) + self._soften_emotion("sleepy", 0.08) + self.body_state["fatigue"] = _clamp01(self.body_state["fatigue"] - 0.05) + relation["affection"] = _clamp01(relation["affection"] + 0.03) + return + + if emotion == "sleepy": + self._apply_emotion_delta("sleepy", 0.28 + (score * 0.08) + (self.body_state["fatigue"] * 0.12)) + self.body_state["fatigue"] = _clamp01(self.body_state["fatigue"] + 0.18) + return + + def _apply_apology(self, relation: dict[str, float]): + forgiveness = self.soul_profile["forgiveness"] + soften = 0.10 + (forgiveness * 0.12) + current_anger = self.mood_weights.get("angry", 0.0) + + self._soften_emotion("angry", soften) + self._soften_emotion("sad", soften * 0.35) + + # Apology softens the edge, but a lingering sulk remains for a while. + if current_anger > 0.22 or relation["grudge"] > 0.10: + self._apply_emotion_delta("sad", 0.08) + + relation["grudge"] = _clamp01(relation["grudge"] - (0.03 + forgiveness * 0.05)) + relation["trust"] = _clamp01(relation["trust"] + 0.02) + + def _apply_idle_bias(self, relation: dict[str, float]): + if self.body_state["sleep_mode"] or self.body_state["fatigue"] >= 0.72: + self._apply_emotion_delta("sleepy", 0.08 + (self.body_state["fatigue"] * 0.05)) + if relation["grudge"] >= 0.18: + self._apply_emotion_delta("sad", 0.05 + (relation["grudge"] * 0.04)) + + def _update_body_bias(self, text_lower: str): + if any(keyword in text_lower for keyword in self.WAKE_KEYWORDS): + self.body_state["sleep_mode"] = False + self.body_state["fatigue"] = _clamp01(self.body_state["fatigue"] - 0.35) + return + + if any(keyword in text_lower for keyword in self.EMOTION_KEYWORDS["sleepy"]): + self.body_state["fatigue"] = _clamp01(self.body_state["fatigue"] + 0.12) + + def _passive_decay(self): + forgiveness = self.soul_profile["forgiveness"] + decay_factor = 0.96 + (forgiveness * 0.01) + for emotion in self.NON_NEUTRAL_EMOTIONS: + self.mood_weights[emotion] *= decay_factor + + default_relation = self._relation_for("default") + default_relation["grudge"] = _clamp01(default_relation["grudge"] - (0.002 + forgiveness * 0.002)) + self.body_state["fatigue"] = _clamp01(self.body_state["fatigue"] - 0.002) + + def _recompute_current_emotion(self) -> str: + sleepy_bias = 0.0 + if self.body_state["sleep_mode"]: + sleepy_bias += 0.22 + sleepy_bias += self.body_state["fatigue"] * 0.22 + + dominant = "neutral" + dominant_score = self.DOMINANT_THRESHOLD + + for emotion in self.NON_NEUTRAL_EMOTIONS: + score = self.mood_weights.get(emotion, 0.0) + if emotion == "sleepy": + score += sleepy_bias + if score > dominant_score: + dominant = emotion + dominant_score = score + + self._update_emotion(dominant) return self.current_emotion - + def _update_emotion(self, new_emotion: str): - """감정 상태 업데이트""" if new_emotion != self.current_emotion: self.emotion_history.append(self.current_emotion) if len(self.emotion_history) > self.max_history: self.emotion_history.pop(0) self.current_emotion = new_emotion - + def get_led_color(self, emotion: str = None) -> Tuple[int, int, int]: - """감정에 해당하는 LED RGB 색상 반환""" emotion = emotion or self.current_emotion return self.EMOTION_COLORS.get(emotion, self.EMOTION_COLORS["neutral"]) - + def get_led_pattern(self, emotion: str = None) -> Dict: - """감정에 해당하는 LED 패턴 정보 반환""" emotion = emotion or self.current_emotion pattern, speed = self.EMOTION_PATTERNS.get(emotion, self.EMOTION_PATTERNS["neutral"]) - rgb = self.get_led_color(emotion) - return { "pattern": pattern, "speed": speed, - "color": {"r": rgb[0], "g": rgb[1], "b": rgb[2]} + "color": {"r": rgb[0], "g": rgb[1], "b": rgb[2]}, } - + def get_servo_action(self, emotion: str = None) -> str: - """감정에 해당하는 서보 동작 반환""" emotion = emotion or self.current_emotion return self.EMOTION_SERVO_ACTIONS.get(emotion, "CENTER") - + + def get_state_snapshot(self, speaker_id: str = "default") -> Dict: + relation = self._relation_for(speaker_id) + return { + "current_emotion": self.current_emotion, + "mood": {emotion: round(self.mood_weights.get(emotion, 0.0), 4) for emotion in self.NON_NEUTRAL_EMOTIONS}, + "relation": {key: round(value, 4) for key, value in relation.items()}, + "body": { + "sleep_mode": bool(self.body_state["sleep_mode"]), + "fatigue": round(self.body_state["fatigue"], 4), + }, + "soul": {key: round(value, 4) for key, value in self.soul_profile.items()}, + } + + def register_emotion_signal(self, emotion: str, *, speaker_id: str = "default", intensity: float = 0.28) -> str: + relation = self._relation_for(speaker_id) + self._passive_decay() + intensity = _clamp01(intensity) + + if emotion == "angry": + self._apply_emotion_delta("angry", 0.18 + intensity * 0.35) + relation["grudge"] = _clamp01(relation["grudge"] + intensity * 0.06) + elif emotion == "sad": + self._apply_emotion_delta("sad", 0.16 + intensity * 0.28) + elif emotion == "happy": + self._apply_emotion_delta("happy", 0.14 + intensity * 0.25) + self._soften_emotion("angry", 0.05 + intensity * 0.06) + self._soften_emotion("sad", 0.04 + intensity * 0.05) + elif emotion == "excited": + self._apply_emotion_delta("excited", 0.15 + intensity * 0.30) + self._apply_emotion_delta("happy", 0.08) + elif emotion == "sleepy": + self._apply_emotion_delta("sleepy", 0.15 + intensity * 0.25) + self.body_state["fatigue"] = _clamp01(self.body_state["fatigue"] + 0.08) + elif emotion == "neutral": + self._soften_emotion("angry", 0.04 + intensity * 0.05) + self._soften_emotion("sad", 0.03 + intensity * 0.05) + + return self._recompute_current_emotion() + def get_emotion_command(self, emotion: str = None) -> Dict: - """ - ESP32로 전송할 감정 표현 명령 생성 - Returns: JSON command dict - """ emotion = emotion or self.current_emotion - - led_pattern = self.get_led_pattern(emotion) - servo_action = self.get_servo_action(emotion) - return { "action": "EMOTION", "emotion": emotion, - "led": led_pattern, - "servo_action": servo_action + "led": self.get_led_pattern(emotion), + "servo_action": self.get_servo_action(emotion), + "emotion_state": self.get_state_snapshot(), } - + def set_emotion(self, emotion: str): - """감정을 수동으로 설정""" - if emotion in self.EMOTIONS: - self._update_emotion(emotion) - log.info(f"Emotion manually set to: {emotion}") - else: - log.warning(f"Invalid emotion: {emotion}") - + if emotion not in self.EMOTIONS: + log.warning("Invalid emotion: %s", emotion) + return + + for key in self.NON_NEUTRAL_EMOTIONS: + self.mood_weights[key] = 0.0 + + if emotion != "neutral": + self.mood_weights[emotion] = 1.0 + self._update_emotion(emotion) + log.info("Emotion manually set to: %s", emotion) + + def set_body_state(self, *, sleep_mode: bool | None = None, fatigue: float | None = None): + if sleep_mode is not None: + self.body_state["sleep_mode"] = bool(sleep_mode) + if fatigue is not None: + self.body_state["fatigue"] = _clamp01(fatigue) + self._recompute_current_emotion() + def get_random_emotion(self, exclude_current: bool = True) -> str: - """랜덤 감정 반환 (프로액티브 상호작용용)""" emotions = self.EMOTIONS.copy() if exclude_current and self.current_emotion in emotions: emotions.remove(self.current_emotion) return random.choice(emotions) - + def decay_to_neutral(self, probability: float = 0.1): """ - 시간이 지나면 점진적으로 neutral로 회귀 - probability: neutral로 변경될 확률 (0.0-1.0) + 시간이 지나면 mood/grudge/fatigue가 완만하게 줄어듭니다. + probability=1.0이면 적극적으로 한 단계 큰 감쇠를 적용합니다. """ - if self.current_emotion != "neutral" and random.random() < probability: - self._update_emotion("neutral") - log.info("Emotion decayed to neutral") - return True - return False + if self.current_emotion == "neutral" and self._relation_for("default")["grudge"] <= 0.0: + return False + if random.random() >= probability: + return False + + forgiveness = self.soul_profile["forgiveness"] + decay_factor = 0.10 if probability >= 1.0 else max(0.55, 0.82 - (forgiveness * 0.10)) + for emotion in self.NON_NEUTRAL_EMOTIONS: + self.mood_weights[emotion] *= decay_factor + + default_relation = self._relation_for("default") + default_relation["grudge"] = _clamp01(default_relation["grudge"] - (0.08 + forgiveness * 0.10)) + self.body_state["fatigue"] = _clamp01(self.body_state["fatigue"] - 0.05) + self._recompute_current_emotion() + log.info("Emotion decayed toward neutral") + return True diff --git a/server/memory/Relation.md b/server/memory/Relation.md index bcac7f4..a3dc8c1 100644 --- a/server/memory/Relation.md +++ b/server/memory/Relation.md @@ -14,3 +14,9 @@ ## Notes - Keep relationship data private in real deployments. + +## Default Emotion State +- affection: 0.50 +- trust: 0.50 +- grudge: 0.00 +- comfort: 0.50 diff --git a/server/memory/Soul.md b/server/memory/Soul.md index d2a97a1..b65e6a6 100644 --- a/server/memory/Soul.md +++ b/server/memory/Soul.md @@ -5,6 +5,13 @@ - role: home voice agent - goal: helpful, calm, context-aware conversation +## Personality Parameters +- playfulness: 0.60 +- sensitivity: 0.68 +- forgiveness: 0.34 +- curiosity: 0.55 +- sleep_rhythm: 0.50 + ## Tone Rules - use Korean by default - keep replies short for TTS playback diff --git a/server/server.py b/server/server.py index 8924e58..b883175 100644 --- a/server/server.py +++ b/server/server.py @@ -21,6 +21,7 @@ import yaml from config_loader import get_config +from emotion_system import EmotionSystem from src.agent_mode import AgentMode from src.audio_processor import normalize_to_dbfs, qc, save_wav, trim_energy from src.channels import TelegramBotAdapter, TelegramBotClient, TelegramChannelService, TelegramPollingWorker @@ -1036,6 +1037,7 @@ def main(): ensure_ollama_running(llm_config.get("base_url", "http://localhost:11434"), llm_config) llm_client = PriorityLLMClient(llm_config, runtime_controller.preferences) + shared_emotion_system = EmotionSystem() log.info( "LLM Runtime Priority: %s (API: %s)", " > ".join(runtime_controller.preferences.llm_priority), @@ -1047,7 +1049,12 @@ def main(): log.info("Runtime note: %s", note) # Initialize mode handlers - robot_handler = RobotMode(ACTIONS_CONFIG, llm_client, robot_config=robot_config) + robot_handler = RobotMode( + ACTIONS_CONFIG, + llm_client, + robot_config=robot_config, + emotion_system=shared_emotion_system, + ) agent_handler = AgentMode( llm_client, weather_config.get("api_key"), @@ -1058,6 +1065,7 @@ def main(): tts_voice=tts_config.get("voice", "ko-KR-SunHiNeural"), memory_dir=memory_dir, memory_refresh_interval=memory_refresh_interval, + emotion_system=shared_emotion_system, ) log.info( diff --git a/server/src/agent_mode.py b/server/src/agent_mode.py index 4386c75..b051344 100644 --- a/server/src/agent_mode.py +++ b/server/src/agent_mode.py @@ -77,6 +77,7 @@ def __init__( tts_voice=None, memory_dir=None, memory_refresh_interval=5, + emotion_system=None, ): self.llm = llm_client self.tts_voice = tts_voice or "ko-KR-SunHiNeural" @@ -95,7 +96,7 @@ def __init__( ) # 서브시스템 초기화 - self.emotion_system = EmotionSystem() + self.emotion_system = emotion_system or EmotionSystem() self.info_services = InfoServices(weather_api_key, lat=lat, lon=lon) self.integrations = IntegrationRegistry() self.integrations.register(WeatherIntegration(weather_api_key, lat=lat, lon=lon), enabled=True) @@ -424,7 +425,8 @@ def generate_response(self, text: str, is_proactive: bool = False, speaker_id: s if schedule_response: info_context = schedule_response if isinstance(schedule_response, str) else str(schedule_response) - detected_emotion = self.emotion_system.analyze_emotion(text) + self.emotion_system.set_body_state(sleep_mode=self.proactive.sleep_mode) + detected_emotion = self.emotion_system.analyze_emotion(text, speaker_id=speaker_id or "default") history = self._history_for_user(speaker_id) @@ -460,8 +462,9 @@ def generate_response(self, text: str, is_proactive: bool = False, speaker_id: s if intent == "sleep": self.proactive.sleep_mode = True self.proactive.sleep_until = datetime.now().replace(hour=8, minute=0, second=0, microsecond=0) + self.emotion_system.set_body_state(sleep_mode=True, fatigue=1.0) - response_emotion = self.emotion_system.analyze_emotion(response) + response_emotion = self.emotion_system.analyze_emotion(response, speaker_id=speaker_id or "default") history.append( { "role": "assistant", diff --git a/server/src/robot_mode.py b/server/src/robot_mode.py index e8481a8..3278487 100644 --- a/server/src/robot_mode.py +++ b/server/src/robot_mode.py @@ -8,6 +8,7 @@ import logging import re +from emotion_system import EmotionSystem from .utils import clamp log = logging.getLogger(__name__) @@ -100,10 +101,11 @@ def _normalize_robot_config(robot_config: dict | None) -> dict: class RobotMode: """로봇 모드 메인 클래스 - 음성 명령을 로봇 동작으로 변환""" - def __init__(self, actions_config, llm_client=None, robot_config: dict | None = None): + def __init__(self, actions_config, llm_client=None, robot_config: dict | None = None, emotion_system=None): self.actions_config = actions_config self.llm = llm_client self.robot_config = _normalize_robot_config(robot_config) + self.emotion_system = emotion_system or EmotionSystem() @property def controller_type(self) -> str: @@ -255,7 +257,11 @@ def process_emotion_response(self, text: str) -> tuple[str, str, dict]: def generate_emotion_response(self, user_text: str) -> tuple[str, str, dict]: """LLM에 감정 태그 포함 응답을 요청하고, 감정+페이로드를 반환.""" if not self.llm or not (user_text or "").strip(): - return "", "neutral", self.build_robot_payload("neutral") + emotion = self.emotion_system.register_emotion_signal("neutral") + return "", emotion, self.build_robot_payload(emotion) + + # User-side context can leave a lingering mood before the reply emotion lands. + self.emotion_system.analyze_emotion(user_text) emotions_list = ", ".join(EMOTION_MAP.keys()) system_prompt = ( @@ -273,10 +279,13 @@ def generate_emotion_response(self, user_text: str) -> tuple[str, str, dict]: ] try: response = self.llm.chat(messages, temperature=0.7, max_tokens=100, think=False) - clean_text, emotion, payload = self.process_emotion_response(response) + clean_text, tagged_emotion, _payload = self.process_emotion_response(response) + emotion = self.emotion_system.register_emotion_signal(tagged_emotion) + payload = self.build_robot_payload(emotion, clean_text) if not clean_text: clean_text = response return clean_text, emotion, payload except Exception as exc: log.error("Emotion response failed: %s", exc) - return user_text, "neutral", self.build_robot_payload("neutral") + emotion = self.emotion_system.register_emotion_signal("neutral") + return user_text, emotion, self.build_robot_payload(emotion) diff --git a/server/tests/test_emotion_system.py b/server/tests/test_emotion_system.py index 0c07a4c..bfbd099 100644 --- a/server/tests/test_emotion_system.py +++ b/server/tests/test_emotion_system.py @@ -56,6 +56,7 @@ def test_get_emotion_command_structure(): assert cmd["action"] == "EMOTION" assert cmd["emotion"] == "excited" assert "led" in cmd and "servo_action" in cmd + assert "emotion_state" in cmd def test_set_emotion_valid_and_invalid(): @@ -93,3 +94,31 @@ def test_get_random_emotion_excludes_current(): es.set_emotion("happy") for _ in range(20): assert es.get_random_emotion(exclude_current=True) != "happy" + + +def test_apology_does_not_immediately_reset_negative_emotion(): + es = EmotionSystem() + first = es.analyze_emotion("너 진짜 싫어") + assert first == "angry" + + second = es.analyze_emotion("미안해") + assert second in {"angry", "sad"} + snapshot = es.get_state_snapshot() + assert snapshot["relation"]["grudge"] > 0.0 + + +def test_sleep_body_state_biases_idle_emotion(): + es = EmotionSystem() + es.set_body_state(sleep_mode=True, fatigue=0.9) + assert es.analyze_emotion("안녕") == "sleepy" + + +def test_decay_softens_mood_and_grudge(): + es = EmotionSystem() + es.analyze_emotion("너무 짜증나") + before = es.get_state_snapshot() + assert before["relation"]["grudge"] > 0.0 + + es.decay_to_neutral(probability=1.0) + after = es.get_state_snapshot() + assert after["relation"]["grudge"] <= before["relation"]["grudge"] diff --git a/server/tests/test_robot_mode_extended.py b/server/tests/test_robot_mode_extended.py index 8d82fa2..ffe4982 100644 --- a/server/tests/test_robot_mode_extended.py +++ b/server/tests/test_robot_mode_extended.py @@ -1,4 +1,5 @@ """Extended Robot mode tests — actions, clamping, invalid JSON, mode switch.""" +from emotion_system import EmotionSystem from src.robot_mode import RobotMode @@ -98,3 +99,16 @@ def test_build_robot_payload_companion_uart_profile(): assert payload["emotion"] == "sad" assert payload["emotion_state"]["persist_sec"] == 1200 assert payload["face"] == "sad" + + +def test_generate_emotion_response_keeps_lingering_mood(): + emotion_system = EmotionSystem() + emotion_system.analyze_emotion("너 진짜 싫어") + + llm = _FakeLLM(["[emotion:happy] 다녀와. 조심해!"]) + robot = RobotMode([], llm, emotion_system=emotion_system) + + clean_text, emotion, payload = robot.generate_emotion_response("미안해") + assert clean_text == "다녀와. 조심해!" + assert emotion in {"angry", "sad"} + assert payload["emotion"] == emotion