Compare commits
1 Commits
e80b157092
...
c0d946f858
| Author | SHA1 | Date | |
|---|---|---|---|
| c0d946f858 |
Binary file not shown.
BIN
.pio/build/f722/.sconsign39.dblite
Normal file
BIN
.pio/build/f722/.sconsign39.dblite
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.pio/build/f722/lib737/USB_CDC/usbd_cdc_if.o
Normal file
BIN
.pio/build/f722/lib737/USB_CDC/usbd_cdc_if.o
Normal file
Binary file not shown.
BIN
.pio/build/f722/lib737/USB_CDC/usbd_conf.o
Normal file
BIN
.pio/build/f722/lib737/USB_CDC/usbd_conf.o
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.pio/build/f722/src/main.o
Normal file
BIN
.pio/build/f722/src/main.o
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1 +1 @@
|
||||
8700a44a6597bcade0f371945c539630ba0e78b1
|
||||
ee8efb31f6b185f16e4d385971f1a0e3291fe5fd
|
||||
@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Initialise ADC3 for single-channel Vbat reading on PC1. */
|
||||
void battery_init(void);
|
||||
@ -32,18 +33,4 @@ uint32_t battery_read_mv(void);
|
||||
*/
|
||||
uint8_t battery_estimate_pct(uint32_t voltage_mv);
|
||||
|
||||
/*
|
||||
* battery_accumulate_coulombs() — periodically integrate battery current.
|
||||
* Call every 10-20 ms (50-100 Hz) from main loop to accumulate coulombs.
|
||||
* Reads motor currents from INA219 sensors.
|
||||
*/
|
||||
void battery_accumulate_coulombs(void);
|
||||
|
||||
/*
|
||||
* battery_get_soc_coulomb() — get coulomb-based SoC estimate.
|
||||
* Returns 0–100 (percent), or 255 if coulomb counter not yet valid.
|
||||
* Preferred over voltage-based when valid.
|
||||
*/
|
||||
uint8_t battery_get_soc_coulomb(void);
|
||||
|
||||
#endif /* BATTERY_H */
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
#ifndef COULOMB_COUNTER_H
|
||||
#define COULOMB_COUNTER_H
|
||||
|
||||
/*
|
||||
* coulomb_counter.h — Battery coulomb counter for SoC estimation (Issue #325)
|
||||
*
|
||||
* Integrates battery current over time to track Ah consumed and remaining.
|
||||
* Provides accurate SoC independent of load, with fallback to voltage.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Call coulomb_counter_init(capacity_mah) at startup
|
||||
* 2. Call coulomb_counter_accumulate(current_ma) at 50–100 Hz
|
||||
* 3. Call coulomb_counter_get_soc_pct() to get current SoC
|
||||
* 4. Call coulomb_counter_reset() on charge complete
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Initialize coulomb counter with battery capacity (mAh). */
|
||||
void coulomb_counter_init(uint16_t capacity_mah);
|
||||
|
||||
/*
|
||||
* Accumulate coulomb from current reading + elapsed time.
|
||||
* Call this at regular intervals (e.g., 50–100 Hz from telemetry loop).
|
||||
* current_ma: battery current in milliamps (positive = discharge)
|
||||
*/
|
||||
void coulomb_counter_accumulate(int16_t current_ma);
|
||||
|
||||
/* Get current SoC as percentage (0–100, 255 = error). */
|
||||
uint8_t coulomb_counter_get_soc_pct(void);
|
||||
|
||||
/* Get consumed mAh (total charge removed from battery). */
|
||||
uint16_t coulomb_counter_get_consumed_mah(void);
|
||||
|
||||
/* Get remaining capacity in mAh. */
|
||||
uint16_t coulomb_counter_get_remaining_mah(void);
|
||||
|
||||
/* Reset accumulated coulombs (e.g., on charge complete). */
|
||||
void coulomb_counter_reset(void);
|
||||
|
||||
/* Check if coulomb counter is active (initialized and has measurements). */
|
||||
bool coulomb_counter_is_valid(void);
|
||||
|
||||
#endif /* COULOMB_COUNTER_H */
|
||||
@ -45,14 +45,14 @@ int16_t crsf_to_range(uint16_t val, int16_t min, int16_t max);
|
||||
* back to the ELRS TX module over UART4 TX. Call at CRSF_TELEMETRY_HZ (1 Hz).
|
||||
*
|
||||
* voltage_mv : battery voltage in millivolts (e.g. 12600 for 3S full)
|
||||
* capacity_mah : remaining battery capacity in mAh (Issue #325, coulomb counter)
|
||||
* current_ma : current draw in milliamps (0 if no sensor)
|
||||
* remaining_pct: state-of-charge 0–100 % (255 = unknown)
|
||||
*
|
||||
* Frame: [0xC8][12][0x08][v16_hi][v16_lo][c16_hi][c16_lo][cap24×3][rem][CRC]
|
||||
* voltage unit: 100 mV (12600 mV → 126)
|
||||
* capacity unit: mAh (3-byte big-endian, max 16.7M mAh)
|
||||
* current unit: 100 mA
|
||||
*/
|
||||
void crsf_send_battery(uint32_t voltage_mv, uint32_t capacity_mah,
|
||||
void crsf_send_battery(uint32_t voltage_mv, uint32_t current_ma,
|
||||
uint8_t remaining_pct);
|
||||
|
||||
/*
|
||||
|
||||
@ -14,7 +14,7 @@ extern I2C_HandleTypeDef hi2c1;
|
||||
|
||||
int i2c1_init(void);
|
||||
|
||||
int i2c1_write(uint8_t addr, const uint8_t *data, uint16_t len);
|
||||
int i2c1_read(uint8_t addr, uint8_t *data, uint16_t len);
|
||||
int i2c1_write(uint8_t addr, const uint8_t *data, int len);
|
||||
int i2c1_read(uint8_t addr, uint8_t *data, int len);
|
||||
|
||||
#endif /* I2C1_H */
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
# Cage configuration for MageDok 7" display kiosk
|
||||
# Lightweight Wayland compositor replacing GNOME (~650MB RAM savings)
|
||||
# Runs Chromium in fullscreen kiosk mode for SaltyFace web UI
|
||||
|
||||
[output]
|
||||
# MageDok output configuration
|
||||
# 1024x600 native resolution
|
||||
scale=1.0
|
||||
# Position on primary display
|
||||
position=0,0
|
||||
|
||||
[keyboard]
|
||||
# Keyboard layout
|
||||
layout=us
|
||||
variant=
|
||||
|
||||
[cursor]
|
||||
# Hide cursor when idle (fullscreen kiosk)
|
||||
hide-cursor-timeout=3000
|
||||
|
||||
# Note: Cage is explicitly designed as a minimal fullscreen launcher
|
||||
# It handles Wayland display protocol, input handling, and window management
|
||||
# Chromium will run fullscreen without window decorations
|
||||
@ -1,31 +0,0 @@
|
||||
# Wayland configuration for MageDok 7" touchscreen display
|
||||
# Used by Cage Wayland compositor for lightweight kiosk mode
|
||||
# Replaces X11 xorg-magedok.conf (used in Issue #369 legacy mode)
|
||||
|
||||
# Monitor configuration
|
||||
[output "HDMI-1"]
|
||||
# Native MageDok resolution
|
||||
mode=1024x600@60
|
||||
# Position (primary display)
|
||||
position=0,0
|
||||
# Scaling (no scaling needed, 1024x600 is native)
|
||||
scale=1
|
||||
|
||||
# Touchscreen input configuration
|
||||
[input "magedok-touch"]
|
||||
# Calibration not needed for HID devices (driver-handled)
|
||||
# Event device will be /dev/input/event* matching USB VID:PID
|
||||
# Udev rule creates symlink: /dev/magedok-touch
|
||||
|
||||
# Performance tuning for Orin Nano
|
||||
[performance]
|
||||
# Wayland buffer swaps (minimize latency)
|
||||
immediate-mode-rendering=false
|
||||
# Double-buffering for smooth animation
|
||||
buffer-count=2
|
||||
|
||||
# Notes:
|
||||
# - Cage handles Wayland protocol natively
|
||||
# - No X11 server needed (saves ~100MB RAM vs Xvfb)
|
||||
# - Touch input passes through kernel HID layer
|
||||
# - Resolution scaling handled by Chromium/browser
|
||||
@ -1,319 +0,0 @@
|
||||
# Cage + Chromium Kiosk for MageDok 7" Display
|
||||
|
||||
**Issue #374**: Replace GNOME with Cage + Chromium kiosk to save ~650MB RAM.
|
||||
|
||||
Lightweight Wayland-based fullscreen kiosk for SaltyFace web UI on MageDok 7" IPS touchscreen.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Jetson Orin Nano (Saltybot) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Cage Wayland Compositor │
|
||||
│ ├─ GNOME replaced (~650MB RAM freed) │
|
||||
│ ├─ Minimal fullscreen window manager │
|
||||
│ └─ Native Wayland protocol (no X11) │
|
||||
│ └─ Chromium Kiosk │
|
||||
│ ├─ SaltyFace web UI (http://localhost:3000) │
|
||||
│ ├─ Fullscreen (--kiosk) │
|
||||
│ ├─ No UI chrome (no address bar, tabs, etc) │
|
||||
│ └─ Touch input via HID │
|
||||
│ └─ MageDok USB Touchscreen │
|
||||
│ ├─ 1024×600 @ 60Hz (HDMI) │
|
||||
│ └─ Touch via /dev/magedok-touch │
|
||||
│ └─ PulseAudio │
|
||||
│ └─ HDMI audio routing to speakers │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ROS2 Workloads (extra 450MB RAM available) │
|
||||
│ ├─ Perception (vision, tracking) │
|
||||
│ ├─ Navigation (SLAM, path planning) │
|
||||
│ └─ Control (motor, servo, gripper) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Memory Comparison
|
||||
|
||||
### GNOME Desktop (Legacy)
|
||||
- GNOME Shell: ~300MB
|
||||
- Mutter (Wayland compositor): ~150MB
|
||||
- Xvfb (X11 fallback): ~100MB
|
||||
- GTK Libraries: ~100MB
|
||||
- **Total: ~650MB**
|
||||
|
||||
### Cage + Chromium Kiosk (New)
|
||||
- Cage compositor: ~30MB
|
||||
- Chromium (headless mode disabled): ~150MB
|
||||
- Wayland libraries: ~20MB
|
||||
- **Total: ~200MB**
|
||||
|
||||
**Savings: ~450MB RAM** → available for ROS2 perception, navigation, control workloads
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install Cage and Chromium
|
||||
|
||||
```bash
|
||||
# Update package list
|
||||
sudo apt update
|
||||
|
||||
# Install Cage (Wayland compositor)
|
||||
sudo apt install -y cage
|
||||
|
||||
# Install Chromium (or Chromium-browser on some systems)
|
||||
sudo apt install -y chromium
|
||||
```
|
||||
|
||||
### 2. Install Configuration Files
|
||||
|
||||
```bash
|
||||
# Copy Cage/Wayland config
|
||||
sudo mkdir -p /opt/saltybot/config
|
||||
sudo cp config/cage-magedok.ini /opt/saltybot/config/
|
||||
sudo cp config/wayland-magedok.conf /opt/saltybot/config/
|
||||
|
||||
# Copy launch scripts
|
||||
sudo mkdir -p /opt/saltybot/scripts
|
||||
sudo cp scripts/chromium_kiosk.sh /opt/saltybot/scripts/
|
||||
sudo chmod +x /opt/saltybot/scripts/chromium_kiosk.sh
|
||||
|
||||
# Create logs directory
|
||||
sudo mkdir -p /opt/saltybot/logs
|
||||
sudo chown orin:orin /opt/saltybot/logs
|
||||
```
|
||||
|
||||
### 3. Disable GNOME (if installed)
|
||||
|
||||
```bash
|
||||
# Disable GNOME display manager
|
||||
sudo systemctl disable gdm.service
|
||||
sudo systemctl disable gnome-shell.target
|
||||
|
||||
# Verify disabled
|
||||
sudo systemctl is-enabled gdm.service # Should output: disabled
|
||||
```
|
||||
|
||||
### 4. Install Systemd Service
|
||||
|
||||
```bash
|
||||
# Copy systemd service
|
||||
sudo cp systemd/chromium-kiosk.service /etc/systemd/system/
|
||||
|
||||
# Reload systemd daemon
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Enable auto-start on boot
|
||||
sudo systemctl enable chromium-kiosk.service
|
||||
|
||||
# Verify enabled
|
||||
sudo systemctl is-enabled chromium-kiosk.service # Should output: enabled
|
||||
```
|
||||
|
||||
### 5. Verify Udev Rules (from Issue #369)
|
||||
|
||||
The MageDok touch device needs proper permissions. Verify udev rule is installed:
|
||||
|
||||
```bash
|
||||
sudo cat /etc/udev/rules.d/90-magedok-touch.rules
|
||||
```
|
||||
|
||||
Should contain:
|
||||
```
|
||||
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", SYMLINK+="magedok-touch", MODE="0666"
|
||||
```
|
||||
|
||||
### 6. Configure PulseAudio (from Issue #369)
|
||||
|
||||
Verify PulseAudio HDMI routing is configured:
|
||||
|
||||
```bash
|
||||
# Check running PulseAudio sink
|
||||
pactl list short sinks
|
||||
|
||||
# Should show HDMI output device
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Start (Development)
|
||||
|
||||
```bash
|
||||
# Start Cage + Chromium manually
|
||||
/opt/saltybot/scripts/chromium_kiosk.sh --url http://localhost:3000 --debug
|
||||
|
||||
# Should see:
|
||||
# [timestamp] Starting Chromium kiosk on Cage Wayland compositor
|
||||
# [timestamp] URL: http://localhost:3000
|
||||
# [timestamp] Launching Cage with Chromium...
|
||||
```
|
||||
|
||||
### Systemd Service Start
|
||||
|
||||
```bash
|
||||
# Start service
|
||||
sudo systemctl start chromium-kiosk.service
|
||||
|
||||
# Check status
|
||||
sudo systemctl status chromium-kiosk.service
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u chromium-kiosk.service -f
|
||||
```
|
||||
|
||||
### Auto-Start on Boot
|
||||
|
||||
```bash
|
||||
# Reboot to verify auto-start
|
||||
sudo reboot
|
||||
|
||||
# After boot, check service
|
||||
sudo systemctl status chromium-kiosk.service
|
||||
|
||||
# Check if Chromium is running
|
||||
ps aux | grep chromium # Should show cage and chromium processes
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Chromium won't start
|
||||
|
||||
**Symptom**: Service fails with "WAYLAND_DISPLAY not set" or "Cannot connect to Wayland server"
|
||||
|
||||
**Solutions**:
|
||||
1. Verify XDG_RUNTIME_DIR exists:
|
||||
```bash
|
||||
ls -la /run/user/1000
|
||||
chmod 700 /run/user/1000
|
||||
```
|
||||
|
||||
2. Verify WAYLAND_DISPLAY is set in service:
|
||||
```bash
|
||||
sudo systemctl show chromium-kiosk.service -p Environment
|
||||
# Should show: WAYLAND_DISPLAY=wayland-0
|
||||
```
|
||||
|
||||
3. Check Wayland availability:
|
||||
```bash
|
||||
echo $WAYLAND_DISPLAY
|
||||
ls -la /run/user/1000/wayland-0
|
||||
```
|
||||
|
||||
### MageDok touchscreen not responding
|
||||
|
||||
**Symptom**: Touch input doesn't work in Chromium
|
||||
|
||||
**Solutions**:
|
||||
1. Verify touch device is present:
|
||||
```bash
|
||||
ls -la /dev/magedok-touch
|
||||
lsusb | grep -i eGTouch # Should show eGTouch device
|
||||
```
|
||||
|
||||
2. Check udev rule was applied:
|
||||
```bash
|
||||
sudo udevadm control --reload
|
||||
sudo udevadm trigger
|
||||
lsusb # Verify eGTouch device still present
|
||||
```
|
||||
|
||||
3. Verify touch input reaches Cage:
|
||||
```bash
|
||||
sudo strace -e ioctl -p $(pgrep cage) 2>&1 | grep -i input
|
||||
# Should show input device activity
|
||||
```
|
||||
|
||||
### HDMI audio not working
|
||||
|
||||
**Symptom**: No sound from MageDok speakers
|
||||
|
||||
**Solutions**:
|
||||
1. Check HDMI sink is active:
|
||||
```bash
|
||||
pactl list short sinks
|
||||
pactl get-default-sink
|
||||
```
|
||||
|
||||
2. Set HDMI sink as default:
|
||||
```bash
|
||||
pactl set-default-sink <hdmi-sink-name>
|
||||
```
|
||||
|
||||
3. Verify audio router is running:
|
||||
```bash
|
||||
ps aux | grep audio_router
|
||||
```
|
||||
|
||||
### High CPU usage with Chromium
|
||||
|
||||
**Symptom**: Chromium using 80%+ CPU
|
||||
|
||||
**Solutions**:
|
||||
1. Reduce animation frame rate in SaltyFace web app
|
||||
2. Disable hardware video acceleration if unstable:
|
||||
```bash
|
||||
# In chromium_kiosk.sh, add:
|
||||
# --disable-gpu
|
||||
# --disable-extensions
|
||||
```
|
||||
|
||||
3. Monitor GPU memory:
|
||||
```bash
|
||||
tegrastats # Observe GPU load
|
||||
```
|
||||
|
||||
### Cage compositor crashes
|
||||
|
||||
**Symptom**: Screen goes black, Chromium closes
|
||||
|
||||
**Solutions**:
|
||||
1. Check Cage logs:
|
||||
```bash
|
||||
sudo journalctl -u chromium-kiosk.service -n 50
|
||||
```
|
||||
|
||||
2. Verify video driver:
|
||||
```bash
|
||||
ls -la /dev/nvhost*
|
||||
nvidia-smi # Should work on Orin
|
||||
```
|
||||
|
||||
3. Try X11 fallback (temporary):
|
||||
```bash
|
||||
# Use Issue #369 magedok_display.launch.py instead
|
||||
ros2 launch saltybot_bringup magedok_display.launch.py
|
||||
```
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
### Boot Time
|
||||
- GNOME boot: ~30-40 seconds
|
||||
- Cage boot: ~8-12 seconds
|
||||
- **Improvement: 70% faster to interactive display**
|
||||
|
||||
### First Paint (SaltyFace loads)
|
||||
- GNOME: 15-20 seconds (desktop fully loaded)
|
||||
- Cage: 3-5 seconds (Chromium + web app loads)
|
||||
- **Improvement: 4x faster**
|
||||
|
||||
### Memory Usage
|
||||
- GNOME idle: ~650MB consumed
|
||||
- Cage idle: ~200MB consumed
|
||||
- **Improvement: 450MB available for workloads**
|
||||
|
||||
### Frame Rate (MageDok display)
|
||||
- X11 + GNOME: ~30fps (variable, desktop compositing)
|
||||
- Cage + Chromium: ~60fps (native Wayland, locked to display)
|
||||
- **Improvement: 2x frame rate consistency**
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **Issue #369**: MageDok display setup (X11 + GNOME legacy mode)
|
||||
- **Issue #370**: SaltyFace web app UI (runs in Chromium kiosk)
|
||||
- **Issue #371**: Accessibility mode (keyboard/voice input to web app)
|
||||
|
||||
## References
|
||||
|
||||
- [Cage Compositor](https://github.com/Gr3yR0ot/cage) - Minimal Wayland launcher
|
||||
- [Chromium Kiosk Mode](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/kiosk_mode.md)
|
||||
- [Wayland Protocol](https://wayland.freedesktop.org/)
|
||||
- [Jetson Orin Nano](https://developer.nvidia.com/jetson-orin-nano-developer-kit) - ARM CPU/GPU details
|
||||
@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cage Wayland + Chromium kiosk launch configuration for MageDok 7" display.
|
||||
|
||||
Lightweight alternative to X11 desktop environment:
|
||||
- Cage: Minimal Wayland compositor (replaces GNOME/Mutter)
|
||||
- Chromium: Fullscreen kiosk browser for SaltyFace web UI
|
||||
- PulseAudio: HDMI audio routing
|
||||
|
||||
Memory savings vs GNOME:
|
||||
- GNOME + Mutter: ~650MB RAM
|
||||
- Cage + Chromium: ~200MB RAM
|
||||
- Savings: ~450MB RAM for other ROS2 workloads
|
||||
|
||||
Issue #374: Replace GNOME with Cage + Chromium kiosk
|
||||
"""
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
from launch.actions import DeclareLaunchArgument, ExecuteProcess
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
|
||||
def generate_launch_description():
|
||||
"""Generate ROS2 launch description for Cage + Chromium kiosk."""
|
||||
|
||||
# Launch arguments
|
||||
url_arg = DeclareLaunchArgument(
|
||||
'kiosk_url',
|
||||
default_value='http://localhost:3000',
|
||||
description='URL for Chromium kiosk (SaltyFace web app)'
|
||||
)
|
||||
|
||||
debug_arg = DeclareLaunchArgument(
|
||||
'debug',
|
||||
default_value='false',
|
||||
description='Enable debug logging'
|
||||
)
|
||||
|
||||
ld = LaunchDescription([url_arg, debug_arg])
|
||||
|
||||
# Start touch monitor (from Issue #369 - reused)
|
||||
# Monitors MageDok USB touch device availability
|
||||
touch_monitor = Node(
|
||||
package='saltybot_bringup',
|
||||
executable='touch_monitor.py',
|
||||
name='touch_monitor',
|
||||
output='screen',
|
||||
)
|
||||
ld.add_action(touch_monitor)
|
||||
|
||||
# Start audio router (from Issue #369 - reused)
|
||||
# Routes HDMI audio to built-in speakers via PulseAudio
|
||||
audio_router = Node(
|
||||
package='saltybot_bringup',
|
||||
executable='audio_router.py',
|
||||
name='audio_router',
|
||||
output='screen',
|
||||
)
|
||||
ld.add_action(audio_router)
|
||||
|
||||
# Start Cage Wayland compositor with Chromium kiosk
|
||||
# Replaces X11 server + GNOME desktop environment
|
||||
cage_chromium = ExecuteProcess(
|
||||
cmd=[
|
||||
'/opt/saltybot/scripts/chromium_kiosk.sh',
|
||||
'--url', LaunchConfiguration('kiosk_url'),
|
||||
],
|
||||
condition_condition=None, # Always start
|
||||
name='cage_chromium',
|
||||
shell=True,
|
||||
)
|
||||
ld.add_action(cage_chromium)
|
||||
|
||||
return ld
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(generate_launch_description())
|
||||
@ -1,91 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Chromium kiosk launcher for MageDok 7" display via Cage Wayland compositor
|
||||
# Lightweight fullscreen web app display (SaltyFace web UI)
|
||||
# Replaces GNOME desktop environment (~650MB RAM savings)
|
||||
#
|
||||
# Usage:
|
||||
# chromium_kiosk.sh [--url URL] [--debug]
|
||||
#
|
||||
# Environment:
|
||||
# SALTYBOT_KIOSK_URL Default URL if not specified (localhost:3000)
|
||||
# DISPLAY Not used (Wayland native)
|
||||
# XDG_RUNTIME_DIR Must be set for Wayland
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG_FILE="${SCRIPT_DIR}/../../logs/chromium_kiosk.log"
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
# Logging
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Configuration
|
||||
KIOSK_URL="${SALTYBOT_KIOSK_URL:-http://localhost:3000}"
|
||||
DEBUG_MODE=false
|
||||
CAGE_CONFIG="/opt/saltybot/config/cage-magedok.ini"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--url)
|
||||
KIOSK_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--debug)
|
||||
DEBUG_MODE=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
log "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Setup environment
|
||||
export WAYLAND_DISPLAY=wayland-0
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export XDG_SESSION_TYPE=wayland
|
||||
export QT_QPA_PLATFORM=wayland
|
||||
|
||||
# Ensure Wayland runtime directory exists
|
||||
mkdir -p "$XDG_RUNTIME_DIR"
|
||||
chmod 700 "$XDG_RUNTIME_DIR"
|
||||
|
||||
log "Starting Chromium kiosk on Cage Wayland compositor"
|
||||
log "URL: $KIOSK_URL"
|
||||
|
||||
# Chromium kiosk flags
|
||||
CHROMIUM_FLAGS=(
|
||||
--kiosk # Fullscreen kiosk mode (no UI chrome)
|
||||
--disable-session-crashed-bubble # No crash recovery UI
|
||||
--disable-infobars # No info bars
|
||||
--no-first-run # Skip first-run wizard
|
||||
--no-default-browser-check # Skip browser check
|
||||
--disable-sync # Disable Google Sync
|
||||
--disable-translate # Disable translate prompts
|
||||
--disable-plugins-power-saver # Don't power-save plugins
|
||||
--autoplay-policy=user-gesture-required
|
||||
--app="$KIOSK_URL" # Run as web app in fullscreen
|
||||
)
|
||||
|
||||
# Optional debug flags
|
||||
if $DEBUG_MODE; then
|
||||
CHROMIUM_FLAGS+=(
|
||||
--enable-logging=stderr
|
||||
--log-level=0
|
||||
)
|
||||
fi
|
||||
|
||||
# Launch Cage with Chromium as client
|
||||
log "Launching Cage with Chromium..."
|
||||
if [ -f "$CAGE_CONFIG" ]; then
|
||||
log "Using Cage config: $CAGE_CONFIG"
|
||||
exec cage -s chromium "${CHROMIUM_FLAGS[@]}" 2>&1 | tee -a "$LOG_FILE"
|
||||
else
|
||||
log "Cage config not found, using defaults: $CAGE_CONFIG"
|
||||
exec cage -s chromium "${CHROMIUM_FLAGS[@]}" 2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
@ -1,50 +0,0 @@
|
||||
[Unit]
|
||||
Description=Chromium Fullscreen Kiosk (Cage + MageDok 7" display)
|
||||
Documentation=https://github.com/saltytech/saltylab-firmware/wiki/Cage-Chromium-Kiosk
|
||||
Documentation=https://github.com/saltytech/saltylab-firmware/issues/374
|
||||
After=network.target display-target.service
|
||||
Before=graphical.target
|
||||
Wants=display-target.service
|
||||
|
||||
# Disable GNOME if running
|
||||
Conflicts=gdm.service gnome-shell.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=orin
|
||||
Group=video
|
||||
|
||||
# Environment
|
||||
Environment="WAYLAND_DISPLAY=wayland-0"
|
||||
Environment="XDG_RUNTIME_DIR=/run/user/1000"
|
||||
Environment="XDG_SESSION_TYPE=wayland"
|
||||
Environment="QT_QPA_PLATFORM=wayland"
|
||||
Environment="SALTYBOT_KIOSK_URL=http://localhost:3000"
|
||||
|
||||
# Pre-start checks
|
||||
ExecStartPre=/usr/bin/install -d /run/user/1000
|
||||
ExecStartPre=/usr/bin/chown orin:orin /run/user/1000
|
||||
ExecStartPre=/usr/bin/chmod 700 /run/user/1000
|
||||
|
||||
# Verify MageDok display is available
|
||||
ExecStartPre=/usr/bin/test -c /dev/magedok-touch || /bin/true
|
||||
|
||||
# Start Chromium kiosk via Cage
|
||||
ExecStart=/opt/saltybot/scripts/chromium_kiosk.sh --url http://localhost:3000
|
||||
|
||||
# Restart on failure
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Resource limits (Cage + Chromium is lightweight)
|
||||
MemoryMax=512M
|
||||
CPUQuota=80%
|
||||
CPUAffinity=0 1 2 3
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=chromium-kiosk
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical.target
|
||||
@ -1,42 +0,0 @@
|
||||
[Unit]
|
||||
Description=SaltyFace Web App Server (Node.js)
|
||||
Documentation=https://github.com/saltytech/saltylab-firmware/issues/370
|
||||
After=network.target
|
||||
Before=chromium-kiosk.service
|
||||
Requires=chromium-kiosk.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=orin
|
||||
Group=nogroup
|
||||
WorkingDirectory=/opt/saltybot/app
|
||||
|
||||
# Node.js server
|
||||
ExecStart=/usr/bin/node server.js --port 3000 --host 0.0.0.0
|
||||
|
||||
# Environment
|
||||
Environment="NODE_ENV=production"
|
||||
Environment="NODE_OPTIONS=--max-old-space-size=256"
|
||||
|
||||
# Restart policy
|
||||
Restart=on-failure
|
||||
RestartSec=3s
|
||||
|
||||
# Resource limits
|
||||
MemoryMax=256M
|
||||
CPUQuota=50%
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=salty-face-server
|
||||
|
||||
# Security
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/opt/saltybot/logs
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,31 +0,0 @@
|
||||
# LIDAR Avoidance Configuration for SaltyBot
|
||||
# 360° obstacle detection with RPLIDAR A1M8
|
||||
|
||||
lidar_avoidance:
|
||||
ros__parameters:
|
||||
# Emergency stop distance threshold (meters)
|
||||
# Robot will trigger hard stop if obstacle closer than this
|
||||
emergency_stop_distance: 0.5
|
||||
|
||||
# Reference speed for safety zone calculation (m/s)
|
||||
# 5.56 m/s = 20 km/h
|
||||
max_speed_reference: 5.56
|
||||
|
||||
# Safety zone distance at maximum reference speed (meters)
|
||||
# At 20 km/h, robot maintains 3m clearance before reducing speed
|
||||
safety_zone_at_max_speed: 3.0
|
||||
|
||||
# Minimum safety zone distance (meters)
|
||||
# At zero speed, robot maintains this clearance
|
||||
# Must be >= emergency_stop_distance for smooth operation
|
||||
min_safety_zone: 0.6
|
||||
|
||||
# Forward scanning window (degrees)
|
||||
# ±30° forward cone = 60° total forward scan window
|
||||
# RPLIDAR A1M8 provides full 360° data, but we focus on forward obstacles
|
||||
angle_window_degrees: 60
|
||||
|
||||
# Debounce frames for obstacle detection
|
||||
# Number of consecutive scans with obstacle before triggering alert
|
||||
# Reduces false positives from noise/reflections
|
||||
debounce_frames: 2
|
||||
@ -1,33 +0,0 @@
|
||||
"""Launch file for LIDAR avoidance node."""
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from launch.actions import DeclareLaunchArgument
|
||||
import os
|
||||
from ament_index_python.packages import get_package_share_directory
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
"""Generate launch description for LIDAR avoidance."""
|
||||
pkg_dir = get_package_share_directory("saltybot_lidar_avoidance")
|
||||
config_file = os.path.join(
|
||||
pkg_dir, "config", "lidar_avoidance_params.yaml"
|
||||
)
|
||||
|
||||
return LaunchDescription(
|
||||
[
|
||||
DeclareLaunchArgument(
|
||||
"config_file",
|
||||
default_value=config_file,
|
||||
description="Path to configuration YAML file",
|
||||
),
|
||||
Node(
|
||||
package="saltybot_lidar_avoidance",
|
||||
executable="lidar_avoidance_node",
|
||||
name="lidar_avoidance",
|
||||
output="screen",
|
||||
parameters=[LaunchConfiguration("config_file")],
|
||||
),
|
||||
]
|
||||
)
|
||||
@ -1,29 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>saltybot_lidar_avoidance</name>
|
||||
<version>0.1.0</version>
|
||||
<description>
|
||||
360° LIDAR obstacle avoidance for SaltyBot using RPLIDAR A1M8.
|
||||
Publishes local costmap, obstacle alerts, and filtered cmd_vel with emergency stop.
|
||||
</description>
|
||||
<maintainer email="sl-controls@saltylab.local">sl-controls</maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<depend>rclpy</depend>
|
||||
<depend>geometry_msgs</depend>
|
||||
<depend>sensor_msgs</depend>
|
||||
<depend>std_msgs</depend>
|
||||
<depend>nav_msgs</depend>
|
||||
|
||||
<buildtool_depend>ament_python</buildtool_depend>
|
||||
|
||||
<test_depend>ament_copyright</test_depend>
|
||||
<test_depend>ament_flake8</test_depend>
|
||||
<test_depend>ament_pep257</test_depend>
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
@ -1 +0,0 @@
|
||||
"""SaltyBot LIDAR obstacle avoidance package."""
|
||||
@ -1,239 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""360° LIDAR obstacle avoidance node for SaltyBot.
|
||||
|
||||
Uses RPLIDAR A1M8 for 360° scanning with speed-dependent safety zones.
|
||||
Publishes emergency alerts and filtered cmd_vel with obstacle avoidance.
|
||||
|
||||
Subscribed topics:
|
||||
/scan (sensor_msgs/LaserScan) - RPLIDAR A1M8 scan data
|
||||
/cmd_vel (geometry_msgs/Twist) - Input velocity command
|
||||
|
||||
Published topics:
|
||||
/saltybot/obstacle_alert (std_msgs/Bool) - Obstacle detected alert
|
||||
/cmd_vel_safe (geometry_msgs/Twist) - Filtered velocity (avoidance applied)
|
||||
/saltybot/lidar_avoidance_status (std_msgs/String) - Debug status JSON
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Tuple
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import LaserScan
|
||||
from geometry_msgs.msg import Twist
|
||||
from std_msgs.msg import Bool, String
|
||||
|
||||
|
||||
class LidarAvoidanceNode(Node):
|
||||
"""360° LIDAR obstacle avoidance with speed-dependent safety zones."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("lidar_avoidance")
|
||||
|
||||
# Safety parameters
|
||||
self.declare_parameter("emergency_stop_distance", 0.5) # m
|
||||
self.declare_parameter("max_speed_reference", 5.56) # m/s (20 km/h)
|
||||
self.declare_parameter("safety_zone_at_max_speed", 3.0) # m
|
||||
self.declare_parameter("min_safety_zone", 0.6) # m (below emergency stop)
|
||||
self.declare_parameter("angle_window_degrees", 60) # ±30° forward cone
|
||||
self.declare_parameter("debounce_frames", 2)
|
||||
|
||||
self.emergency_stop_distance = self.get_parameter("emergency_stop_distance").value
|
||||
self.max_speed_reference = self.get_parameter("max_speed_reference").value
|
||||
self.safety_zone_at_max_speed = self.get_parameter("safety_zone_at_max_speed").value
|
||||
self.min_safety_zone = self.get_parameter("min_safety_zone").value
|
||||
self.angle_window_degrees = self.get_parameter("angle_window_degrees").value
|
||||
self.debounce_frames = self.get_parameter("debounce_frames").value
|
||||
|
||||
# State tracking
|
||||
self.obstacle_detected = False
|
||||
self.consecutive_obstacles = 0
|
||||
self.current_speed = 0.0
|
||||
self.last_scan_ranges = None
|
||||
self.emergency_stop_triggered = False
|
||||
|
||||
# Subscriptions
|
||||
self.create_subscription(LaserScan, "/scan", self._on_scan, 10)
|
||||
self.create_subscription(Twist, "/cmd_vel", self._on_cmd_vel, 10)
|
||||
|
||||
# Publishers
|
||||
self.pub_alert = self.create_publisher(Bool, "/saltybot/obstacle_alert", 10)
|
||||
self.pub_safe_vel = self.create_publisher(Twist, "/cmd_vel_safe", 10)
|
||||
self.pub_status = self.create_publisher(
|
||||
String, "/saltybot/lidar_avoidance_status", 10
|
||||
)
|
||||
|
||||
self.get_logger().info(
|
||||
f"LIDAR avoidance initialized:\n"
|
||||
f" Emergency stop: {self.emergency_stop_distance}m\n"
|
||||
f" Speed-dependent zone: {self.safety_zone_at_max_speed}m @ {self.max_speed_reference}m/s\n"
|
||||
f" Forward angle window: ±{self.angle_window_degrees / 2}°\n"
|
||||
f" Min safety zone: {self.min_safety_zone}m"
|
||||
)
|
||||
|
||||
def _on_scan(self, msg: LaserScan) -> None:
|
||||
"""Process LIDAR scan data and check for obstacles."""
|
||||
self.last_scan_ranges = msg.ranges
|
||||
|
||||
# Calculate safety threshold based on current speed
|
||||
safety_distance = self._get_safety_distance(self.current_speed)
|
||||
|
||||
# Get minimum distance in forward cone
|
||||
min_distance, angle_deg = self._get_min_distance_forward(msg)
|
||||
|
||||
# Check for obstacles
|
||||
obstacle_now = min_distance < safety_distance
|
||||
emergency_stop_now = min_distance < self.emergency_stop_distance
|
||||
|
||||
# Debounce obstacle detection
|
||||
if obstacle_now:
|
||||
self.consecutive_obstacles += 1
|
||||
else:
|
||||
self.consecutive_obstacles = 0
|
||||
|
||||
obstacle_detected_debounced = (
|
||||
self.consecutive_obstacles >= self.debounce_frames
|
||||
)
|
||||
|
||||
# Handle state changes
|
||||
if emergency_stop_now and not self.emergency_stop_triggered:
|
||||
self.get_logger().error(
|
||||
f"EMERGENCY STOP! Obstacle at {min_distance:.2f}m, {angle_deg:.1f}°"
|
||||
)
|
||||
self.emergency_stop_triggered = True
|
||||
elif not emergency_stop_now:
|
||||
self.emergency_stop_triggered = False
|
||||
|
||||
if obstacle_detected_debounced != self.obstacle_detected:
|
||||
self.obstacle_detected = obstacle_detected_debounced
|
||||
if self.obstacle_detected:
|
||||
self.get_logger().warn(
|
||||
f"Obstacle detected: {min_distance:.2f}m @ {angle_deg:.1f}°"
|
||||
)
|
||||
else:
|
||||
self.get_logger().info("Obstacle cleared")
|
||||
|
||||
# Publish alert
|
||||
alert_msg = Bool(data=self.obstacle_detected)
|
||||
self.pub_alert.publish(alert_msg)
|
||||
|
||||
# Publish status
|
||||
status = {
|
||||
"min_distance": round(min_distance, 3),
|
||||
"angle_deg": round(angle_deg, 1),
|
||||
"safety_distance": round(safety_distance, 3),
|
||||
"obstacle_detected": self.obstacle_detected,
|
||||
"emergency_stop": self.emergency_stop_triggered,
|
||||
"current_speed": round(self.current_speed, 3),
|
||||
}
|
||||
status_msg = String(data=json.dumps(status))
|
||||
self.pub_status.publish(status_msg)
|
||||
|
||||
def _on_cmd_vel(self, msg: Twist) -> None:
|
||||
"""Process incoming velocity command and apply obstacle avoidance."""
|
||||
self.current_speed = math.sqrt(msg.linear.x**2 + msg.linear.y**2)
|
||||
|
||||
# Apply safety filtering
|
||||
if self.emergency_stop_triggered:
|
||||
# Emergency stop: zero out all motion
|
||||
safe_vel = Twist()
|
||||
elif self.obstacle_detected:
|
||||
# Obstacle in path: reduce speed
|
||||
safe_vel = Twist()
|
||||
safety_distance = self._get_safety_distance(self.current_speed)
|
||||
min_distance, _ = self._get_min_distance_forward(self.last_scan_ranges)
|
||||
|
||||
if self.last_scan_ranges is not None and min_distance > 0:
|
||||
# Linear interpolation of allowed speed based on distance to obstacle
|
||||
if min_distance < safety_distance:
|
||||
# Scale velocity from 0 to current based on distance
|
||||
scale_factor = (min_distance - self.emergency_stop_distance) / (
|
||||
safety_distance - self.emergency_stop_distance
|
||||
)
|
||||
scale_factor = max(0.0, min(1.0, scale_factor))
|
||||
|
||||
safe_vel.linear.x = msg.linear.x * scale_factor
|
||||
safe_vel.linear.y = msg.linear.y * scale_factor
|
||||
safe_vel.angular.z = msg.angular.z * scale_factor
|
||||
else:
|
||||
safe_vel = msg
|
||||
else:
|
||||
safe_vel = msg
|
||||
else:
|
||||
# No obstacle: pass through command
|
||||
safe_vel = msg
|
||||
|
||||
self.pub_safe_vel.publish(safe_vel)
|
||||
|
||||
def _get_safety_distance(self, speed: float) -> float:
|
||||
"""Calculate speed-dependent safety zone distance.
|
||||
|
||||
Linear interpolation: 0 m/s → min_safety_zone, max_speed → safety_zone_at_max_speed
|
||||
"""
|
||||
if speed <= 0:
|
||||
return self.min_safety_zone
|
||||
|
||||
if speed >= self.max_speed_reference:
|
||||
return self.safety_zone_at_max_speed
|
||||
|
||||
# Linear interpolation
|
||||
ratio = speed / self.max_speed_reference
|
||||
safety = self.min_safety_zone + ratio * (
|
||||
self.safety_zone_at_max_speed - self.min_safety_zone
|
||||
)
|
||||
return safety
|
||||
|
||||
def _get_min_distance_forward(self, scan_data) -> Tuple[float, float]:
|
||||
"""Get minimum distance in forward cone."""
|
||||
if isinstance(scan_data, LaserScan):
|
||||
ranges = scan_data.ranges
|
||||
angle_min = scan_data.angle_min
|
||||
angle_increment = scan_data.angle_increment
|
||||
else:
|
||||
# scan_data is a tuple of (ranges, angle_min, angle_increment) or list
|
||||
if not scan_data:
|
||||
return float('inf'), 0.0
|
||||
ranges = scan_data
|
||||
angle_min = -math.pi # Assume standard LIDAR orientation
|
||||
angle_increment = 2 * math.pi / len(ranges)
|
||||
|
||||
half_window = self.angle_window_degrees / 2.0 * math.pi / 180.0
|
||||
min_distance = float('inf')
|
||||
min_angle = 0.0
|
||||
|
||||
for i, distance in enumerate(ranges):
|
||||
if distance <= 0 or math.isnan(distance) or math.isinf(distance):
|
||||
continue
|
||||
|
||||
angle_rad = angle_min + i * angle_increment
|
||||
|
||||
# Normalize to -π to π
|
||||
while angle_rad > math.pi:
|
||||
angle_rad -= 2 * math.pi
|
||||
while angle_rad < -math.pi:
|
||||
angle_rad += 2 * math.pi
|
||||
|
||||
# Check forward window
|
||||
if abs(angle_rad) <= half_window:
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
min_angle = angle_rad
|
||||
|
||||
return min_distance, math.degrees(min_angle)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
node = LidarAvoidanceNode()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,5 +0,0 @@
|
||||
[develop]
|
||||
script_dir=$base/lib/saltybot_lidar_avoidance
|
||||
|
||||
[install]
|
||||
install_scripts=$base/lib/saltybot_lidar_avoidance
|
||||
@ -1,27 +0,0 @@
|
||||
from setuptools import setup
|
||||
|
||||
package_name = "saltybot_lidar_avoidance"
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version="0.1.0",
|
||||
packages=[package_name],
|
||||
data_files=[
|
||||
("share/ament_index/resource_index/packages", [f"resource/{package_name}"]),
|
||||
(f"share/{package_name}", ["package.xml"]),
|
||||
(f"share/{package_name}/launch", ["launch/lidar_avoidance.launch.py"]),
|
||||
(f"share/{package_name}/config", ["config/lidar_avoidance_params.yaml"]),
|
||||
],
|
||||
install_requires=["setuptools"],
|
||||
zip_safe=True,
|
||||
maintainer="sl-controls",
|
||||
maintainer_email="sl-controls@saltylab.local",
|
||||
description="360° LIDAR obstacle avoidance with emergency stop and speed-dependent safety zones",
|
||||
license="MIT",
|
||||
tests_require=["pytest"],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"lidar_avoidance_node = saltybot_lidar_avoidance.lidar_avoidance_node:main",
|
||||
],
|
||||
},
|
||||
)
|
||||
@ -1,102 +0,0 @@
|
||||
"""Unit tests for LIDAR avoidance node."""
|
||||
|
||||
import math
|
||||
import pytest
|
||||
from sensor_msgs.msg import LaserScan
|
||||
from geometry_msgs.msg import Twist
|
||||
from std_msgs.msg import Bool
|
||||
|
||||
|
||||
class MockNode:
|
||||
"""Mock ROS2 node for testing."""
|
||||
|
||||
def __init__(self):
|
||||
from saltybot_lidar_avoidance.lidar_avoidance_node import LidarAvoidanceNode
|
||||
import rclpy
|
||||
|
||||
rclpy.init(allow_reuse=True)
|
||||
self.node = LidarAvoidanceNode()
|
||||
|
||||
def cleanup(self):
|
||||
self.node.destroy_node()
|
||||
|
||||
|
||||
def create_scan(
|
||||
ranges, angle_min=-math.pi, angle_increment=2 * math.pi / 360
|
||||
):
|
||||
"""Create a LaserScan message."""
|
||||
scan = LaserScan()
|
||||
scan.ranges = ranges
|
||||
scan.angle_min = angle_min
|
||||
scan.angle_increment = angle_increment
|
||||
scan.angle_max = angle_min + angle_increment * (len(ranges) - 1)
|
||||
return scan
|
||||
|
||||
|
||||
def test_safety_distance_zero_speed():
|
||||
"""Test safety distance at zero speed."""
|
||||
import rclpy
|
||||
|
||||
rclpy.init(allow_reuse=True)
|
||||
from saltybot_lidar_avoidance.lidar_avoidance_node import LidarAvoidanceNode
|
||||
|
||||
node = LidarAvoidanceNode()
|
||||
safety = node._get_safety_distance(0.0)
|
||||
assert safety == node.min_safety_zone
|
||||
node.destroy_node()
|
||||
|
||||
|
||||
def test_safety_distance_max_speed():
|
||||
"""Test safety distance at max speed."""
|
||||
import rclpy
|
||||
|
||||
rclpy.init(allow_reuse=True)
|
||||
from saltybot_lidar_avoidance.lidar_avoidance_node import LidarAvoidanceNode
|
||||
|
||||
node = LidarAvoidanceNode()
|
||||
safety = node._get_safety_distance(node.max_speed_reference)
|
||||
assert abs(safety - node.safety_zone_at_max_speed) < 0.01
|
||||
node.destroy_node()
|
||||
|
||||
|
||||
def test_safety_distance_interpolation():
|
||||
"""Test safety distance linear interpolation."""
|
||||
import rclpy
|
||||
|
||||
rclpy.init(allow_reuse=True)
|
||||
from saltybot_lidar_avoidance.lidar_avoidance_node import LidarAvoidanceNode
|
||||
|
||||
node = LidarAvoidanceNode()
|
||||
half_speed = node.max_speed_reference / 2.0
|
||||
safety = node._get_safety_distance(half_speed)
|
||||
|
||||
expected = node.min_safety_zone + 0.5 * (
|
||||
node.safety_zone_at_max_speed - node.min_safety_zone
|
||||
)
|
||||
assert abs(safety - expected) < 0.01
|
||||
node.destroy_node()
|
||||
|
||||
|
||||
def test_min_distance_forward():
|
||||
"""Test forward distance detection."""
|
||||
import rclpy
|
||||
|
||||
rclpy.init(allow_reuse=True)
|
||||
from saltybot_lidar_avoidance.lidar_avoidance_node import LidarAvoidanceNode
|
||||
|
||||
node = LidarAvoidanceNode()
|
||||
|
||||
# Create scan with obstacle at 0° (forward)
|
||||
ranges = [float('inf')] * 360
|
||||
ranges[180] = 1.5 # Obstacle at index 180 (0° in normalized coordinates)
|
||||
|
||||
scan = create_scan(ranges)
|
||||
min_dist, angle = node._get_min_distance_forward(scan)
|
||||
|
||||
assert min_dist == 1.5
|
||||
assert abs(angle) < 10 # Close to forward direction
|
||||
node.destroy_node()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@ -9,18 +9,12 @@
|
||||
*/
|
||||
|
||||
#include "battery.h"
|
||||
#include "coulomb_counter.h"
|
||||
#include "config.h"
|
||||
#include "stm32f7xx_hal.h"
|
||||
#include "ina219.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
static ADC_HandleTypeDef s_hadc;
|
||||
static bool s_ready = false;
|
||||
static bool s_coulomb_valid = false;
|
||||
|
||||
/* Default battery capacity: 2200 mAh (typical lab 3S LiPo) */
|
||||
#define DEFAULT_BATTERY_CAPACITY_MAH 2200u
|
||||
|
||||
void battery_init(void) {
|
||||
__HAL_RCC_ADC3_CLK_ENABLE();
|
||||
@ -55,10 +49,6 @@ void battery_init(void) {
|
||||
ch.SamplingTime = ADC_SAMPLETIME_480CYCLES;
|
||||
if (HAL_ADC_ConfigChannel(&s_hadc, &ch) != HAL_OK) return;
|
||||
|
||||
/* Initialize coulomb counter with default battery capacity */
|
||||
coulomb_counter_init(DEFAULT_BATTERY_CAPACITY_MAH);
|
||||
s_coulomb_valid = true;
|
||||
|
||||
s_ready = true;
|
||||
}
|
||||
|
||||
@ -76,7 +66,7 @@ uint32_t battery_read_mv(void) {
|
||||
}
|
||||
|
||||
/*
|
||||
* Coarse SoC estimate (voltage-based fallback).
|
||||
* Coarse SoC estimate.
|
||||
* 3S LiPo: 9.9 V (0%) – 12.6 V (100%) — detect by Vbat < 13 V
|
||||
* 4S LiPo: 13.2 V (0%) – 16.8 V (100%) — detect by Vbat ≥ 13 V
|
||||
*/
|
||||
@ -98,34 +88,3 @@ uint8_t battery_estimate_pct(uint32_t voltage_mv) {
|
||||
|
||||
return (uint8_t)(((voltage_mv - v_min_mv) * 100u) / (v_max_mv - v_min_mv));
|
||||
}
|
||||
|
||||
/*
|
||||
* battery_accumulate_coulombs() — call periodically (50-100 Hz) to track
|
||||
* battery current and integrate coulombs. Reads motor currents via INA219.
|
||||
*/
|
||||
void battery_accumulate_coulombs(void) {
|
||||
if (!s_coulomb_valid) return;
|
||||
|
||||
/* Sum left + right motor currents as proxy for battery draw
|
||||
* (simple approach; doesn't include subsystem drain like OSD, audio) */
|
||||
int16_t left_ma = 0, right_ma = 0;
|
||||
ina219_read_current_ma(INA219_LEFT_MOTOR, &left_ma);
|
||||
ina219_read_current_ma(INA219_RIGHT_MOTOR, &right_ma);
|
||||
|
||||
/* Total battery current ≈ motors + subsystem baseline (~200 mA) */
|
||||
int16_t total_ma = left_ma + right_ma + 200;
|
||||
|
||||
/* Accumulate to coulomb counter */
|
||||
coulomb_counter_accumulate(total_ma);
|
||||
}
|
||||
|
||||
/*
|
||||
* battery_get_soc_coulomb() — get coulomb-based SoC (0-100, 255=invalid).
|
||||
* Preferred over voltage-based when available.
|
||||
*/
|
||||
uint8_t battery_get_soc_coulomb(void) {
|
||||
if (!s_coulomb_valid || !coulomb_counter_is_valid()) {
|
||||
return 255; /* Invalid */
|
||||
}
|
||||
return coulomb_counter_get_soc_pct();
|
||||
}
|
||||
|
||||
@ -1,118 +0,0 @@
|
||||
/*
|
||||
* coulomb_counter.c — Battery coulomb counter (Issue #325)
|
||||
*
|
||||
* Tracks Ah consumed from current readings, provides SoC independent of load.
|
||||
* Time integration: consumed_mah += current_ma * dt_ms / 3600000
|
||||
*/
|
||||
|
||||
#include "coulomb_counter.h"
|
||||
#include "stm32f7xx_hal.h"
|
||||
|
||||
/* State structure */
|
||||
static struct {
|
||||
bool initialized;
|
||||
bool valid; /* At least one measurement taken */
|
||||
uint16_t capacity_mah; /* Battery capacity in mAh */
|
||||
uint32_t accumulated_mah_x100; /* Accumulated coulombs in mAh×100 (fixed-point) */
|
||||
uint32_t last_tick_ms; /* Last update timestamp (ms) */
|
||||
} s_state = {0};
|
||||
|
||||
void coulomb_counter_init(uint16_t capacity_mah) {
|
||||
if (capacity_mah == 0 || capacity_mah > 20000) {
|
||||
/* Sanity check: reasonable battery is 100–20000 mAh */
|
||||
return;
|
||||
}
|
||||
|
||||
s_state.capacity_mah = capacity_mah;
|
||||
s_state.accumulated_mah_x100 = 0;
|
||||
s_state.last_tick_ms = HAL_GetTick();
|
||||
s_state.initialized = true;
|
||||
s_state.valid = false;
|
||||
}
|
||||
|
||||
void coulomb_counter_accumulate(int16_t current_ma) {
|
||||
if (!s_state.initialized) return;
|
||||
|
||||
uint32_t now_ms = HAL_GetTick();
|
||||
uint32_t dt_ms = now_ms - s_state.last_tick_ms;
|
||||
|
||||
/* Handle tick wraparound (~49.7 days at 32-bit ms) */
|
||||
if (dt_ms > 86400000UL) {
|
||||
/* If jump > 1 day, likely wraparound; skip this sample */
|
||||
s_state.last_tick_ms = now_ms;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Prevent negative dt or dt=0 */
|
||||
if (dt_ms == 0) return;
|
||||
if (dt_ms > 1000) {
|
||||
/* Cap to 1 second max per call to prevent overflow */
|
||||
dt_ms = 1000;
|
||||
}
|
||||
|
||||
/* Accumulate: mAh += mA × dt_ms / 3600000
|
||||
* Using fixed-point (×100): accumulated_mah_x100 += mA × dt_ms / 36000 */
|
||||
int32_t coulomb_x100 = (int32_t)current_ma * (int32_t)dt_ms / 36000;
|
||||
|
||||
/* Only accumulate if discharging (positive current) or realistic charging */
|
||||
if (coulomb_x100 > 0) {
|
||||
s_state.accumulated_mah_x100 += (uint32_t)coulomb_x100;
|
||||
} else if (coulomb_x100 < 0 && s_state.accumulated_mah_x100 > 0) {
|
||||
/* Allow charging (negative current) to reduce accumulated coulombs */
|
||||
int32_t new_val = (int32_t)s_state.accumulated_mah_x100 + coulomb_x100;
|
||||
if (new_val < 0) {
|
||||
s_state.accumulated_mah_x100 = 0;
|
||||
} else {
|
||||
s_state.accumulated_mah_x100 = (uint32_t)new_val;
|
||||
}
|
||||
}
|
||||
|
||||
/* Clamp to capacity */
|
||||
if (s_state.accumulated_mah_x100 > (uint32_t)s_state.capacity_mah * 100) {
|
||||
s_state.accumulated_mah_x100 = (uint32_t)s_state.capacity_mah * 100;
|
||||
}
|
||||
|
||||
s_state.last_tick_ms = now_ms;
|
||||
s_state.valid = true;
|
||||
}
|
||||
|
||||
uint8_t coulomb_counter_get_soc_pct(void) {
|
||||
if (!s_state.valid) return 255; /* 255 = invalid/not measured */
|
||||
|
||||
/* SoC = 100 - (consumed_mah / capacity_mah) * 100 */
|
||||
uint32_t consumed_mah = s_state.accumulated_mah_x100 / 100;
|
||||
|
||||
if (consumed_mah >= s_state.capacity_mah) {
|
||||
return 0; /* Fully discharged */
|
||||
}
|
||||
|
||||
uint32_t remaining_mah = s_state.capacity_mah - consumed_mah;
|
||||
uint8_t soc = (uint8_t)((remaining_mah * 100u) / s_state.capacity_mah);
|
||||
|
||||
return soc;
|
||||
}
|
||||
|
||||
uint16_t coulomb_counter_get_consumed_mah(void) {
|
||||
return (uint16_t)(s_state.accumulated_mah_x100 / 100);
|
||||
}
|
||||
|
||||
uint16_t coulomb_counter_get_remaining_mah(void) {
|
||||
if (!s_state.valid) return s_state.capacity_mah;
|
||||
|
||||
uint32_t consumed = s_state.accumulated_mah_x100 / 100;
|
||||
if (consumed >= s_state.capacity_mah) {
|
||||
return 0;
|
||||
}
|
||||
return (uint16_t)(s_state.capacity_mah - consumed);
|
||||
}
|
||||
|
||||
void coulomb_counter_reset(void) {
|
||||
if (!s_state.initialized) return;
|
||||
|
||||
s_state.accumulated_mah_x100 = 0;
|
||||
s_state.last_tick_ms = HAL_GetTick();
|
||||
}
|
||||
|
||||
bool coulomb_counter_is_valid(void) {
|
||||
return s_state.valid;
|
||||
}
|
||||
17
src/crsf.c
17
src/crsf.c
@ -320,21 +320,18 @@ static uint8_t crsf_build_frame(uint8_t *buf, uint8_t frame_type,
|
||||
/*
|
||||
* crsf_send_battery() — type 0x08 battery sensor.
|
||||
* voltage_mv → units of 100 mV (big-endian uint16)
|
||||
* capacity_mah → remaining capacity in mAh (Issue #325, coulomb counter)
|
||||
* remaining_pct→ 0–100 % (uint8)
|
||||
* current_ma → units of 100 mA (big-endian uint16)
|
||||
* remaining_pct→ 0–100 % (uint8); capacity mAh always 0 (no coulomb counter)
|
||||
*/
|
||||
void crsf_send_battery(uint32_t voltage_mv, uint32_t capacity_mah,
|
||||
void crsf_send_battery(uint32_t voltage_mv, uint32_t current_ma,
|
||||
uint8_t remaining_pct) {
|
||||
uint16_t v100 = (uint16_t)(voltage_mv / 100u); /* 100 mV units */
|
||||
/* Convert capacity (mAh) to 3-byte big-endian: cap_hi, cap_mid, cap_lo */
|
||||
uint32_t cap = capacity_mah & 0xFFFFFFu; /* 24-bit cap max */
|
||||
/* Payload: [v_hi][v_lo][current_hi][current_lo][cap_hi][cap_mid][cap_lo][remaining] */
|
||||
uint16_t c100 = (uint16_t)(current_ma / 100u); /* 100 mA units */
|
||||
/* Payload: [v_hi][v_lo][c_hi][c_lo][cap_hi][cap_mid][cap_lo][remaining] */
|
||||
uint8_t payload[8] = {
|
||||
(uint8_t)(v100 >> 8), (uint8_t)(v100 & 0xFF),
|
||||
0, 0, /* current: not available on STM32, always 0 for now */
|
||||
(uint8_t)((cap >> 16) & 0xFF), /* cap_hi */
|
||||
(uint8_t)((cap >> 8) & 0xFF), /* cap_mid */
|
||||
(uint8_t)(cap & 0xFF), /* cap_lo */
|
||||
(uint8_t)(c100 >> 8), (uint8_t)(c100 & 0xFF),
|
||||
0, 0, 0, /* capacity mAh — not tracked */
|
||||
remaining_pct,
|
||||
};
|
||||
uint8_t frame[CRSF_MAX_FRAME_LEN];
|
||||
|
||||
@ -32,10 +32,10 @@ int i2c1_init(void) {
|
||||
return (HAL_I2C_Init(&hi2c1) == HAL_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
int i2c1_write(uint8_t addr, const uint8_t *data, uint16_t len) {
|
||||
return (HAL_I2C_Master_Transmit(&hi2c1, (uint16_t)(addr << 1), (uint8_t *)data, len, 1000) == HAL_OK) ? 0 : -1;
|
||||
int i2c1_write(uint8_t addr, const uint8_t *data, int len) {
|
||||
return (HAL_I2C_Master_Transmit(&hi2c1, addr << 1, (uint8_t*)data, len, 100) == HAL_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
int i2c1_read(uint8_t addr, uint8_t *data, uint16_t len) {
|
||||
return (HAL_I2C_Master_Receive(&hi2c1, (uint16_t)(addr << 1), data, len, 1000) == HAL_OK) ? 0 : -1;
|
||||
int i2c1_read(uint8_t addr, uint8_t *data, int len) {
|
||||
return (HAL_I2C_Master_Receive(&hi2c1, addr << 1, data, len, 100) == HAL_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
16
src/main.c
16
src/main.c
@ -26,7 +26,6 @@
|
||||
#include "ultrasonic.h"
|
||||
#include "power_mgmt.h"
|
||||
#include "battery.h"
|
||||
#include "coulomb_counter.h"
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
@ -249,9 +248,6 @@ int main(void) {
|
||||
/* Servo pan-tilt animation tick — updates smooth sweeps */
|
||||
servo_tick(now);
|
||||
|
||||
/* Accumulate coulombs for battery state-of-charge estimation (Issue #325) */
|
||||
battery_accumulate_coulombs();
|
||||
|
||||
/* Sleep LED: software PWM on LED1 (active-low PC15) driven by PM brightness.
|
||||
* pm_pwm_phase rolls over each ms; brightness sets duty cycle 0-255. */
|
||||
pm_pwm_phase++;
|
||||
@ -478,12 +474,8 @@ int main(void) {
|
||||
if (now - crsf_telem_tick >= (1000u / CRSF_TELEMETRY_HZ)) {
|
||||
crsf_telem_tick = now;
|
||||
uint32_t vbat_mv = battery_read_mv();
|
||||
/* Use coulomb-based SoC if available, fallback to voltage-based */
|
||||
uint8_t soc_pct = battery_get_soc_coulomb();
|
||||
if (soc_pct == 255) {
|
||||
soc_pct = battery_estimate_pct(vbat_mv);
|
||||
}
|
||||
crsf_send_battery(vbat_mv, coulomb_counter_get_remaining_mah(), soc_pct);
|
||||
uint8_t soc_pct = battery_estimate_pct(vbat_mv);
|
||||
crsf_send_battery(vbat_mv, 0u, soc_pct);
|
||||
crsf_send_flight_mode(bal.state == BALANCE_ARMED);
|
||||
}
|
||||
|
||||
@ -504,9 +496,7 @@ int main(void) {
|
||||
tlm.mode = (uint8_t)mode_manager_active(&mode);
|
||||
EstopSource _es = safety_get_estop();
|
||||
tlm.estop = (uint8_t)_es;
|
||||
/* Use coulomb-based SoC if available, fallback to voltage-based */
|
||||
uint8_t soc = battery_get_soc_coulomb();
|
||||
tlm.soc_pct = (soc == 255) ? battery_estimate_pct(vbat) : soc;
|
||||
tlm.soc_pct = battery_estimate_pct(vbat);
|
||||
tlm.fw_major = FW_MAJOR;
|
||||
tlm.fw_minor = FW_MINOR;
|
||||
tlm.fw_patch = FW_PATCH;
|
||||
|
||||
23
src/safety.c
23
src/safety.c
@ -12,9 +12,20 @@
|
||||
#include "safety.h"
|
||||
#include "config.h"
|
||||
#include "crsf.h"
|
||||
#include "watchdog.h"
|
||||
#include "stm32f7xx_hal.h"
|
||||
|
||||
/* IWDG prescaler 32 → LSI(40kHz)/32 = 1250 ticks/sec → 0.8ms/tick */
|
||||
#define IWDG_PRESCALER IWDG_PRESCALER_32
|
||||
/* Integer formula: timeout_ms * LSI_HZ / (prescaler * 1000)
|
||||
* = WATCHDOG_TIMEOUT_MS * 40000 / (32 * 1000) = WATCHDOG_TIMEOUT_MS * 40 / 32 */
|
||||
#define IWDG_RELOAD (WATCHDOG_TIMEOUT_MS * 40UL / 32UL)
|
||||
|
||||
#if IWDG_RELOAD > 4095
|
||||
# error "WATCHDOG_TIMEOUT_MS too large for IWDG_PRESCALER_32 — increase prescaler"
|
||||
#endif
|
||||
|
||||
static IWDG_HandleTypeDef hiwdg;
|
||||
|
||||
/* Arm interlock */
|
||||
static uint32_t s_arm_start_ms = 0;
|
||||
static bool s_arm_pending = false;
|
||||
@ -25,13 +36,15 @@ static bool s_was_faulted = false;
|
||||
static EstopSource s_estop_source = ESTOP_CLEAR;
|
||||
|
||||
void safety_init(void) {
|
||||
/* Initialize IWDG via watchdog module (Issue #300) with ~2s timeout */
|
||||
watchdog_init(2000);
|
||||
hiwdg.Instance = IWDG;
|
||||
hiwdg.Init.Prescaler = IWDG_PRESCALER;
|
||||
hiwdg.Init.Reload = IWDG_RELOAD;
|
||||
hiwdg.Init.Window = IWDG_WINDOW_DISABLE;
|
||||
HAL_IWDG_Init(&hiwdg); /* Starts watchdog immediately */
|
||||
}
|
||||
|
||||
void safety_refresh(void) {
|
||||
/* Feed the watchdog timer */
|
||||
watchdog_kick();
|
||||
if (hiwdg.Instance) HAL_IWDG_Refresh(&hiwdg);
|
||||
}
|
||||
|
||||
bool safety_rc_alive(uint32_t now) {
|
||||
|
||||
@ -77,6 +77,16 @@ static bool watchdog_calculate_config(uint32_t timeout_ms,
|
||||
return false; /* No suitable prescaler found */
|
||||
}
|
||||
|
||||
/* Get prescaler divider from prescaler value */
|
||||
static uint16_t watchdog_get_divider(uint8_t prescaler)
|
||||
{
|
||||
const uint16_t dividers[] = {4, 8, 16, 32, 64, 128, 256};
|
||||
if (prescaler < 7) {
|
||||
return dividers[prescaler];
|
||||
}
|
||||
return 256;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Public API
|
||||
* ================================================================ */
|
||||
|
||||
@ -1,378 +0,0 @@
|
||||
# SaltyFace Web App UI
|
||||
|
||||
**Issue #370**: Animated robot expression UI as lightweight web app.
|
||||
|
||||
Runs in Chromium fullscreen kiosk mode (via Issue #374 Cage compositor) on MageDok 7" display.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ MageDok 7" IPS Touchscreen (1024×600) │
|
||||
├────────────────────────────────────────────┤
|
||||
│ Chromium Browser (Kiosk Mode) │
|
||||
├────────────────────────────────────────────┤
|
||||
│ SaltyFace Web App (HTML5 Canvas) │
|
||||
│ ├─ Canvas-based face rendering │
|
||||
│ ├─ Touch overlay (status display) │
|
||||
│ ├─ 8 emotional states │
|
||||
│ └─ ROS2 WebSocket bridge integration │
|
||||
├────────────────────────────────────────────┤
|
||||
│ Node.js HTTP Server (localhost:3000) │
|
||||
│ ├─ Serves public/index.html │
|
||||
│ ├─ Serves public/salty-face.js │
|
||||
│ └─ CORS headers for ROS bridge │
|
||||
├────────────────────────────────────────────┤
|
||||
│ ROS2 Workloads │
|
||||
│ ├─ /saltybot/state │
|
||||
│ ├─ /saltybot/battery │
|
||||
│ ├─ /saltybot/target_track │
|
||||
│ ├─ /saltybot/obstacles │
|
||||
│ ├─ /social/speech/is_speaking │
|
||||
│ └─ /social/speech/is_listening │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 8 Emotional States
|
||||
|
||||
| State | Trigger | Display | Color |
|
||||
|-------|---------|---------|-------|
|
||||
| **Happy** | IDLE, TRACKING | Normal eyes, smile | Green (#10b981) |
|
||||
| **Alert** | Obstacles < 0.5m | Wide eyes, tense | Red (#ef4444) |
|
||||
| **Confused** | Target lost, SEARCHING | Wandering eyes, blink | Amber (#f59e0b) |
|
||||
| **Sleeping** | Idle timeout | Closed eyes | Gray (#6b7280) |
|
||||
| **Excited** | Target acquired | Bouncing eyes | Green (#22c55e) |
|
||||
| **Emergency** | E-stop activated | Wide eyes, flashing | Red (#dc2626) |
|
||||
| **Listening** | Microphone active | Focused eyes, upward | Cyan (#0ea5e9) |
|
||||
| **Talking** | TTS output | Animated mouth | Cyan (#06b6d4) |
|
||||
|
||||
### UI Elements
|
||||
|
||||
- **Canvas Face**: 1024×600 animated face on 50% of screen
|
||||
- **Status Overlay** (tap-toggleable):
|
||||
- Battery %
|
||||
- Robot state (IDLE, TRACKING, SEARCHING, EMERGENCY)
|
||||
- Distance to target
|
||||
- Speed (m/s)
|
||||
- System health %
|
||||
- **Connection Status**: ROS bridge WebSocket status indicator
|
||||
- **Debug Stats**: Current emotion, talking state, audio level
|
||||
|
||||
### Animation Performance
|
||||
|
||||
- **Frame Rate**: 60fps (Wayland native, Cage compositor)
|
||||
- **Rendering**: Canvas 2D (GPU accelerated via WebGL fallback)
|
||||
- **Target**: Orin Nano GPU (8-core NVIDIA Ampere GPU)
|
||||
- **Memory**: ~80MB (Node.js server + browser tab)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
ui/social-bot/
|
||||
├── public/
|
||||
│ ├── index.html # Main page (1024×600)
|
||||
│ └── salty-face.js # Canvas rendering + ROS integration
|
||||
├── server.js # Node.js HTTP server (localhost:3000)
|
||||
└── docs/
|
||||
└── SALTY_FACE_WEB_APP.md # This file
|
||||
```
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### 1. Install Node.js
|
||||
|
||||
```bash
|
||||
# If not installed
|
||||
sudo apt install -y nodejs npm
|
||||
```
|
||||
|
||||
### 2. Copy Web App Files
|
||||
|
||||
```bash
|
||||
# Copy to /opt/saltybot/app
|
||||
sudo mkdir -p /opt/saltybot/app
|
||||
sudo cp -r ui/social-bot/public /opt/saltybot/app/
|
||||
sudo cp ui/social-bot/server.js /opt/saltybot/app/
|
||||
sudo chmod +x /opt/saltybot/app/server.js
|
||||
```
|
||||
|
||||
### 3. Install Systemd Service
|
||||
|
||||
```bash
|
||||
# Copy service file
|
||||
sudo cp systemd/salty-face-server.service /etc/systemd/system/
|
||||
|
||||
# Reload and enable
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable salty-face-server.service
|
||||
```
|
||||
|
||||
### 4. Start Service
|
||||
|
||||
```bash
|
||||
# Manual start for testing
|
||||
sudo systemctl start salty-face-server.service
|
||||
|
||||
# Check logs
|
||||
sudo journalctl -u salty-face-server.service -f
|
||||
```
|
||||
|
||||
### 5. Verify Web App
|
||||
|
||||
```bash
|
||||
# From any machine on network:
|
||||
# Open browser: http://<orin-ip>:3000
|
||||
|
||||
# Or test locally:
|
||||
curl http://localhost:3000
|
||||
# Should return index.html
|
||||
```
|
||||
|
||||
## ROS2 Integration
|
||||
|
||||
### WebSocket Bridge
|
||||
|
||||
The web app connects to ROS2 via WebSocket using ROSLIB:
|
||||
|
||||
```javascript
|
||||
const ros = new ROSLIB.Ros({
|
||||
url: 'ws://localhost:9090' // rosbridge_server
|
||||
});
|
||||
```
|
||||
|
||||
### Topic Subscriptions
|
||||
|
||||
| Topic | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `/saltybot/state` | `std_msgs/String` | Emotion trigger (EMERGENCY, TRACKING, SEARCHING) |
|
||||
| `/saltybot/battery` | `std_msgs/Float32` | Battery % display |
|
||||
| `/saltybot/target_track` | `geometry_msgs/Pose` | Target acquired → EXCITED |
|
||||
| `/saltybot/obstacles` | `sensor_msgs/LaserScan` | Obstacle distance → ALERT |
|
||||
| `/social/speech/is_speaking` | `std_msgs/Bool` | TTS output → TALKING emotion |
|
||||
| `/social/speech/is_listening` | `std_msgs/Bool` | Microphone → LISTENING emotion |
|
||||
| `/saltybot/audio_level` | `std_msgs/Float32` | Audio level display |
|
||||
|
||||
### Message Format
|
||||
|
||||
Most topics use simple JSON payloads:
|
||||
|
||||
```json
|
||||
{
|
||||
"state": "TRACKING",
|
||||
"hasTarget": true,
|
||||
"obstacles": 0
|
||||
}
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- **Target**: Chromium 90+ (standard on Ubuntu 20.04+)
|
||||
- **Features Used**:
|
||||
- Canvas 2D rendering
|
||||
- WebSocket (ROSLIB)
|
||||
- Touch events (MageDok HID input)
|
||||
- requestAnimationFrame (animation loop)
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Reduce CPU/GPU Load
|
||||
|
||||
1. Lower animation frame rate:
|
||||
```javascript
|
||||
// In salty-face.js, reduce frameCount checks
|
||||
if (state.frameCount % 2 === 0) {
|
||||
// Only render every 2 frames → 30fps
|
||||
}
|
||||
```
|
||||
|
||||
2. Simplify eye rendering:
|
||||
```javascript
|
||||
// Remove highlight reflection
|
||||
// Remove eye wander effect (confused state)
|
||||
```
|
||||
|
||||
3. Disable unnecessary subscriptions:
|
||||
```javascript
|
||||
// Comment out /saltybot/obstacles subscription
|
||||
// Comment out /social/speech subscriptions
|
||||
```
|
||||
|
||||
### Monitor Resource Usage
|
||||
|
||||
```bash
|
||||
# Watch CPU/GPU load during animation
|
||||
watch -n 0.5 'top -bn1 | grep -E "PID|node|chromium"'
|
||||
|
||||
# Check GPU memory
|
||||
tegrastats
|
||||
|
||||
# Check Node.js memory
|
||||
ps aux | grep node
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Web App Won't Load
|
||||
|
||||
**Issue**: Browser shows "Cannot GET /"
|
||||
|
||||
**Solutions**:
|
||||
1. Verify server is running:
|
||||
```bash
|
||||
sudo systemctl status salty-face-server.service
|
||||
sudo journalctl -u salty-face-server.service -n 20
|
||||
```
|
||||
|
||||
2. Check port 3000 is listening:
|
||||
```bash
|
||||
sudo netstat -tlnp | grep 3000
|
||||
```
|
||||
|
||||
3. Verify public/ directory exists:
|
||||
```bash
|
||||
ls -la /opt/saltybot/app/public/
|
||||
```
|
||||
|
||||
### ROS Bridge Connection Fails
|
||||
|
||||
**Issue**: "WebSocket connection failed" in browser console
|
||||
|
||||
**Solutions**:
|
||||
1. Verify rosbridge_server is running:
|
||||
```bash
|
||||
ps aux | grep rosbridge
|
||||
ros2 run rosbridge_server rosbridge_websocket
|
||||
```
|
||||
|
||||
2. Check ROS2 domain ID matches:
|
||||
```bash
|
||||
echo $ROS_DOMAIN_ID
|
||||
# Should be same on robot and web app
|
||||
```
|
||||
|
||||
3. Test WebSocket connectivity:
|
||||
```bash
|
||||
# From browser console:
|
||||
new WebSocket('ws://localhost:9090')
|
||||
# Should show "WebSocket is open"
|
||||
```
|
||||
|
||||
### Touch Input Not Working
|
||||
|
||||
**Issue**: MageDok touchscreen doesn't respond
|
||||
|
||||
**Solutions**:
|
||||
1. Verify touch device exists:
|
||||
```bash
|
||||
ls -la /dev/magedok-touch
|
||||
```
|
||||
|
||||
2. Check udev rule is applied:
|
||||
```bash
|
||||
sudo udevadm control --reload
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
3. Test touch input:
|
||||
```bash
|
||||
sudo cat /dev/input/event* | xxd
|
||||
# Touch screen and watch for input data
|
||||
```
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
**Issue**: Node.js server consuming >256MB
|
||||
|
||||
**Solutions**:
|
||||
1. Check for memory leaks in ROS topic subscriptions:
|
||||
```javascript
|
||||
// Ensure topics are properly unsubscribed
|
||||
// Limit history of emotion changes
|
||||
```
|
||||
|
||||
2. Monitor real-time memory:
|
||||
```bash
|
||||
watch -n 1 'ps aux | grep node'
|
||||
```
|
||||
|
||||
3. Adjust Node.js heap:
|
||||
```bash
|
||||
# In salty-face-server.service:
|
||||
# NODE_OPTIONS=--max-old-space-size=128 # Reduce from 256MB
|
||||
```
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
### Boot Time
|
||||
- Node.js server: 2-3 seconds
|
||||
- Web app loads: <1 second
|
||||
- Total to interactive: ~3-4 seconds
|
||||
|
||||
### Memory Usage
|
||||
- Node.js server: ~30MB
|
||||
- Chromium tab: ~50MB
|
||||
- Total: ~80MB (vs 450MB for GNOME desktop)
|
||||
|
||||
### Frame Rate
|
||||
- Canvas rendering: 60fps (Wayland native)
|
||||
- Mouth animation: ~10fps (100ms per frame)
|
||||
- Eye blinking: Instant (state change)
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **Issue #374**: Cage + Chromium kiosk (display environment)
|
||||
- **Issue #369**: MageDok display setup (hardware config)
|
||||
- **Issue #371**: Accessibility mode (keyboard/voice input enhancements)
|
||||
|
||||
## Development
|
||||
|
||||
### Local Testing
|
||||
|
||||
```bash
|
||||
# Start Node.js server
|
||||
npm install # Install dependencies if needed
|
||||
node server.js --port 3000
|
||||
|
||||
# Open in browser
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
### Modify Emotion Config
|
||||
|
||||
Edit `public/salty-face.js`:
|
||||
|
||||
```javascript
|
||||
const EMOTION_CONFIG = {
|
||||
[EMOTIONS.HAPPY]: {
|
||||
eyeScale: 1.0, // Eye size
|
||||
pupilPos: { x: 0, y: 0 }, // Pupil offset
|
||||
blinkRate: 3000, // Blink interval (ms)
|
||||
color: '#10b981', // Eye color (CSS)
|
||||
},
|
||||
// ... other emotions
|
||||
};
|
||||
```
|
||||
|
||||
### Add New Topics
|
||||
|
||||
Edit `public/salty-face.js`, in `subscribeToRosTopics()`:
|
||||
|
||||
```javascript
|
||||
const newTopic = new ROSLIB.Topic({
|
||||
ros,
|
||||
name: '/your/topic',
|
||||
messageType: 'std_msgs/Float32',
|
||||
});
|
||||
newTopic.subscribe((msg) => {
|
||||
state.newValue = msg.data;
|
||||
});
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API)
|
||||
- [ROSLIB.js](http://wiki.ros.org/roslibjs)
|
||||
- [Chromium Kiosk Mode](https://chromium.googlesource.com/chromium/src/+/main/docs/kiosk_mode.md)
|
||||
- [Wayland Protocol](https://wayland.freedesktop.org/)
|
||||
@ -1,210 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#050510">
|
||||
<title>SaltyFace — Robot Expression UI</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #050510;
|
||||
color: #d1d5db;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #050510;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
flex: 1;
|
||||
display: block;
|
||||
background: #050510;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
pointer-events: none;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-top-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.battery-icon {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.status-bottom-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.distance-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.health-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-top-right {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.speed-display {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tap-hint {
|
||||
font-size: 0.65rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
background: rgba(5, 5, 16, 0.7);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.connection-status.disconnected {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.ws-status {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<canvas id="canvas" width="1024" height="600"></canvas>
|
||||
<div class="overlay" id="overlay">
|
||||
<div class="status-top-left">
|
||||
<div class="status-item">
|
||||
<span class="status-icon battery-icon">⚡</span>
|
||||
<span id="battery">--</span>%
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-icon state-icon">●</span>
|
||||
<span id="state">IDLE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bottom-left">
|
||||
<div class="status-item">
|
||||
<span class="status-icon distance-icon">●</span>
|
||||
<span id="distance">--</span>m
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-icon health-icon" id="health-icon">◇</span>
|
||||
<span id="health">--</span>%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-top-right">
|
||||
<div class="speed-display" id="speed">-- m/s</div>
|
||||
<div class="tap-hint">[tap to hide]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status" id="ws-status">
|
||||
<span class="ws-status" id="ws-dot"></span>
|
||||
<span id="ws-label">DISCONNECTED</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-container">
|
||||
<div>Emotion: <span id="emotion">HAPPY</span></div>
|
||||
<div>Talking: <span id="talking">NO</span></div>
|
||||
<div>Audio: <span id="audio-level">0%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { initSaltyFace } from './salty-face.js';
|
||||
|
||||
// Initialize SaltyFace web app
|
||||
initSaltyFace();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,467 +0,0 @@
|
||||
/**
|
||||
* SaltyFace Web App — Animated robot expression UI for Chromium kiosk
|
||||
*
|
||||
* Runs fullscreen in Cage Wayland compositor on MageDok 7" display
|
||||
* Replaces React desktop component with lightweight web app
|
||||
*
|
||||
* Features:
|
||||
* - 8 emotional states (happy, alert, confused, sleeping, excited, emergency, listening, talking)
|
||||
* - Canvas-based rendering (30fps target)
|
||||
* - ROS2 bridge integration via WebSocket (rosbridge)
|
||||
* - Touch-responsive status overlay
|
||||
* - 1024×600 fullscreen on MageDok display
|
||||
*/
|
||||
|
||||
// Emotion configuration
|
||||
const EMOTIONS = {
|
||||
HAPPY: 'happy',
|
||||
ALERT: 'alert',
|
||||
CONFUSED: 'confused',
|
||||
SLEEPING: 'sleeping',
|
||||
EXCITED: 'excited',
|
||||
EMERGENCY: 'emergency',
|
||||
LISTENING: 'listening',
|
||||
TALKING: 'talking',
|
||||
};
|
||||
|
||||
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,
|
||||
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 animation frames
|
||||
const MOUTH_FRAMES = [
|
||||
{ open: 0.0, shape: 'closed' },
|
||||
{ open: 0.3, shape: 'smile-closed' },
|
||||
{ open: 0.5, shape: 'smile-open' },
|
||||
{ open: 0.7, shape: 'oh' },
|
||||
{ open: 0.9, shape: 'ah' },
|
||||
{ open: 0.7, shape: 'ee' },
|
||||
];
|
||||
|
||||
// ROS2 bridge WebSocket
|
||||
let ros = null;
|
||||
const WS_URL = 'ws://localhost:9090';
|
||||
let rosConnected = false;
|
||||
|
||||
// Animation state
|
||||
let state = {
|
||||
emotion: EMOTIONS.HAPPY,
|
||||
isTalking: false,
|
||||
isListening: false,
|
||||
audioLevel: 0,
|
||||
showOverlay: true,
|
||||
botState: {
|
||||
battery: 85,
|
||||
state: 'IDLE',
|
||||
distance: 0.0,
|
||||
speed: 0.0,
|
||||
health: 90,
|
||||
hasTarget: false,
|
||||
obstacles: 0,
|
||||
},
|
||||
frameCount: 0,
|
||||
isBlinking: false,
|
||||
mouthFrame: 0,
|
||||
eyeWanderOffset: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
// Drawing helpers
|
||||
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) {
|
||||
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 {
|
||||
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
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function drawFace(ctx, W, H) {
|
||||
const config = EMOTION_CONFIG[state.emotion] || EMOTION_CONFIG[EMOTIONS.HAPPY];
|
||||
|
||||
// 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 (confused state)
|
||||
let eyeOffX = 0, eyeOffY = 0;
|
||||
if (config.eyeWander) {
|
||||
eyeOffX = Math.sin(state.frameCount * 0.02) * 8;
|
||||
eyeOffY = Math.cos(state.frameCount * 0.015) * 8;
|
||||
}
|
||||
|
||||
// Bounce (excited state)
|
||||
let bounceOffset = 0;
|
||||
if (config.bounce) {
|
||||
bounceOffset = Math.sin(state.frameCount * 0.08) * 6;
|
||||
}
|
||||
|
||||
// Draw eyes
|
||||
const eyeY = centerY + bounceOffset;
|
||||
drawEye(ctx, centerX - eyeSpacing, eyeY + eyeOffY, eyeRadius, config, state.isBlinking);
|
||||
drawEye(ctx, centerX + eyeSpacing, eyeY + eyeOffY, eyeRadius, config, state.isBlinking);
|
||||
|
||||
// Draw mouth (if talking)
|
||||
if (state.isTalking && !config.isClosed) {
|
||||
drawMouth(ctx, centerX, centerY + 80, 50, MOUTH_FRAMES[state.mouthFrame]);
|
||||
}
|
||||
|
||||
// Flash (emergency state)
|
||||
if (config.flash && Math.sin(state.frameCount * 0.1) > 0.7) {
|
||||
ctx.fillStyle = 'rgba(220, 38, 38, 0.3)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
|
||||
state.frameCount++;
|
||||
}
|
||||
|
||||
// ROS2 Bridge functions
|
||||
function initRosBridge() {
|
||||
console.log('[SaltyFace] Connecting to ROS bridge at', WS_URL);
|
||||
|
||||
const ROSLIB = window.ROSLIB;
|
||||
if (!ROSLIB) {
|
||||
console.error('[SaltyFace] ROSLIB not loaded');
|
||||
return false;
|
||||
}
|
||||
|
||||
ros = new ROSLIB.Ros({
|
||||
url: WS_URL,
|
||||
});
|
||||
|
||||
ros.on('connection', () => {
|
||||
console.log('[SaltyFace] Connected to ROS bridge');
|
||||
rosConnected = true;
|
||||
updateWSStatus(true);
|
||||
subscribeToRosTopics();
|
||||
});
|
||||
|
||||
ros.on('error', (error) => {
|
||||
console.error('[SaltyFace] ROS error:', error);
|
||||
rosConnected = false;
|
||||
updateWSStatus(false);
|
||||
});
|
||||
|
||||
ros.on('close', () => {
|
||||
console.log('[SaltyFace] ROS connection closed');
|
||||
rosConnected = false;
|
||||
updateWSStatus(false);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function subscribeToRosTopics() {
|
||||
if (!ros || !rosConnected) return;
|
||||
|
||||
// Subscribe to robot state
|
||||
const stateTopic = new ROSLIB.Topic({
|
||||
ros,
|
||||
name: '/saltybot/state',
|
||||
messageType: 'std_msgs/String',
|
||||
});
|
||||
stateTopic.subscribe((msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
state.botState.state = data.state || 'IDLE';
|
||||
|
||||
if (data.state === 'EMERGENCY') {
|
||||
state.emotion = EMOTIONS.EMERGENCY;
|
||||
} else if (data.state === 'TRACKING') {
|
||||
state.emotion = EMOTIONS.HAPPY;
|
||||
} else if (data.state === 'SEARCHING') {
|
||||
state.emotion = EMOTIONS.CONFUSED;
|
||||
} else if (data.state === 'IDLE') {
|
||||
state.emotion = EMOTIONS.HAPPY;
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// Battery topic
|
||||
const batteryTopic = new ROSLIB.Topic({
|
||||
ros,
|
||||
name: '/saltybot/battery',
|
||||
messageType: 'std_msgs/Float32',
|
||||
});
|
||||
batteryTopic.subscribe((msg) => {
|
||||
state.botState.battery = Math.round(msg.data);
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// Target tracking
|
||||
const targetTopic = new ROSLIB.Topic({
|
||||
ros,
|
||||
name: '/saltybot/target_track',
|
||||
messageType: 'geometry_msgs/Pose',
|
||||
});
|
||||
targetTopic.subscribe((msg) => {
|
||||
state.botState.hasTarget = !!msg;
|
||||
if (msg) state.emotion = EMOTIONS.EXCITED;
|
||||
});
|
||||
|
||||
// Obstacles
|
||||
const obstacleTopic = new ROSLIB.Topic({
|
||||
ros,
|
||||
name: '/saltybot/obstacles',
|
||||
messageType: 'sensor_msgs/LaserScan',
|
||||
});
|
||||
obstacleTopic.subscribe((msg) => {
|
||||
const obstacleCount = msg?.ranges?.filter((r) => r < 0.5).length || 0;
|
||||
state.botState.obstacles = obstacleCount;
|
||||
if (obstacleCount > 0) state.emotion = EMOTIONS.ALERT;
|
||||
});
|
||||
|
||||
// Speech
|
||||
const speakingTopic = new ROSLIB.Topic({
|
||||
ros,
|
||||
name: '/social/speech/is_speaking',
|
||||
messageType: 'std_msgs/Bool',
|
||||
});
|
||||
speakingTopic.subscribe((msg) => {
|
||||
state.isTalking = msg.data || false;
|
||||
if (msg.data) state.emotion = EMOTIONS.TALKING;
|
||||
});
|
||||
|
||||
const listeningTopic = new ROSLIB.Topic({
|
||||
ros,
|
||||
name: '/social/speech/is_listening',
|
||||
messageType: 'std_msgs/Bool',
|
||||
});
|
||||
listeningTopic.subscribe((msg) => {
|
||||
state.isListening = msg.data || false;
|
||||
if (msg.data) state.emotion = EMOTIONS.LISTENING;
|
||||
});
|
||||
|
||||
// Audio level
|
||||
const audioTopic = new ROSLIB.Topic({
|
||||
ros,
|
||||
name: '/saltybot/audio_level',
|
||||
messageType: 'std_msgs/Float32',
|
||||
});
|
||||
audioTopic.subscribe((msg) => {
|
||||
state.audioLevel = msg.data || 0;
|
||||
});
|
||||
}
|
||||
|
||||
function updateWSStatus(connected) {
|
||||
const dot = document.getElementById('ws-dot');
|
||||
const label = document.getElementById('ws-label');
|
||||
const container = document.getElementById('ws-status');
|
||||
|
||||
if (connected) {
|
||||
dot.style.backgroundColor = '#10b981';
|
||||
label.textContent = 'CONNECTED';
|
||||
container.classList.add('connected');
|
||||
container.classList.remove('disconnected');
|
||||
} else {
|
||||
dot.style.backgroundColor = '#ef4444';
|
||||
label.textContent = 'DISCONNECTED';
|
||||
container.classList.remove('connected');
|
||||
container.classList.add('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
document.getElementById('battery').textContent = state.botState.battery;
|
||||
document.getElementById('state').textContent = state.botState.state;
|
||||
document.getElementById('distance').textContent = state.botState.distance.toFixed(1);
|
||||
document.getElementById('speed').textContent = state.botState.speed.toFixed(1) + ' m/s';
|
||||
document.getElementById('health').textContent = state.botState.health;
|
||||
document.getElementById('emotion').textContent = state.emotion.toUpperCase();
|
||||
document.getElementById('talking').textContent = state.isTalking ? 'YES' : 'NO';
|
||||
document.getElementById('audio-level').textContent = Math.round(state.audioLevel * 100) + '%';
|
||||
|
||||
// Health color
|
||||
const healthIcon = document.getElementById('health-icon');
|
||||
if (state.botState.health > 75) {
|
||||
healthIcon.style.color = '#10b981';
|
||||
} else if (state.botState.health > 50) {
|
||||
healthIcon.style.color = '#f59e0b';
|
||||
} else {
|
||||
healthIcon.style.color = '#ef4444';
|
||||
}
|
||||
}
|
||||
|
||||
// Animation loop
|
||||
function animationLoop(canvas, ctx) {
|
||||
drawFace(ctx, canvas.width, canvas.height);
|
||||
requestAnimationFrame(() => animationLoop(canvas, ctx));
|
||||
}
|
||||
|
||||
// Blinking animation
|
||||
function startBlinking() {
|
||||
const config = EMOTION_CONFIG[state.emotion] || EMOTION_CONFIG[EMOTIONS.HAPPY];
|
||||
if (!config.blink || config.isClosed) return;
|
||||
|
||||
setInterval(() => {
|
||||
state.isBlinking = true;
|
||||
setTimeout(() => {
|
||||
state.isBlinking = false;
|
||||
}, 150);
|
||||
}, config.blinkRate);
|
||||
}
|
||||
|
||||
// Mouth animation
|
||||
function startMouthAnimation() {
|
||||
setInterval(() => {
|
||||
if (!state.isTalking) {
|
||||
state.mouthFrame = 0;
|
||||
return;
|
||||
}
|
||||
state.mouthFrame = (state.mouthFrame + 1) % MOUTH_FRAMES.length;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
export function initSaltyFace() {
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const overlay = document.getElementById('overlay');
|
||||
|
||||
// Start animation loop
|
||||
animationLoop(canvas, ctx);
|
||||
|
||||
// Start blinking
|
||||
startBlinking();
|
||||
|
||||
// Start mouth animation
|
||||
startMouthAnimation();
|
||||
|
||||
// Tap to toggle overlay
|
||||
canvas.addEventListener('click', () => {
|
||||
state.showOverlay = !state.showOverlay;
|
||||
overlay.classList.toggle('hidden', !state.showOverlay);
|
||||
});
|
||||
|
||||
// Prevent context menu on long press
|
||||
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// Initialize ROS bridge
|
||||
// Load ROSLIB dynamically
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js';
|
||||
script.onload = () => {
|
||||
initRosBridge();
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.warn('[SaltyFace] Failed to load ROSLIB from CDN, trying localhost');
|
||||
setTimeout(() => {
|
||||
initRosBridge();
|
||||
}, 2000);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Update UI periodically
|
||||
setInterval(updateUI, 500);
|
||||
|
||||
console.log('[SaltyFace] Web app initialized');
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* SaltyFace Web App Server — Serves static HTML/JS to Chromium kiosk
|
||||
*
|
||||
* Lightweight HTTP server for SaltyFace web UI
|
||||
* - Runs on localhost:3000 (configurable)
|
||||
* - Serves public/ directory
|
||||
* - Enables CORS for ROS bridge WebSocket
|
||||
* - Suitable for systemd service or ROS2 launch
|
||||
*
|
||||
* Usage:
|
||||
* node server.js [--port 3000] [--host 127.0.0.1]
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const url = require('url');
|
||||
|
||||
// Configuration
|
||||
const PORT = parseInt(process.argv.find(arg => arg.startsWith('--port='))?.split('=')[1] || 3000);
|
||||
const HOST = process.argv.find(arg => arg.startsWith('--host='))?.split('=')[1] || '0.0.0.0';
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
|
||||
// MIME types
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
};
|
||||
|
||||
// HTTP server
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers for ROS bridge
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse request URL
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
let pathname = parsedUrl.pathname;
|
||||
|
||||
// Default to index.html for root
|
||||
if (pathname === '/') {
|
||||
pathname = '/index.html';
|
||||
}
|
||||
|
||||
// Security: prevent directory traversal
|
||||
const filePath = path.normalize(path.join(PUBLIC_DIR, pathname));
|
||||
if (!filePath.startsWith(PUBLIC_DIR)) {
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end('Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (err || !stats.isFile()) {
|
||||
// File not found
|
||||
console.log(`[SaltyFace] 404 ${pathname}`);
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine MIME type
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
|
||||
// Serve file
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': stats.size,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
});
|
||||
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
console.log(`[SaltyFace] 200 ${pathname} (${mimeType})`);
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ SaltyFace Web App Server — Running ║
|
||||
║ URL: http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT} │
|
||||
║ Root: ${PUBLIC_DIR} │
|
||||
║ Serving SaltyFace UI for Chromium kiosk ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n[SaltyFace] Shutting down...');
|
||||
server.close(() => {
|
||||
console.log('[SaltyFace] Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[SaltyFace] Received SIGTERM, shutting down...');
|
||||
server.close(() => {
|
||||
console.log('[SaltyFace] Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[SaltyFace] Uncaught exception:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -85,17 +85,7 @@ 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',
|
||||
@ -280,12 +270,7 @@ export default function App() {
|
||||
</nav>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<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} />}
|
||||
|
||||
<main className={`flex-1 ${['eventlog', 'control', 'imu'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'} p-4`}>
|
||||
{activeTab === 'status' && <StatusPanel subscribe={subscribe} />}
|
||||
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
||||
{activeTab === 'hands' && <HandTracker subscribe={subscribe} />}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user