Compare commits

..

No commits in common. "73742fe726f6254aabee8d6d5635ac6067855548" and "9c2f830c8ee50cd7846e315dff59e026a4f68d9f" have entirely different histories.

126 changed files with 87 additions and 4437 deletions

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

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.

View File

@ -1 +1 @@
8700a44a6597bcade0f371945c539630ba0e78b1 ee8efb31f6b185f16e4d385971f1a0e3291fe5fd

View File

@ -32,18 +32,4 @@ uint32_t battery_read_mv(void);
*/ */
uint8_t battery_estimate_pct(uint32_t voltage_mv); 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 0100 (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 */ #endif /* BATTERY_H */

View File

@ -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 50100 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., 50100 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 (0100, 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 */

View File

@ -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). * 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) * 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 0100 % (255 = unknown) * remaining_pct: state-of-charge 0100 % (255 = unknown)
* *
* Frame: [0xC8][12][0x08][v16_hi][v16_lo][c16_hi][c16_lo][cap24×3][rem][CRC] * Frame: [0xC8][12][0x08][v16_hi][v16_lo][c16_hi][c16_lo][cap24×3][rem][CRC]
* voltage unit: 100 mV (12600 mV 126) * 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); uint8_t remaining_pct);
/* /*

View File

@ -31,13 +31,9 @@ typedef struct {
uint16_t current_angle_deg[SERVO_COUNT]; /* Current angle in degrees (0-180) */ 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 target_angle_deg[SERVO_COUNT]; /* Target angle in degrees */
uint16_t pulse_us[SERVO_COUNT]; /* Pulse width in microseconds (500-2500) */ uint16_t pulse_us[SERVO_COUNT]; /* Pulse width in microseconds (500-2500) */
uint32_t sweep_start_ms;
/* Sweep state (per-servo) */ uint32_t sweep_duration_ms;
uint32_t sweep_start_ms[SERVO_COUNT]; bool is_sweeping;
uint32_t sweep_duration_ms[SERVO_COUNT];
uint16_t sweep_start_deg[SERVO_COUNT];
uint16_t sweep_end_deg[SERVO_COUNT];
bool is_sweeping[SERVO_COUNT];
} ServoState; } ServoState;
/* /*

View File

@ -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"

View File

@ -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")},
],
),
]
)

View File

@ -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>

View File

@ -1 +0,0 @@
"""SaltyBot Accessibility Mode package."""

View File

@ -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()

View File

@ -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"])

View File

@ -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()

View File

@ -1,5 +0,0 @@
[develop]
script_dir=$base/lib/saltybot_accessibility_mode
[install]
install_scripts=$base/lib/saltybot_accessibility_mode

View File

@ -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",
],
},
)

View File

@ -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;
}

View File

@ -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>

View File

@ -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();
});

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")],
),
]
)

View 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>

View File

@ -1 +0,0 @@
"""SaltyBot LIDAR obstacle avoidance package."""

View File

@ -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()

View File

@ -1,5 +0,0 @@
[develop]
script_dir=$base/lib/saltybot_lidar_avoidance
[install]
install_scripts=$base/lib/saltybot_lidar_avoidance

View File

@ -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