"""Robot motion — DoA tracking + expressive actions.""" import asyncio import math import time import logging import re import numpy as np def create_head_pose(roll=0, pitch=0, yaw=0, degrees=False): if degrees: roll, pitch, yaw = math.radians(roll), math.radians(pitch), math.radians(yaw) cr, sr = math.cos(roll), math.sin(roll) cp, sp = math.cos(pitch), math.sin(pitch) cy, sy = math.cos(yaw), math.sin(yaw) R = np.array([ [cy*cp, cy*sp*sr - sy*cr, cy*sp*cr + sy*sr, 0], [sy*cp, sy*sp*sr + cy*cr, sy*sp*cr - cy*sr, 0], [-sp, cp*sr, cp*cr, 0], [0, 0, 0, 1], ], dtype=np.float64) return R from .config import DOA_SMOOTHING, CONTROL_LOOP_HZ logger = logging.getLogger(__name__) ACTION_RE = re.compile(r"\[ACTION:(\w+)\]", re.IGNORECASE) SALE_RE = re.compile(r"\[SALE:([^\]]+)\]", re.IGNORECASE) def parse_response(text: str) -> tuple[str, list[str], list[str]]: actions = [m.group(1).lower() for m in ACTION_RE.finditer(text)] sales = [m.group(1).strip() for m in SALE_RE.finditer(text)] clean = ACTION_RE.sub("", text) clean = SALE_RE.sub("", clean).strip() return clean, actions, sales class MotionController: def __init__(self, mini): self.mini = mini self._running = False self._doa_angle: float | None = None self._speech_detected = False self._is_speaking = False self._action_queue: asyncio.Queue[str] = asyncio.Queue() async def start(self): self._running = True async def stop(self): self._running = False def set_speaking(self, val: bool): self._is_speaking = val def trigger(self, action: str): try: self._action_queue.put_nowait(action) except asyncio.QueueFull: pass async def doa_tracking_loop(self): loop = asyncio.get_event_loop() while self._running: try: result = await loop.run_in_executor(None, self.mini.media.get_DoA) if result: angle, speech = result self._speech_detected = speech if self._doa_angle is None: self._doa_angle = angle else: self._doa_angle = DOA_SMOOTHING * angle + (1 - DOA_SMOOTHING) * self._doa_angle except Exception: pass await asyncio.sleep(0.05) async def motion_loop(self): while self._running: t = time.monotonic() head_yaw = 0.0 if self._doa_angle is not None and self._speech_detected: head_yaw = max(min((math.pi / 2) - self._doa_angle, 0.7), -0.7) ant_l, ant_r = 0.0, 0.0 if self._is_speaking: amp = math.radians(25) ant_l = amp * math.sin(2 * math.pi * 1.5 * t) ant_r = amp * math.sin(2 * math.pi * 1.5 * t + math.pi / 3) head = create_head_pose(roll=0, pitch=0, yaw=head_yaw, degrees=False) self.mini.set_target(head=head, antennas=[ant_r, ant_l]) await asyncio.sleep(1.0 / CONTROL_LOOP_HZ) async def action_loop(self): while self._running: try: action = await asyncio.wait_for(self._action_queue.get(), timeout=0.5) await self._execute(action) except asyncio.TimeoutError: continue async def _execute(self, action: str): m = self.mini if action == "wave": for _ in range(3): m.goto_target(antennas=[0, math.radians(-40)], duration=0.2) await asyncio.sleep(0.25) m.goto_target(antennas=[math.radians(-40), 0], duration=0.2) await asyncio.sleep(0.25) m.goto_target(antennas=[0, 0], duration=0.3) elif action == "nod": for angle in [-15, 10, -10, 0]: head = create_head_pose(pitch=math.radians(angle), degrees=False) m.goto_target(head=head, duration=0.2) await asyncio.sleep(0.25) elif action == "shake": for yaw in [-20, 20, -15, 0]: head = create_head_pose(yaw=math.radians(yaw), degrees=False) m.goto_target(head=head, duration=0.2) await asyncio.sleep(0.2) elif action == "happy": for _ in range(3): m.goto_target(antennas=[math.radians(-30), math.radians(-30)], duration=0.15) await asyncio.sleep(0.2) m.goto_target(antennas=[math.radians(10), math.radians(10)], duration=0.15) await asyncio.sleep(0.2) m.goto_target(antennas=[0, 0], duration=0.3) elif action == "dance": for _ in range(4): m.goto_target(body_yaw=math.radians(20), antennas=[math.radians(-30), math.radians(10)], duration=0.25) await asyncio.sleep(0.3) m.goto_target(body_yaw=math.radians(-20), antennas=[math.radians(10), math.radians(-30)], duration=0.25) await asyncio.sleep(0.3) m.goto_target(body_yaw=0, antennas=[0, 0], duration=0.3) elif action == "think": head = create_head_pose(roll=math.radians(5), pitch=math.radians(-10), yaw=math.radians(15), degrees=False) m.goto_target(head=head, antennas=[math.radians(-20), math.radians(-20)], duration=0.4) await asyncio.sleep(1.5) m.goto_target(head=create_head_pose(degrees=False), antennas=[0, 0], duration=0.4) elif action == "greet": head = create_head_pose(pitch=math.radians(-15), degrees=False) m.goto_target(head=head, antennas=[math.radians(-20), math.radians(-20)], duration=0.3) await asyncio.sleep(0.5) m.goto_target(head=create_head_pose(degrees=False), antennas=[0, 0], duration=0.3)