Compare commits
No commits in common. "cb12cfa519e225568511d44d19b2605ab9f42844" and "94d12159b49b57f8cc9efc49da2245927055a089" have entirely different histories.
cb12cfa519
...
94d12159b4
@ -14,8 +14,4 @@ extern I2C_HandleTypeDef hi2c1;
|
|||||||
|
|
||||||
int i2c1_init(void);
|
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 */
|
#endif /* I2C1_H */
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,218 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
#!/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',
|
|
||||||
),
|
|
||||||
])
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
[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
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# 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,7 +11,6 @@
|
|||||||
#include "battery.h"
|
#include "battery.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "stm32f7xx_hal.h"
|
#include "stm32f7xx_hal.h"
|
||||||
#include <stdbool.h>
|
|
||||||
|
|
||||||
static ADC_HandleTypeDef s_hadc;
|
static ADC_HandleTypeDef s_hadc;
|
||||||
static bool s_ready = false;
|
static bool s_ready = false;
|
||||||
|
|||||||
21
src/fan.c
21
src/fan.c
@ -47,8 +47,6 @@ static FanState_t s_fan = {
|
|||||||
.is_ramping = false
|
.is_ramping = false
|
||||||
};
|
};
|
||||||
|
|
||||||
static TIM_HandleTypeDef s_htim1 = {0};
|
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Hardware Initialization
|
* Hardware Initialization
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
@ -73,13 +71,14 @@ void fan_init(void)
|
|||||||
* For 25kHz frequency: PSC = 346, ARR = 25
|
* For 25kHz frequency: PSC = 346, ARR = 25
|
||||||
* Duty cycle = CCR / ARR (e.g., 12.5/25 = 50%)
|
* Duty cycle = CCR / ARR (e.g., 12.5/25 = 50%)
|
||||||
*/
|
*/
|
||||||
s_htim1.Instance = FAN_TIM;
|
TIM_HandleTypeDef htim1 = {0};
|
||||||
s_htim1.Init.Prescaler = 346 - 1; /* 216MHz / 346 ≈ 624kHz clock */
|
htim1.Instance = FAN_TIM;
|
||||||
s_htim1.Init.CounterMode = TIM_COUNTERMODE_UP;
|
htim1.Init.Prescaler = 346 - 1; /* 216MHz / 346 ≈ 624kHz clock */
|
||||||
s_htim1.Init.Period = 25 - 1; /* 624kHz / 25 = 25kHz */
|
htim1.Init.CounterMode = TIM_COUNTERMODE_UP;
|
||||||
s_htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
|
htim1.Init.Period = 25 - 1; /* 624kHz / 25 = 25kHz */
|
||||||
s_htim1.Init.RepetitionCounter = 0;
|
htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
|
||||||
HAL_TIM_PWM_Init(&s_htim1);
|
htim1.Init.RepetitionCounter = 0;
|
||||||
|
HAL_TIM_PWM_Init(&htim1);
|
||||||
|
|
||||||
/* Configure PWM on CH2: 0% duty initially (fan off) */
|
/* Configure PWM on CH2: 0% duty initially (fan off) */
|
||||||
TIM_OC_InitTypeDef oc_init = {0};
|
TIM_OC_InitTypeDef oc_init = {0};
|
||||||
@ -87,10 +86,10 @@ void fan_init(void)
|
|||||||
oc_init.Pulse = 0; /* Start at 0% duty (off) */
|
oc_init.Pulse = 0; /* Start at 0% duty (off) */
|
||||||
oc_init.OCPolarity = TIM_OCPOLARITY_HIGH;
|
oc_init.OCPolarity = TIM_OCPOLARITY_HIGH;
|
||||||
oc_init.OCFastMode = TIM_OCFAST_DISABLE;
|
oc_init.OCFastMode = TIM_OCFAST_DISABLE;
|
||||||
HAL_TIM_PWM_ConfigChannel(&s_htim1, &oc_init, FAN_TIM_CHANNEL);
|
HAL_TIM_PWM_ConfigChannel(&htim1, &oc_init, FAN_TIM_CHANNEL);
|
||||||
|
|
||||||
/* Start PWM generation */
|
/* Start PWM generation */
|
||||||
HAL_TIM_PWM_Start(&s_htim1, FAN_TIM_CHANNEL);
|
HAL_TIM_PWM_Start(FAN_TIM, FAN_TIM_CHANNEL);
|
||||||
|
|
||||||
s_fan.current_speed = 0;
|
s_fan.current_speed = 0;
|
||||||
s_fan.target_speed = 0;
|
s_fan.target_speed = 0;
|
||||||
|
|||||||
12
src/i2c1.c
12
src/i2c1.c
@ -31,15 +31,3 @@ int i2c1_init(void) {
|
|||||||
|
|
||||||
return (HAL_I2C_Init(&hi2c1) == HAL_OK) ? 0 : -1;
|
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,20 +587,3 @@ int main(void) {
|
|||||||
HAL_Delay(1);
|
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,6 +24,19 @@
|
|||||||
#define SERVO_PRESCALER 53u /* APB1 54 MHz / 54 = 1 MHz */
|
#define SERVO_PRESCALER 53u /* APB1 54 MHz / 54 = 1 MHz */
|
||||||
#define SERVO_ARR 19999u /* 1 MHz / 20000 = 50 Hz */
|
#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 ServoState s_servo = {0};
|
||||||
static TIM_HandleTypeDef s_tim_handle = {0};
|
static TIM_HandleTypeDef s_tim_handle = {0};
|
||||||
|
|
||||||
|
|||||||
@ -42,8 +42,6 @@ static WatchdogState s_watchdog = {
|
|||||||
.reload_value = 0
|
.reload_value = 0
|
||||||
};
|
};
|
||||||
|
|
||||||
static IWDG_HandleTypeDef s_hiwdg = {0};
|
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Helper Functions
|
* Helper Functions
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
@ -110,12 +108,13 @@ bool watchdog_init(uint32_t timeout_ms)
|
|||||||
s_watchdog.timeout_ms = timeout_ms;
|
s_watchdog.timeout_ms = timeout_ms;
|
||||||
|
|
||||||
/* Configure and start IWDG */
|
/* Configure and start IWDG */
|
||||||
s_hiwdg.Instance = IWDG;
|
IWDG_HandleTypeDef hiwdg = {0};
|
||||||
s_hiwdg.Init.Prescaler = prescaler;
|
hiwdg.Instance = IWDG;
|
||||||
s_hiwdg.Init.Reload = reload;
|
hiwdg.Init.Prescaler = prescaler;
|
||||||
s_hiwdg.Init.Window = reload; /* Window == Reload means full timeout */
|
hiwdg.Init.Reload = reload;
|
||||||
|
hiwdg.Init.Window = reload; /* Window == Reload means full timeout */
|
||||||
|
|
||||||
HAL_IWDG_Init(&s_hiwdg);
|
HAL_IWDG_Init(&hiwdg);
|
||||||
|
|
||||||
s_watchdog.is_initialized = true;
|
s_watchdog.is_initialized = true;
|
||||||
s_watchdog.is_running = true;
|
s_watchdog.is_running = true;
|
||||||
|
|||||||
@ -85,17 +85,7 @@ import { Diagnostics } from './components/Diagnostics.jsx';
|
|||||||
// Hand tracking visualization (issue #344)
|
// Hand tracking visualization (issue #344)
|
||||||
import { HandTracker } from './components/HandTracker.jsx';
|
import { HandTracker } from './components/HandTracker.jsx';
|
||||||
|
|
||||||
// Salty Face animated expression UI (issue #370)
|
|
||||||
import { SaltyFace } from './components/SaltyFace.jsx';
|
|
||||||
|
|
||||||
const TAB_GROUPS = [
|
const TAB_GROUPS = [
|
||||||
{
|
|
||||||
label: 'DISPLAY',
|
|
||||||
color: 'text-rose-600',
|
|
||||||
tabs: [
|
|
||||||
{ id: 'salty-face', label: 'Salty Face', },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'SOCIAL',
|
label: 'SOCIAL',
|
||||||
color: 'text-cyan-600',
|
color: 'text-cyan-600',
|
||||||
@ -280,12 +270,7 @@ export default function App() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ── Content ── */}
|
{/* ── Content ── */}
|
||||||
<main className={`flex-1 ${
|
<main className={`flex-1 ${['eventlog', 'control', 'imu'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'} p-4`}>
|
||||||
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 === 'status' && <StatusPanel subscribe={subscribe} />}
|
||||||
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
||||||
{activeTab === 'hands' && <HandTracker subscribe={subscribe} />}
|
{activeTab === 'hands' && <HandTracker subscribe={subscribe} />}
|
||||||
|
|||||||
@ -1,400 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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