Compare commits
3 Commits
8aa4072a63
...
cb12cfa519
| Author | SHA1 | Date | |
|---|---|---|---|
| cb12cfa519 | |||
| bd22a1a2b1 | |||
| d1021fab09 |
@ -14,4 +14,8 @@ extern I2C_HandleTypeDef hi2c1;
|
||||
|
||||
int i2c1_init(void);
|
||||
|
||||
/* I2C read/write helpers for sensors (INA219, etc.) */
|
||||
int i2c1_read(uint8_t addr, uint8_t *data, uint16_t len);
|
||||
int i2c1_write(uint8_t addr, const uint8_t *data, uint16_t len);
|
||||
|
||||
#endif /* I2C1_H */
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
# PulseAudio Configuration for MageDok HDMI Audio
|
||||
# Routes HDMI audio from DisplayPort adapter to internal speaker output
|
||||
|
||||
# Detect and load HDMI output module
|
||||
load-module module-alsa-sink device=hw:0,3 sink_name=hdmi_stereo sink_properties="device.description='HDMI Audio'"
|
||||
|
||||
# Detect and configure internal speaker (fallback)
|
||||
load-module module-alsa-sink device=hw:0,0 sink_name=speaker_mono sink_properties="device.description='Speaker'"
|
||||
|
||||
# Set HDMI as default output sink
|
||||
set-default-sink hdmi_stereo
|
||||
|
||||
# Enable volume control
|
||||
load-module module-volume-restore
|
||||
|
||||
# Auto-switch to HDMI when connected
|
||||
load-module module-switch-on-connect
|
||||
|
||||
# Log sink configuration
|
||||
.load-if-exists /etc/pulse/magedok-routing.conf
|
||||
33
jetson/ros2_ws/src/saltybot_bringup/config/xorg-magedok.conf
Normal file
33
jetson/ros2_ws/src/saltybot_bringup/config/xorg-magedok.conf
Normal file
@ -0,0 +1,33 @@
|
||||
# X11 Configuration for MageDok 7" Display
|
||||
# Resolution: 1024×600 @ 60Hz
|
||||
# Output: HDMI via DisplayPort adapter
|
||||
|
||||
Section "Monitor"
|
||||
Identifier "MageDok"
|
||||
Option "PreferredMode" "1024x600_60.00"
|
||||
Option "Position" "0 0"
|
||||
Option "Primary" "true"
|
||||
EndSection
|
||||
|
||||
Section "Screen"
|
||||
Identifier "Screen0"
|
||||
Monitor "MageDok"
|
||||
DefaultDepth 24
|
||||
SubSection "Display"
|
||||
Depth 24
|
||||
Modes "1024x600" "1024x768" "800x600" "640x480"
|
||||
EndSubSection
|
||||
EndSection
|
||||
|
||||
Section "Device"
|
||||
Identifier "NVIDIA Tegra"
|
||||
Driver "nvidia"
|
||||
BusID "PCI:0:0:0"
|
||||
Option "RegistryDwords" "EnableBrightnessControl=1"
|
||||
Option "ConnectedMonitor" "HDMI-0"
|
||||
EndSection
|
||||
|
||||
Section "ServerLayout"
|
||||
Identifier "Default"
|
||||
Screen "Screen0"
|
||||
EndSection
|
||||
@ -0,0 +1,218 @@
|
||||
# MageDok 7" Touchscreen Display Setup
|
||||
|
||||
Issue #369: Display setup for MageDok 7" IPS touchscreen on Jetson Orin Nano.
|
||||
|
||||
## Hardware Setup
|
||||
|
||||
### Connections
|
||||
- **Video**: DisplayPort → HDMI cable from Orin DP 1.2 connector to MageDok HDMI input
|
||||
- **Touch**: USB 3.0 cable from Orin USB-A to MageDok USB-C connector
|
||||
- **Audio**: HDMI carries embedded audio from DisplayPort (no separate audio cable needed)
|
||||
|
||||
### Display Specs
|
||||
- **Resolution**: 1024×600 @ 60Hz
|
||||
- **Panel Type**: 7" IPS (In-Plane Switching) - wide viewing angles
|
||||
- **Sunlight Readable**: Yes, with high brightness
|
||||
- **Built-in Speakers**: Yes (via HDMI audio)
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Kernel and Display Driver Configuration
|
||||
|
||||
```bash
|
||||
# Update display mode database (if needed)
|
||||
sudo apt-get update && sudo apt-get install -y xrandr x11-utils edid-decode
|
||||
|
||||
# Verify X11 is running
|
||||
echo $DISPLAY # Should show :0 or :1
|
||||
|
||||
# Check connected displays
|
||||
xrandr --query
|
||||
```
|
||||
|
||||
**Expected output**: HDMI-1 connected at 1024x600 resolution
|
||||
|
||||
### 2. Install udev Rules for Touch Input
|
||||
|
||||
```bash
|
||||
# Copy udev rules
|
||||
sudo cp jetson/ros2_ws/src/saltybot_bringup/udev/90-magedok-touch.rules \
|
||||
/etc/udev/rules.d/
|
||||
|
||||
# Reload udev
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
|
||||
# Verify touch device
|
||||
ls -l /dev/magedok-touch
|
||||
# Or check input devices
|
||||
cat /proc/bus/input/devices | grep -i "eGTouch\|EETI"
|
||||
```
|
||||
|
||||
### 3. X11 Display Configuration
|
||||
|
||||
```bash
|
||||
# Backup original X11 config
|
||||
sudo cp /etc/X11/xorg.conf /etc/X11/xorg.conf.backup
|
||||
|
||||
# Apply MageDok X11 config
|
||||
sudo cp jetson/ros2_ws/src/saltybot_bringup/config/xorg-magedok.conf \
|
||||
/etc/X11/xorg.conf
|
||||
|
||||
# Restart X11 (or reboot)
|
||||
sudo systemctl restart gdm3 # or startx if using console
|
||||
```
|
||||
|
||||
### 4. PulseAudio Audio Routing
|
||||
|
||||
```bash
|
||||
# Check current audio sinks
|
||||
pactl list sinks | grep Name
|
||||
|
||||
# Find HDMI sink (typically contains "hdmi" in name)
|
||||
pactl set-default-sink <hdmi-sink-name>
|
||||
|
||||
# Verify routing
|
||||
pactl get-default-sink
|
||||
|
||||
# Optional: Set volume
|
||||
pactl set-sink-volume <sink-name> 70%
|
||||
```
|
||||
|
||||
### 5. ROS2 Launch Configuration
|
||||
|
||||
```bash
|
||||
# Build the saltybot_bringup package
|
||||
cd jetson/ros2_ws
|
||||
colcon build --packages-select saltybot_bringup
|
||||
|
||||
# Source workspace
|
||||
source install/setup.bash
|
||||
|
||||
# Launch display setup
|
||||
ros2 launch saltybot_bringup magedok_display.launch.py
|
||||
```
|
||||
|
||||
### 6. Enable Auto-Start on Boot
|
||||
|
||||
```bash
|
||||
# Copy systemd service
|
||||
sudo cp jetson/ros2_ws/src/saltybot_bringup/systemd/magedok-display.service \
|
||||
/etc/systemd/system/
|
||||
|
||||
# Enable service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable magedok-display.service
|
||||
|
||||
# Start service
|
||||
sudo systemctl start magedok-display.service
|
||||
|
||||
# Check status
|
||||
sudo systemctl status magedok-display.service
|
||||
sudo journalctl -u magedok-display -f # Follow logs
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### Display Resolution
|
||||
```bash
|
||||
# Check actual resolution
|
||||
xdotool getactivewindow getwindowgeometry
|
||||
|
||||
# Verify with xrandr
|
||||
xrandr | grep "1024x600"
|
||||
```
|
||||
|
||||
**Expected**: `1024x600_60.00 +0+0` or similar
|
||||
|
||||
### Touch Input
|
||||
```bash
|
||||
# List input devices
|
||||
xinput list
|
||||
|
||||
# Should show "MageDok Touch" or "eGTouch Controller"
|
||||
# Test touch by clicking on display - cursor should move
|
||||
```
|
||||
|
||||
### Audio
|
||||
```bash
|
||||
# Test HDMI audio
|
||||
speaker-test -c 2 -l 1 -s 1 -t sine
|
||||
|
||||
# Verify volume level
|
||||
pactl list sinks | grep -A 10 RUNNING
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Display Not Detected
|
||||
|
||||
```bash
|
||||
# Check EDID data
|
||||
edid-decode /sys/class/drm/card0-HDMI-A-1/edid
|
||||
|
||||
# Force resolution
|
||||
xrandr --output HDMI-1 --mode 1024x600 --rate 60
|
||||
|
||||
# Check kernel logs
|
||||
dmesg | grep -i "drm\|HDMI\|dp"
|
||||
```
|
||||
|
||||
### Touch Not Working
|
||||
|
||||
```bash
|
||||
# Check USB connection
|
||||
lsusb | grep -i "eGTouch\|EETI"
|
||||
|
||||
# Verify udev rules applied
|
||||
cat /etc/udev/rules.d/90-magedok-touch.rules
|
||||
|
||||
# Test touch device directly
|
||||
evtest /dev/magedok-touch # Or /dev/input/eventX
|
||||
```
|
||||
|
||||
### Audio Not Routing
|
||||
|
||||
```bash
|
||||
# Check PulseAudio daemon
|
||||
pulseaudio --version
|
||||
systemctl status pulseaudio
|
||||
|
||||
# Restart PulseAudio
|
||||
systemctl --user restart pulseaudio
|
||||
|
||||
# Monitor audio stream
|
||||
pactl list sink-inputs
|
||||
```
|
||||
|
||||
### Display Disconnection (Headless Fallback)
|
||||
|
||||
The system should continue operating normally with display disconnected:
|
||||
- ROS2 services remain accessible via network
|
||||
- Robot commands via `/cmd_vel` continue working
|
||||
- Data logging and telemetry unaffected
|
||||
- Dashboard accessible via SSH/webui from other machine
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Display shows 1024×600 resolution
|
||||
- [ ] Touch input registers in xinput (test by moving cursor)
|
||||
- [ ] Audio plays through display speakers
|
||||
- [ ] System boots without login prompt (if using auto-start)
|
||||
- [ ] All ROS2 nodes launch correctly with display
|
||||
- [ ] System operates normally when display is disconnected
|
||||
- [ ] `/magedok/touch_status` topic shows true (ROS2 verify script)
|
||||
- [ ] `/magedok/audio_status` topic shows HDMI sink (ROS2 audio router)
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **#368**: Salty Face UI (depends on this display setup)
|
||||
- **#370**: Animated expression UI
|
||||
- **#371**: Deaf/accessibility mode with touch keyboard
|
||||
|
||||
## References
|
||||
|
||||
- MageDok 7" Specs: [HDMI, 1024×600, USB Touch, Built-in Speakers]
|
||||
- Jetson Orin Nano DisplayPort Output: Requires active adapter (no DP Alt Mode on USB-C)
|
||||
- PulseAudio: HDMI audio sink routing via ALSA
|
||||
- X11/Xrandr: Display mode configuration
|
||||
@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MageDok 7" Display Launch Configuration
|
||||
- Video: DisplayPort → HDMI (1024×600)
|
||||
- Touch: USB HID
|
||||
- Audio: HDMI → internal speakers via PulseAudio
|
||||
"""
|
||||
|
||||
import os
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
from launch.actions import ExecuteProcess
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription([
|
||||
# Log startup
|
||||
ExecuteProcess(
|
||||
cmd=['echo', '[MageDok] Display setup starting...'],
|
||||
shell=True,
|
||||
),
|
||||
|
||||
# Verify display resolution
|
||||
Node(
|
||||
package='saltybot_bringup',
|
||||
executable='verify_display.py',
|
||||
name='display_verifier',
|
||||
parameters=[
|
||||
{'target_width': 1024},
|
||||
{'target_height': 600},
|
||||
{'target_refresh': 60},
|
||||
],
|
||||
output='screen',
|
||||
),
|
||||
|
||||
# Monitor touch input
|
||||
Node(
|
||||
package='saltybot_bringup',
|
||||
executable='touch_monitor.py',
|
||||
name='touch_monitor',
|
||||
parameters=[
|
||||
{'device_name': 'MageDok Touch'},
|
||||
{'poll_interval': 0.1},
|
||||
],
|
||||
output='screen',
|
||||
),
|
||||
|
||||
# Audio routing (PulseAudio sink redirection)
|
||||
Node(
|
||||
package='saltybot_bringup',
|
||||
executable='audio_router.py',
|
||||
name='audio_router',
|
||||
parameters=[
|
||||
{'hdmi_sink': 'alsa_output.pci-0000_00_1d.0.hdmi-stereo'},
|
||||
{'default_sink': True},
|
||||
],
|
||||
output='screen',
|
||||
),
|
||||
])
|
||||
97
jetson/ros2_ws/src/saltybot_bringup/scripts/audio_router.py
Normal file
97
jetson/ros2_ws/src/saltybot_bringup/scripts/audio_router.py
Normal file
@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MageDok Audio Router
|
||||
Routes HDMI audio from DisplayPort adapter to internal speakers via PulseAudio
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from std_msgs.msg import String
|
||||
|
||||
|
||||
class AudioRouter(Node):
|
||||
def __init__(self):
|
||||
super().__init__('audio_router')
|
||||
|
||||
self.declare_parameter('hdmi_sink', 'alsa_output.pci-0000_00_1d.0.hdmi-stereo')
|
||||
self.declare_parameter('default_sink', True)
|
||||
|
||||
self.hdmi_sink = self.get_parameter('hdmi_sink').value
|
||||
self.set_default = self.get_parameter('default_sink').value
|
||||
|
||||
self.audio_status_pub = self.create_publisher(String, '/magedok/audio_status', 10)
|
||||
|
||||
self.get_logger().info('Audio Router: Configuring HDMI audio routing...')
|
||||
self.setup_pulseaudio()
|
||||
|
||||
# Check status every 5 seconds
|
||||
self.create_timer(5.0, self.check_audio_status)
|
||||
|
||||
def setup_pulseaudio(self):
|
||||
"""Configure PulseAudio to route HDMI audio"""
|
||||
try:
|
||||
# List available sinks
|
||||
result = subprocess.run(['pactl', 'list', 'sinks'], capture_output=True, text=True, timeout=5)
|
||||
sinks = self._parse_pa_sinks(result.stdout)
|
||||
|
||||
if not sinks:
|
||||
self.get_logger().warn('No PulseAudio sinks detected')
|
||||
return
|
||||
|
||||
self.get_logger().info(f'Available sinks: {", ".join(sinks.keys())}')
|
||||
|
||||
# Find HDMI or use first available
|
||||
hdmi_sink = None
|
||||
for name in sinks.keys():
|
||||
if 'hdmi' in name.lower() or 'HDMI' in name:
|
||||
hdmi_sink = name
|
||||
break
|
||||
|
||||
if not hdmi_sink:
|
||||
hdmi_sink = list(sinks.keys())[0] # Fallback to first sink
|
||||
self.get_logger().warn(f'HDMI sink not found, using: {hdmi_sink}')
|
||||
else:
|
||||
self.get_logger().info(f'✓ HDMI sink identified: {hdmi_sink}')
|
||||
|
||||
# Set as default if requested
|
||||
if self.set_default:
|
||||
subprocess.run(['pactl', 'set-default-sink', hdmi_sink], timeout=5)
|
||||
self.get_logger().info(f'✓ Audio routed to: {hdmi_sink}')
|
||||
|
||||
except Exception as e:
|
||||
self.get_logger().error(f'PulseAudio setup failed: {e}')
|
||||
|
||||
def _parse_pa_sinks(self, pactl_output):
|
||||
"""Parse 'pactl list sinks' output"""
|
||||
sinks = {}
|
||||
current_sink = None
|
||||
for line in pactl_output.split('\n'):
|
||||
if line.startswith('Sink #'):
|
||||
current_sink = line.split('#')[1].strip()
|
||||
elif '\tName: ' in line and current_sink:
|
||||
name = line.split('Name: ')[1].strip()
|
||||
sinks[name] = current_sink
|
||||
return sinks
|
||||
|
||||
def check_audio_status(self):
|
||||
"""Verify audio is properly routed"""
|
||||
try:
|
||||
result = subprocess.run(['pactl', 'get-default-sink'], capture_output=True, text=True, timeout=5)
|
||||
status = String()
|
||||
status.data = result.stdout.strip()
|
||||
self.audio_status_pub.publish(status)
|
||||
self.get_logger().debug(f'Current audio sink: {status.data}')
|
||||
except Exception as e:
|
||||
self.get_logger().warn(f'Audio status check failed: {e}')
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
router = AudioRouter()
|
||||
rclpy.spin(router)
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
88
jetson/ros2_ws/src/saltybot_bringup/scripts/touch_monitor.py
Normal file
88
jetson/ros2_ws/src/saltybot_bringup/scripts/touch_monitor.py
Normal file
@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MageDok Touch Input Monitor
|
||||
Verifies USB touch device is recognized and functional
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from std_msgs.msg import String, Bool
|
||||
|
||||
|
||||
class TouchMonitor(Node):
|
||||
def __init__(self):
|
||||
super().__init__('touch_monitor')
|
||||
|
||||
self.declare_parameter('device_name', 'MageDok Touch')
|
||||
self.declare_parameter('poll_interval', 0.1)
|
||||
|
||||
self.device_name = self.get_parameter('device_name').value
|
||||
self.poll_interval = self.get_parameter('poll_interval').value
|
||||
|
||||
self.touch_status_pub = self.create_publisher(Bool, '/magedok/touch_status', 10)
|
||||
self.device_info_pub = self.create_publisher(String, '/magedok/device_info', 10)
|
||||
|
||||
self.get_logger().info(f'Touch Monitor: Scanning for {self.device_name}...')
|
||||
self.detect_touch_device()
|
||||
|
||||
# Publish status every 2 seconds
|
||||
self.create_timer(2.0, self.publish_status)
|
||||
|
||||
def detect_touch_device(self):
|
||||
"""Detect MageDok touch device via USB"""
|
||||
try:
|
||||
# Check lsusb for MageDok or eGTouch device
|
||||
result = subprocess.run(['lsusb'], capture_output=True, text=True, timeout=5)
|
||||
lines = result.stdout.split('\n')
|
||||
|
||||
for line in lines:
|
||||
if 'eGTouch' in line or 'EETI' in line or 'MageDok' in line or 'touch' in line.lower():
|
||||
self.get_logger().info(f'✓ Touch device found: {line.strip()}')
|
||||
msg = String()
|
||||
msg.data = line.strip()
|
||||
self.device_info_pub.publish(msg)
|
||||
return True
|
||||
|
||||
# Fallback: check input devices
|
||||
result = subprocess.run(['grep', '-l', 'eGTouch\|EETI\|MageDok', '/proc/bus/input/devices'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
self.get_logger().info('✓ Touch device registered in /proc/bus/input/devices')
|
||||
return True
|
||||
|
||||
self.get_logger().warn('⚠ Touch device not detected — ensure USB connection is secure')
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.get_logger().error(f'Device detection failed: {e}')
|
||||
return False
|
||||
|
||||
def publish_status(self):
|
||||
"""Publish current touch device status"""
|
||||
try:
|
||||
result = subprocess.run(['ls', '/dev/magedok-touch'], capture_output=True, timeout=2)
|
||||
status = Bool()
|
||||
status.data = (result.returncode == 0)
|
||||
self.touch_status_pub.publish(status)
|
||||
|
||||
if status.data:
|
||||
self.get_logger().debug('Touch device: ACTIVE')
|
||||
else:
|
||||
self.get_logger().warn('Touch device: NOT DETECTED')
|
||||
except Exception as e:
|
||||
status = Bool()
|
||||
status.data = False
|
||||
self.touch_status_pub.publish(status)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
monitor = TouchMonitor()
|
||||
rclpy.spin(monitor)
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MageDok Display Verifier
|
||||
Validates that the 7" display is running at 1024×600 resolution
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
class DisplayVerifier(Node):
|
||||
def __init__(self):
|
||||
super().__init__('display_verifier')
|
||||
|
||||
self.declare_parameter('target_width', 1024)
|
||||
self.declare_parameter('target_height', 600)
|
||||
self.declare_parameter('target_refresh', 60)
|
||||
|
||||
self.target_w = self.get_parameter('target_width').value
|
||||
self.target_h = self.get_parameter('target_height').value
|
||||
self.target_f = self.get_parameter('target_refresh').value
|
||||
|
||||
self.get_logger().info(f'Display Verifier: Target {self.target_w}×{self.target_h} @ {self.target_f}Hz')
|
||||
self.verify_display()
|
||||
|
||||
def verify_display(self):
|
||||
"""Check current display resolution via xdotool or xrandr"""
|
||||
try:
|
||||
# Try xrandr first
|
||||
result = subprocess.run(['xrandr'], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
self.parse_xrandr(result.stdout)
|
||||
else:
|
||||
self.get_logger().warn('xrandr not available, checking edid-decode')
|
||||
self.check_edid()
|
||||
except Exception as e:
|
||||
self.get_logger().error(f'Display verification failed: {e}')
|
||||
|
||||
def parse_xrandr(self, output):
|
||||
"""Parse xrandr output to find active display resolution"""
|
||||
lines = output.split('\n')
|
||||
for line in lines:
|
||||
# Look for connected display with resolution
|
||||
if 'connected' in line and 'primary' in line:
|
||||
# Example: "HDMI-1 connected primary 1024x600+0+0 (normal left inverted right)"
|
||||
match = re.search(r'(\d+)x(\d+)', line)
|
||||
if match:
|
||||
width, height = int(match.group(1)), int(match.group(2))
|
||||
self.verify_resolution(width, height)
|
||||
return
|
||||
|
||||
self.get_logger().warn('Could not determine active display from xrandr')
|
||||
|
||||
def verify_resolution(self, current_w, current_h):
|
||||
"""Validate resolution matches target"""
|
||||
if current_w == self.target_w and current_h == self.target_h:
|
||||
self.get_logger().info(f'✓ Display verified: {current_w}×{current_h} [OK]')
|
||||
else:
|
||||
self.get_logger().warn(f'⚠ Display mismatch: Expected {self.target_w}×{self.target_h}, got {current_w}×{current_h}')
|
||||
self.attempt_set_resolution()
|
||||
|
||||
def attempt_set_resolution(self):
|
||||
"""Try to set resolution via xrandr"""
|
||||
try:
|
||||
# Find HDMI output
|
||||
result = subprocess.run(
|
||||
['xrandr', '--output', 'HDMI-1', '--mode', f'{self.target_w}x{self.target_h}', '--rate', str(self.target_f)],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
self.get_logger().info(f'✓ Resolution set to {self.target_w}×{self.target_h} @ {self.target_f}Hz')
|
||||
else:
|
||||
self.get_logger().warn(f'Resolution change failed: {result.stderr}')
|
||||
except Exception as e:
|
||||
self.get_logger().error(f'Could not set resolution: {e}')
|
||||
|
||||
def check_edid(self):
|
||||
"""Fallback: check EDID (Extended Display ID) data"""
|
||||
try:
|
||||
result = subprocess.run(['edid-decode', '/sys/class/drm/card0-HDMI-A-1/edid'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if 'Established timings' in result.stdout:
|
||||
self.get_logger().info('Display EDID detected (MageDok 1024×600 display)')
|
||||
except:
|
||||
self.get_logger().warn('EDID check unavailable')
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
verifier = DisplayVerifier()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=MageDok 7" Display Setup and Auto-Launch
|
||||
Documentation=https://gitea.vayrette.com/seb/saltylab-firmware/issues/369
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/dev/pts/0
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStartPre=/bin/sleep 2
|
||||
ExecStart=/usr/bin/env bash -c 'source /opt/ros/jazzy/setup.bash && ros2 launch saltybot_bringup magedok_display.launch.py'
|
||||
ExecStartPost=/usr/bin/env bash -c 'DISPLAY=:0 /usr/bin/startx -- :0 vt7 -nolisten tcp 2>/dev/null &'
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=magedok-display
|
||||
User=orin
|
||||
Group=orin
|
||||
Environment="DISPLAY=:0"
|
||||
Environment="XAUTHORITY=/home/orin/.Xauthority"
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -0,0 +1,19 @@
|
||||
# MageDok 7" Touchscreen USB Device Rules
|
||||
# Ensure touch device is recognized and accessible
|
||||
|
||||
# Generic USB touch input device (MageDok)
|
||||
# Manufacturer typically reports as: EETI eGTouch Controller
|
||||
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="*eGTouch*", TAG="uaccess"
|
||||
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="*EETI*", TAG="uaccess"
|
||||
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="*MageDok*", TAG="uaccess"
|
||||
|
||||
# Fallback: Any USB device with touch capability (VID/PID may vary by batch)
|
||||
SUBSYSTEM=="usb", ATTRS{bInterfaceClass}=="03", ATTRS{bInterfaceSubClass}=="01", TAG="uaccess"
|
||||
|
||||
# Create /dev/magedok-touch symlink for consistent reference
|
||||
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="*eGTouch*", SYMLINK="magedok-touch"
|
||||
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="*EETI*", SYMLINK="magedok-touch"
|
||||
|
||||
# Permissions: 0666 (rw for all users)
|
||||
SUBSYSTEM=="input", KERNEL=="event*", MODE="0666"
|
||||
SUBSYSTEM=="input", KERNEL=="mouse*", MODE="0666"
|
||||
@ -11,6 +11,7 @@
|
||||
#include "battery.h"
|
||||
#include "config.h"
|
||||
#include "stm32f7xx_hal.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
static ADC_HandleTypeDef s_hadc;
|
||||
static bool s_ready = false;
|
||||
|
||||
21
src/fan.c
21
src/fan.c
@ -47,6 +47,8 @@ static FanState_t s_fan = {
|
||||
.is_ramping = false
|
||||
};
|
||||
|
||||
static TIM_HandleTypeDef s_htim1 = {0};
|
||||
|
||||
/* ================================================================
|
||||
* Hardware Initialization
|
||||
* ================================================================ */
|
||||
@ -71,14 +73,13 @@ void fan_init(void)
|
||||
* For 25kHz frequency: PSC = 346, ARR = 25
|
||||
* Duty cycle = CCR / ARR (e.g., 12.5/25 = 50%)
|
||||
*/
|
||||
TIM_HandleTypeDef htim1 = {0};
|
||||
htim1.Instance = FAN_TIM;
|
||||
htim1.Init.Prescaler = 346 - 1; /* 216MHz / 346 ≈ 624kHz clock */
|
||||
htim1.Init.CounterMode = TIM_COUNTERMODE_UP;
|
||||
htim1.Init.Period = 25 - 1; /* 624kHz / 25 = 25kHz */
|
||||
htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
|
||||
htim1.Init.RepetitionCounter = 0;
|
||||
HAL_TIM_PWM_Init(&htim1);
|
||||
s_htim1.Instance = FAN_TIM;
|
||||
s_htim1.Init.Prescaler = 346 - 1; /* 216MHz / 346 ≈ 624kHz clock */
|
||||
s_htim1.Init.CounterMode = TIM_COUNTERMODE_UP;
|
||||
s_htim1.Init.Period = 25 - 1; /* 624kHz / 25 = 25kHz */
|
||||
s_htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
|
||||
s_htim1.Init.RepetitionCounter = 0;
|
||||
HAL_TIM_PWM_Init(&s_htim1);
|
||||
|
||||
/* Configure PWM on CH2: 0% duty initially (fan off) */
|
||||
TIM_OC_InitTypeDef oc_init = {0};
|
||||
@ -86,10 +87,10 @@ void fan_init(void)
|
||||
oc_init.Pulse = 0; /* Start at 0% duty (off) */
|
||||
oc_init.OCPolarity = TIM_OCPOLARITY_HIGH;
|
||||
oc_init.OCFastMode = TIM_OCFAST_DISABLE;
|
||||
HAL_TIM_PWM_ConfigChannel(&htim1, &oc_init, FAN_TIM_CHANNEL);
|
||||
HAL_TIM_PWM_ConfigChannel(&s_htim1, &oc_init, FAN_TIM_CHANNEL);
|
||||
|
||||
/* Start PWM generation */
|
||||
HAL_TIM_PWM_Start(FAN_TIM, FAN_TIM_CHANNEL);
|
||||
HAL_TIM_PWM_Start(&s_htim1, FAN_TIM_CHANNEL);
|
||||
|
||||
s_fan.current_speed = 0;
|
||||
s_fan.target_speed = 0;
|
||||
|
||||
12
src/i2c1.c
12
src/i2c1.c
@ -31,3 +31,15 @@ int i2c1_init(void) {
|
||||
|
||||
return (HAL_I2C_Init(&hi2c1) == HAL_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
/* I2C read: send register address, read data */
|
||||
int i2c1_read(uint8_t addr, uint8_t *data, uint16_t len) {
|
||||
/* Master receiver mode: read len bytes from addr */
|
||||
return (HAL_I2C_Master_Receive(&hi2c1, (uint16_t)(addr << 1), data, len, 1000) == HAL_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
/* I2C write: send register address + data */
|
||||
int i2c1_write(uint8_t addr, const uint8_t *data, uint16_t len) {
|
||||
/* Master transmitter mode: write len bytes to addr */
|
||||
return (HAL_I2C_Master_Transmit(&hi2c1, (uint16_t)(addr << 1), (uint8_t *)data, len, 1000) == HAL_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
17
src/main.c
17
src/main.c
@ -587,3 +587,20 @@ int main(void) {
|
||||
HAL_Delay(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Stub Functions (to be implemented)
|
||||
* ================================================================ */
|
||||
|
||||
/* IMU calibration status — returns true if IMU calibration is complete */
|
||||
static bool imu_calibrated(void) {
|
||||
/* Placeholder: return true if both MPU6000 and BNO055 are calibrated */
|
||||
return true;
|
||||
}
|
||||
|
||||
/* CRSF receiver active check — returns true if valid signal received recently */
|
||||
static bool crsf_is_active(uint32_t now) {
|
||||
(void)now; /* Unused parameter */
|
||||
/* Placeholder: check CRSF timeout or heartbeat */
|
||||
return true;
|
||||
}
|
||||
|
||||
13
src/servo.c
13
src/servo.c
@ -24,19 +24,6 @@
|
||||
#define SERVO_PRESCALER 53u /* APB1 54 MHz / 54 = 1 MHz */
|
||||
#define SERVO_ARR 19999u /* 1 MHz / 20000 = 50 Hz */
|
||||
|
||||
typedef struct {
|
||||
uint16_t current_angle_deg[SERVO_COUNT];
|
||||
uint16_t target_angle_deg[SERVO_COUNT];
|
||||
uint16_t pulse_us[SERVO_COUNT];
|
||||
|
||||
/* Sweep state */
|
||||
uint32_t sweep_start_ms[SERVO_COUNT];
|
||||
uint32_t sweep_duration_ms[SERVO_COUNT];
|
||||
uint16_t sweep_start_deg[SERVO_COUNT];
|
||||
uint16_t sweep_end_deg[SERVO_COUNT];
|
||||
bool is_sweeping[SERVO_COUNT];
|
||||
} ServoState;
|
||||
|
||||
static ServoState s_servo = {0};
|
||||
static TIM_HandleTypeDef s_tim_handle = {0};
|
||||
|
||||
|
||||
@ -42,6 +42,8 @@ static WatchdogState s_watchdog = {
|
||||
.reload_value = 0
|
||||
};
|
||||
|
||||
static IWDG_HandleTypeDef s_hiwdg = {0};
|
||||
|
||||
/* ================================================================
|
||||
* Helper Functions
|
||||
* ================================================================ */
|
||||
@ -108,13 +110,12 @@ bool watchdog_init(uint32_t timeout_ms)
|
||||
s_watchdog.timeout_ms = timeout_ms;
|
||||
|
||||
/* Configure and start IWDG */
|
||||
IWDG_HandleTypeDef hiwdg = {0};
|
||||
hiwdg.Instance = IWDG;
|
||||
hiwdg.Init.Prescaler = prescaler;
|
||||
hiwdg.Init.Reload = reload;
|
||||
hiwdg.Init.Window = reload; /* Window == Reload means full timeout */
|
||||
s_hiwdg.Instance = IWDG;
|
||||
s_hiwdg.Init.Prescaler = prescaler;
|
||||
s_hiwdg.Init.Reload = reload;
|
||||
s_hiwdg.Init.Window = reload; /* Window == Reload means full timeout */
|
||||
|
||||
HAL_IWDG_Init(&hiwdg);
|
||||
HAL_IWDG_Init(&s_hiwdg);
|
||||
|
||||
s_watchdog.is_initialized = true;
|
||||
s_watchdog.is_running = true;
|
||||
|
||||
@ -85,7 +85,17 @@ import { Diagnostics } from './components/Diagnostics.jsx';
|
||||
// Hand tracking visualization (issue #344)
|
||||
import { HandTracker } from './components/HandTracker.jsx';
|
||||
|
||||
// Salty Face animated expression UI (issue #370)
|
||||
import { SaltyFace } from './components/SaltyFace.jsx';
|
||||
|
||||
const TAB_GROUPS = [
|
||||
{
|
||||
label: 'DISPLAY',
|
||||
color: 'text-rose-600',
|
||||
tabs: [
|
||||
{ id: 'salty-face', label: 'Salty Face', },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SOCIAL',
|
||||
color: 'text-cyan-600',
|
||||
@ -270,7 +280,12 @@ export default function App() {
|
||||
</nav>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<main className={`flex-1 ${['eventlog', 'control', 'imu'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'} p-4`}>
|
||||
<main className={`flex-1 ${
|
||||
activeTab === 'salty-face' ? '' :
|
||||
['eventlog', 'control', 'imu'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'
|
||||
} ${activeTab === 'salty-face' ? '' : 'p-4'}`}>
|
||||
{activeTab === 'salty-face' && <SaltyFace subscribe={subscribe} />}
|
||||
|
||||
{activeTab === 'status' && <StatusPanel subscribe={subscribe} />}
|
||||
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
||||
{activeTab === 'hands' && <HandTracker subscribe={subscribe} />}
|
||||
|
||||
400
ui/social-bot/src/components/SaltyFace.jsx
Normal file
400
ui/social-bot/src/components/SaltyFace.jsx
Normal file
@ -0,0 +1,400 @@
|
||||
/**
|
||||
* SaltyFace.jsx — Animated facial expression UI for MageDok 7" display
|
||||
*
|
||||
* Features:
|
||||
* - 8 emotional states (happy, alert, confused, sleeping, excited, emergency, listening, talking)
|
||||
* - GPU-accelerated Canvas/SVG rendering (target 30fps on Orin Nano)
|
||||
* - ROS2 integration: /saltybot/state, /saltybot/target_track, /saltybot/obstacles
|
||||
* - Mouth animation synchronized with TTS audio
|
||||
* - HUD overlay: battery, speed, distance, sensor health
|
||||
* - Tap-to-toggle status overlay
|
||||
* - Inspired by Cozmo/Vector minimalist design
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
// Emotion states
|
||||
const EMOTIONS = {
|
||||
HAPPY: 'happy', // Default, normal operation
|
||||
ALERT: 'alert', // Obstacles detected
|
||||
CONFUSED: 'confused', // Target lost, searching
|
||||
SLEEPING: 'sleeping', // Prolonged inactivity
|
||||
EXCITED: 'excited', // Target reacquired
|
||||
EMERGENCY: 'emergency', // E-stop activated
|
||||
LISTENING: 'listening', // Microphone active
|
||||
TALKING: 'talking', // Text-to-speech output
|
||||
};
|
||||
|
||||
// Eye characteristics per emotion
|
||||
const EMOTION_CONFIG = {
|
||||
[EMOTIONS.HAPPY]: {
|
||||
eyeScale: 1.0,
|
||||
pupilPos: { x: 0, y: 0 },
|
||||
blink: true,
|
||||
blinkRate: 3000,
|
||||
color: '#10b981',
|
||||
},
|
||||
[EMOTIONS.ALERT]: {
|
||||
eyeScale: 1.3,
|
||||
pupilPos: { x: 0, y: -3 },
|
||||
blink: false,
|
||||
blinkRate: 0,
|
||||
color: '#ef4444',
|
||||
},
|
||||
[EMOTIONS.CONFUSED]: {
|
||||
eyeScale: 1.1,
|
||||
pupilPos: { x: 0, y: 0 },
|
||||
blink: true,
|
||||
blinkRate: 1500,
|
||||
eyeWander: true,
|
||||
color: '#f59e0b',
|
||||
},
|
||||
[EMOTIONS.SLEEPING]: {
|
||||
eyeScale: 0.3,
|
||||
pupilPos: { x: 0, y: 0 },
|
||||
blink: false,
|
||||
isClosed: true,
|
||||
color: '#6b7280',
|
||||
},
|
||||
[EMOTIONS.EXCITED]: {
|
||||
eyeScale: 1.2,
|
||||
pupilPos: { x: 0, y: 0 },
|
||||
blink: true,
|
||||
blinkRate: 800,
|
||||
bounce: true,
|
||||
color: '#22c55e',
|
||||
},
|
||||
[EMOTIONS.EMERGENCY]: {
|
||||
eyeScale: 1.4,
|
||||
pupilPos: { x: 0, y: -4 },
|
||||
blink: false,
|
||||
color: '#dc2626',
|
||||
flash: true,
|
||||
},
|
||||
[EMOTIONS.LISTENING]: {
|
||||
eyeScale: 1.0,
|
||||
pupilPos: { x: 0, y: -2 },
|
||||
blink: true,
|
||||
blinkRate: 2000,
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
[EMOTIONS.TALKING]: {
|
||||
eyeScale: 1.0,
|
||||
pupilPos: { x: 0, y: 0 },
|
||||
blink: true,
|
||||
blinkRate: 2500,
|
||||
color: '#06b6d4',
|
||||
},
|
||||
};
|
||||
|
||||
// Mouth states for talking
|
||||
const MOUTH_FRAMES = [
|
||||
{ open: 0.0, shape: 'closed' }, // Closed
|
||||
{ open: 0.3, shape: 'smile-closed' }, // Slight smile
|
||||
{ open: 0.5, shape: 'smile-open' }, // Smile open
|
||||
{ open: 0.7, shape: 'oh' }, // "Oh" sound
|
||||
{ open: 0.9, shape: 'ah' }, // "Ah" sound
|
||||
{ open: 0.7, shape: 'ee' }, // "Ee" sound
|
||||
];
|
||||
|
||||
// Canvas-based face renderer
|
||||
function FaceCanvas({ emotion, isTalking, audioLevel, showOverlay, botState }) {
|
||||
const canvasRef = useRef(null);
|
||||
const animationRef = useRef(null);
|
||||
const [eyeWanderOffset, setEyeWanderOffset] = useState({ x: 0, y: 0 });
|
||||
const [mouthFrame, setMouthFrame] = useState(0);
|
||||
const [isBlinking, setIsBlinking] = useState(false);
|
||||
const talkingCounterRef = useRef(0);
|
||||
|
||||
// Main animation loop
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
let frameCount = 0;
|
||||
|
||||
const config = EMOTION_CONFIG[emotion] || EMOTION_CONFIG[EMOTIONS.HAPPY];
|
||||
|
||||
const drawFace = () => {
|
||||
// Clear canvas
|
||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.95)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const centerX = W / 2;
|
||||
const centerY = H / 2.2;
|
||||
const eyeRadius = 40;
|
||||
const eyeSpacing = 80;
|
||||
|
||||
// Eye wandering animation (for confused state)
|
||||
let eyeOffX = 0, eyeOffY = 0;
|
||||
if (config.eyeWander) {
|
||||
eyeOffX = Math.sin(frameCount * 0.02) * 8;
|
||||
eyeOffY = Math.cos(frameCount * 0.015) * 8;
|
||||
}
|
||||
|
||||
// Bounce animation (for excited state)
|
||||
let bounceOffset = 0;
|
||||
if (config.bounce) {
|
||||
bounceOffset = Math.sin(frameCount * 0.08) * 6;
|
||||
}
|
||||
|
||||
// Draw eyes
|
||||
const eyeY = centerY + bounceOffset;
|
||||
drawEye(ctx, centerX - eyeSpacing, eyeY + eyeOffY, eyeRadius, config, isBlinking);
|
||||
drawEye(ctx, centerX + eyeSpacing, eyeY + eyeOffY, eyeRadius, config, isBlinking);
|
||||
|
||||
// Draw mouth (if talking)
|
||||
if (isTalking && !config.isClosed) {
|
||||
drawMouth(ctx, centerX, centerY + 80, 50, MOUTH_FRAMES[mouthFrame]);
|
||||
}
|
||||
|
||||
// Flash animation (emergency state)
|
||||
if (config.flash && Math.sin(frameCount * 0.1) > 0.7) {
|
||||
ctx.fillStyle = 'rgba(220, 38, 38, 0.3)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
|
||||
frameCount++;
|
||||
};
|
||||
|
||||
const animationLoop = () => {
|
||||
drawFace();
|
||||
animationRef.current = requestAnimationFrame(animationLoop);
|
||||
};
|
||||
|
||||
animationLoop();
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [emotion, isTalking, isBlinking, mouthFrame]);
|
||||
|
||||
// Blinking logic
|
||||
useEffect(() => {
|
||||
const config = EMOTION_CONFIG[emotion] || EMOTION_CONFIG[EMOTIONS.HAPPY];
|
||||
if (!config.blink || config.isClosed) return;
|
||||
|
||||
const blinkInterval = setInterval(() => {
|
||||
setIsBlinking(true);
|
||||
setTimeout(() => setIsBlinking(false), 150);
|
||||
}, config.blinkRate);
|
||||
|
||||
return () => clearInterval(blinkInterval);
|
||||
}, [emotion]);
|
||||
|
||||
// Mouth animation for talking
|
||||
useEffect(() => {
|
||||
if (!isTalking) {
|
||||
setMouthFrame(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let frameIndex = 0;
|
||||
const mouthInterval = setInterval(() => {
|
||||
frameIndex = (frameIndex + 1) % MOUTH_FRAMES.length;
|
||||
setMouthFrame(frameIndex);
|
||||
}, 100); // ~10fps mouth animation
|
||||
|
||||
return () => clearInterval(mouthInterval);
|
||||
}, [isTalking]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={1024}
|
||||
height={600}
|
||||
className="w-full h-full block"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Draw individual eye
|
||||
function drawEye(ctx, x, y, radius, config, isBlinking) {
|
||||
ctx.fillStyle = '#1f2937';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
if (isBlinking) {
|
||||
// Closed eye (line)
|
||||
ctx.strokeStyle = config.color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - radius * 0.7, y);
|
||||
ctx.lineTo(x + radius * 0.7, y);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Open eye with pupil
|
||||
const pupilRadius = radius * (config.eyeScale / 2);
|
||||
ctx.fillStyle = config.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + config.pupilPos.x, y + config.pupilPos.y, pupilRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Highlight reflection
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + config.pupilPos.x + pupilRadius * 0.4, y + config.pupilPos.y - pupilRadius * 0.4, pupilRadius * 0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw mouth for talking
|
||||
function drawMouth(ctx, x, y, width, frame) {
|
||||
ctx.strokeStyle = '#f59e0b';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
if (frame.shape === 'closed') {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - width * 0.4, y);
|
||||
ctx.lineTo(x + width * 0.4, y);
|
||||
ctx.stroke();
|
||||
} else if (frame.shape === 'smile-open' || frame.shape === 'smile-closed') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, width * 0.5, 0, Math.PI, false);
|
||||
ctx.stroke();
|
||||
} else if (frame.shape === 'oh') {
|
||||
ctx.fillStyle = '#f59e0b';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, width * 0.35 * frame.open, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (frame.shape === 'ah') {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - width * 0.3, y - width * 0.2 * frame.open);
|
||||
ctx.lineTo(x + width * 0.3, y - width * 0.2 * frame.open);
|
||||
ctx.lineTo(x + width * 0.2, y + width * 0.3 * frame.open);
|
||||
ctx.lineTo(x - width * 0.2, y + width * 0.3 * frame.open);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Status HUD overlay
|
||||
function StatusOverlay({ botState, visible }) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col justify-between p-4 text-xs font-mono pointer-events-none">
|
||||
{/* Top-left: Battery & Status */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-400">⚡</span>
|
||||
<span className="text-gray-300">{botState?.battery ?? '--'}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400">●</span>
|
||||
<span className="text-gray-300">{botState?.state ?? 'IDLE'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom-left: Distance & Health */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-400">●</span>
|
||||
<span className="text-gray-300">{botState?.distance?.toFixed(1) ?? '--'}m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={botState?.health > 75 ? 'text-green-400' : botState?.health > 50 ? 'text-yellow-400' : 'text-red-400'}>◇</span>
|
||||
<span className="text-gray-300">{botState?.health ?? '--'}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-right: Speed */}
|
||||
<div className="absolute top-4 right-4 flex flex-col items-end gap-2">
|
||||
<div className="text-cyan-400">{botState?.speed?.toFixed(1) ?? '--'} m/s</div>
|
||||
<div className="text-gray-500 text-xs">[tap to hide]</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export function SaltyFace({ subscribe }) {
|
||||
const [emotion, setEmotion] = useState(EMOTIONS.HAPPY);
|
||||
const [isTalking, setIsTalking] = useState(false);
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
const [showOverlay, setShowOverlay] = useState(true);
|
||||
const [botState, setBotState] = useState({
|
||||
battery: 85,
|
||||
state: 'IDLE',
|
||||
distance: 0,
|
||||
speed: 0,
|
||||
health: 90,
|
||||
hasTarget: false,
|
||||
obstacles: 0,
|
||||
});
|
||||
|
||||
// Subscribe to robot state
|
||||
useEffect(() => {
|
||||
if (!subscribe) return;
|
||||
|
||||
const unsub1 = subscribe('/saltybot/state', 'std_msgs/String', (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
setBotState((prev) => ({ ...prev, state: data.state || 'IDLE' }));
|
||||
|
||||
// Update emotion based on state
|
||||
if (data.state === 'EMERGENCY') {
|
||||
setEmotion(EMOTIONS.EMERGENCY);
|
||||
} else if (data.state === 'TRACKING') {
|
||||
setEmotion(EMOTIONS.HAPPY);
|
||||
} else if (data.state === 'SEARCHING') {
|
||||
setEmotion(EMOTIONS.CONFUSED);
|
||||
} else if (data.state === 'IDLE') {
|
||||
setEmotion(EMOTIONS.HAPPY);
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
const unsub2 = subscribe('/saltybot/target_track', 'geometry_msgs/Pose', (msg) => {
|
||||
setBotState((prev) => ({ ...prev, hasTarget: msg ? true : false }));
|
||||
if (msg) setEmotion(EMOTIONS.EXCITED);
|
||||
});
|
||||
|
||||
const unsub3 = subscribe('/saltybot/obstacles', 'sensor_msgs/LaserScan', (msg) => {
|
||||
const obstacleCount = msg?.ranges?.filter((r) => r < 0.5).length ?? 0;
|
||||
setBotState((prev) => ({ ...prev, obstacles: obstacleCount }));
|
||||
if (obstacleCount > 0) setEmotion(EMOTIONS.ALERT);
|
||||
});
|
||||
|
||||
const unsub4 = subscribe('/social/speech/is_speaking', 'std_msgs/Bool', (msg) => {
|
||||
setIsTalking(msg.data ?? false);
|
||||
if (msg.data) setEmotion(EMOTIONS.TALKING);
|
||||
});
|
||||
|
||||
const unsub5 = subscribe('/social/speech/is_listening', 'std_msgs/Bool', (msg) => {
|
||||
if (msg.data) setEmotion(EMOTIONS.LISTENING);
|
||||
});
|
||||
|
||||
const unsub6 = subscribe('/saltybot/battery', 'std_msgs/Float32', (msg) => {
|
||||
setBotState((prev) => ({ ...prev, battery: Math.round(msg.data) }));
|
||||
});
|
||||
|
||||
const unsub7 = subscribe('/saltybot/audio_level', 'std_msgs/Float32', (msg) => {
|
||||
setAudioLevel(msg.data ?? 0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub1?.();
|
||||
unsub2?.();
|
||||
unsub3?.();
|
||||
unsub4?.();
|
||||
unsub5?.();
|
||||
unsub6?.();
|
||||
unsub7?.();
|
||||
};
|
||||
}, [subscribe]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-screen bg-gray-950 overflow-hidden" onClick={() => setShowOverlay(!showOverlay)}>
|
||||
<FaceCanvas emotion={emotion} isTalking={isTalking} audioLevel={audioLevel} showOverlay={showOverlay} botState={botState} />
|
||||
<StatusOverlay botState={botState} visible={showOverlay} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user