Compare commits
No commits in common. "73742fe726f6254aabee8d6d5635ac6067855548" and "9c2f830c8ee50cd7846e315dff59e026a4f68d9f" have entirely different histories.
73742fe726
...
9c2f830c8e
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
|
||||
@ -32,18 +32,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);
|
||||
|
||||
/*
|
||||
|
||||
@ -31,13 +31,9 @@ typedef struct {
|
||||
uint16_t current_angle_deg[SERVO_COUNT]; /* Current angle in degrees (0-180) */
|
||||
uint16_t target_angle_deg[SERVO_COUNT]; /* Target angle in degrees */
|
||||
uint16_t pulse_us[SERVO_COUNT]; /* Pulse width in microseconds (500-2500) */
|
||||
|
||||
/* Sweep state (per-servo) */
|
||||
uint32_t sweep_start_ms[SERVO_COUNT];
|
||||
uint32_t sweep_duration_ms[SERVO_COUNT];
|
||||
uint16_t sweep_start_deg[SERVO_COUNT];
|
||||
uint16_t sweep_end_deg[SERVO_COUNT];
|
||||
bool is_sweeping[SERVO_COUNT];
|
||||
uint32_t sweep_start_ms;
|
||||
uint32_t sweep_duration_ms;
|
||||
bool is_sweeping;
|
||||
} ServoState;
|
||||
|
||||
/*
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
# Accessibility Mode Configuration for SaltyBot
|
||||
# Deaf/hearing-impaired user interface with speech-to-text, keyboard, and TTS
|
||||
|
||||
accessibility_mode:
|
||||
ros__parameters:
|
||||
# Enable/disable accessibility mode
|
||||
enabled: true
|
||||
|
||||
# Maximum number of transcript entries to display
|
||||
max_display_history: 10
|
||||
|
||||
# Auto-speak-back confirmation for STT input
|
||||
auto_tts: true
|
||||
|
||||
# Enable touch keyboard input
|
||||
keyboard_enabled: true
|
||||
|
||||
# Timeout for display before clearing (seconds)
|
||||
display_timeout_s: 30.0
|
||||
|
||||
# Audio settings
|
||||
audio:
|
||||
# PulseAudio sink for TTS output (MageDok HDMI speakers)
|
||||
output_sink: "alsa_output.pci-0000_00_1d.0.hdmi-stereo"
|
||||
# Volume level (0-1)
|
||||
volume: 0.8
|
||||
|
||||
# STT settings (Whisper integration)
|
||||
stt:
|
||||
# Whisper model size: tiny, base, small, medium, large
|
||||
model: "base"
|
||||
# Language code (empty = auto-detect)
|
||||
language: ""
|
||||
# Enable partial results display
|
||||
show_partial: true
|
||||
# Device: "cuda" for GPU, "cpu" for CPU-only
|
||||
device: "cuda"
|
||||
|
||||
# TTS settings (Piper integration)
|
||||
tts:
|
||||
# Voice model (en_US-lessac-medium by default)
|
||||
voice_model: "en_US-lessac-medium"
|
||||
# Sample rate (Hz)
|
||||
sample_rate: 22050
|
||||
# Enable streaming output
|
||||
streaming_enabled: true
|
||||
|
||||
# Display settings for MageDok 7in
|
||||
display:
|
||||
# Resolution (1024x600)
|
||||
width: 1024
|
||||
height: 600
|
||||
# Refresh rate (Hz)
|
||||
refresh_rate: 60
|
||||
# Font size for transcript (pixels)
|
||||
transcript_font_size: 16
|
||||
# Background color (CSS color)
|
||||
background_color: "#FFFFFF"
|
||||
@ -1,54 +0,0 @@
|
||||
"""Launch file for accessibility mode on MageDok 7in display."""
|
||||
|
||||
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 accessibility mode."""
|
||||
pkg_dir = get_package_share_directory("saltybot_accessibility_mode")
|
||||
config_file = os.path.join(
|
||||
pkg_dir, "config", "accessibility_params.yaml"
|
||||
)
|
||||
|
||||
return LaunchDescription(
|
||||
[
|
||||
DeclareLaunchArgument(
|
||||
"config_file",
|
||||
default_value=config_file,
|
||||
description="Path to configuration YAML file",
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
"ui_port",
|
||||
default_value="8080",
|
||||
description="Port for UI server (MageDok display)",
|
||||
),
|
||||
# Accessibility mode coordinator node
|
||||
Node(
|
||||
package="saltybot_accessibility_mode",
|
||||
executable="accessibility_mode_node",
|
||||
name="accessibility_mode",
|
||||
output="screen",
|
||||
parameters=[LaunchConfiguration("config_file")],
|
||||
remappings=[
|
||||
("/social/speech/transcript", "/social/speech/transcript"),
|
||||
("/accessibility/keyboard_input", "/accessibility/keyboard_input"),
|
||||
("/social/conversation/request", "/social/conversation/request"),
|
||||
],
|
||||
),
|
||||
# Web UI server for touch keyboard and STT display
|
||||
Node(
|
||||
package="saltybot_accessibility_mode",
|
||||
executable="ui_server",
|
||||
name="accessibility_ui",
|
||||
output="screen",
|
||||
parameters=[
|
||||
{"port": LaunchConfiguration("ui_port")},
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
@ -1,28 +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_accessibility_mode</name>
|
||||
<version>0.1.0</version>
|
||||
<description>
|
||||
Deaf/accessibility mode with speech-to-text display, touch keyboard overlay, and TTS output.
|
||||
Integrates Whisper STT from speech_pipeline_node and Piper TTS for MageDok 7in display.
|
||||
</description>
|
||||
<maintainer email="sl-controls@saltylab.local">sl-controls</maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<depend>rclpy</depend>
|
||||
<depend>geometry_msgs</depend>
|
||||
<depend>std_msgs</depend>
|
||||
<depend>saltybot_social_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 Accessibility Mode package."""
|
||||
@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deaf/accessibility mode node for SaltyBot.
|
||||
|
||||
Integrates speech-to-text (Whisper), touch keyboard input, and TTS output.
|
||||
Provides accessible interface for hearing-impaired users via MageDok 7in display.
|
||||
|
||||
Subscribed topics:
|
||||
/social/speech/transcript (SpeechTranscript) - STT from speech pipeline
|
||||
/accessibility/keyboard_input (String) - Touch keyboard input
|
||||
|
||||
Published topics:
|
||||
/accessibility/text_display (String) - Text to display on screen
|
||||
/social/conversation/request (ConversationRequest) - TTS request
|
||||
/accessibility/mode_state (Bool) - Accessibility mode enabled state
|
||||
"""
|
||||
|
||||
import json
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from std_msgs.msg import String, Bool
|
||||
from saltybot_social_msgs.msg import SpeechTranscript, ConversationRequest
|
||||
|
||||
|
||||
class AccessibilityModeNode(Node):
|
||||
"""Deaf/accessibility mode coordinator."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("accessibility_mode")
|
||||
|
||||
# Parameters
|
||||
self.declare_parameter("enabled", True)
|
||||
self.declare_parameter("max_display_history", 10)
|
||||
self.declare_parameter("auto_tts", True)
|
||||
self.declare_parameter("keyboard_enabled", True)
|
||||
self.declare_parameter("display_timeout_s", 30.0)
|
||||
|
||||
self.enabled = self.get_parameter("enabled").value
|
||||
self.max_history = self.get_parameter("max_display_history").value
|
||||
self.auto_tts = self.get_parameter("auto_tts").value
|
||||
self.keyboard_enabled = self.get_parameter("keyboard_enabled").value
|
||||
self.display_timeout = self.get_parameter("display_timeout_s").value
|
||||
|
||||
# State
|
||||
self.display_history = []
|
||||
self.last_transcript = ""
|
||||
self.keyboard_buffer = ""
|
||||
|
||||
# Subscriptions
|
||||
self.create_subscription(
|
||||
SpeechTranscript,
|
||||
"/social/speech/transcript",
|
||||
self._on_transcript,
|
||||
10,
|
||||
)
|
||||
self.create_subscription(
|
||||
String, "/accessibility/keyboard_input", self._on_keyboard_input, 10
|
||||
)
|
||||
|
||||
# Publishers
|
||||
self.pub_display = self.create_publisher(String, "/accessibility/text_display", 10)
|
||||
self.pub_tts = self.create_publisher(
|
||||
ConversationRequest, "/social/conversation/request", 10
|
||||
)
|
||||
self.pub_state = self.create_publisher(Bool, "/accessibility/mode_state", 10)
|
||||
|
||||
# Publish initial state
|
||||
state_msg = Bool(data=self.enabled)
|
||||
self.pub_state.publish(state_msg)
|
||||
|
||||
self.get_logger().info(
|
||||
f"Accessibility mode initialized: "
|
||||
f"enabled={self.enabled}, auto_tts={self.auto_tts}, "
|
||||
f"keyboard={self.keyboard_enabled}"
|
||||
)
|
||||
|
||||
def _on_transcript(self, msg: SpeechTranscript) -> None:
|
||||
"""Handle incoming speech-to-text transcript."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
transcript = msg.text.strip()
|
||||
if not transcript:
|
||||
return
|
||||
|
||||
self.last_transcript = transcript
|
||||
self.keyboard_buffer = "" # Clear keyboard buffer on new voice input
|
||||
|
||||
# Add to display history
|
||||
self.display_history.append({"type": "stt", "text": transcript, "final": msg.is_final})
|
||||
if len(self.display_history) > self.max_history:
|
||||
self.display_history.pop(0)
|
||||
|
||||
# Update display
|
||||
self._update_display()
|
||||
|
||||
# Auto-speak back for confirmation (optional)
|
||||
if self.auto_tts and msg.is_final:
|
||||
self._send_tts_confirmation(transcript)
|
||||
|
||||
self.get_logger().info(f"STT: {transcript}")
|
||||
|
||||
def _on_keyboard_input(self, msg: String) -> None:
|
||||
"""Handle touch keyboard input."""
|
||||
if not self.enabled or not self.keyboard_enabled:
|
||||
return
|
||||
|
||||
text = msg.data.strip()
|
||||
|
||||
if text == "[CLEAR]":
|
||||
# Clear keyboard buffer
|
||||
self.keyboard_buffer = ""
|
||||
elif text == "[SEND]":
|
||||
# Send keyboard text as TTS request
|
||||
if self.keyboard_buffer:
|
||||
self._send_tts_request(self.keyboard_buffer)
|
||||
self.display_history.append(
|
||||
{"type": "keyboard", "text": self.keyboard_buffer, "final": True}
|
||||
)
|
||||
if len(self.display_history) > self.max_history:
|
||||
self.display_history.pop(0)
|
||||
self.keyboard_buffer = ""
|
||||
elif text == "[BACKSPACE]":
|
||||
# Remove last character
|
||||
self.keyboard_buffer = self.keyboard_buffer[:-1]
|
||||
elif text == "[SPACE]":
|
||||
# Add space
|
||||
self.keyboard_buffer += " "
|
||||
else:
|
||||
# Regular character input
|
||||
self.keyboard_buffer += text
|
||||
|
||||
# Update display
|
||||
self._update_display()
|
||||
self.get_logger().info(f"Keyboard: {self.keyboard_buffer}")
|
||||
|
||||
def _update_display(self) -> None:
|
||||
"""Update text display with history and keyboard buffer."""
|
||||
display_data = {
|
||||
"history": self.display_history,
|
||||
"keyboard_buffer": self.keyboard_buffer,
|
||||
"mode": "accessibility",
|
||||
"timestamp": self.get_clock().now().to_msg(),
|
||||
}
|
||||
|
||||
msg = String(data=json.dumps(display_data))
|
||||
self.pub_display.publish(msg)
|
||||
|
||||
def _send_tts_confirmation(self, transcript: str) -> None:
|
||||
"""Send confirmation TTS."""
|
||||
msg = ConversationRequest()
|
||||
msg.text = f"You said: {transcript}"
|
||||
msg.language = "en"
|
||||
self.pub_tts.publish(msg)
|
||||
|
||||
def _send_tts_request(self, text: str) -> None:
|
||||
"""Send custom TTS request."""
|
||||
msg = ConversationRequest()
|
||||
msg.text = text
|
||||
msg.language = "en"
|
||||
self.pub_tts.publish(msg)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
node = AccessibilityModeNode()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,50 +0,0 @@
|
||||
"""Unit tests for accessibility mode node."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_accessibility_node_init():
|
||||
"""Test accessibility mode node initialization."""
|
||||
import rclpy
|
||||
|
||||
rclpy.init(allow_reuse=True)
|
||||
from saltybot_accessibility_mode.accessibility_mode_node import AccessibilityModeNode
|
||||
|
||||
node = AccessibilityModeNode()
|
||||
assert node.enabled is True
|
||||
assert node.keyboard_enabled is True
|
||||
assert len(node.display_history) == 0
|
||||
node.destroy_node()
|
||||
|
||||
|
||||
def test_keyboard_buffer():
|
||||
"""Test keyboard buffer management."""
|
||||
import rclpy
|
||||
|
||||
rclpy.init(allow_reuse=True)
|
||||
from saltybot_accessibility_mode.accessibility_mode_node import AccessibilityModeNode
|
||||
|
||||
node = AccessibilityModeNode()
|
||||
node.keyboard_buffer = "HELLO"
|
||||
assert node.keyboard_buffer == "HELLO"
|
||||
node.destroy_node()
|
||||
|
||||
|
||||
def test_history_limit():
|
||||
"""Test display history limit."""
|
||||
import rclpy
|
||||
|
||||
rclpy.init(allow_reuse=True)
|
||||
from saltybot_accessibility_mode.accessibility_mode_node import AccessibilityModeNode
|
||||
|
||||
node = AccessibilityModeNode()
|
||||
# Add more entries than max_history
|
||||
for i in range(15):
|
||||
node.display_history.append({"type": "test", "text": f"Entry {i}"})
|
||||
|
||||
assert len(node.display_history) <= node.max_history
|
||||
node.destroy_node()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Web UI server for accessibility mode on MageDok display.
|
||||
|
||||
Serves touch keyboard interface and displays STT transcripts.
|
||||
Communicates with accessibility_mode_node via ROS2.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from std_msgs.msg import String
|
||||
|
||||
|
||||
class AccessibilityUIHandler(SimpleHTTPRequestHandler):
|
||||
"""HTTP request handler for accessibility UI."""
|
||||
|
||||
def do_GET(self):
|
||||
"""Serve HTML/CSS/JS files."""
|
||||
if self.path == "/" or self.path == "/index.html":
|
||||
self.path = "/accessibility.html"
|
||||
return super().do_GET()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Suppress request logging."""
|
||||
pass
|
||||
|
||||
|
||||
class UIServerNode(Node):
|
||||
"""ROS2 node for accessibility UI server."""
|
||||
|
||||
def __init__(self, port=8000):
|
||||
super().__init__("accessibility_ui_server")
|
||||
|
||||
self.port = port
|
||||
self.display_data = {}
|
||||
|
||||
# Subscription to display updates
|
||||
self.create_subscription(
|
||||
String, "/accessibility/text_display", self._on_display_update, 10
|
||||
)
|
||||
|
||||
# Publisher for keyboard input
|
||||
self.pub_keyboard = self.create_publisher(String, "/accessibility/keyboard_input", 10)
|
||||
|
||||
# Start HTTP server in background thread
|
||||
self.server = HTTPServer(("0.0.0.0", port), AccessibilityUIHandler)
|
||||
self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.server_thread.start()
|
||||
|
||||
self.get_logger().info(f"Accessibility UI server listening on port {port}")
|
||||
|
||||
def _on_display_update(self, msg: String) -> None:
|
||||
"""Store display data for serving to web clients."""
|
||||
try:
|
||||
self.display_data = json.loads(msg.data)
|
||||
except json.JSONDecodeError:
|
||||
self.get_logger().error(f"Invalid display data JSON: {msg.data}")
|
||||
|
||||
def send_keyboard_input(self, text: str) -> None:
|
||||
"""Send keyboard input to accessibility mode node."""
|
||||
msg = String(data=text)
|
||||
self.pub_keyboard.publish(msg)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
node = UIServerNode(port=8080)
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node.server.shutdown()
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,5 +0,0 @@
|
||||
[develop]
|
||||
script_dir=$base/lib/saltybot_accessibility_mode
|
||||
|
||||
[install]
|
||||
install_scripts=$base/lib/saltybot_accessibility_mode
|
||||
@ -1,33 +0,0 @@
|
||||
from setuptools import setup
|
||||
|
||||
package_name = "saltybot_accessibility_mode"
|
||||
|
||||
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/accessibility_mode.launch.py"]),
|
||||
(f"share/{package_name}/config", ["config/accessibility_params.yaml"]),
|
||||
(f"share/{package_name}/ui", [
|
||||
"ui/accessibility.html",
|
||||
"ui/accessibility.css",
|
||||
"ui/accessibility.js",
|
||||
]),
|
||||
],
|
||||
install_requires=["setuptools"],
|
||||
zip_safe=True,
|
||||
maintainer="sl-controls",
|
||||
maintainer_email="sl-controls@saltylab.local",
|
||||
description="Deaf/accessibility mode with STT display, touch keyboard, and TTS output",
|
||||
license="MIT",
|
||||
tests_require=["pytest"],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"accessibility_mode_node = saltybot_accessibility_mode.accessibility_mode_node:main",
|
||||
"ui_server = saltybot_accessibility_mode.ui_server:main",
|
||||
],
|
||||
},
|
||||
)
|
||||
@ -1,279 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
height: 600px;
|
||||
background: white;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Transcript Area */
|
||||
.transcript-area {
|
||||
flex: 0 0 100px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 8px 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.transcript-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.transcript-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.transcript-content .placeholder {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Keyboard Area */
|
||||
.keyboard-area {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.keyboard-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.keyboard-buffer {
|
||||
min-height: 32px;
|
||||
background: white;
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
word-wrap: break-word;
|
||||
min-height: 40px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Keyboard Layout */
|
||||
.keyboard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.keyboard-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.key {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 4px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #333;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.key:hover {
|
||||
background: #e0e0e0;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.key:active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.key.num {
|
||||
background: #fff3cd;
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.key.num:active {
|
||||
background: #ffc107;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.key.sym {
|
||||
background: #e7f3ff;
|
||||
border-color: #2196F3;
|
||||
}
|
||||
|
||||
.key.sym:active {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.key.special {
|
||||
background: #f3e5f5;
|
||||
border-color: #9c27b0;
|
||||
}
|
||||
|
||||
.key.special:active {
|
||||
background: #9c27b0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.keyboard-row.action {
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.key.action {
|
||||
background: #f5f5f5;
|
||||
border-color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.key.action.primary {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border-color: #45a049;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.key.action.primary:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.key.action.primary:active {
|
||||
background: #3d8b40;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding: 8px 15px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
color: #4CAF50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.key {
|
||||
font-size: 11px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.keyboard-buffer {
|
||||
font-size: 16px;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.transcript-content::-webkit-scrollbar,
|
||||
.keyboard-area::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.transcript-content::-webkit-scrollbar-track,
|
||||
.keyboard-area::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.transcript-content::-webkit-scrollbar-thumb,
|
||||
.keyboard-area::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.transcript-content::-webkit-scrollbar-thumb:hover,
|
||||
.keyboard-area::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
@ -1,109 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SaltyBot Accessibility Mode</title>
|
||||
<link rel="stylesheet" href="accessibility.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<h1>🔊 Accessibility Mode</h1>
|
||||
<div class="status">
|
||||
<span id="mode-indicator" class="status-indicator">STT Active</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Transcript Display -->
|
||||
<div class="transcript-area">
|
||||
<div class="transcript-label">Transcript</div>
|
||||
<div id="transcript-display" class="transcript-content">
|
||||
<div class="placeholder">Waiting for speech...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Input Area -->
|
||||
<div class="keyboard-area">
|
||||
<div class="keyboard-label">Type or Speak</div>
|
||||
<div id="keyboard-buffer" class="keyboard-buffer"></div>
|
||||
|
||||
<!-- Soft Keyboard -->
|
||||
<div class="keyboard">
|
||||
<!-- Row 1 -->
|
||||
<div class="keyboard-row">
|
||||
<button class="key" data-char="Q">Q</button>
|
||||
<button class="key" data-char="W">W</button>
|
||||
<button class="key" data-char="E">E</button>
|
||||
<button class="key" data-char="R">R</button>
|
||||
<button class="key" data-char="T">T</button>
|
||||
<button class="key" data-char="Y">Y</button>
|
||||
<button class="key" data-char="U">U</button>
|
||||
<button class="key" data-char="I">I</button>
|
||||
<button class="key" data-char="O">O</button>
|
||||
<button class="key" data-char="P">P</button>
|
||||
</div>
|
||||
|
||||
<!-- Row 2 -->
|
||||
<div class="keyboard-row">
|
||||
<button class="key" data-char="A">A</button>
|
||||
<button class="key" data-char="S">S</button>
|
||||
<button class="key" data-char="D">D</button>
|
||||
<button class="key" data-char="F">F</button>
|
||||
<button class="key" data-char="G">G</button>
|
||||
<button class="key" data-char="H">H</button>
|
||||
<button class="key" data-char="J">J</button>
|
||||
<button class="key" data-char="K">K</button>
|
||||
<button class="key" data-char="L">L</button>
|
||||
<button class="key special" id="backspace-btn">⌫</button>
|
||||
</div>
|
||||
|
||||
<!-- Row 3 -->
|
||||
<div class="keyboard-row">
|
||||
<button class="key" data-char="Z">Z</button>
|
||||
<button class="key" data-char="X">X</button>
|
||||
<button class="key" data-char="C">C</button>
|
||||
<button class="key" data-char="V">V</button>
|
||||
<button class="key" data-char="B">B</button>
|
||||
<button class="key" data-char="N">N</button>
|
||||
<button class="key" data-char="M">M</button>
|
||||
<button class="key special" id="space-btn">SPACE</button>
|
||||
</div>
|
||||
|
||||
<!-- Row 4 - Numbers & Symbols -->
|
||||
<div class="keyboard-row">
|
||||
<button class="key num" data-char="1">1</button>
|
||||
<button class="key num" data-char="2">2</button>
|
||||
<button class="key num" data-char="3">3</button>
|
||||
<button class="key num" data-char="4">4</button>
|
||||
<button class="key num" data-char="5">5</button>
|
||||
<button class="key sym" data-char=".">.</button>
|
||||
<button class="key sym" data-char="?">?</button>
|
||||
<button class="key sym" data-char="!">!</button>
|
||||
</div>
|
||||
|
||||
<!-- Row 5 - Action buttons -->
|
||||
<div class="keyboard-row action">
|
||||
<button class="key action" id="clear-btn">CLEAR</button>
|
||||
<button class="key action primary" id="send-btn">SEND</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Footer -->
|
||||
<footer class="footer">
|
||||
<div class="status-item">
|
||||
<span class="label">STT:</span>
|
||||
<span id="stt-status" class="value">Ready</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">TTS:</span>
|
||||
<span id="tts-status" class="value">Ready</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="accessibility.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Accessibility Mode UI - Touch Keyboard & STT Display
|
||||
*/
|
||||
|
||||
class AccessibilityUI {
|
||||
constructor() {
|
||||
this.keyboardBuffer = '';
|
||||
this.displayData = { history: [], keyboard_buffer: '' };
|
||||
this.wsConnected = false;
|
||||
|
||||
this.initializeElements();
|
||||
this.attachEventListeners();
|
||||
this.startPolling();
|
||||
}
|
||||
|
||||
initializeElements() {
|
||||
this.keyboardBufferEl = document.getElementById('keyboard-buffer');
|
||||
this.transcriptDisplay = document.getElementById('transcript-display');
|
||||
this.transcriptHistoryEl = null;
|
||||
this.sttStatusEl = document.getElementById('stt-status');
|
||||
this.ttsStatusEl = document.getElementById('tts-status');
|
||||
this.modeIndicatorEl = document.getElementById('mode-indicator');
|
||||
|
||||
// Keyboard buttons
|
||||
this.keyButtons = document.querySelectorAll('.key[data-char]');
|
||||
this.backspaceBtn = document.getElementById('backspace-btn');
|
||||
this.spaceBtn = document.getElementById('space-btn');
|
||||
this.clearBtn = document.getElementById('clear-btn');
|
||||
this.sendBtn = document.getElementById('send-btn');
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
// Character keys
|
||||
this.keyButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => this.inputCharacter(btn.dataset.char));
|
||||
btn.addEventListener('touch start', (e) => e.preventDefault());
|
||||
});
|
||||
|
||||
// Special keys
|
||||
this.backspaceBtn.addEventListener('click', () => this.backspace());
|
||||
this.spaceBtn.addEventListener('click', () => this.inputCharacter(' '));
|
||||
this.clearBtn.addEventListener('click', () => this.clearBuffer());
|
||||
this.sendBtn.addEventListener('click', () => this.sendInput());
|
||||
|
||||
// Physical keyboard support
|
||||
document.addEventListener('keydown', (e) => this.handlePhysicalKey(e));
|
||||
}
|
||||
|
||||
inputCharacter(char) {
|
||||
this.keyboardBuffer += char.toUpperCase();
|
||||
this.updateDisplay();
|
||||
this.sendToROS('', false); // Update display on ROS
|
||||
}
|
||||
|
||||
backspace() {
|
||||
this.keyboardBuffer = this.keyboardBuffer.slice(0, -1);
|
||||
this.updateDisplay();
|
||||
this.sendToROS('', false);
|
||||
}
|
||||
|
||||
clearBuffer() {
|
||||
this.keyboardBuffer = '';
|
||||
this.updateDisplay();
|
||||
this.sendToROS('[CLEAR]', false);
|
||||
}
|
||||
|
||||
sendInput() {
|
||||
if (this.keyboardBuffer.trim()) {
|
||||
this.sendToROS('[SEND]', true);
|
||||
this.keyboardBuffer = '';
|
||||
this.updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
handlePhysicalKey(e) {
|
||||
if (e.target !== document.body) return;
|
||||
|
||||
const char = e.key.toUpperCase();
|
||||
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
this.backspace();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.sendInput();
|
||||
} else if (char.match(/^[A-Z0-9 .,!?]$/)) {
|
||||
this.inputCharacter(char);
|
||||
}
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
this.keyboardBufferEl.textContent = this.keyboardBuffer || '(empty)';
|
||||
this.renderTranscriptHistory();
|
||||
}
|
||||
|
||||
renderTranscriptHistory() {
|
||||
if (!this.displayData.history) return;
|
||||
|
||||
let html = '';
|
||||
this.displayData.history.forEach(entry => {
|
||||
const cls = entry.type === 'stt' ? 'transcript-stt' : 'transcript-keyboard';
|
||||
const icon = entry.type === 'stt' ? '🎤' : '⌨️';
|
||||
const text = entry.text || '';
|
||||
html += `<div class="transcript-entry ${cls}">${icon} ${this.escapeHtml(text)}</div>`;
|
||||
});
|
||||
|
||||
if (!html) {
|
||||
html = '<div class="placeholder">Waiting for speech...</div>';
|
||||
}
|
||||
|
||||
this.transcriptDisplay.innerHTML = html;
|
||||
// Auto-scroll to bottom
|
||||
this.transcriptDisplay.scrollTop = this.transcriptDisplay.scrollHeight;
|
||||
}
|
||||
|
||||
sendToROS(command, isFinal) {
|
||||
// This will be called when we have a WebSocket connection to the ROS2 bridge
|
||||
const data = {
|
||||
command: command,
|
||||
buffer: this.keyboardBuffer,
|
||||
timestamp: Date.now(),
|
||||
final: isFinal
|
||||
};
|
||||
|
||||
console.log('Sending to ROS:', data);
|
||||
|
||||
// Send via fetch API to UI server
|
||||
fetch('/api/keyboard', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).catch(err => console.error('ROS send error:', err));
|
||||
}
|
||||
|
||||
startPolling() {
|
||||
// Poll for display updates from ROS2
|
||||
setInterval(() => this.pollDisplayUpdate(), 500);
|
||||
}
|
||||
|
||||
pollDisplayUpdate() {
|
||||
fetch('/api/display')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.history) {
|
||||
this.displayData = data;
|
||||
this.renderTranscriptHistory();
|
||||
this.updateStatusIndicators();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Display poll error:', err));
|
||||
}
|
||||
|
||||
updateStatusIndicators() {
|
||||
// Update STT/TTS status based on display data
|
||||
if (this.displayData.history && this.displayData.history.length > 0) {
|
||||
const lastEntry = this.displayData.history[this.displayData.history.length - 1];
|
||||
if (lastEntry.type === 'stt') {
|
||||
this.sttStatusEl.textContent = lastEntry.final ? 'Complete' : 'Listening...';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.accessibilityUI = new AccessibilityUI();
|
||||
});
|
||||
@ -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",
|
||||
],
|
||||
},
|
||||
)
|
||||
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