Compare commits
No commits in common. "main" and "sl-jetson/command-protocol" have entirely different histories.
main
...
sl-jetson/
@ -1,308 +0,0 @@
|
||||
# Issue #469: Terrain Classification Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
SaltyBot currently has good sensor infrastructure (IMU, cameras, RealSense) and a robust velocity control system with the `VelocityRamp` class. However, it lacks terrain awareness for surface type detection and speed adaptation. This feature will enable:
|
||||
|
||||
1. **Surface detection** via IMU vibration analysis and camera texture analysis
|
||||
2. **Automatic speed adaptation** based on terrain type and roughness
|
||||
3. **Terrain logging** for mapping and future learning
|
||||
4. **Improved robot safety** by reducing speed on rough/unstable terrain
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The implementation follows the existing ROS2 patterns:
|
||||
|
||||
```
|
||||
IMU/Camera Data
|
||||
↓
|
||||
[terrain_classifier_node] ← New node
|
||||
↓
|
||||
/saltybot/terrain_state (TerrainState.msg)
|
||||
↓
|
||||
[terrain_speed_adapter_node] ← New node
|
||||
↓
|
||||
Adjusted /cmd_vel_terrain → existing cmd_vel_bridge
|
||||
↓
|
||||
Speed-adapted robot motion
|
||||
|
||||
Parallel: [terrain_mapper_node] logs data for mapping
|
||||
```
|
||||
|
||||
## Implementation Components
|
||||
|
||||
### 1. **Message Definition: TerrainState.msg**
|
||||
**File to create:** `jetson/ros2_ws/src/saltybot_social_msgs/msg/TerrainState.msg`
|
||||
|
||||
Fields:
|
||||
- `std_msgs/Header header` — timestamp/frame_id
|
||||
- `uint8 terrain_type` — enum (0=unknown, 1=pavement, 2=grass, 3=gravel, 4=sand, 5=indoor)
|
||||
- `float32 roughness` — 0.0=smooth, 1.0=very rough
|
||||
- `float32 confidence` — 0.0-1.0 classification confidence
|
||||
- `float32 recommended_speed_ratio` — 0.1-1.0 (fraction of max speed)
|
||||
- `string source` — "imu_vibration" or "camera_texture" or "fused"
|
||||
|
||||
**Update files:**
|
||||
- `jetson/ros2_ws/src/saltybot_social_msgs/CMakeLists.txt` — add TerrainState.msg to rosidl_generate_interfaces()
|
||||
- `jetson/ros2_ws/src/saltybot_social_msgs/package.xml` — no changes needed (std_msgs already a dependency)
|
||||
|
||||
### 2. **Terrain Classifier Node**
|
||||
**File to create:** `jetson/ros2_ws/src/saltybot_bringup/saltybot_bringup/terrain_classifier_node.py`
|
||||
|
||||
**Purpose:** Analyze IMU and camera data to classify terrain type and estimate roughness.
|
||||
|
||||
**Subscribes to:**
|
||||
- `/camera/imu` (sensor_msgs/Imu) — RealSense IMU at 200 Hz
|
||||
- `/camera/color/image_raw` (sensor_msgs/Image) — camera RGB at 15 Hz
|
||||
|
||||
**Publishes:**
|
||||
- `/saltybot/terrain_state` (TerrainState.msg) — at 5 Hz
|
||||
|
||||
**Key functions:**
|
||||
- `_analyze_imu_vibration()` — FFT analysis on accel data (window: 200 samples = 1 sec)
|
||||
- Compute power spectral density in 0-50 Hz band
|
||||
- Extract features: peak frequency, energy distribution, RMS acceleration
|
||||
- Roughness = normalized RMS of high-freq components (>10 Hz)
|
||||
|
||||
- `_analyze_camera_texture()` — CNN-based texture analysis
|
||||
- Uses MobileNetV2 pre-trained on ImageNet as feature extractor
|
||||
- Extracts high-level texture/surface features from camera image
|
||||
- Lightweight model (~3.5M parameters, ~50-100ms inference on Jetson)
|
||||
- Outputs feature vector fed to classification layer
|
||||
|
||||
- `_classify_terrain()` — decision logic
|
||||
- Simple rule-based classifier (can be upgraded to CNN)
|
||||
- Input: [imu_roughness, camera_texture_variance, accel_magnitude]
|
||||
- Decision tree or thresholds to classify into 5 types
|
||||
- Output: terrain_type, roughness, confidence
|
||||
|
||||
**Node Parameters:**
|
||||
- `imu_window_size` (int, default 200) — samples for FFT window
|
||||
- `publish_rate_hz` (float, default 5.0)
|
||||
- `roughness_threshold` (float, default 0.3) — FFT roughness threshold
|
||||
- `terrain_timeout_s` (float, default 5.0) — how long to keep previous estimate if no new data
|
||||
|
||||
### 3. **Speed Adapter Node**
|
||||
**File to create:** `jetson/ros2_ws/src/saltybot_bringup/saltybot_bringup/terrain_speed_adapter_node.py`
|
||||
|
||||
**Purpose:** Adapt cmd_vel speed based on terrain state and integration with velocity ramp.
|
||||
|
||||
**Subscribes to:**
|
||||
- `/cmd_vel` (geometry_msgs/Twist) — raw velocity commands
|
||||
- `/saltybot/terrain_state` (TerrainState.msg) — terrain classification
|
||||
|
||||
**Publishes:**
|
||||
- `/cmd_vel_terrain` (geometry_msgs/Twist) — terrain-adapted velocity
|
||||
|
||||
**Logic:**
|
||||
- Extract target linear velocity from cmd_vel
|
||||
- Apply terrain speed ratio: `adapted_speed = target_speed × recommended_speed_ratio`
|
||||
- Preserve angular velocity (steering not affected by terrain)
|
||||
- Publish adapted command
|
||||
|
||||
**Node Parameters:**
|
||||
- `enable_terrain_adaptation` (bool, default true)
|
||||
- `min_speed_ratio` (float, default 0.1) — never go below 10% of requested speed
|
||||
- `debug_logging` (bool, default false)
|
||||
|
||||
**Note:** This is a lightweight adapter. The existing `velocity_ramp_node` handles acceleration/deceleration smoothing independently.
|
||||
|
||||
### 4. **Terrain Mapper Node** (Logging/Mapping)
|
||||
**File to create:** `jetson/ros2_ws/src/saltybot_bringup/saltybot_bringup/terrain_mapper_node.py`
|
||||
|
||||
**Purpose:** Log terrain detections with robot pose for future mapping.
|
||||
|
||||
**Subscribes to:**
|
||||
- `/saltybot/terrain_state` (TerrainState.msg)
|
||||
- `/odom` (nav_msgs/Odometry) — robot pose
|
||||
|
||||
**Publishes:**
|
||||
- `/saltybot/terrain_log` (std_msgs/String) — CSV formatted log messages (optional, mainly for logging)
|
||||
|
||||
**Logic:**
|
||||
- Store terrain observations: (timestamp, pose_x, pose_y, terrain_type, roughness, confidence)
|
||||
- Log to file: `~/.ros/terrain_map_<timestamp>.csv`
|
||||
- Resample to 1 Hz to avoid spam
|
||||
|
||||
**Node Parameters:**
|
||||
- `log_dir` (string, default "~/.ros/")
|
||||
- `resample_rate_hz` (float, default 1.0)
|
||||
|
||||
### 5. **Launch Configuration**
|
||||
**File to update:** `jetson/ros2_ws/src/saltybot_bringup/launch/full_stack.launch.py`
|
||||
|
||||
Add terrain nodes:
|
||||
```python
|
||||
terrain_classifier_node = Node(
|
||||
package='saltybot_bringup',
|
||||
executable='terrain_classifier',
|
||||
name='terrain_classifier',
|
||||
parameters=[{
|
||||
'imu_window_size': 200,
|
||||
'publish_rate_hz': 5.0,
|
||||
}],
|
||||
remappings=[
|
||||
('/imu_in', '/camera/imu'),
|
||||
('/camera_in', '/camera/color/image_raw'),
|
||||
],
|
||||
)
|
||||
|
||||
terrain_speed_adapter_node = Node(
|
||||
package='saltybot_bringup',
|
||||
executable='terrain_speed_adapter',
|
||||
name='terrain_speed_adapter',
|
||||
parameters=[{
|
||||
'enable_terrain_adaptation': True,
|
||||
'min_speed_ratio': 0.1,
|
||||
}],
|
||||
remappings=[
|
||||
('/cmd_vel_in', '/cmd_vel'),
|
||||
('/cmd_vel_out', '/cmd_vel_terrain'),
|
||||
],
|
||||
)
|
||||
|
||||
terrain_mapper_node = Node(
|
||||
package='saltybot_bringup',
|
||||
executable='terrain_mapper',
|
||||
name='terrain_mapper',
|
||||
)
|
||||
```
|
||||
|
||||
**Update setup.py entry points:**
|
||||
```python
|
||||
'terrain_classifier = saltybot_bringup.terrain_classifier_node:main'
|
||||
'terrain_speed_adapter = saltybot_bringup.terrain_speed_adapter_node:main'
|
||||
'terrain_mapper = saltybot_bringup.terrain_mapper_node:main'
|
||||
```
|
||||
|
||||
### 6. **Integration with Existing Stack**
|
||||
- The existing velocity ramp (`velocity_ramp_node.py`) processes `/cmd_vel_smooth` or `/cmd_vel`
|
||||
- Optionally, update cmd_vel_bridge to use `/cmd_vel_terrain` if available, else fall back to `/cmd_vel`
|
||||
- Terrain classification runs independently at 5 Hz (much slower than velocity ramping at 50 Hz)
|
||||
|
||||
### 7. **Future CNN Enhancement**
|
||||
The current implementation uses rule-based classification with IMU FFT and camera edge detection. A future enhancement could add a lightweight CNN for texture classification (e.g., MobileNet) by:
|
||||
1. Creating a `terrain_classifier_cnn.py` with TensorFlow/ONNX model
|
||||
2. Replacing decision logic in `terrain_classifier_node.py` with CNN inference
|
||||
3. Maintaining same message interface
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
1. ✅ **Phase 1: Message Definition**
|
||||
- Create `TerrainState.msg` in saltybot_social_msgs
|
||||
- Update `CMakeLists.txt`
|
||||
|
||||
2. ✅ **Phase 2: Terrain Classifier Node**
|
||||
- Implement `terrain_classifier_node.py` with IMU FFT analysis
|
||||
- Implement camera texture analysis
|
||||
- Decision logic for classification
|
||||
|
||||
3. ✅ **Phase 3: Speed Adapter Node**
|
||||
- Implement `terrain_speed_adapter_node.py`
|
||||
- Velocity command adaptation
|
||||
|
||||
4. ✅ **Phase 4: Terrain Mapper Node**
|
||||
- Implement `terrain_mapper_node.py` for logging
|
||||
|
||||
5. ✅ **Phase 5: Integration**
|
||||
- Update `full_stack.launch.py` with new nodes
|
||||
- Update `setup.py` with entry points
|
||||
- Test integration
|
||||
|
||||
## Testing & Verification
|
||||
|
||||
**Unit Tests:**
|
||||
- Test IMU FFT feature extraction with synthetic vibration data
|
||||
- Test terrain classification decision logic
|
||||
- Test speed ratio application
|
||||
- Test CSV logging format
|
||||
|
||||
**Integration Tests:**
|
||||
- Run full stack with simulated IMU/camera data
|
||||
- Verify terrain messages published at 5 Hz
|
||||
- Verify cmd_vel_terrain adapts speeds correctly
|
||||
- Check terrain log file is created and properly formatted
|
||||
|
||||
**Manual Testing:**
|
||||
- Drive robot on different surfaces
|
||||
- Verify terrain detection changes appropriately
|
||||
- Verify speed adaptation is smooth (no jerks from ramping)
|
||||
- Check terrain log CSV has correct format
|
||||
|
||||
## Critical Files Summary
|
||||
|
||||
**To Create:**
|
||||
- `jetson/ros2_ws/src/saltybot_social_msgs/msg/TerrainState.msg`
|
||||
- `jetson/ros2_ws/src/saltybot_bringup/saltybot_bringup/terrain_classifier_node.py`
|
||||
- `jetson/ros2_ws/src/saltybot_bringup/saltybot_bringup/terrain_speed_adapter_node.py`
|
||||
- `jetson/ros2_ws/src/saltybot_bringup/saltybot_bringup/terrain_mapper_node.py`
|
||||
|
||||
**To Modify:**
|
||||
- `jetson/ros2_ws/src/saltybot_social_msgs/CMakeLists.txt` (add TerrainState.msg)
|
||||
- `jetson/ros2_ws/src/saltybot_bringup/launch/full_stack.launch.py` (add nodes)
|
||||
- `jetson/ros2_ws/src/saltybot_bringup/setup.py` (add entry points)
|
||||
|
||||
**Key Dependencies:**
|
||||
- `numpy` — FFT analysis (already available in saltybot)
|
||||
- `scipy.signal` — Butterworth filter (optional, for smoothing)
|
||||
- `cv2` (OpenCV) — image processing (already available)
|
||||
- `tensorflow` or `tf-lite` — MobileNetV2 pre-trained model for texture CNN
|
||||
- `rclpy` — ROS2 Python client
|
||||
|
||||
**CNN Model Details:**
|
||||
- Model: MobileNetV2 pre-trained on ImageNet
|
||||
- Input: 224×224 RGB image (downsampled from camera)
|
||||
- Output: 1280-dim feature vector from last conv layer before classification
|
||||
- Strategy: Use pre-trained features directly (transfer learning) for quick MVP, no fine-tuning needed initially
|
||||
- Alternative: Pre-trained weights can be fine-tuned on terrain image dataset in future iterations
|
||||
- Inference: ~50-100ms on Jetson Xavier (acceptable at 5 Hz publish rate)
|
||||
|
||||
## Terrain Classification Logic (IMU + CNN Fusion)
|
||||
|
||||
**Features extracted:**
|
||||
1. `imu_roughness` = normalized RMS of high-freq (>10 Hz) accel components (0-1)
|
||||
- Computed from FFT power spectral density in 10-50 Hz band
|
||||
- Reflects mechanical vibration from surface contact
|
||||
|
||||
2. `cnn_texture_features` = 1280-dim feature vector from MobileNetV2
|
||||
- Pre-trained features capture texture, edge, and surface characteristics
|
||||
- Reduced to 2-3 principal components via PCA or simple aggregation
|
||||
|
||||
3. `accel_magnitude` = RMS of total acceleration (m/s²)
|
||||
- Helps distinguish stationary (9.81 m/s²) vs. moving
|
||||
|
||||
**Classification approach (Version 1):**
|
||||
- Simple decision tree with IMU-dominant logic + CNN support:
|
||||
```
|
||||
if imu_roughness < 0.2 and accel_magnitude < 9.8:
|
||||
terrain = PAVEMENT (confidence boosted if CNN agrees)
|
||||
elif imu_roughness < 0.35 and cnn_grainy_score < 0.4:
|
||||
terrain = GRASS
|
||||
elif imu_roughness > 0.45 and cnn_granular_score > 0.5:
|
||||
terrain = GRAVEL
|
||||
elif cnn_sand_texture_score > 0.6 and imu_roughness > 0.3:
|
||||
terrain = SAND
|
||||
else:
|
||||
terrain = INDOOR
|
||||
```
|
||||
|
||||
- Confidence: weighted combination of IMU and CNN agreement
|
||||
- Roughness metric: `0.0 = smooth, 1.0 = very rough` derived from IMU FFT energy ratio
|
||||
|
||||
**Speed recommendations:**
|
||||
- Pavement: 1.0 (full speed)
|
||||
- Grass: 0.8 (20% slower)
|
||||
- Gravel: 0.5 (50% slower)
|
||||
- Sand: 0.4 (60% slower)
|
||||
- Indoor: 0.7 (30% slower by default)
|
||||
|
||||
**Future improvement:** Replace decision tree with trained classifier (Random Forest, SVM, or small Dense net) on labeled terrain dataset once collected.
|
||||
|
||||
---
|
||||
|
||||
This plan follows SaltyBot's established patterns:
|
||||
- Pure Python libraries for core logic (_terrain_analysis.py)
|
||||
- ROS2 node wrappers for integration
|
||||
- Parameter-based configuration in YAML
|
||||
- Message-based pub/sub architecture
|
||||
- Integration with existing velocity control pipeline
|
||||
@ -1,162 +0,0 @@
|
||||
# .gitea/workflows/ota-release.yml
|
||||
# Gitea Actions — ESP32 OTA firmware build & release (bd-9kod)
|
||||
#
|
||||
# Triggers on signed release tags:
|
||||
# esp32-balance/vX.Y.Z → builds esp32s3/balance/ (ESP32-S3 Balance board)
|
||||
# esp32-io/vX.Y.Z → builds esp32s3-io/ (ESP32-S3 IO board)
|
||||
#
|
||||
# Uses the official espressif/idf Docker image for reproducible builds.
|
||||
# Attaches <app>_<version>.bin + <app>_<version>.sha256 to the Gitea release.
|
||||
# The ESP32 Balance OTA system fetches the .bin from the release asset URL.
|
||||
|
||||
name: OTA release — build & attach firmware
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "esp32-balance/v*"
|
||||
- "esp32-io/v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build ${{ github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.2.2
|
||||
options: --user root
|
||||
|
||||
steps:
|
||||
# ── 1. Checkout ───────────────────────────────────────────────────────────
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# ── 2. Resolve build target from tag ─────────────────────────────────────
|
||||
# Tag format: esp32-balance/v1.2.3 or esp32-io/v1.2.3
|
||||
- name: Resolve project from tag
|
||||
id: proj
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
case "$TAG" in
|
||||
esp32-balance/*)
|
||||
DIR="esp32s3/balance"
|
||||
APP="esp32s3_balance"
|
||||
;;
|
||||
esp32-io/*)
|
||||
DIR="esp32s3-io"
|
||||
APP="esp32s3_io"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unrecognised tag prefix: ${TAG}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
VERSION="${TAG#*/}"
|
||||
echo "dir=${DIR}" >> "$GITHUB_OUTPUT"
|
||||
echo "app=${APP}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Build: ${APP} ${VERSION} from ${DIR}"
|
||||
|
||||
# ── 3. Build with ESP-IDF ─────────────────────────────────────────────────
|
||||
- name: Build firmware (idf.py build)
|
||||
shell: bash
|
||||
run: |
|
||||
. "${IDF_PATH}/export.sh"
|
||||
cd "${{ steps.proj.outputs.dir }}"
|
||||
idf.py build
|
||||
|
||||
# ── 4. Collect binary & generate checksum ────────────────────────────────
|
||||
- name: Collect artifacts
|
||||
id: art
|
||||
shell: bash
|
||||
run: |
|
||||
APP="${{ steps.proj.outputs.app }}"
|
||||
VER="${{ steps.proj.outputs.version }}"
|
||||
BIN_SRC="${{ steps.proj.outputs.dir }}/build/${APP}.bin"
|
||||
BIN_OUT="${APP}_${VER}.bin"
|
||||
SHA_OUT="${APP}_${VER}.sha256"
|
||||
|
||||
cp "$BIN_SRC" "$BIN_OUT"
|
||||
sha256sum "$BIN_OUT" > "$SHA_OUT"
|
||||
|
||||
echo "bin=${BIN_OUT}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=${SHA_OUT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Binary: ${BIN_OUT} ($(wc -c < "$BIN_OUT") bytes)"
|
||||
echo "Checksum: $(cat "$SHA_OUT")"
|
||||
|
||||
# ── 5. Archive artifacts in CI workspace ─────────────────────────────────
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: firmware-${{ steps.proj.outputs.app }}-${{ steps.proj.outputs.version }}
|
||||
path: |
|
||||
${{ steps.art.outputs.bin }}
|
||||
${{ steps.art.outputs.sha }}
|
||||
|
||||
# ── 6. Create Gitea release (if needed) & upload assets ──────────────────
|
||||
# Uses GITHUB_TOKEN (auto-provided, contents:write from permissions block).
|
||||
# URL-encodes the tag to handle the slash in esp32-balance/vX.Y.Z.
|
||||
- name: Publish assets to Gitea release
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_URL: https://gitea.vayrette.com
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ steps.proj.outputs.tag }}
|
||||
BIN: ${{ steps.art.outputs.bin }}
|
||||
SHA: ${{ steps.art.outputs.sha }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# URL-encode the tag (slash in esp32-balance/vX.Y.Z must be escaped)
|
||||
TAG_ENC=$(python3 -c "
|
||||
import urllib.parse, sys
|
||||
print(urllib.parse.quote(sys.argv[1], safe=''))
|
||||
" "$TAG")
|
||||
|
||||
# Try to fetch an existing release for this tag
|
||||
RELEASE=$(curl -sf \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG_ENC}") || true
|
||||
|
||||
# If no release yet, create it
|
||||
if [ -z "$RELEASE" ]; then
|
||||
echo "Creating release for tag: ${TAG}"
|
||||
RELEASE=$(curl -sf \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(python3 -c "
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
'tag_name': sys.argv[1],
|
||||
'name': sys.argv[1],
|
||||
'draft': False,
|
||||
'prerelease': False,
|
||||
}))
|
||||
" "$TAG")" \
|
||||
"${API}/releases")
|
||||
fi
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "
|
||||
import sys, json; print(json.load(sys.stdin)['id'])
|
||||
")
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
|
||||
# Upload binary and checksum
|
||||
for FILE in "$BIN" "$SHA"; do
|
||||
FNAME=$(basename "$FILE")
|
||||
echo "Uploading: ${FNAME}"
|
||||
curl -sf \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-F "attachment=@${FILE}" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${FNAME}"
|
||||
done
|
||||
|
||||
echo "Published: ${BIN} + ${SHA} → release ${TAG}"
|
||||
@ -1,239 +0,0 @@
|
||||
# .gitea/workflows/social-tests-ci.yml
|
||||
# Gitea Actions CI pipeline for social-bot integration tests (Issue #108)
|
||||
#
|
||||
# Triggers: push or PR to main, and any branch matching sl-jetson/*
|
||||
#
|
||||
# Jobs:
|
||||
# 1. lint — flake8 + pep257 on saltybot_social_tests
|
||||
# 2. core-tests — launch + topic + services + GPU-budget + shutdown (no GPU)
|
||||
# 3. latency — end-to-end latency profiling (GPU runner, optional)
|
||||
|
||||
name: social-bot integration tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "sl-jetson/**"
|
||||
paths:
|
||||
- "jetson/ros2_ws/src/saltybot_social/**"
|
||||
- "jetson/ros2_ws/src/saltybot_social_tests/**"
|
||||
- "jetson/ros2_ws/src/saltybot_social_msgs/**"
|
||||
- "jetson/ros2_ws/src/saltybot_social_nav/**"
|
||||
- "jetson/ros2_ws/src/saltybot_social_face/**"
|
||||
- "jetson/ros2_ws/src/saltybot_social_tracking/**"
|
||||
- ".gitea/workflows/social-tests-ci.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "jetson/ros2_ws/src/saltybot_social*/**"
|
||||
- ".gitea/workflows/social-tests-ci.yml"
|
||||
|
||||
env:
|
||||
ROS_DOMAIN_ID: "108"
|
||||
SOCIAL_TEST_FULL: "0"
|
||||
SOCIAL_STACK_RUNNING: "1"
|
||||
|
||||
jobs:
|
||||
# ── 1. Lint ──────────────────────────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint (flake8 + pep257)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ros:humble-ros-base-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install lint tools
|
||||
run: |
|
||||
pip3 install --no-cache-dir flake8 pydocstyle
|
||||
|
||||
- name: flake8 — saltybot_social_tests
|
||||
run: |
|
||||
flake8 \
|
||||
jetson/ros2_ws/src/saltybot_social_tests/saltybot_social_tests/ \
|
||||
jetson/ros2_ws/src/saltybot_social_tests/test/ \
|
||||
--max-line-length=100 \
|
||||
--ignore=E501,W503 \
|
||||
--exclude=__pycache__
|
||||
|
||||
- name: pep257 — saltybot_social_tests
|
||||
run: |
|
||||
pydocstyle \
|
||||
jetson/ros2_ws/src/saltybot_social_tests/saltybot_social_tests/ \
|
||||
jetson/ros2_ws/src/saltybot_social_tests/test/ \
|
||||
--add-ignore=D100,D104,D205,D400 \
|
||||
--match='(?!test_helpers|mock_sensors).*\.py' || true
|
||||
|
||||
# ── 2. Core integration tests (no GPU) ───────────────────────────────────────
|
||||
core-tests:
|
||||
name: Core integration tests (mock sensors, no GPU)
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ros:humble-ros-base-jammy
|
||||
env:
|
||||
ROS_DOMAIN_ID: "108"
|
||||
SOCIAL_TEST_FULL: "0"
|
||||
SOCIAL_STACK_RUNNING: "1"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get update -q && apt-get install -y --no-install-recommends \
|
||||
ros-humble-launch-testing \
|
||||
ros-humble-launch-testing-ros \
|
||||
ros-humble-vision-msgs \
|
||||
ros-humble-tf2-ros \
|
||||
ros-humble-tf2-geometry-msgs \
|
||||
ros-humble-cv-bridge \
|
||||
ros-humble-nav-msgs \
|
||||
ros-humble-std-srvs \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
pip3 install --no-cache-dir "pytest>=7.0" "pytest-timeout>=2.1"
|
||||
|
||||
- name: Build workspace
|
||||
run: |
|
||||
. /opt/ros/humble/setup.bash
|
||||
cd /
|
||||
# Symlink source into a temp workspace to avoid long paths
|
||||
mkdir -p /ros2_ws/src
|
||||
for pkg in saltybot_social_msgs saltybot_social saltybot_social_face \
|
||||
saltybot_social_nav saltybot_social_enrollment \
|
||||
saltybot_social_tracking saltybot_social_personality \
|
||||
saltybot_social_tests; do
|
||||
ln -sfn \
|
||||
"$GITHUB_WORKSPACE/jetson/ros2_ws/src/$pkg" \
|
||||
"/ros2_ws/src/$pkg"
|
||||
done
|
||||
cd /ros2_ws
|
||||
colcon build \
|
||||
--packages-select \
|
||||
saltybot_social_msgs \
|
||||
saltybot_social \
|
||||
saltybot_social_face \
|
||||
saltybot_social_nav \
|
||||
saltybot_social_enrollment \
|
||||
saltybot_social_tracking \
|
||||
saltybot_social_personality \
|
||||
saltybot_social_tests \
|
||||
--symlink-install \
|
||||
--event-handlers console_cohesion+
|
||||
|
||||
- name: Launch test stack (background)
|
||||
run: |
|
||||
. /opt/ros/humble/setup.bash
|
||||
. /ros2_ws/install/setup.bash
|
||||
ros2 daemon start || true
|
||||
ros2 launch saltybot_social_tests social_test.launch.py \
|
||||
enable_speech:=false enable_llm:=false enable_tts:=false \
|
||||
&
|
||||
echo "LAUNCH_PID=$!" >> "$GITHUB_ENV"
|
||||
# Wait for stack to come up
|
||||
sleep 15
|
||||
|
||||
- name: Run core tests
|
||||
run: |
|
||||
. /opt/ros/humble/setup.bash
|
||||
. /ros2_ws/install/setup.bash
|
||||
pytest \
|
||||
"$GITHUB_WORKSPACE/jetson/ros2_ws/src/saltybot_social_tests/test/" \
|
||||
-v \
|
||||
--timeout=120 \
|
||||
--tb=short \
|
||||
-m "not gpu_required and not full_stack and not slow" \
|
||||
--junit-xml=test-results-core.xml \
|
||||
-k "not test_latency_sla and not test_shutdown"
|
||||
|
||||
- name: Shutdown test stack
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${LAUNCH_PID:-}" ] && kill -SIGTERM "$LAUNCH_PID" 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
- name: Run shutdown tests
|
||||
run: |
|
||||
. /opt/ros/humble/setup.bash
|
||||
. /ros2_ws/install/setup.bash
|
||||
pytest \
|
||||
"$GITHUB_WORKSPACE/jetson/ros2_ws/src/saltybot_social_tests/test/test_shutdown.py" \
|
||||
-v --timeout=60 --tb=short \
|
||||
--junit-xml=test-results-shutdown.xml
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-core
|
||||
path: test-results-*.xml
|
||||
|
||||
# ── 3. GPU latency tests (Orin runner, optional) ──────────────────────────────
|
||||
latency-gpu:
|
||||
name: Latency profiling (GPU, Orin)
|
||||
needs: core-tests
|
||||
runs-on: [self-hosted, orin, gpu]
|
||||
if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'run-gpu-tests')
|
||||
env:
|
||||
ROS_DOMAIN_ID: "108"
|
||||
SOCIAL_TEST_FULL: "1"
|
||||
SOCIAL_TEST_SPEECH: "0"
|
||||
SOCIAL_STACK_RUNNING: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build workspace (Orin)
|
||||
run: |
|
||||
. /opt/ros/humble/setup.bash
|
||||
cd /ros2_ws
|
||||
colcon build \
|
||||
--packages-select saltybot_social_tests saltybot_social \
|
||||
--symlink-install
|
||||
|
||||
- name: Launch full stack (with LLM, no speech/TTS)
|
||||
run: |
|
||||
. /opt/ros/humble/setup.bash
|
||||
. /ros2_ws/install/setup.bash
|
||||
SOCIAL_TEST_FULL=1 \
|
||||
ros2 launch saltybot_social_tests social_test.launch.py \
|
||||
enable_speech:=false enable_tts:=false \
|
||||
&
|
||||
echo "LAUNCH_PID=$!" >> "$GITHUB_ENV"
|
||||
sleep 20
|
||||
|
||||
- name: GPU memory check
|
||||
run: |
|
||||
. /opt/ros/humble/setup.bash
|
||||
. /ros2_ws/install/setup.bash
|
||||
SOCIAL_STACK_RUNNING=1 \
|
||||
GPU_BASELINE_MB=$(nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits) \
|
||||
pytest \
|
||||
"$GITHUB_WORKSPACE/jetson/ros2_ws/src/saltybot_social_tests/test/test_gpu_memory.py" \
|
||||
-v --timeout=120 --tb=short \
|
||||
--junit-xml=test-results-gpu.xml
|
||||
|
||||
- name: Latency profiling
|
||||
run: |
|
||||
. /opt/ros/humble/setup.bash
|
||||
. /ros2_ws/install/setup.bash
|
||||
SOCIAL_TEST_FULL=1 SOCIAL_STACK_RUNNING=1 \
|
||||
pytest \
|
||||
"$GITHUB_WORKSPACE/jetson/ros2_ws/src/saltybot_social_tests/test/test_latency.py" \
|
||||
-v --timeout=300 --tb=short \
|
||||
--junit-xml=test-results-latency.xml
|
||||
|
||||
- name: Shutdown
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${LAUNCH_PID:-}" ] && kill -SIGTERM "$LAUNCH_PID" 2>/dev/null || true
|
||||
sleep 5
|
||||
|
||||
- name: Upload GPU results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-gpu
|
||||
path: test-results-*.xml
|
||||
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.
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.
BIN
.pio/build/f722/src/hoverboard.o
Normal file
BIN
.pio/build/f722/src/hoverboard.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.
@ -1 +1 @@
|
||||
ffc01fb580c81760bdda9a672fe1212be4578e3e
|
||||
ee8efb31f6b185f16e4d385971f1a0e3291fe5fd
|
||||
@ -1,148 +0,0 @@
|
||||
# Autonomous Arming (Issue #512)
|
||||
|
||||
## Overview
|
||||
The robot can now be armed and operated autonomously from the Jetson without requiring an RC transmitter. The RC receiver (ELRS) is now optional and serves as an override/kill-switch rather than a requirement.
|
||||
|
||||
## Arming Sources
|
||||
|
||||
### Jetson Autonomous Arming
|
||||
- Command: `A\n` (single byte 'A' followed by newline)
|
||||
<<<<<<< HEAD
|
||||
- Sent via USB CDC to the ESP32 BALANCE firmware
|
||||
=======
|
||||
- Sent via USB Serial (CH343) to the ESP32-S3 firmware
|
||||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||||
- Robot arms after ARMING_HOLD_MS (~500ms) safety hold period
|
||||
- Works even when RC is not connected or not armed
|
||||
|
||||
### RC Arming (Optional Override)
|
||||
- Command: CH5 switch on ELRS transmitter
|
||||
- When RC is connected and armed, robot can be armed via RC
|
||||
- RC and Jetson can both request arming independently
|
||||
|
||||
## Safety Features
|
||||
|
||||
### Maintained from Original Design
|
||||
1. **Arming Hold Timer** — 500ms hold before motors enable (prevents accidental arming)
|
||||
2. **Tilt Safety** — Robot must be within ±10° level to arm
|
||||
3. **IMU Calibration** — Gyro must be calibrated before arming
|
||||
4. **Remote E-Stop Override** — `safety_remote_estop_active()` blocks all arming
|
||||
|
||||
### New for Autonomous Operation
|
||||
1. **RC Kill Switch** (CH5 OFF when RC connected)
|
||||
- Triggers emergency stop (motor cutoff) instead of disarm
|
||||
- Allows Jetson-armed robots to remain armed when RC disconnects
|
||||
- Maintains safety of kill switch for emergency situations
|
||||
|
||||
2. **RC Failsafe**
|
||||
- If RC signal is lost after being established, robot disarms (500ms timeout)
|
||||
- Prevents runaway if RC connection drops during flight
|
||||
- USB-only mode (no RC ever connected) is unaffected
|
||||
|
||||
3. **Jetson Timeout** (200ms heartbeat)
|
||||
- Jetson must send heartbeat (H command) every 500ms
|
||||
- Prevents autonomous runaway if Jetson crashes/loses connection
|
||||
- Handled by `jetson_cmd_is_active()` checks
|
||||
|
||||
## Command Protocol
|
||||
|
||||
<<<<<<< HEAD
|
||||
### From Jetson to ESP32 BALANCE (USB CDC)
|
||||
=======
|
||||
### From Jetson to ESP32-S3 (USB Serial (CH343))
|
||||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||||
```
|
||||
A — Request arm (triggers safety hold, then motors enable)
|
||||
D — Request disarm (immediate motor stop)
|
||||
E — Emergency stop (immediate motor cutoff, latched)
|
||||
Z — Clear emergency stop latch
|
||||
H — Heartbeat (refresh timeout timer, every 500ms)
|
||||
C<spd>,<str> — Drive command: speed, steer (also refreshes heartbeat)
|
||||
```
|
||||
|
||||
<<<<<<< HEAD
|
||||
### From ESP32 BALANCE to Jetson (USB CDC)
|
||||
=======
|
||||
### From ESP32-S3 to Jetson (USB Serial (CH343))
|
||||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||||
Motor commands are gated by `bal.state == BALANCE_ARMED`:
|
||||
- When ARMED: Motor commands sent every 20ms (50 Hz)
|
||||
- When DISARMED: Zero sent every 20ms (prevents ESC timeout)
|
||||
|
||||
## Arming State Machine
|
||||
|
||||
```
|
||||
DISARMED
|
||||
↓
|
||||
+-- Jetson sends 'A' OR RC CH5 rises (with conditions met)
|
||||
↓
|
||||
safety_arm_start() called
|
||||
(arm hold timer starts)
|
||||
↓
|
||||
Wait ARMING_HOLD_MS
|
||||
↓
|
||||
safety_arm_ready() returns true
|
||||
↓
|
||||
balance_arm() called
|
||||
ARMED ← (motors now respond to commands)
|
||||
|
||||
ARMED
|
||||
↓
|
||||
+-- Jetson sends 'D' → balance_disarm()
|
||||
+-- RC CH5 falls AND RC still alive → balance_disarm()
|
||||
+-- RC signal lost (failsafe) → balance_disarm()
|
||||
+-- Tilt fault detected → immediate motor stop
|
||||
+-- RC kill switch (CH5 OFF) → emergency stop (not disarm)
|
||||
```
|
||||
|
||||
## RC Override Priority
|
||||
|
||||
When RC is connected and active:
|
||||
- **Steer channel**: Blended with Jetson via `mode_manager` (per active mode)
|
||||
- **Kill switch**: RC CH5 OFF triggers emergency stop (overrides everything)
|
||||
- **Failsafe**: RC signal loss triggers disarm (prevents runaway)
|
||||
|
||||
When RC is disconnected:
|
||||
- Robot operates under Jetson commands alone
|
||||
- Emergency stop remains available via 'E' command from Jetson
|
||||
- No automatic mode change; mission continues autonomously
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Jetson can arm robot without RC (send 'A' command)
|
||||
- [ ] Robot motors respond to Jetson drive commands when armed
|
||||
- [ ] Robot disarms on Jetson 'D' command
|
||||
- [ ] RC kill switch (CH5 OFF) triggers emergency stop without disarming
|
||||
- [ ] Robot can be re-armed after RC kill switch via Jetson 'A' command
|
||||
- [ ] RC failsafe still works (500ms signal loss = disarm)
|
||||
- [ ] Jetson heartbeat timeout works (500ms without H/C = motors zero)
|
||||
- [ ] Tilt fault still triggers immediate stop
|
||||
- [ ] IMU calibration required before arm
|
||||
- [ ] Arming hold timer (500ms) enforced
|
||||
|
||||
## Migration from RC-Only
|
||||
|
||||
### Old Workflow (ELRS-Required)
|
||||
1. Power on robot
|
||||
2. Arm via RC CH5
|
||||
3. Send speed/steer commands via RC
|
||||
4. Disarm via RC CH5
|
||||
|
||||
### New Workflow (Autonomous)
|
||||
1. Power on robot
|
||||
2. Send heartbeat 'H' every 500ms from Jetson
|
||||
3. When ready to move, send 'A' command (wait 500ms)
|
||||
4. Send drive commands 'C<spd>,<str>' every ≤200ms
|
||||
5. When done, send 'D' command to disarm
|
||||
|
||||
### New Workflow (RC + Autonomous Mixed)
|
||||
1. Power on robot, bring up RC
|
||||
2. Jetson sends heartbeat 'H'
|
||||
3. Arm via RC CH5 OR Jetson 'A' (both valid)
|
||||
4. Control via RC sticks OR Jetson drive commands (blended)
|
||||
5. Emergency kill: RC CH5 OFF (emergency stop) OR Jetson 'E'
|
||||
6. Disarm: RC CH5 OFF then ON, OR Jetson 'D'
|
||||
|
||||
## References
|
||||
- Issue #512: Remove ELRS arm requirement
|
||||
- Files: `/src/main.c` (arming logic), `/lib/USB_CDC/src/usbd_cdc_if.c` (CDC commands)
|
||||
31
CLAUDE.md
31
CLAUDE.md
@ -1,36 +1,17 @@
|
||||
# SaltyLab Firmware — Agent Playbook
|
||||
|
||||
## Project
|
||||
<<<<<<< HEAD
|
||||
**SAUL-TEE** — 4-wheel wagon (870×510×550 mm, 23 kg).
|
||||
Two ESP32-S3 boards + Jetson Orin via CAN. Full spec: `docs/SAUL-TEE-SYSTEM-REFERENCE.md`
|
||||
|
||||
| Board | Role |
|
||||
|-------|------|
|
||||
| **ESP32-S3 BALANCE** | QMI8658 IMU, PID balance, CAN→VESC (L:68 / R:56), GC9A01 LCD (Waveshare Touch LCD 1.28) |
|
||||
| **ESP32-S3 IO** | TBS Crossfire RC, ELRS failover, BTS7960 motors, NFC/baro/ToF, WS2812 |
|
||||
| **Jetson Orin** | AI/SLAM, CANable2 USB→CAN, cmds 0x300–0x303, telemetry 0x400–0x401 |
|
||||
|
||||
> **Legacy:** `src/` and `include/` = archived STM32 HAL — do not extend. New firmware in `esp32/`.
|
||||
=======
|
||||
Self-balancing two-wheeled robot: ESP32-S3 ESP32-S3 BALANCE, hoverboard hub motors, Jetson Orin Nano Super for AI/SLAM.
|
||||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||||
Self-balancing two-wheeled robot: STM32F722 flight controller, hoverboard hub motors, Jetson Nano for AI/SLAM.
|
||||
|
||||
## Team
|
||||
| Agent | Role | Focus |
|
||||
|-------|------|-------|
|
||||
<<<<<<< HEAD
|
||||
| **sl-firmware** | Embedded Firmware Lead | ESP32-S3, ESP-IDF, QMI8658, CAN/UART protocol, BTS7960 |
|
||||
| **sl-controls** | Control Systems Engineer | PID tuning, IMU fusion, balance loop, safety |
|
||||
| **sl-perception** | Perception / SLAM Engineer | Jetson Orin, RealSense D435i, RPLIDAR, ROS2, Nav2 |
|
||||
=======
|
||||
| **sl-firmware** | Embedded Firmware Lead | ESP-IDF, USB Serial (CH343) debugging, SPI/UART, PlatformIO, DFU bootloader |
|
||||
| **sl-firmware** | Embedded Firmware Lead | STM32 HAL, USB CDC debugging, SPI/UART, PlatformIO, DFU bootloader |
|
||||
| **sl-controls** | Control Systems Engineer | PID tuning, IMU sensor fusion, real-time control loops, safety systems |
|
||||
| **sl-perception** | Perception / SLAM Engineer | Jetson Orin Nano Super, RealSense D435i, RPLIDAR, ROS2, Nav2 |
|
||||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||||
| **sl-perception** | Perception / SLAM Engineer | Jetson Nano, RealSense D435i, RPLIDAR, ROS2, Nav2 |
|
||||
|
||||
## Status
|
||||
USB Serial (CH343) TX bug resolved (PR #10 — DCache MPU non-cacheable region + IWDG ordering fix).
|
||||
USB CDC TX bug resolved (PR #10 — DCache MPU non-cacheable region + IWDG ordering fix).
|
||||
|
||||
## Repo Structure
|
||||
- `projects/saltybot/SALTYLAB.md` — Design doc
|
||||
@ -48,11 +29,11 @@ USB Serial (CH343) TX bug resolved (PR #10 — DCache MPU non-cacheable region +
|
||||
| `saltyrover-dev` | Integration — rover variant |
|
||||
| `saltytank` | Stable — tracked tank variant |
|
||||
| `saltytank-dev` | Integration — tank variant |
|
||||
| `main` | Shared code only (IMU drivers, USB Serial (CH343), balance core, safety) |
|
||||
| `main` | Shared code only (IMU drivers, USB CDC, balance core, safety) |
|
||||
|
||||
### Rules
|
||||
- Agents branch FROM `<variant>-dev` and PR back TO `<variant>-dev`
|
||||
- Shared/infrastructure code (IMU drivers, USB Serial (CH343), balance core, safety) goes in `main`
|
||||
- Shared/infrastructure code (IMU drivers, USB CDC, balance core, safety) goes in `main`
|
||||
- Variant-specific code (motor topology, kinematics, config) goes in variant branches
|
||||
- Stable branches get promoted from `-dev` after review and hardware testing
|
||||
- **Current SaltyLab team** works against `saltylab-dev`
|
||||
|
||||
52
TEAM.md
52
TEAM.md
@ -1,22 +1,12 @@
|
||||
# SaltyLab — Ideal Team
|
||||
|
||||
## Project
|
||||
<<<<<<< HEAD
|
||||
**SAUL-TEE** — 4-wheel wagon (870×510×550 mm, 23 kg).
|
||||
Two ESP32-S3 boards (BALANCE + IO) + Jetson Orin. See `docs/SAUL-TEE-SYSTEM-REFERENCE.md`.
|
||||
|
||||
## Current Status
|
||||
- **Hardware:** ESP32-S3 BALANCE (Waveshare Touch LCD 1.28, CH343 USB) + ESP32-S3 IO (bare devkit, JTAG USB)
|
||||
- **Firmware:** ESP-IDF/PlatformIO target; legacy `src/` STM32 HAL archived
|
||||
- **Comms:** UART 460800 baud inter-board; CANable2 USB→CAN for Orin; CAN 500 kbps to VESCs (L:68 / R:56)
|
||||
=======
|
||||
Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hoverboard hub motors, and eventually a Jetson Orin Nano Super for AI/SLAM.
|
||||
Self-balancing two-wheeled robot using a drone flight controller (STM32F722), hoverboard hub motors, and eventually a Jetson Nano for AI/SLAM.
|
||||
|
||||
## Current Status
|
||||
- **Hardware:** Assembled — FC, motors, ESC, IMU, battery, RC all on hand
|
||||
- **Firmware:** Balance PID + hoverboard ESC protocol written, but blocked by USB Serial (CH343) bug
|
||||
- **Blocker:** USB Serial (CH343) TX stops working when peripheral inits (SPI/UART/GPIO) are added alongside USB on ESP32-S3 — see `legacy/stm32/USB_CDC_BUG.md` for historical context
|
||||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||||
- **Firmware:** Balance PID + hoverboard ESC protocol written, but blocked by USB CDC bug
|
||||
- **Blocker:** USB CDC TX stops working when peripheral inits (SPI/UART/GPIO) are added alongside USB OTG FS — see `USB_CDC_BUG.md`
|
||||
|
||||
---
|
||||
|
||||
@ -24,30 +14,18 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
|
||||
|
||||
### 1. Embedded Firmware Engineer (Lead)
|
||||
**Must-have:**
|
||||
<<<<<<< HEAD
|
||||
- Deep ESP32 (Arduino/ESP-IDF) or STM32 HAL experience
|
||||
- Deep STM32 HAL experience (F7 series specifically)
|
||||
- USB OTG FS / CDC ACM debugging (TxState, endpoint management, DMA conflicts)
|
||||
- SPI + UART + USB coexistence on ESP32
|
||||
- PlatformIO or bare-metal ESP32 toolchain
|
||||
- SPI + UART + USB coexistence on STM32
|
||||
- PlatformIO or bare-metal STM32 toolchain
|
||||
- DFU bootloader implementation
|
||||
=======
|
||||
- Deep ESP-IDF experience (ESP32-S3 specifically)
|
||||
- USB Serial (CH343) / UART debugging on ESP32-S3
|
||||
- SPI + UART + USB coexistence on ESP32-S3
|
||||
- ESP-IDF / Arduino-ESP32 toolchain
|
||||
- OTA firmware update implementation
|
||||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||||
|
||||
**Nice-to-have:**
|
||||
- ESP32-S3 peripheral coexistence (SPI + UART + USB)
|
||||
- Betaflight/iNav/ArduPilot codebase familiarity
|
||||
- PID control loop tuning for balance robots
|
||||
- FOC motor control (hoverboard ESC protocol)
|
||||
|
||||
<<<<<<< HEAD
|
||||
**Why:** The immediate blocker is a USB peripheral conflict. Need someone who's debugged STM32 USB issues before — ESP32 firmware for the balance loop and I/O needs to be written from scratch.
|
||||
=======
|
||||
**Why:** The immediate blocker is a USB peripheral conflict on ESP32-S3. Need someone who's debugged ESP32-S3 USB Serial (CH343) issues before — this is not a software logic bug, it's a hardware peripheral interaction issue.
|
||||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||||
**Why:** The immediate blocker is a USB peripheral conflict. Need someone who's debugged STM32 USB issues before — this is not a software logic bug, it's a hardware peripheral interaction issue.
|
||||
|
||||
### 2. Control Systems / Robotics Engineer
|
||||
**Must-have:**
|
||||
@ -65,7 +43,7 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
|
||||
|
||||
### 3. Perception / SLAM Engineer (Phase 2)
|
||||
**Must-have:**
|
||||
- Jetson Orin Nano Super / NVIDIA Jetson platform
|
||||
- Jetson Nano / NVIDIA Jetson platform
|
||||
- Intel RealSense D435i depth camera
|
||||
- RPLIDAR integration
|
||||
- SLAM (ORB-SLAM3, RTAB-Map, or similar)
|
||||
@ -76,23 +54,19 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
|
||||
- Obstacle avoidance
|
||||
- Nav2 stack
|
||||
|
||||
**Why:** Phase 2 goal is autonomous navigation. Jetson Orin Nano Super with RealSense + RPLIDAR for indoor mapping and person following.
|
||||
**Why:** Phase 2 goal is autonomous navigation. Jetson Nano with RealSense + RPLIDAR for indoor mapping and person following.
|
||||
|
||||
---
|
||||
|
||||
## Hardware Reference
|
||||
| Component | Details |
|
||||
|-----------|---------|
|
||||
<<<<<<< HEAD
|
||||
| FC | ESP32 BALANCE (ESP32RET6, MPU6000) |
|
||||
=======
|
||||
| FC | ESP32-S3 BALANCE (ESP32-S3RET6, QMI8658) |
|
||||
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
|
||||
| FC | MAMBA F722S (STM32F722RET6, MPU6000) |
|
||||
| Motors | 2x 8" pneumatic hoverboard hub motors |
|
||||
| ESC | Hoverboard ESC (EFeru FOC firmware) |
|
||||
| Battery | 36V pack |
|
||||
| RC | BetaFPV ELRS 2.4GHz TX + RX |
|
||||
| AI Brain | Jetson Orin Nano Super + Noctua fan |
|
||||
| AI Brain | Jetson Nano + Noctua fan |
|
||||
| Depth | Intel RealSense D435i |
|
||||
| LIDAR | RPLIDAR A1M8 |
|
||||
| Spare IMUs | BNO055, MPU6050 |
|
||||
@ -100,4 +74,4 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
|
||||
## Repo
|
||||
- Gitea: https://gitea.vayrette.com/seb/saltylab-firmware
|
||||
- Design doc: `projects/saltybot/SALTYLAB.md`
|
||||
- Bug doc: `legacy/stm32/USB_CDC_BUG.md` (archived — STM32 era)
|
||||
- Bug doc: `USB_CDC_BUG.md`
|
||||
|
||||
44
USB_CDC_BUG.md
Normal file
44
USB_CDC_BUG.md
Normal file
@ -0,0 +1,44 @@
|
||||
# USB CDC TX Bug — 2026-02-28
|
||||
|
||||
## Problem
|
||||
Balance firmware produces no USB CDC output. Minimal "hello" test firmware works fine.
|
||||
|
||||
## What Works
|
||||
- **Test firmware** (just sends `{"hello":N}` at 10Hz after 3s delay): **DATA FLOWS**
|
||||
- USB enumeration works in both cases (port appears as `/dev/cu.usbmodemSALTY0011`)
|
||||
- DFU reboot via RTC backup register works (Betaflight-proven pattern)
|
||||
|
||||
## What Doesn't Work
|
||||
- **Balance firmware**: port opens, no data ever arrives
|
||||
- Tried: removing init transmit, 3s boot delay, TxState recovery, DTR detection, streaming flags
|
||||
- None of it helps
|
||||
|
||||
## Key Difference Between Working & Broken
|
||||
- **Working test**: main.c only includes USB CDC headers, HAL, string, stdio
|
||||
- **Balance firmware**: includes icm42688.h, bmp280.h, balance.h, hoverboard.h, config.h, status.h
|
||||
- Balance firmware inits SPI1 (IMU), USART2 (hoverboard), GPIO (LEDs, buzzer)
|
||||
- Likely culprit: **peripheral init (SPI/UART/GPIO) is interfering with USB OTG FS**
|
||||
|
||||
## Suspected Root Cause
|
||||
One of the additional peripheral inits (SPI1 for IMU, USART2 for hoverboard ESC, or GPIO for status LEDs) is likely conflicting with the USB OTG FS peripheral — either a clock conflict, GPIO pin conflict, or interrupt priority issue.
|
||||
|
||||
## Hardware
|
||||
- MAMBA F722S FC (STM32F722RET6)
|
||||
- Betaflight target: DIAT-MAMBAF722_2022B
|
||||
- IMU: MPU6000 on SPI1 (PA4/PA5/PA6/PA7)
|
||||
- USB: OTG FS (PA11/PA12)
|
||||
- Hoverboard ESC: USART2 (PA2/PA3)
|
||||
- LEDs: PC14, PC15
|
||||
- Buzzer: PB2
|
||||
|
||||
## Files
|
||||
- PlatformIO project: `~/Projects/saltylab-firmware/` on mbpm4 (192.168.87.40)
|
||||
- Working test: was in src/main.c (replaced with balance code)
|
||||
- Balance main.c backup: src/main.c.bak
|
||||
- CDC implementation: lib/USB_CDC/src/usbd_cdc_if.c
|
||||
|
||||
## To Debug
|
||||
1. Add peripherals one at a time to the test firmware to find which one breaks CDC TX
|
||||
2. Check for GPIO pin conflicts with USB OTG FS (PA11/PA12)
|
||||
3. Check interrupt priorities — USB OTG FS IRQ might be getting starved
|
||||
4. Check if DCache (disabled via SCB_DisableDCache) is needed for USB DMA
|
||||
@ -1,46 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 34
|
||||
namespace 'com.saltylab.uwbtag'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.saltylab.uwbtag"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- BLE permissions (API 31+) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
|
||||
<!-- Legacy BLE (API < 31) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="UWB Tag Config"
|
||||
android:theme="@style/Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".UwbTagBleActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@ -1,444 +0,0 @@
|
||||
package com.saltylab.uwbtag
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.*
|
||||
import android.bluetooth.le.*
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.gson.Gson
|
||||
import com.saltylab.uwbtag.databinding.ActivityUwbTagBleBinding
|
||||
import java.util.UUID
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GATT service / characteristic UUIDs
|
||||
// ---------------------------------------------------------------------------
|
||||
private val SERVICE_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef0")
|
||||
private val CHAR_CONFIG_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef1") // read/write JSON config
|
||||
private val CHAR_STATUS_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef2") // notify: tag status string
|
||||
private val CHAR_BATT_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef3") // notify: battery %
|
||||
private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// BLE scan timeout
|
||||
private const val SCAN_TIMEOUT_MS = 15_000L
|
||||
|
||||
// Permissions request code
|
||||
private const val REQ_PERMISSIONS = 1001
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data model
|
||||
// ---------------------------------------------------------------------------
|
||||
data class TagConfig(
|
||||
val tag_name: String = "UWB_TAG_0001",
|
||||
val sleep_timeout_s: Int = 300,
|
||||
val display_brightness: Int = 50,
|
||||
val uwb_channel: Int = 9,
|
||||
val ranging_interval_ms: Int = 100,
|
||||
val battery_report: Boolean = true
|
||||
)
|
||||
|
||||
data class ScannedDevice(
|
||||
val name: String,
|
||||
val address: String,
|
||||
var rssi: Int,
|
||||
val device: BluetoothDevice
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RecyclerView adapter for scanned devices
|
||||
// ---------------------------------------------------------------------------
|
||||
class DeviceAdapter(
|
||||
private val onConnect: (ScannedDevice) -> Unit
|
||||
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
|
||||
|
||||
private val items = mutableListOf<ScannedDevice>()
|
||||
|
||||
fun update(device: ScannedDevice) {
|
||||
val idx = items.indexOfFirst { it.address == device.address }
|
||||
if (idx >= 0) {
|
||||
items[idx] = device
|
||||
notifyItemChanged(idx)
|
||||
} else {
|
||||
items.add(device)
|
||||
notifyItemInserted(items.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
items.clear()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_ble_device, parent, false)
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(items[position])
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
private val tvName = view.findViewById<TextView>(R.id.tvDeviceName)
|
||||
private val tvAddress = view.findViewById<TextView>(R.id.tvDeviceAddress)
|
||||
private val tvRssi = view.findViewById<TextView>(R.id.tvRssi)
|
||||
private val btnConn = view.findViewById<Button>(R.id.btnConnect)
|
||||
|
||||
fun bind(item: ScannedDevice) {
|
||||
tvName.text = item.name
|
||||
tvAddress.text = item.address
|
||||
tvRssi.text = "${item.rssi} dBm"
|
||||
btnConn.setOnClickListener { onConnect(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activity
|
||||
// ---------------------------------------------------------------------------
|
||||
@SuppressLint("MissingPermission") // permissions checked at runtime before any BLE call
|
||||
class UwbTagBleActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityUwbTagBleBinding
|
||||
private val gson = Gson()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// BLE
|
||||
private val btManager by lazy { getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager }
|
||||
private val btAdapter by lazy { btManager.adapter }
|
||||
private var bleScanner: BluetoothLeScanner? = null
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var configChar: BluetoothGattCharacteristic? = null
|
||||
private var statusChar: BluetoothGattCharacteristic? = null
|
||||
private var battChar: BluetoothGattCharacteristic? = null
|
||||
private var isScanning = false
|
||||
|
||||
private val deviceAdapter = DeviceAdapter(onConnect = ::connectToDevice)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityUwbTagBleBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.toolbar)
|
||||
|
||||
binding.rvDevices.layoutManager = LinearLayoutManager(this)
|
||||
binding.rvDevices.adapter = deviceAdapter
|
||||
|
||||
binding.btnScan.setOnClickListener {
|
||||
if (isScanning) stopScan() else startScanIfPermitted()
|
||||
}
|
||||
binding.btnDisconnect.setOnClickListener { disconnectGatt() }
|
||||
binding.btnReadConfig.setOnClickListener { readConfig() }
|
||||
binding.btnWriteConfig.setOnClickListener { writeConfig() }
|
||||
|
||||
requestBlePermissions()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
stopScan()
|
||||
disconnectGatt()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permissions
|
||||
// ---------------------------------------------------------------------------
|
||||
private fun requestBlePermissions() {
|
||||
val needed = mutableListOf<String>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (!hasPermission(Manifest.permission.BLUETOOTH_SCAN))
|
||||
needed += Manifest.permission.BLUETOOTH_SCAN
|
||||
if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT))
|
||||
needed += Manifest.permission.BLUETOOTH_CONNECT
|
||||
} else {
|
||||
if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION))
|
||||
needed += Manifest.permission.ACCESS_FINE_LOCATION
|
||||
}
|
||||
if (needed.isNotEmpty()) {
|
||||
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQ_PERMISSIONS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasPermission(perm: String) =
|
||||
ContextCompat.checkSelfPermission(this, perm) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == REQ_PERMISSIONS &&
|
||||
grantResults.any { it != PackageManager.PERMISSION_GRANTED }) {
|
||||
toast("BLE permissions required")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BLE Scan
|
||||
// ---------------------------------------------------------------------------
|
||||
private fun startScanIfPermitted() {
|
||||
if (btAdapter?.isEnabled != true) { toast("Bluetooth is off"); return }
|
||||
bleScanner = btAdapter.bluetoothLeScanner
|
||||
val filter = ScanFilter.Builder()
|
||||
.setDeviceNamePattern("UWB_TAG_.*".toRegex().toPattern())
|
||||
.build()
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
deviceAdapter.clear()
|
||||
bleScanner?.startScan(listOf(filter), settings, scanCallback)
|
||||
isScanning = true
|
||||
binding.btnScan.text = "Stop"
|
||||
binding.tvScanStatus.text = "Scanning…"
|
||||
mainHandler.postDelayed({ stopScan() }, SCAN_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private fun stopScan() {
|
||||
bleScanner?.stopScan(scanCallback)
|
||||
isScanning = false
|
||||
binding.btnScan.text = "Scan"
|
||||
binding.tvScanStatus.text = "Scan stopped"
|
||||
}
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val name = result.device.name ?: return
|
||||
if (!name.startsWith("UWB_TAG_")) return
|
||||
val dev = ScannedDevice(
|
||||
name = name,
|
||||
address = result.device.address,
|
||||
rssi = result.rssi,
|
||||
device = result.device
|
||||
)
|
||||
mainHandler.post { deviceAdapter.update(dev) }
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
mainHandler.post {
|
||||
binding.tvScanStatus.text = "Scan failed (code $errorCode)"
|
||||
isScanning = false
|
||||
binding.btnScan.text = "Scan"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GATT Connection
|
||||
// ---------------------------------------------------------------------------
|
||||
private fun connectToDevice(scanned: ScannedDevice) {
|
||||
stopScan()
|
||||
binding.tvScanStatus.text = "Connecting to ${scanned.name}…"
|
||||
gatt = scanned.device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
}
|
||||
|
||||
private fun disconnectGatt() {
|
||||
gatt?.disconnect()
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
configChar = null
|
||||
statusChar = null
|
||||
battChar = null
|
||||
mainHandler.post {
|
||||
binding.cardConfig.visibility = View.GONE
|
||||
binding.tvScanStatus.text = "Disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
mainHandler.post { binding.tvScanStatus.text = "Connected — discovering services…" }
|
||||
g.discoverServices()
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
mainHandler.post {
|
||||
binding.cardConfig.visibility = View.GONE
|
||||
binding.tvScanStatus.text = "Disconnected"
|
||||
toast("Tag disconnected")
|
||||
}
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
mainHandler.post { toast("Service discovery failed") }
|
||||
return
|
||||
}
|
||||
val service = g.getService(SERVICE_UUID)
|
||||
if (service == null) {
|
||||
mainHandler.post { toast("UWB config service not found on tag") }
|
||||
return
|
||||
}
|
||||
configChar = service.getCharacteristic(CHAR_CONFIG_UUID)
|
||||
statusChar = service.getCharacteristic(CHAR_STATUS_UUID)
|
||||
battChar = service.getCharacteristic(CHAR_BATT_UUID)
|
||||
|
||||
// Subscribe to status notifications
|
||||
statusChar?.let { enableNotifications(g, it) }
|
||||
battChar?.let { enableNotifications(g, it) }
|
||||
|
||||
// Initial config read
|
||||
configChar?.let { g.readCharacteristic(it) }
|
||||
|
||||
mainHandler.post {
|
||||
val devName = g.device.name ?: g.device.address
|
||||
binding.tvConnectedName.text = "Connected: $devName"
|
||||
binding.cardConfig.visibility = View.VISIBLE
|
||||
binding.tvScanStatus.text = "Connected to $devName"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int
|
||||
) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) return
|
||||
if (characteristic.uuid == CHAR_CONFIG_UUID) {
|
||||
val json = characteristic.value?.toString(Charsets.UTF_8) ?: return
|
||||
val cfg = runCatching { gson.fromJson(json, TagConfig::class.java) }.getOrNull() ?: return
|
||||
mainHandler.post { populateFields(cfg) }
|
||||
}
|
||||
}
|
||||
|
||||
// API 33+ callback
|
||||
override fun onCharacteristicRead(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
status: Int
|
||||
) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) return
|
||||
if (characteristic.uuid == CHAR_CONFIG_UUID) {
|
||||
val json = value.toString(Charsets.UTF_8)
|
||||
val cfg = runCatching { gson.fromJson(json, TagConfig::class.java) }.getOrNull() ?: return
|
||||
mainHandler.post { populateFields(cfg) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int
|
||||
) {
|
||||
val msg = if (status == BluetoothGatt.GATT_SUCCESS) "Config written" else "Write failed ($status)"
|
||||
mainHandler.post { toast(msg) }
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic
|
||||
) {
|
||||
val value = characteristic.value ?: return
|
||||
handleNotification(characteristic.uuid, value)
|
||||
}
|
||||
|
||||
// API 33+ callback
|
||||
override fun onCharacteristicChanged(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray
|
||||
) {
|
||||
handleNotification(characteristic.uuid, value)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notification helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
private fun enableNotifications(g: BluetoothGatt, char: BluetoothGattCharacteristic) {
|
||||
g.setCharacteristicNotification(char, true)
|
||||
val descriptor = char.getDescriptor(CCCD_UUID) ?: return
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(descriptor)
|
||||
}
|
||||
|
||||
private fun handleNotification(uuid: UUID, value: ByteArray) {
|
||||
val text = value.toString(Charsets.UTF_8)
|
||||
mainHandler.post {
|
||||
when (uuid) {
|
||||
CHAR_STATUS_UUID -> binding.tvTagStatus.text = "Status: $text"
|
||||
CHAR_BATT_UUID -> {
|
||||
val pct = text.toIntOrNull() ?: return@post
|
||||
binding.tvTagStatus.text = binding.tvTagStatus.text.toString()
|
||||
.replace(Regex("\\| Batt:.*"), "")
|
||||
.trimEnd() + " | Batt: $pct%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config read / write
|
||||
// ---------------------------------------------------------------------------
|
||||
private fun readConfig() {
|
||||
val g = gatt ?: run { toast("Not connected"); return }
|
||||
val c = configChar ?: run { toast("Config char not found"); return }
|
||||
g.readCharacteristic(c)
|
||||
}
|
||||
|
||||
private fun writeConfig() {
|
||||
val g = gatt ?: run { toast("Not connected"); return }
|
||||
val c = configChar ?: run { toast("Config char not found"); return }
|
||||
val cfg = buildConfigFromFields()
|
||||
val json = gson.toJson(cfg)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeCharacteristic(c, json.toByteArray(Charsets.UTF_8),
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
c.value = json.toByteArray(Charsets.UTF_8)
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeCharacteristic(c)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
private fun populateFields(cfg: TagConfig) {
|
||||
binding.etTagName.setText(cfg.tag_name)
|
||||
binding.etSleepTimeout.setText(cfg.sleep_timeout_s.toString())
|
||||
binding.etBrightness.setText(cfg.display_brightness.toString())
|
||||
binding.etUwbChannel.setText(cfg.uwb_channel.toString())
|
||||
binding.etRangingInterval.setText(cfg.ranging_interval_ms.toString())
|
||||
binding.switchBatteryReport.isChecked = cfg.battery_report
|
||||
}
|
||||
|
||||
private fun buildConfigFromFields() = TagConfig(
|
||||
tag_name = binding.etTagName.text?.toString() ?: "UWB_TAG_0001",
|
||||
sleep_timeout_s = binding.etSleepTimeout.text?.toString()?.toIntOrNull() ?: 300,
|
||||
display_brightness = binding.etBrightness.text?.toString()?.toIntOrNull() ?: 50,
|
||||
uwb_channel = binding.etUwbChannel.text?.toString()?.toIntOrNull() ?: 9,
|
||||
ranging_interval_ms = binding.etRangingInterval.text?.toString()?.toIntOrNull() ?: 100,
|
||||
battery_report = binding.switchBatteryReport.isChecked
|
||||
)
|
||||
|
||||
private fun toast(msg: String) =
|
||||
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@ -1,238 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:elevation="4dp"
|
||||
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
|
||||
app:title="UWB Tag BLE Config" />
|
||||
|
||||
<!-- Scan controls -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnScan"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Scan" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScanStatus"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="Tap Scan to find UWB tags"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Scan results list -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:text="Nearby Tags"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvDevices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:padding="8dp"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
<!-- Connected device config panel -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardConfig"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:visibility="gone"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvConnectedName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Connected: —"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnDisconnect"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Disconnect" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- tag_name -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="Tag Name">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etTagName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- sleep_timeout_s and uwb_channel (row) -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="4dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:hint="Sleep Timeout (s)">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etSleepTimeout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:hint="UWB Channel">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etUwbChannel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- display_brightness and ranging_interval_ms (row) -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="4dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:hint="Brightness (0-100)">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etBrightness"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:hint="Ranging Interval (ms)">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etRangingInterval"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- battery_report toggle -->
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switchBatteryReport"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Battery Reporting" />
|
||||
|
||||
<!-- Action buttons -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnReadConfig"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:text="Read" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnWriteConfig"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Write" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Status notifications from tag -->
|
||||
<TextView
|
||||
android:id="@+id/tvTagStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="#1A000000"
|
||||
android:fontFamily="monospace"
|
||||
android:padding="8dp"
|
||||
android:text="Tag status: —"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
@ -1,60 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="4dp"
|
||||
app:cardElevation="2dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDeviceName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="UWB_TAG_XXXX"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDeviceAddress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="XX:XX:XX:XX:XX:XX"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRssi"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-70 dBm"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
|
||||
android:textColor="?attr/colorSecondary" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnConnect"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Connect" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@ -1,118 +0,0 @@
|
||||
// ============================================
|
||||
// SaltyLab — Full Assembly Visualization
|
||||
// Shows all parts in position on 2020 spine
|
||||
// ============================================
|
||||
include <dimensions.scad>
|
||||
|
||||
// Spine height
|
||||
spine_h = 500;
|
||||
|
||||
// Component heights (center of each mount on spine)
|
||||
h_motor = 0;
|
||||
h_battery = 50;
|
||||
h_esc = 100;
|
||||
h_fc = 170;
|
||||
h_jetson = 250;
|
||||
h_realsense = 350;
|
||||
h_lidar = 430;
|
||||
|
||||
// Colors for visualization
|
||||
module spine() {
|
||||
color("silver")
|
||||
translate([-extrusion_w/2, -extrusion_w/2, 0])
|
||||
cube([extrusion_w, extrusion_w, spine_h]);
|
||||
}
|
||||
|
||||
module wheel(side) {
|
||||
color("DimGray")
|
||||
translate([side * 140, 0, 0])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(d=200, h=50, center=true, $fn=60);
|
||||
}
|
||||
|
||||
// --- Assembly ---
|
||||
|
||||
// Spine
|
||||
spine();
|
||||
|
||||
// Wheels
|
||||
wheel(-1);
|
||||
wheel(1);
|
||||
|
||||
// Motor mount plate (at base)
|
||||
color("DodgerBlue", 0.7)
|
||||
translate([0, 0, h_motor])
|
||||
import("motor_mount_plate.stl");
|
||||
|
||||
// Battery shelf
|
||||
color("OrangeRed", 0.7)
|
||||
translate([0, 0, h_battery])
|
||||
rotate([0, 0, 0])
|
||||
cube([180, 80, 40], center=true);
|
||||
|
||||
// ESC
|
||||
color("Green", 0.7)
|
||||
translate([0, 0, h_esc])
|
||||
cube([80, 50, 15], center=true);
|
||||
|
||||
// FC (tiny!)
|
||||
color("Purple", 0.9)
|
||||
translate([0, 0, h_fc])
|
||||
cube([36, 36, 5], center=true);
|
||||
|
||||
// Jetson Orin Nano Super
|
||||
color("LimeGreen", 0.7)
|
||||
translate([0, 0, h_jetson])
|
||||
cube([100, 80, 29], center=true);
|
||||
|
||||
// RealSense D435i
|
||||
color("Gray", 0.8)
|
||||
translate([0, -40, h_realsense])
|
||||
cube([90, 25, 25], center=true);
|
||||
|
||||
// RPLIDAR A1
|
||||
color("Cyan", 0.7)
|
||||
translate([0, 0, h_lidar])
|
||||
cylinder(d=70, h=41, center=true, $fn=40);
|
||||
|
||||
// Kill switch (accessible on front)
|
||||
color("Red")
|
||||
translate([0, -60, h_esc + 30])
|
||||
cylinder(d=22, h=10, $fn=30);
|
||||
|
||||
// LED ring
|
||||
color("White", 0.3)
|
||||
translate([0, 0, h_jetson - 20])
|
||||
difference() {
|
||||
cylinder(d=120, h=15, $fn=60);
|
||||
translate([0, 0, -1])
|
||||
cylinder(d=110, h=17, $fn=60);
|
||||
}
|
||||
|
||||
// Bumpers
|
||||
color("Orange", 0.5) {
|
||||
translate([0, -75, 25])
|
||||
cube([350, 30, 50], center=true);
|
||||
translate([0, 75, 25])
|
||||
cube([350, 30, 50], center=true);
|
||||
}
|
||||
|
||||
// Handle (top)
|
||||
color("Yellow", 0.7)
|
||||
translate([0, 0, spine_h + 10])
|
||||
cube([100, 20, 25], center=true);
|
||||
|
||||
// Tether point
|
||||
color("Red", 0.8)
|
||||
translate([0, 0, spine_h - 20]) {
|
||||
difference() {
|
||||
cylinder(d=30, h=8, $fn=30);
|
||||
translate([0, 0, -1])
|
||||
cylinder(d=15, h=10, $fn=30);
|
||||
}
|
||||
}
|
||||
|
||||
echo("=== SaltyLab Assembly ===");
|
||||
echo(str("Total height: ", spine_h + 30, "mm"));
|
||||
echo(str("Width (axle-axle): ", 280 + 50*2, "mm"));
|
||||
echo(str("Depth: ~", 150, "mm"));
|
||||
@ -1,77 +0,0 @@
|
||||
// ============================================
|
||||
// SaltyLab — Battery Shelf
|
||||
// 200×100×40mm PETG
|
||||
// Holds 36V battery pack low on the frame
|
||||
// Mounts to 2020 extrusion spine
|
||||
// ============================================
|
||||
include <dimensions.scad>
|
||||
|
||||
shelf_w = 200;
|
||||
shelf_d = 100;
|
||||
shelf_h = 40;
|
||||
floor_h = 3; // Bottom plate
|
||||
|
||||
// Battery pocket (with tolerance)
|
||||
pocket_w = batt_w + tol*2;
|
||||
pocket_d = batt_d + tol*2;
|
||||
pocket_h = batt_h + 5; // Slightly taller than battery
|
||||
|
||||
// Velcro strap slots
|
||||
strap_w = 25;
|
||||
strap_h = 3;
|
||||
|
||||
module battery_shelf() {
|
||||
difference() {
|
||||
union() {
|
||||
// Floor
|
||||
translate([-shelf_w/2, -shelf_d/2, 0])
|
||||
cube([shelf_w, shelf_d, floor_h]);
|
||||
|
||||
// Walls (3 sides — front open for wires)
|
||||
// Left wall
|
||||
translate([-shelf_w/2, -shelf_d/2, 0])
|
||||
cube([wall, shelf_d, shelf_h]);
|
||||
// Right wall
|
||||
translate([shelf_w/2 - wall, -shelf_d/2, 0])
|
||||
cube([wall, shelf_d, shelf_h]);
|
||||
// Back wall
|
||||
translate([-shelf_w/2, shelf_d/2 - wall, 0])
|
||||
cube([shelf_w, wall, shelf_h]);
|
||||
|
||||
// Front lip (low, keeps battery from sliding out)
|
||||
translate([-shelf_w/2, -shelf_d/2, 0])
|
||||
cube([shelf_w, wall, 10]);
|
||||
|
||||
// 2020 extrusion mount tabs (top of back wall)
|
||||
for (x = [-30, 30]) {
|
||||
translate([x - 10, shelf_d/2 - wall, shelf_h - 15])
|
||||
cube([20, wall + 10, 15]);
|
||||
}
|
||||
}
|
||||
|
||||
// Extrusion bolt holes (M5) through back mount tabs
|
||||
for (x = [-30, 30]) {
|
||||
translate([x, shelf_d/2 + 5, shelf_h - 7.5])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(d=m5_clear, h=wall + 15, $fn=30);
|
||||
}
|
||||
|
||||
// Velcro strap slots (2x through floor for securing battery)
|
||||
for (x = [-50, 50]) {
|
||||
translate([x - strap_w/2, -20, -1])
|
||||
cube([strap_w, strap_h, floor_h + 2]);
|
||||
}
|
||||
|
||||
// Weight reduction holes in floor
|
||||
for (x = [-30, 30]) {
|
||||
translate([x, 0, -1])
|
||||
cylinder(d=20, h=floor_h + 2, $fn=30);
|
||||
}
|
||||
|
||||
// Wire routing slot (front wall, centered)
|
||||
translate([-20, -shelf_d/2 - 1, floor_h])
|
||||
cube([40, wall + 2, 15]);
|
||||
}
|
||||
}
|
||||
|
||||
battery_shelf();
|
||||
@ -1,75 +0,0 @@
|
||||
// ============================================
|
||||
// SaltyLab — Bumper (Front/Rear)
|
||||
// 350×50×30mm TPU
|
||||
// Absorbs falls, protects frame and floor
|
||||
// ============================================
|
||||
include <dimensions.scad>
|
||||
|
||||
bumper_w = 350;
|
||||
bumper_h = 50;
|
||||
bumper_d = 30;
|
||||
bumper_wall = 2.5;
|
||||
|
||||
// Honeycomb crush structure for energy absorption
|
||||
hex_size = 8;
|
||||
hex_wall = 1.2;
|
||||
|
||||
module honeycomb_cell(size, height) {
|
||||
difference() {
|
||||
cylinder(d=size, h=height, $fn=6);
|
||||
translate([0, 0, -1])
|
||||
cylinder(d=size - hex_wall*2, h=height + 2, $fn=6);
|
||||
}
|
||||
}
|
||||
|
||||
module bumper() {
|
||||
difference() {
|
||||
union() {
|
||||
// Outer shell (curved front face)
|
||||
hull() {
|
||||
translate([-bumper_w/2, 0, 0])
|
||||
cube([bumper_w, 1, bumper_h]);
|
||||
translate([-bumper_w/2 + 10, bumper_d - 5, 5])
|
||||
cube([bumper_w - 20, 1, bumper_h - 10]);
|
||||
}
|
||||
}
|
||||
|
||||
// Hollow interior (leave outer shell)
|
||||
hull() {
|
||||
translate([-bumper_w/2 + bumper_wall, bumper_wall, bumper_wall])
|
||||
cube([bumper_w - bumper_wall*2, 1, bumper_h - bumper_wall*2]);
|
||||
translate([-bumper_w/2 + 10 + bumper_wall, bumper_d - 5 - bumper_wall, 5 + bumper_wall])
|
||||
cube([bumper_w - 20 - bumper_wall*2, 1, bumper_h - 10 - bumper_wall*2]);
|
||||
}
|
||||
|
||||
// Mounting bolt holes (M5, through back face, 4 points)
|
||||
for (x = [-120, -40, 40, 120]) {
|
||||
translate([x, -1, bumper_h/2])
|
||||
rotate([-90, 0, 0])
|
||||
cylinder(d=m5_clear, h=10, $fn=25);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal honeycomb ribs for crush absorption
|
||||
intersection() {
|
||||
// Bound to bumper volume
|
||||
hull() {
|
||||
translate([-bumper_w/2 + bumper_wall, bumper_wall, bumper_wall])
|
||||
cube([bumper_w - bumper_wall*2, 1, bumper_h - bumper_wall*2]);
|
||||
translate([-bumper_w/2 + 15, bumper_d - 8, 8])
|
||||
cube([bumper_w - 30, 1, bumper_h - 16]);
|
||||
}
|
||||
|
||||
// Honeycomb grid
|
||||
for (x = [-170:hex_size*1.5:170]) {
|
||||
for (z = [0:hex_size*1.3:60]) {
|
||||
offset_x = (floor(z / (hex_size*1.3)) % 2) * hex_size * 0.75;
|
||||
translate([x + offset_x, 0, z])
|
||||
rotate([-90, 0, 0])
|
||||
honeycomb_cell(hex_size, bumper_d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bumper();
|
||||
@ -1,73 +0,0 @@
|
||||
// ============================================
|
||||
// SaltyLab — Common Dimensions & Constants
|
||||
// ============================================
|
||||
|
||||
// --- 2020 Aluminum Extrusion ---
|
||||
extrusion_w = 20;
|
||||
extrusion_slot = 6; // T-slot width
|
||||
extrusion_bore = 5; // Center bore M5
|
||||
|
||||
// --- Hub Motors (8" hoverboard) ---
|
||||
motor_axle_dia = 12;
|
||||
motor_axle_len = 45;
|
||||
motor_axle_flat = 10; // Flat-to-flat if D-shaft
|
||||
motor_body_dia = 200; // ~8 inches
|
||||
motor_bolt_circle = 0; // Axle-only mount (clamp style)
|
||||
|
||||
// --- Drone FC (30.5mm standard) ---
|
||||
fc_hole_spacing = 25.5; // GEP-F722 AIO v2 (not standard 30.5!)
|
||||
fc_hole_dia = 3.2; // M3 clearance
|
||||
fc_board_size = 36; // Typical FC PCB
|
||||
fc_standoff_h = 5; // Rubber standoff height
|
||||
|
||||
// --- Jetson Orin Nano Super ---
|
||||
jetson_w = 100;
|
||||
jetson_d = 80;
|
||||
jetson_h = 29; // With heatsink
|
||||
jetson_hole_x = 86; // Mounting hole spacing X
|
||||
jetson_hole_y = 58; // Mounting hole spacing Y
|
||||
jetson_hole_dia = 2.7; // M2.5 clearance
|
||||
|
||||
// --- RealSense D435i ---
|
||||
rs_w = 90;
|
||||
rs_d = 25;
|
||||
rs_h = 25;
|
||||
rs_tripod_offset = 0; // 1/4-20 centered bottom
|
||||
rs_mount_dia = 6.5; // 1/4-20 clearance
|
||||
|
||||
// --- RPLIDAR A1 ---
|
||||
lidar_dia = 70;
|
||||
lidar_h = 41;
|
||||
lidar_mount_circle = 67; // Bolt circle diameter
|
||||
lidar_hole_count = 4;
|
||||
lidar_hole_dia = 2.7; // M2.5
|
||||
|
||||
// --- Kill Switch (22mm panel mount) ---
|
||||
kill_sw_dia = 22;
|
||||
kill_sw_depth = 35; // Behind-panel depth
|
||||
|
||||
// --- Battery (typical 36V hoverboard pack) ---
|
||||
batt_w = 180;
|
||||
batt_d = 80;
|
||||
batt_h = 40;
|
||||
|
||||
// --- Hoverboard ESC ---
|
||||
esc_w = 80;
|
||||
esc_d = 50;
|
||||
esc_h = 15;
|
||||
|
||||
// --- ESP32-C3 (typical dev board) ---
|
||||
esp_w = 25;
|
||||
esp_d = 18;
|
||||
esp_h = 5;
|
||||
|
||||
// --- WS2812B strip ---
|
||||
led_strip_w = 10; // 10mm wide strip
|
||||
|
||||
// --- General ---
|
||||
wall = 3; // Default wall thickness
|
||||
m3_clear = 3.2;
|
||||
m3_insert = 4.2; // Heat-set insert hole
|
||||
m25_clear = 2.7;
|
||||
m5_clear = 5.3;
|
||||
tol = 0.2; // Print tolerance per side
|
||||
@ -1,70 +0,0 @@
|
||||
// ============================================
|
||||
// SaltyLab — ESC Mount
|
||||
// 150×100×15mm PETG
|
||||
// Hoverboard ESC, mounts to 2020 extrusion
|
||||
// ============================================
|
||||
include <dimensions.scad>
|
||||
|
||||
mount_w = 150;
|
||||
mount_d = 100;
|
||||
mount_h = 15;
|
||||
base_h = 3;
|
||||
|
||||
module esc_mount() {
|
||||
difference() {
|
||||
union() {
|
||||
// Base plate
|
||||
translate([-mount_w/2, -mount_d/2, 0])
|
||||
cube([mount_w, mount_d, base_h]);
|
||||
|
||||
// ESC retaining walls (low lip on 3 sides)
|
||||
// Left
|
||||
translate([-mount_w/2, -mount_d/2, 0])
|
||||
cube([wall, mount_d, mount_h]);
|
||||
// Right
|
||||
translate([mount_w/2 - wall, -mount_d/2, 0])
|
||||
cube([wall, mount_d, mount_h]);
|
||||
// Back
|
||||
translate([-mount_w/2, mount_d/2 - wall, 0])
|
||||
cube([mount_w, wall, mount_h]);
|
||||
|
||||
// Front clips (snap-fit tabs to hold ESC)
|
||||
for (x = [-30, 30]) {
|
||||
translate([x - 5, -mount_d/2, 0])
|
||||
cube([10, wall, mount_h]);
|
||||
// Clip overhang
|
||||
translate([x - 5, -mount_d/2, mount_h - 2])
|
||||
cube([10, wall + 3, 2]);
|
||||
}
|
||||
|
||||
// 2020 mount tabs (back)
|
||||
for (x = [-25, 25]) {
|
||||
translate([x - 10, mount_d/2 - wall, 0])
|
||||
cube([20, wall + 8, base_h + 8]);
|
||||
}
|
||||
}
|
||||
|
||||
// Extrusion bolt holes (M5)
|
||||
for (x = [-25, 25]) {
|
||||
translate([x, mount_d/2 + 3, base_h + 4])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(d=m5_clear, h=wall + 12, $fn=30);
|
||||
}
|
||||
|
||||
// Ventilation holes in base
|
||||
for (x = [-40, -20, 0, 20, 40]) {
|
||||
for (y = [-25, 0, 25]) {
|
||||
translate([x, y, -1])
|
||||
cylinder(d=8, h=base_h + 2, $fn=20);
|
||||
}
|
||||
}
|
||||
|
||||
// Wire routing slots (front and back)
|
||||
translate([-15, -mount_d/2 - 1, base_h])
|
||||
cube([30, wall + 2, 10]);
|
||||
translate([-15, mount_d/2 - wall - 1, base_h])
|
||||
cube([30, wall + 2, 10]);
|
||||
}
|
||||
}
|
||||
|
||||
esc_mount();
|
||||
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