Compare commits

..

No commits in common. "main" and "sl-perception/issue-430-audio-direction" have entirely different histories.

1070 changed files with 3572 additions and 99292 deletions

View File

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

View File

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

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.

View File

@ -1 +1 @@
ffc01fb580c81760bdda9a672fe1212be4578e3e 8700a44a6597bcade0f371945c539630ba0e78b1

View File

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

View File

@ -1,36 +1,17 @@
# SaltyLab Firmware — Agent Playbook # SaltyLab Firmware — Agent Playbook
## Project ## Project
<<<<<<< HEAD Self-balancing two-wheeled robot: STM32F722 flight controller, hoverboard hub motors, Jetson Nano for AI/SLAM.
**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 0x3000x303, telemetry 0x4000x401 |
> **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)
## Team ## Team
| Agent | Role | Focus | | Agent | Role | Focus |
|-------|------|-------| |-------|------|-------|
<<<<<<< HEAD | **sl-firmware** | Embedded Firmware Lead | STM32 HAL, USB CDC debugging, SPI/UART, PlatformIO, DFU bootloader |
| **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-controls** | Control Systems Engineer | PID tuning, IMU sensor fusion, real-time control loops, safety systems | | **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 | | **sl-perception** | Perception / SLAM Engineer | Jetson Nano, RealSense D435i, RPLIDAR, ROS2, Nav2 |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Status ## 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 ## Repo Structure
- `projects/saltybot/SALTYLAB.md` — Design doc - `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 | | `saltyrover-dev` | Integration — rover variant |
| `saltytank` | Stable — tracked tank variant | | `saltytank` | Stable — tracked tank variant |
| `saltytank-dev` | Integration — 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 ### Rules
- Agents branch FROM `<variant>-dev` and PR back TO `<variant>-dev` - 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 - Variant-specific code (motor topology, kinematics, config) goes in variant branches
- Stable branches get promoted from `-dev` after review and hardware testing - Stable branches get promoted from `-dev` after review and hardware testing
- **Current SaltyLab team** works against `saltylab-dev` - **Current SaltyLab team** works against `saltylab-dev`

52
TEAM.md
View File

@ -1,22 +1,12 @@
# SaltyLab — Ideal Team # SaltyLab — Ideal Team
## Project ## Project
<<<<<<< HEAD Self-balancing two-wheeled robot using a drone flight controller (STM32F722), hoverboard hub motors, and eventually a Jetson Nano for AI/SLAM.
**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.
## Current Status ## Current Status
- **Hardware:** Assembled — FC, motors, ESC, IMU, battery, RC all on hand - **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 - **Firmware:** Balance PID + hoverboard ESC protocol written, but blocked by USB CDC 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 - **Blocker:** USB CDC TX stops working when peripheral inits (SPI/UART/GPIO) are added alongside USB OTG FS — see `USB_CDC_BUG.md`
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
--- ---
@ -24,30 +14,18 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
### 1. Embedded Firmware Engineer (Lead) ### 1. Embedded Firmware Engineer (Lead)
**Must-have:** **Must-have:**
<<<<<<< HEAD - Deep STM32 HAL experience (F7 series specifically)
- Deep ESP32 (Arduino/ESP-IDF) or STM32 HAL experience
- USB OTG FS / CDC ACM debugging (TxState, endpoint management, DMA conflicts) - USB OTG FS / CDC ACM debugging (TxState, endpoint management, DMA conflicts)
- SPI + UART + USB coexistence on ESP32 - SPI + UART + USB coexistence on STM32
- PlatformIO or bare-metal ESP32 toolchain - PlatformIO or bare-metal STM32 toolchain
- DFU bootloader implementation - 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:** **Nice-to-have:**
- ESP32-S3 peripheral coexistence (SPI + UART + USB) - Betaflight/iNav/ArduPilot codebase familiarity
- PID control loop tuning for balance robots - PID control loop tuning for balance robots
- FOC motor control (hoverboard ESC protocol) - 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 — this is not a software logic bug, it's a hardware peripheral interaction issue.
**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)
### 2. Control Systems / Robotics Engineer ### 2. Control Systems / Robotics Engineer
**Must-have:** **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) ### 3. Perception / SLAM Engineer (Phase 2)
**Must-have:** **Must-have:**
- Jetson Orin Nano Super / NVIDIA Jetson platform - Jetson Nano / NVIDIA Jetson platform
- Intel RealSense D435i depth camera - Intel RealSense D435i depth camera
- RPLIDAR integration - RPLIDAR integration
- SLAM (ORB-SLAM3, RTAB-Map, or similar) - 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 - Obstacle avoidance
- Nav2 stack - 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 ## Hardware Reference
| Component | Details | | Component | Details |
|-----------|---------| |-----------|---------|
<<<<<<< HEAD | FC | MAMBA F722S (STM32F722RET6, MPU6000) |
| FC | ESP32 BALANCE (ESP32RET6, MPU6000) |
=======
| FC | ESP32-S3 BALANCE (ESP32-S3RET6, QMI8658) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| Motors | 2x 8" pneumatic hoverboard hub motors | | Motors | 2x 8" pneumatic hoverboard hub motors |
| ESC | Hoverboard ESC (EFeru FOC firmware) | | ESC | Hoverboard ESC (EFeru FOC firmware) |
| Battery | 36V pack | | Battery | 36V pack |
| RC | BetaFPV ELRS 2.4GHz TX + RX | | 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 | | Depth | Intel RealSense D435i |
| LIDAR | RPLIDAR A1M8 | | LIDAR | RPLIDAR A1M8 |
| Spare IMUs | BNO055, MPU6050 | | Spare IMUs | BNO055, MPU6050 |
@ -100,4 +74,4 @@ Self-balancing two-wheeled robot using a drone ESP32-S3 BALANCE (ESP32-S3), hove
## Repo ## Repo
- Gitea: https://gitea.vayrette.com/seb/saltylab-firmware - Gitea: https://gitea.vayrette.com/seb/saltylab-firmware
- Design doc: `projects/saltybot/SALTYLAB.md` - 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
View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,57 +0,0 @@
// ============================================
// SaltyLab ESP32-C3 Mount
// 30×25×10mm PETG
// Tiny mount for LED controller MCU
// ============================================
include <dimensions.scad>
mount_w = 30;
mount_d = 25;
mount_h = 10;
base_h = 2;
module esp32c3_mount() {
difference() {
union() {
// Base
translate([-mount_w/2, -mount_d/2, 0])
cube([mount_w, mount_d, base_h]);
// Retaining walls (3 sides, front open for USB)
translate([-mount_w/2, -mount_d/2, 0])
cube([wall, mount_d, mount_h]);
translate([mount_w/2 - wall, -mount_d/2, 0])
cube([wall, mount_d, mount_h]);
translate([-mount_w/2, mount_d/2 - wall, 0])
cube([mount_w, wall, mount_h]);
// Clip tabs (front corners)
for (x = [-mount_w/2, mount_w/2 - wall]) {
translate([x, -mount_d/2, mount_h - 2])
cube([wall, 4, 2]);
}
// Zip-tie slot wings
for (x = [-mount_w/2 - 4, mount_w/2 + 1]) {
translate([x, -5, 0])
cube([3, 10, base_h]);
}
}
// Board pocket (recessed)
translate([-esp_w/2 - tol, -esp_d/2 - tol, base_h])
cube([esp_w + tol*2, esp_d + tol*2, mount_h]);
// Zip-tie slots
for (x = [-mount_w/2 - 4, mount_w/2 + 1]) {
translate([x, -2, -1])
cube([3, 4, base_h + 2]);
}
// USB port clearance (front)
translate([-5, -mount_d/2 - 1, base_h])
cube([10, wall + 2, 5]);
}
}
esp32c3_mount();

View File

@ -1,86 +0,0 @@
// ============================================
// SaltyLab Flight Controller Mount
// Vibration-isolated, 30.5mm pattern
// TPU dampers + PETG frame
// ============================================
include <dimensions.scad>
// FC mount attaches to 2020 extrusion via T-slot
// Rubber/TPU grommets isolate FC from frame vibration
mount_w = 45; // Overall width
mount_d = 45; // Overall depth
mount_h = 15; // Total height (base + standoffs)
base_h = 4; // Base plate thickness
// TPU grommet dimensions
grommet_od = 7;
grommet_id = 3.2; // M3 clearance
grommet_h = 5; // Soft mount height
module fc_mount() {
difference() {
union() {
// Base plate
translate([-mount_w/2, -mount_d/2, 0])
cube([mount_w, mount_d, base_h]);
// Standoff posts (PETG, FC sits on TPU grommets on top)
for (x = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
for (y = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
translate([x, y, 0])
cylinder(d=8, h=base_h + grommet_h, $fn=30);
}
}
// 2020 extrusion clamp tabs (sides)
for (side = [-1, 1]) {
translate([side * (extrusion_w/2 + wall), -15, 0])
cube([wall, 30, base_h + 10]);
}
}
// FC mounting holes (M3 through standoffs)
for (x = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
for (y = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
translate([x, y, -1])
cylinder(d=fc_hole_dia, h=base_h + grommet_h + 2, $fn=25);
}
}
// Extrusion channel (20mm wide slot through base)
translate([-extrusion_w/2 - tol, -20, -1])
cube([extrusion_w + tol*2, 40, base_h + 2]);
// Clamp bolt holes (M3, horizontal through side tabs)
for (side = [-1, 1]) {
translate([side * (extrusion_w/2 + wall + 1), 0, base_h + 5])
rotate([0, 90, 0])
cylinder(d=m3_clear, h=wall + 2, center=true, $fn=25);
}
// Center cutout for airflow / weight reduction
translate([0, 0, -1])
cylinder(d=15, h=base_h + 2, $fn=30);
}
}
// TPU grommet (print separately in TPU)
module tpu_grommet() {
difference() {
cylinder(d=grommet_od, h=grommet_h, $fn=30);
translate([0, 0, -1])
cylinder(d=grommet_id, h=grommet_h + 2, $fn=25);
}
}
// Show assembled
fc_mount();
// Show grommets in position (for visualization)
%for (x = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
for (y = [-fc_hole_spacing/2, fc_hole_spacing/2]) {
translate([x, y, base_h])
tpu_grommet();
}
}

View File

@ -1,59 +0,0 @@
// ============================================
// SaltyLab Carry Handle
// 150×30×30mm PETG
// Comfortable grip, mounts on top of spine
// ============================================
include <dimensions.scad>
handle_w = 150;
handle_h = 30;
grip_dia = 25; // Comfortable grip diameter
grip_len = 100; // Grip section length
module handle() {
difference() {
union() {
// Grip bar (rounded for comfort)
translate([-grip_len/2, 0, handle_h])
rotate([0, 90, 0])
cylinder(d=grip_dia, h=grip_len, $fn=40);
// Left support leg
hull() {
translate([-handle_w/2, -10, 0])
cube([20, 20, 3]);
translate([-grip_len/2, 0, handle_h])
rotate([0, 90, 0])
cylinder(d=grip_dia, h=5, $fn=40);
}
// Right support leg
hull() {
translate([handle_w/2 - 20, -10, 0])
cube([20, 20, 3]);
translate([grip_len/2 - 5, 0, handle_h])
rotate([0, 90, 0])
cylinder(d=grip_dia, h=5, $fn=40);
}
}
// 2020 extrusion slot (center of base)
translate([-extrusion_w/2 - tol, -extrusion_w/2 - tol, -1])
cube([extrusion_w + tol*2, extrusion_w + tol*2, 5]);
// M5 bolt holes for extrusion (2x)
for (x = [-30, 30]) {
translate([x, 0, -1])
cylinder(d=m5_clear, h=5, $fn=25);
}
// Finger grooves on grip
for (x = [-30, -10, 10, 30]) {
translate([x, 0, handle_h])
rotate([0, 90, 0])
cylinder(d=grip_dia + 4, h=5, center=true, $fn=40);
}
}
}
handle();

View File

@ -1,69 +0,0 @@
// ============================================
// SaltyLab Jetson Orin Nano Super Shelf
// 120×100×15mm PETG
// Mounts Jetson Orin Nano Super to 2020 extrusion
// ============================================
include <dimensions.scad>
shelf_w = 120;
shelf_d = 100;
shelf_h = 15;
base_h = 3;
standoff_h = 8; // Clearance for Jetson underside components
module jetson_shelf() {
difference() {
union() {
// Base plate
translate([-shelf_w/2, -shelf_d/2, 0])
cube([shelf_w, shelf_d, base_h]);
// Jetson standoffs (M2.5, 86mm × 58mm pattern)
for (x = [-jetson_hole_x/2, jetson_hole_x/2]) {
for (y = [-jetson_hole_y/2, jetson_hole_y/2]) {
translate([x, y, 0])
cylinder(d=6, h=base_h + standoff_h, $fn=25);
}
}
// 2020 extrusion clamp (back edge)
translate([-15, shelf_d/2 - wall, 0])
cube([30, wall + 10, base_h + 12]);
// Side rails for Jetson alignment
for (x = [-jetson_w/2 - wall, jetson_w/2]) {
translate([x, -jetson_d/2, base_h + standoff_h])
cube([wall, jetson_d, 4]);
}
}
// Jetson M2.5 holes (through standoffs)
for (x = [-jetson_hole_x/2, jetson_hole_x/2]) {
for (y = [-jetson_hole_y/2, jetson_hole_y/2]) {
translate([x, y, -1])
cylinder(d=jetson_hole_dia, h=base_h + standoff_h + 2, $fn=25);
}
}
// Extrusion bolt hole (M5, through back clamp)
translate([0, shelf_d/2 + 3, base_h + 6])
rotate([90, 0, 0])
cylinder(d=m5_clear, h=wall + 15, $fn=30);
// Extrusion channel slot
translate([-extrusion_w/2 - tol, shelf_d/2 - wall - 1, -1])
cube([extrusion_w + tol*2, wall + 2, base_h + 2]);
// Ventilation / cable routing
for (x = [-25, 0, 25]) {
translate([x, 0, -1])
cylinder(d=15, h=base_h + 2, $fn=25);
}
// USB/Ethernet/GPIO access cutouts (front edge)
translate([-jetson_w/2, -shelf_d/2 - 1, base_h])
cube([jetson_w, wall + 2, shelf_h]);
}
}
jetson_shelf();

View File

@ -1,56 +0,0 @@
// ============================================
// SaltyLab Kill Switch Mount
// 60×60×40mm PETG
// 22mm panel-mount emergency stop button
// Mounts to 2020 extrusion, easily reachable
// ============================================
include <dimensions.scad>
mount_w = 60;
mount_d = 60;
mount_h = 40;
panel_h = 3; // Panel face thickness
module kill_switch_mount() {
difference() {
union() {
// Main body (angled face for visibility)
hull() {
translate([-mount_w/2, 0, 0])
cube([mount_w, mount_d, 1]);
translate([-mount_w/2, 5, mount_h])
cube([mount_w, mount_d - 5, 1]);
}
// 2020 extrusion mount bracket (back)
translate([-15, mount_d, 0])
cube([30, 10, 20]);
}
// Kill switch hole (22mm, through angled face)
translate([0, mount_d/2, mount_h/2])
rotate([10, 0, 0]) // Slight angle for ergonomics
cylinder(d=kill_sw_dia + tol, h=panel_h + 2, center=true, $fn=50);
// Interior cavity (hollow for switch body)
translate([-kill_sw_dia/2 - 3, 5, 3])
cube([kill_sw_dia + 6, mount_d - 10, mount_h - 3]);
// Wire exit hole (bottom)
translate([0, mount_d/2, -1])
cylinder(d=10, h=5, $fn=25);
// Extrusion bolt holes (M5, through back bracket)
for (z = [7, 15]) {
translate([-20, mount_d + 5, z])
rotate([90, 0, 0])
cylinder(d=m5_clear, h=15, center=true, $fn=25);
}
// Label recess ("EMERGENCY STOP" flat area for sticker)
translate([-25, 5, mount_h - 1])
cube([50, 15, 1.5]);
}
}
kill_switch_mount();

View File

@ -1,53 +0,0 @@
// ============================================
// SaltyLab LED Diffuser Ring
// Ø120×15mm Clear PETG 30% infill
// Wraps around frame, holds WS2812B strip
// Print in clear/natural PETG for diffusion
// ============================================
include <dimensions.scad>
ring_od = 120;
ring_id = 110; // Inner diameter (strip sits inside)
ring_h = 15;
strip_channel_w = led_strip_w + 1; // Strip channel
strip_channel_d = 3; // Depth for strip
module led_diffuser_ring() {
difference() {
// Outer ring
cylinder(d=ring_od, h=ring_h, $fn=80);
// Inner hollow
translate([0, 0, -1])
cylinder(d=ring_id, h=ring_h + 2, $fn=80);
// LED strip channel (groove on inner wall)
translate([0, 0, (ring_h - strip_channel_w)/2])
difference() {
cylinder(d=ring_id + 2, h=strip_channel_w, $fn=80);
cylinder(d=ring_id - strip_channel_d*2, h=strip_channel_w, $fn=80);
}
// Wire entry slot
translate([ring_od/2 - 5, -3, ring_h/2 - 3])
cube([10, 6, 6]);
// 2020 extrusion clearance (center)
translate([-extrusion_w/2 - 5, -extrusion_w/2 - 5, -1])
cube([extrusion_w + 10, extrusion_w + 10, ring_h + 2]);
}
// Mounting tabs (clip onto extrusion, 4x)
for (angle = [0, 90, 180, 270]) {
rotate([0, 0, angle])
translate([extrusion_w/2 + 1, -5, 0])
difference() {
cube([3, 10, ring_h]);
translate([-1, 2, ring_h/2])
rotate([0, 90, 0])
cylinder(d=m3_clear, h=5, $fn=20);
}
}
}
led_diffuser_ring();

View File

@ -1,61 +0,0 @@
// ============================================
// SaltyLab LIDAR Standoff
// Ø80×80mm ASA
// Raises RPLIDAR above all other components
// for unobstructed 360° scan
// Connects sensor_tower_top to 2020 extrusion
// ============================================
include <dimensions.scad>
standoff_od = 80;
standoff_h = 80;
wall_t = 3;
module lidar_standoff() {
difference() {
union() {
// Main cylinder
cylinder(d=standoff_od, h=standoff_h, $fn=60);
// Bottom flange (bolts to extrusion bracket below)
cylinder(d=standoff_od + 10, h=4, $fn=60);
}
// Hollow interior
translate([0, 0, wall_t])
cylinder(d=standoff_od - wall_t*2, h=standoff_h, $fn=60);
// Cable routing hole (bottom)
translate([0, 0, -1])
cylinder(d=20, h=wall_t + 2, $fn=30);
// Ventilation / weight reduction slots (4x around circumference)
for (angle = [0, 90, 180, 270]) {
rotate([0, 0, angle])
translate([0, standoff_od/2, standoff_h/2])
rotate([90, 0, 0])
hull() {
translate([0, -15, 0])
cylinder(d=10, h=wall_t + 2, center=true, $fn=25);
translate([0, 15, 0])
cylinder(d=10, h=wall_t + 2, center=true, $fn=25);
}
}
// Bottom flange bolt holes (M5, 4x for mounting)
for (angle = [45, 135, 225, 315]) {
rotate([0, 0, angle])
translate([standoff_od/2, 0, -1])
cylinder(d=m5_clear, h=6, $fn=25);
}
// Top mating holes (M3, align with sensor_tower_top)
for (angle = [0, 90, 180, 270]) {
rotate([0, 0, angle])
translate([standoff_od/2 - wall_t - 3, 0, standoff_h - 8])
cylinder(d=m3_clear, h=10, $fn=25);
}
}
}
lidar_standoff();

View File

@ -1,94 +0,0 @@
// ============================================
// SaltyLab Motor Mount Plate
// 350×150×6mm PETG
// Mounts both 8" hub motors + 2020 extrusion spine
// ============================================
include <dimensions.scad>
plate_w = 350; // Width (axle to axle direction)
plate_d = 150; // Depth (front to back)
plate_h = 6; // Thickness
// Motor axle positions (centered, symmetric)
motor_spacing = 280; // Center-to-center axle distance
// Extrusion spine mount (centered, 2x M5 bolts)
spine_offset_y = 0; // Centered front-to-back
spine_bolt_spacing = 60; // Two bolts along spine
// Motor clamp dimensions
clamp_w = 30;
clamp_h = 25; // Height above plate for clamping axle
clamp_gap = motor_axle_dia + tol*2; // Slot for axle
clamp_bolt_offset = 10; // M5 clamp bolt offset from center
module motor_clamp() {
difference() {
// Clamp block
translate([-clamp_w/2, -clamp_w/2, 0])
cube([clamp_w, clamp_w, plate_h + clamp_h]);
// Axle hole (through, slightly oversized)
translate([0, 0, plate_h + clamp_h/2 + 5])
rotate([0, 90, 0])
cylinder(d=clamp_gap, h=clamp_w+2, center=true, $fn=40);
// Clamp slit (allows tightening)
translate([0, 0, plate_h + clamp_h/2 + 5])
cube([clamp_w+2, 1.5, clamp_h], center=true);
// Clamp bolt holes (M5, horizontal through clamp ears)
translate([0, clamp_bolt_offset, plate_h + clamp_h/2 + 5])
rotate([0, 90, 0])
cylinder(d=m5_clear, h=clamp_w+2, center=true, $fn=30);
translate([0, -clamp_bolt_offset, plate_h + clamp_h/2 + 5])
rotate([0, 90, 0])
cylinder(d=m5_clear, h=clamp_w+2, center=true, $fn=30);
}
}
module motor_mount_plate() {
difference() {
union() {
// Main plate
translate([-plate_w/2, -plate_d/2, 0])
cube([plate_w, plate_d, plate_h]);
// Left motor clamp
translate([-motor_spacing/2, 0, 0])
motor_clamp();
// Right motor clamp
translate([motor_spacing/2, 0, 0])
motor_clamp();
// Reinforcement ribs (bottom)
for (x = [-100, 0, 100]) {
translate([x - 2, -plate_d/2, 0])
cube([4, plate_d, plate_h]);
}
}
// Extrusion spine bolt holes (M5, 2x along center)
for (y = [-spine_bolt_spacing/2, spine_bolt_spacing/2]) {
translate([0, y, -1])
cylinder(d=m5_clear, h=plate_h+2, $fn=30);
// Counterbore for bolt head
translate([0, y, plate_h - 2.5])
cylinder(d=10, h=3, $fn=30);
}
// Weight reduction holes
for (x = [-70, 70]) {
for (y = [-40, 40]) {
translate([x, y, -1])
cylinder(d=25, h=plate_h+2, $fn=40);
}
}
// Corner rounding (chamfer edges)
// (simplified round in slicer or add minkowski)
}
}
motor_mount_plate();

View File

@ -1,64 +0,0 @@
// ============================================
// SaltyLab RealSense D435i Bracket
// 100×50×40mm PETG
// Adjustable tilt mount on 2020 extrusion
// ============================================
include <dimensions.scad>
bracket_w = 100;
bracket_d = 50;
bracket_h = 40;
// Camera cradle
cradle_w = rs_w + wall*2 + tol*2;
cradle_d = rs_d + wall + tol*2;
cradle_h = rs_h + 5;
module realsense_bracket() {
// Extrusion clamp base
difference() {
union() {
// Clamp block
translate([-20, -20, 0])
cube([40, 40, 15]);
// Tilt arm (vertical, supports camera above)
translate([-wall, -wall, 0])
cube([wall*2, wall*2, bracket_h]);
// Camera cradle at top
translate([-cradle_w/2, -cradle_d/2, bracket_h - 5]) {
difference() {
cube([cradle_w, cradle_d, cradle_h]);
// Camera pocket
translate([wall, -1, 3])
cube([rs_w + tol*2, rs_d + tol*2 + 1, rs_h + tol*2]);
}
}
// Tripod mount boss (1/4-20 bolt from bottom of cradle)
translate([0, 0, bracket_h - 5])
cylinder(d=15, h=3, $fn=30);
}
// 2020 extrusion channel
translate([-extrusion_w/2 - tol, -extrusion_w/2 - tol, -1])
cube([extrusion_w + tol*2, extrusion_w + tol*2, 17]);
// Clamp bolt (M5, through side)
translate([-25, 0, 7.5])
rotate([0, 90, 0])
cylinder(d=m5_clear, h=50, $fn=30);
// Camera 1/4-20 bolt hole (from bottom of cradle)
translate([0, 0, bracket_h - 6])
cylinder(d=rs_mount_dia, h=10, $fn=30);
// Cable routing slot (back of cradle)
translate([-10, cradle_d/2 - wall - 1, bracket_h])
cube([20, wall + 2, cradle_h - 2]);
}
}
realsense_bracket();

View File

@ -1,58 +0,0 @@
// ============================================
// SaltyLab Sensor Tower Top
// 120×120×10mm ASA
// Mounts RPLIDAR A1 on top of 2020 spine
// ============================================
include <dimensions.scad>
top_w = 120;
top_d = 120;
top_h = 10;
base_h = 4;
module sensor_tower_top() {
difference() {
union() {
// Circular plate (RPLIDAR needs 360° clearance)
cylinder(d=top_w, h=base_h, $fn=60);
// RPLIDAR standoffs (4x M2.5 on 67mm bolt circle)
for (i = [0:3]) {
angle = i * 90 + 45; // 45° offset
translate([cos(angle) * lidar_mount_circle/2,
sin(angle) * lidar_mount_circle/2, 0])
cylinder(d=6, h=top_h, $fn=25);
}
// 2020 extrusion socket (bottom center)
translate([-extrusion_w/2 - wall, -extrusion_w/2 - wall, -15])
cube([extrusion_w + wall*2, extrusion_w + wall*2, 15]);
}
// RPLIDAR M2.5 through-holes
for (i = [0:3]) {
angle = i * 90 + 45;
translate([cos(angle) * lidar_mount_circle/2,
sin(angle) * lidar_mount_circle/2, -1])
cylinder(d=lidar_hole_dia, h=top_h + 2, $fn=25);
}
// Center hole (RPLIDAR motor shaft clearance + cable routing)
translate([0, 0, -1])
cylinder(d=25, h=base_h + 2, $fn=40);
// 2020 extrusion socket (square hole)
translate([-extrusion_w/2 - tol, -extrusion_w/2 - tol, -16])
cube([extrusion_w + tol*2, extrusion_w + tol*2, 16]);
// Set screw holes for extrusion (M3, 2x perpendicular)
for (angle = [0, 90]) {
rotate([0, 0, angle])
translate([0, extrusion_w/2 + wall, -7.5])
rotate([90, 0, 0])
cylinder(d=m3_clear, h=wall + 5, $fn=25);
}
}
}
sensor_tower_top();

View File

@ -1,46 +0,0 @@
// ============================================
// SaltyLab Tether Anchor Point
// 50×50×20mm PETG 100% infill
// For ceiling tether during balance testing
// Must be STRONG 100% infill mandatory
// ============================================
include <dimensions.scad>
anchor_w = 50;
anchor_d = 50;
anchor_h = 20;
ring_dia = 30; // Carabiner ring outer
ring_hole = 15; // Carabiner hook clearance
ring_h = 8;
module tether_anchor() {
difference() {
union() {
// Base (clamps to 2020 extrusion)
translate([-anchor_w/2, -anchor_d/2, 0])
cube([anchor_w, anchor_d, anchor_h - ring_h]);
// Tether ring (stands up from base)
translate([0, 0, anchor_h - ring_h])
cylinder(d=ring_dia, h=ring_h, $fn=50);
}
// Ring hole (for carabiner)
translate([0, 0, anchor_h - ring_h - 1])
cylinder(d=ring_hole, h=ring_h + 2, $fn=40);
// 2020 extrusion channel (through base)
translate([-extrusion_w/2 - tol, -extrusion_w/2 - tol, -1])
cube([extrusion_w + tol*2, extrusion_w + tol*2, anchor_h - ring_h + 2]);
// Clamp bolt holes (M5, through sides)
for (angle = [0, 90]) {
rotate([0, 0, angle])
translate([0, anchor_d/2 + 1, (anchor_h - ring_h)/2])
rotate([90, 0, 0])
cylinder(d=m5_clear, h=anchor_d + 2, $fn=25);
}
}
}
tether_anchor();

View File

@ -56,24 +56,15 @@
3. Fasten 4× M4×12 SHCS. Torque 2.5 N·m. 3. Fasten 4× M4×12 SHCS. Torque 2.5 N·m.
4. Insert battery pack; route Velcro straps through slots and cinch. 4. Insert battery pack; route Velcro straps through slots and cinch.
<<<<<<< HEAD ### 7 FC mount (MAMBA F722S)
### 7 MCU mount (ESP32 BALANCE + ESP32 IO)
> ⚠️ **ARCHITECTURE CHANGE (2026-04-03):** ESP32 BALANCE retired. Two ESP32 boards replace it.
> Board dimensions and hole patterns TBD — await spec from max before machining mount plate.
=======
### 7 FC mount (ESP32-S3 BALANCE)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
1. Place silicone anti-vibration grommets onto nylon M3 standoffs. 1. Place silicone anti-vibration grommets onto nylon M3 standoffs.
2. Lower ESP32 BALANCE board onto standoffs; secure with M3×6 BHCS. Snug only. 2. Lower FC onto standoffs; secure with M3×6 BHCS. Snug only — do not over-torque.
3. Mount ESP32 IO board adjacent — exact placement TBD pending board dimensions. 3. Orient USB-C port toward front of robot for cable access.
4. Orient USB connectors toward front of robot for cable access.
### 8 Jetson Orin Nano Super mount plate ### 8 Jetson Nano mount plate
1. Press or thread M3 nylon standoffs (8mm) into plate holes. 1. Press or thread M3 nylon standoffs (8mm) into plate holes.
2. Bolt plate to deck: 4× M3×10 SHCS at deck corners. 2. Bolt plate to deck: 4× M3×10 SHCS at deck corners.
3. Set Jetson Orin Nano Super B01 carrier onto plate standoffs; fasten M3×6 BHCS. 3. Set Jetson Nano B01 carrier onto plate standoffs; fasten M3×6 BHCS.
### 9 Bumper brackets ### 9 Bumper brackets
1. Slide 22mm EMT conduit through saddle clamp openings. 1. Slide 22mm EMT conduit through saddle clamp openings.
@ -95,8 +86,7 @@
| Wheelbase (axle C/L to C/L) | 600 mm | ±1 mm | | Wheelbase (axle C/L to C/L) | 600 mm | ±1 mm |
| Motor fork slot width | 24 mm | +0.5 / 0 | | Motor fork slot width | 24 mm | +0.5 / 0 |
| Motor fork dropout depth | 60 mm | ±0.5 mm | | Motor fork dropout depth | 60 mm | ±0.5 mm |
| ESP32 BALANCE hole pattern | TBD — await spec from max | ±0.2 mm | | FC hole pattern | 30.5 × 30.5 mm | ±0.2 mm |
| ESP32 IO hole pattern | TBD — await spec from max | ±0.2 mm |
| Jetson hole pattern | 58 × 58 mm | ±0.2 mm | | Jetson hole pattern | 58 × 58 mm | ±0.2 mm |
| Battery tray inner | 185 × 72 × 52 mm | +2 / 0 mm | | Battery tray inner | 185 × 72 × 52 mm | +2 / 0 mm |

View File

@ -41,11 +41,7 @@ PR #7 (`chassis_frame.scad`) used placeholder values. The table below records th
| 3 | Dropout clamp — upper | 2 | 8mm 6061-T6 Al | 90×70mm blank | D-cut bore; `RENDER="clamp_upper_2d"` | | 3 | Dropout clamp — upper | 2 | 8mm 6061-T6 Al | 90×70mm blank | D-cut bore; `RENDER="clamp_upper_2d"` |
| 4 | Stem flange ring | 2 | 6mm Al or acrylic | Ø82mm disc | One above + one below plate; `RENDER="stem_flange_2d"` | | 4 | Stem flange ring | 2 | 6mm Al or acrylic | Ø82mm disc | One above + one below plate; `RENDER="stem_flange_2d"` |
| 5 | Vertical stem tube | 1 | 38.1mm OD × 1.5mm wall 6061-T6 Al | 1050mm length | 1.5" EMT conduit is a drop-in alternative | | 5 | Vertical stem tube | 1 | 38.1mm OD × 1.5mm wall 6061-T6 Al | 1050mm length | 1.5" EMT conduit is a drop-in alternative |
<<<<<<< HEAD | 6 | FC standoff M3×6mm nylon | 4 | Nylon | — | MAMBA F722S vibration isolation |
| 6 | MCU standoff M3×6mm nylon | 4 | Nylon | — | ESP32 BALANCE / IO board isolation (dimensions TBD) |
=======
| 6 | FC standoff M3×6mm nylon | 4 | Nylon | — | ESP32-S3 BALANCE vibration isolation |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 7 | Ø4mm × 16mm alignment pin | 8 | Steel dowel | — | Dropout clamp-to-plate alignment | | 7 | Ø4mm × 16mm alignment pin | 8 | Steel dowel | — | Dropout clamp-to-plate alignment |
### Battery Stem Clamp (`stem_battery_clamp.scad`) — Part B ### Battery Stem Clamp (`stem_battery_clamp.scad`) — Part B
@ -74,7 +70,7 @@ PR #7 (`chassis_frame.scad`) used placeholder values. The table below records th
| 10 | Motor fork bracket (R) | 1 | 8mm 6061 aluminium | Mirror of item 9 | | 10 | Motor fork bracket (R) | 1 | 8mm 6061 aluminium | Mirror of item 9 |
| 11 | Battery tray | 1 | 3mm PETG FDM or 3mm aluminium fold | `chassis_frame.scad``battery_tray()` module | | 11 | Battery tray | 1 | 3mm PETG FDM or 3mm aluminium fold | `chassis_frame.scad``battery_tray()` module |
| 12 | FC mount plate / standoffs | 1 set | PETG or nylon FDM | Includes 4× M3 nylon standoffs, 6mm height | | 12 | FC mount plate / standoffs | 1 set | PETG or nylon FDM | Includes 4× M3 nylon standoffs, 6mm height |
| 13 | Jetson Orin Nano Super mount plate | 1 | 4mm 5052 aluminium or 4mm PETG FDM | B01 58×58mm hole pattern | | 13 | Jetson Nano mount plate | 1 | 4mm 5052 aluminium or 4mm PETG FDM | B01 58×58mm hole pattern |
| 14 | Front bumper bracket | 1 | 5mm PETG FDM | Saddle clamps for 22mm EMT conduit | | 14 | Front bumper bracket | 1 | 5mm PETG FDM | Saddle clamps for 22mm EMT conduit |
| 15 | Rear bumper bracket | 1 | 5mm PETG FDM | Mirror of item 14 | | 15 | Rear bumper bracket | 1 | 5mm PETG FDM | Mirror of item 14 |
@ -92,23 +88,12 @@ PR #7 (`chassis_frame.scad`) used placeholder values. The table below records th
## Electronics Mounts ## Electronics Mounts
> ⚠️ **ARCHITECTURE CHANGE (2026-04-03):** ESP32 BALANCE (ESP32) is retired.
> Replaced by **ESP32 BALANCE** + **ESP32 IO**. Board dimensions and hole patterns TBD — await spec from max.
| # | Part | Qty | Spec | Notes | | # | Part | Qty | Spec | Notes |
|---|------|-----|------|-------| |---|------|-----|------|-------|
<<<<<<< HEAD | 13 | STM32 MAMBA F722S FC | 1 | 36×36mm PCB, 30.5×30.5mm M3 mount | Oriented USB-C port toward front |
| 13 | ESP32 BALANCE board | 1 | TBD — mount pattern TBD | PID balance loop; replaces ESP32 BALANCE |
| 13b | ESP32 IO board | 1 | TBD — mount pattern TBD | Motor/sensor/comms I/O |
| 14 | Nylon M3 standoff 6mm | 4 | F/F nylon | ESP32 board isolation |
| 15 | Anti-vibration grommet M3 | 4 | Ø6mm silicone | Under ESP32 mount pads |
| 16 | Jetson Orin module | 1 | 69.6×45mm module + carrier | 58×58mm M3 carrier hole pattern |
=======
| 13 | ESP32-S3 ESP32-S3 BALANCE FC | 1 | 36×36mm PCB, 30.5×30.5mm M3 mount | Oriented USB-C port toward front |
| 14 | Nylon M3 standoff 6mm | 4 | F/F nylon | FC vibration isolation | | 14 | Nylon M3 standoff 6mm | 4 | F/F nylon | FC vibration isolation |
| 15 | Anti-vibration grommet M3 | 4 | Ø6mm silicone | Under FC mount pads | | 15 | Anti-vibration grommet M3 | 4 | Ø6mm silicone | Under FC mount pads |
| 16 | Jetson Orin Nano Super B01 module | 1 | 69.6×45mm module + carrier | 58×58mm M3 carrier hole pattern | | 16 | Jetson Nano B01 module | 1 | 69.6×45mm module + carrier | 58×58mm M3 carrier hole pattern |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| 17 | Nylon M3 standoff 8mm | 4 | F/F nylon | Jetson board standoffs | | 17 | Nylon M3 standoff 8mm | 4 | F/F nylon | Jetson board standoffs |
--- ---
@ -159,8 +144,8 @@ Slide entire carousel up/down the stem with M6 collar bolts loosened. Tighten at
| 26 | M6×60 SHCS | 4 | ISO 4762, SS | Collar clamping bolts | | 26 | M6×60 SHCS | 4 | ISO 4762, SS | Collar clamping bolts |
| 27 | M6 hex nut | 4 | ISO 4032, SS | Captured in collar pockets | | 27 | M6 hex nut | 4 | ISO 4032, SS | Captured in collar pockets |
| 28 | M6×12 set screw | 2 | ISO 4026, SS cup-point | Stem height lock (1 per collar half) | | 28 | M6×12 set screw | 2 | ISO 4026, SS cup-point | Stem height lock (1 per collar half) |
| 29 | M3×10 SHCS | 12 | ISO 4762, SS | ESP32 mount + miscellaneous | | 29 | M3×10 SHCS | 12 | ISO 4762, SS | FC mount + miscellaneous |
| 30 | M3×6 BHCS | 4 | ISO 4762, SS | ESP32 board bolts (qty TBD pending board spec) | | 30 | M3×6 BHCS | 4 | ISO 4762, SS | FC board bolts |
| 31 | Axle lock nut (match axle tip thread) | 4 | Flanged, confirm thread | 2 per motor | | 31 | Axle lock nut (match axle tip thread) | 4 | Flanged, confirm thread | 2 per motor |
| 32 | Flat washer M5 | 32 | SS | | | 32 | Flat washer M5 | 32 | SS | |
| 33 | Flat washer M4 | 32 | SS | | | 33 | Flat washer M4 | 32 | SS | |

View File

@ -1,619 +0,0 @@
# Issue #505: 24V Charging Dock Hardware Design
**Agent:** sl-mechanical
**Status:** In Progress
**Date Started:** 2026-03-06
**Related Issues:** #159 (5V dock), #489 (docking node)
---
## Design Overview
Upgraded charging dock system for 24V DC power delivery with improved reliability, higher power capacity, and integrated ArUco marker (ID 42) for precision alignment.
### Key Specifications
| Parameter | Specification | Notes |
|-----------|---------------|-------|
| **Voltage** | 24 V DC | Upgrade from 5V (Issue #159) |
| **Power capacity** | 480 W (20 A @ 24V) | Supports battery charging + auxiliary systems |
| **Contact type** | Spring-loaded brass pads (Ø12 mm, 2 pads) | 20 mm CL-to-CL spacing |
| **Alignment method** | V-channel rails + ArUco marker ID 42 | Precision ±15 mm tolerance |
| **Docking nodes** | Compatible with Issue #489 (ROS2 docking node) | MQTT status reporting |
| **Frame material** | PETG (3D-printable) | All parts exportable as STL |
| **Contact height** | 35 mm above dock floor (configurable per robot) | Same as Issue #159 |
---
## Subsystem Design
### A. Power Distribution
#### PSU Selection (24V upgrade)
**Primary:** Mean Well IRM-240-24 or equivalent
- 240W / 10A @ 24V, open frame
- Input: 100-240V AC 50/60Hz
- Output: 24V ±5% regulated
- Recommended alternatives:
- HLK-240M24 (Hi-Link, 240W, compact)
- RECOM R-120-24 (half-power option, 120W)
- TDK-Lambda DRB-240-24 (industrial grade)
**Specifications:**
- PCB-mount or chassis-mount (via aluminum bracket)
- 2× PG7 cable glands for AC input + 24V output
- Thermal shutdown at 70°C (add heatsink if needed)
#### Power Delivery Cables
| Component | Spec | Notes |
|-----------|------|-------|
| PSU to pogo pins | 12 AWG silicone wire (red/black) | 600V rated, max 20A |
| Cable gland exits | PG7, M20 thread, 5-8 mm cable | IP67 rated |
| Strain relief | Silicone sleeve, 5 mm ID | 150 mm sections at terminations |
| Crimp terminals | M3/M4 ring lug, 12 AWG | Solder + crimped (both) |
#### Contact Resistance & Safety
- **Target contact resistance:** <50 (brass pad to pogo pin)
- **Transient voltage suppression:** Varistor (MOV) across 24V rail (14-28V clamping)
- **Inrush current limiting:** NTC thermistor (10Ω @ 25°C) or soft-start relay
- **Over-current protection:** 25A fuse (slow-blow) on PSU output
---
### B. Mechanical Structure
#### Dock Base Plate
**Material:** PETG (3D-printed)
**Dimensions:** 300 × 280 × 12 mm (L×W×H)
**Ballast:** 8× M20 hex nuts (4 pockets, 2 nuts per pocket) = ~690 g stabilization
**Features:**
- 4× M4 threaded inserts (deck mounting)
- 4× ballast pockets (underside, 32×32×8 mm each)
- Wiring channel routing (10×10 mm), PSU mounting rails
- Cable exit slot with strain relief
#### Back Wall / Pogo Housing
**Material:** PETG
**Dimensions:** 250 × 85 × 10 mm (W×H×T)
**Contact face:** 2× pogo pin bores (Ø5.7 mm, 20 mm deep)
**Features:**
- Pogo pin spring pre-load: 4 mm travel (contact engage at ~3 mm approach)
- LED status bezel mount (4× 5 mm LED holes)
- Smooth contact surface (0.4 mm finish to reduce arcing)
#### V-Guide Rails (Left & Right)
**Material:** PETG
**Function:** Self-aligning funnel for robot receiver plate
**Geometry:**
- V-channel depth: 15 mm (±7.5 mm from centerline)
- Channel angle: 60° (Vee angle) for self-centering
- Guide length: 250 mm (front edge to back wall)
- 2.5 mm wall thickness (resists impact deformation)
**Design goal:** Robot can approach ±20 mm off-center; V-rails funnel it to ±5 mm at dock contact.
#### ArUco Marker Frame
**Design:** 15 cm × 15 cm frame (150×150 mm outer), marker ID 42
**Frame mounting:**
- Material: PETG (3D-printed frame + acrylic cover)
- Marker insertion: Side-slot, captures 100×100 mm laminated ArUco label
- Position: Dock entrance, 1.5 m height for camera visibility
- Lighting: Optional white LED ring around frame for contrast
**Marker specs:**
- Dictionary: `cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)`
- Marker ID: 42 (uint8, fits DICT_4X4_250: 0-249)
- Printed size: 100×100 mm
- Media: Glossy photo paper + 80 µm lamination (weather protection)
#### PSU Bracket
**Material:** PETG
**Attachment:** 4× M4 SHCS to base rear, bolts through PSU flanges
**Features:**
- Mounting pads for PSU feet
- Cable routing guides (AC input + 24V output)
- Thermal airflow clearance (30 mm minimum)
- Optional DIN-rail adapter (for rackmount variant)
#### LED Status Bezel
**Material:** PETG
**Function:** 4× LED indicator display (charging state feedback)
**LEDs & Resistors:**
| LED | Color | State | Vf (typ) | Resistor | Notes |
|-----|-------|-------|----------|----------|-------|
| L1 | Red | SEARCHING | 2.0 V | 180 Ω | No robot contact |
| L2 | Yellow | ALIGNED | 2.1 V | 180 Ω | Contact made, BMS pre-charge |
| L3 | Blue | CHARGING | 3.2 V | 100 Ω | Active charging |
| L4 | Green | FULL | 2.1 V | 180 Ω | Trickle/float mode |
**Current calculation (for 24V rail):**
- Red/Yellow/Green: R = (24 Vf) / 0.020 ≈ 1000 Ω (use 1.0 kΩ 1/4W)
- Blue: R = (24 3.2) / 0.020 = 1040 Ω (use 1.0 kΩ)
**Control:**
- Jetson Orin NX GPIO output (via I2C LED driver or direct GPIO)
- Pulldown resistor (10 kΩ) on each GPIO if using direct drive
- Alternative: TP4056 analog output pins (if in feedback path)
---
### C. Robot Receiver (Mating Interface)
**Cross-variant compliance:** Same receiver design works for SaltyLab, SaltyRover, SaltyTank with different mounting interfaces.
#### Contact Pads
- **Material:** Bare brass (10-12 mm OD, 2 mm thick)
- **Pressing:** 0.1 mm interference fit into PETG housing
- **Polarity marking:** "+" slot on right side (+X), "-" unmarked on left
- **Solder lug:** M3 ring lug on rear face (connects to robot BMS)
#### V-Nose Guide
- **Profile:** Chamfered 14° V-nose (30 mm wide)
- **Function:** Mates with dock V-rails for alignment funnel
#### Mounting Variants
| Robot | Mount Type | Fastener | Height Adjustment |
|-------|-----------|----------|------------------|
| SaltyLab | Stem collar (split, 2×) | M4 × 16 SHCS (2×) | Tune via firmware offset |
| SaltyRover | Deck flange (bolt-on) | M4 × 16 SHCS (4×) | 20 mm shim if needed |
| SaltyTank | Skid plate (bolt-on) | M4 × 16 SHCS (4×) | 55 mm ramp shim recommended |
---
## 3D-Printable Parts (STL Exports)
All parts print in PETG, 0.2 mm layer height, 40-60% infill:
| Part | File | Qty | Infill | Est. Mass | Notes |
|------|------|-----|--------|----------|-------|
| Dock base | `charging_dock_505.scad` (base_stl) | 1 | 60% | ~420 g | Print on large bed (300×280 mm) |
| Back wall + pogo | `charging_dock_505.scad` (back_wall_stl) | 1 | 40% | ~140 g | Smooth face finish required |
| V-rail left | `charging_dock_505.scad` (guide_rail_stl) | 1 | 50% | ~65 g | Mirror for right side in slicer |
| V-rail right | *(mirror of left)* | 1 | 50% | ~65 g | — |
| ArUco frame | `charging_dock_505.scad` (aruco_frame_stl) | 1 | 30% | ~35 g | Slot accepts 100×100 mm marker |
| PSU bracket | `charging_dock_505.scad` (psu_bracket_stl) | 1 | 40% | ~45 g | — |
| LED bezel | `charging_dock_505.scad` (led_bezel_stl) | 1 | 40% | ~15 g | — |
| **Receiver (Lab)** | `charging_dock_receiver_505.scad` (lab_stl) | 1 | 60% | ~32 g | Stem collar variant |
| **Receiver (Rover)** | `charging_dock_receiver_505.scad` (rover_stl) | 1 | 60% | ~36 g | Deck flange variant |
| **Receiver (Tank)** | `charging_dock_receiver_505.scad` (tank_stl) | 1 | 60% | ~42 g | Extended nose variant |
---
## Bill of Materials (BOM)
### Electrical Components
#### Power Supply & Wiring
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| E1 | PSU — 24V 10A | Mean Well IRM-240-24 or Hi-Link HLK-240M24 | 1 | ~$4060 | ~$50 | Digi-Key, Amazon |
| E2 | 12 AWG silicone wire | Red + black, 600V rated, 5 m spool | 1 | ~$15 | ~$15 | McMaster-Carr, AliExpress |
| E3 | PG7 cable gland | M20 thread, IP67, 58 mm cable | 2 | ~$3 | ~$6 | AliExpress, Heilind |
| E4 | Varistor (MOV) | 1828V, 1 kA | 1 | ~$1 | ~$1 | Digi-Key |
| E5 | Fuse — 25A | T25 slow-blow, 5×20 mm | 1 | ~$0.50 | ~$0.50 | Digi-Key |
| E6 | Fuse holder | 5×20 mm inline, 20A rated | 1 | ~$2 | ~$2 | Amazon |
| E7 | Crimp ring terminals | M3, 12 AWG, tin-plated | 8 | ~$0.20 | ~$1.60 | Heilind, AliExpress |
| E8 | Strain relief sleeve | 5 mm ID silicone, 1 m | 1 | ~$5 | ~$5 | McMaster-Carr |
#### Pogo Pins & Contacts
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| C1 | Pogo pin assembly | Spring-loaded, Ø5.5 mm OD, 20 mm, 20A rated, 4 mm travel | 2 | ~$812 | ~$20 | Preci-Dip, Jst, AliExpress |
| C2 | Brass contact pad | Ø12 × 2 mm, H68 brass, bare finish | 2 | ~$3 | ~$6 | Metal supplier (Metals USA, OnlineMetals) |
| C3 | Solder lug — M3 | Copper ring, tin-plated | 4 | ~$0.40 | ~$1.60 | Heilind, Amazon |
#### LED Status Circuit
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| L1 | 5 mm LED — Red | 2.0 V, 20 mA, diffuse | 1 | ~$0.30 | ~$0.30 | Digi-Key |
| L2 | 5 mm LED — Yellow | 2.1 V, 20 mA, diffuse | 1 | ~$0.30 | ~$0.30 | Digi-Key |
| L3 | 5 mm LED — Blue | 3.2 V, 20 mA, diffuse | 1 | ~$0.50 | ~$0.50 | Digi-Key |
| L4 | 5 mm LED — Green | 2.1 V, 20 mA, diffuse | 1 | ~$0.30 | ~$0.30 | Digi-Key |
| R1R4 | Resistor — 1 kΩ 1/4W | Metal film, 1% tolerance | 4 | ~$0.10 | ~$0.40 | Digi-Key |
| J1 | Pin header 2.54 mm | 1×6 right-angle | 1 | ~$0.50 | ~$0.50 | Digi-Key |
#### Current Sensing (Optional)
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| S1 | INA219 I2C shunt monitor | 16-bit, I2C addr 0x40, 26V max | 1 | ~$5 | ~$5 | Adafruit, Digi-Key |
| S2 | SMD resistor — 0.1 Ω | 1206, 1W | 1 | ~$1 | ~$1 | Digi-Key |
### Mechanical Hardware
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| M1 | M20 hex nut | Steel DIN 934, ~86 g | 8 | ~$0.80 | ~$6.40 | Grainger, Home Depot |
| M2 | M4 × 16 SHCS | Stainless A4 DIN 912 | 16 | ~$0.30 | ~$4.80 | Grainger |
| M3 | M4 × 10 BHCS | Stainless A4 DIN 7380 | 8 | ~$0.25 | ~$2.00 | Grainger |
| M4 | M4 heat-set insert | Brass, threaded, M4 | 20 | ~$0.15 | ~$3.00 | McMaster-Carr |
| M5 | M3 × 16 SHCS | Stainless, LED bezel | 4 | ~$0.20 | ~$0.80 | Grainger |
| M6 | M3 hex nut | DIN 934 | 4 | ~$0.10 | ~$0.40 | Grainger |
| M7 | M8 × 40 BHCS | Zinc-plated, floor anchors (optional) | 4 | ~$0.50 | ~$2.00 | Grainger |
| M8 | Rubber foot | Ø20 × 5 mm, self-adhesive | 4 | ~$0.80 | ~$3.20 | Amazon |
### ArUco Marker & Frame
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| A1 | ArUco marker print | 100×100 mm, ID=42, DICT_4X4_250, glossy photo paper | 2 | ~$1.50 | ~$3.00 | Print locally or AliExpress |
| A2 | Lamination pouch | A4, 80 µm thick | 2 | ~$0.40 | ~$0.80 | Amazon, Staples |
| A3 | Acrylic cover sheet | Clear, 3 mm, 150×150 mm | 1 | ~$3 | ~$3.00 | McMaster-Carr |
### Consumables & Assembly
| # | Description | Spec | Qty | Unit Cost | Total | Source |
|---|---|---|---|---|---|---|
| X1 | Solder wire | 63/37 Sn/Pb or lead-free, 1 m | 1 | ~$3 | ~$3.00 | Digi-Key |
| X2 | Flux paste | No-clean, 25 mL | 1 | ~$4 | ~$4.00 | Digi-Key |
| X3 | Loctite 243 | Thread-locker (medium strength), 10 mL | 1 | ~$4 | ~$4.00 | Grainger |
| X4 | Epoxy adhesive | Two-part, 25 mL | 1 | ~$6 | ~$6.00 | Home Depot |
---
## Assembly Procedure
### Phase 1: Preparation
1. **Print all PETG parts** (see STL export list above)
- Base: 0.3 mm layer, 60% infill (heavy/stable)
- Back wall: 0.2 mm, 40% infill
- Rails & brackets: 0.2 mm, 40-50% infill
- Support removal: slow, avoid pogo bore damage
2. **Prepare ballast nuts**
- Sort 8× M20 hex nuts (stack in 4 pockets, 2 per pocket)
- Optional: fill pockets with epoxy to prevent rattling
3. **Press brass contact pads**
- Apply 0.1 mm interference press-fit into receiver housing bores
- Use arbor press @ ~2 tons force
- Or use slow manual press (avoid chipping brass edges)
### Phase 2: Base Assembly
4. **Install heat-set M4 inserts** into base plate
- Back wall attach points (3×)
- Guide rail attach points (4× each side)
- ArUco mast feet (4×)
- PSU bracket mount (4×)
- Use soldering iron (350°C) or insert tool, press vertically
5. **Ballast installation**
- Insert M20 hex nuts into base pockets (from underside)
- Verify pockets are flush, no protrusions into wiring channel
- Optional: epoxy-lock nuts with 5-minute epoxy
6. **Install pogo pins** into back wall
- Press spring-loaded pins from front face into Ø5.7 mm bores (20 mm deep)
- Flange seats against counterbore shoulder at 1.5 mm depth
- Apply small drop of Loctite 243 to bore wall (prevents rotation)
### Phase 3: Electrical Assembly
7. **Solder wires to pogo pin terminals**
- 12 AWG red wire → POGO+ pin
- 12 AWG black wire → POGO- pin
- Solder both in & out of lug for redundancy
- Add ~50 mm strain relief sleeve over each joint
8. **Route pogo wires through base wiring channel**
- Guide down channel (10×10 mm trough)
- Exit through cable gland slot on rear
9. **Assemble PSU bracket**
- Bolt Mean Well IRM-240-24 (or equivalent) to bracket pads
- 4× M4 fasteners through bracket to base rear
- Orient PSU exhaust away from dock (for ventilation)
10. **Connect 24V wiring**
- Pogo+ wire (red) → PSU V+ terminal
- Pogo- wire (black) → PSU COM/GND terminal
- Observe polarity strictly (reverse = short circuit)
11. **Install power protection**
- Fuse holder in-line on PSU V+ output (25A slow-blow)
- Varistor (MOV, 1828V) across V+/COM rails (clamp transients)
- Optional: NTC thermistor (10Ω @ 25°C) in series for soft-start
12. **Wire AC mains input** (if not pre-assembled)
- Route AC input through cable gland on PSU bracket
- Connect to PSU AC terminals (L, N, PE if applicable)
- Ensure all connections are soldered + crimped
### Phase 4: LED Assembly
13. **Install LED bezel into back wall**
- 4× 5 mm LEDs press-fit into bezel holes (bodies recessed ~2 mm)
- Solder resistors (1 kΩ 1/4W) to LED anodes on rear
- Connect all LED cathodes to common GND line (black wire to PSU COM)
- Wire LED control lines to Jetson Orin NX GPIO (via I2C expander if needed)
14. **Connect LED header**
- 2.54 mm pin header (1×6) plugs into LED control harness
- Pin 1: LED1 (red, SEARCHING)
- Pin 2: LED2 (yellow, ALIGNED)
- Pin 3: LED3 (blue, CHARGING)
- Pin 4: LED4 (green, FULL)
- Pins 56: GND, +24V (power for LED feedback monitoring)
### Phase 5: Mechanical Assembly
15. **Bolt back wall to base**
- 3× M4×16 SHCS from underside of base
- Tighten to ~5 Nm (snug, don't overtighten plastic)
- Back wall should be perpendicular to base (verify with level)
16. **Attach V-guide rails**
- Left rail: 4× M4 fasteners into base inserts (front & rear attach)
- Right rail: Mirror (flip STL in slicer) or manually mirror geometry
- Verify V-channels are parallel & symmetrical (±2 mm tolerance)
17. **Mount ArUco marker frame**
- Bolt 4× M4×10 fasteners to frame feet (attach to base front)
- Insert laminated 100×100 mm ArUco marker (ID 42) into frame slot
- Verify marker is flat & centered (no curl or shadow)
18. **Attach rubber feet** (or floor anchors)
- 4× self-adhesive rubber feet on base underside corners
- OR drill M8 holes through base (optional: permanent floor mounting)
### Phase 6: Robot Receiver Assembly
19. **Assemble robot receiver** (per variant)
- **SaltyLab:** 2-piece stem collar (M4×16 clamps Ø25 mm stem)
- **SaltyRover:** Single flange piece (4× M4 to deck underbelly)
- **SaltyTank:** Single piece w/ extended nose (4× M4 to skid plate)
20. **Press brass pads into receiver**
- Ø12 mm pads press into 0.1 mm interference bores
- Apply Loctite 603 retaining compound to bore before pressing
- Manual arbor press @ ~1-2 tons force; pads should be proud 0.2 mm
21. **Solder receiver wires**
- 12 AWG wires (red/black) solder to M3 solder lugs on pad rear
- Route wires through wire channel on mount face
- Terminate to robot BMS/charging PCB input
---
## Wiring Diagram (24V System)
```
┌─────────────────────────────────────────────────────────────┐
│ MAINS INPUT (AC) │
│ 110/220 V AC │
└────────────┬────────────────────────────────────────────────┘
┌──────────────┐
│ IRM-240-24 │ 24V / 10A out (240W)
│ PSU │ ±5% regulated, open-frame
└──┬───────┬───┘
+24V │ │ GND
│ │
┌────┴┐ ┌─┴────┐
│ [F] │ │ [F] │ Fuse holder (25A slow-blow)
│ │ │ │
│ +24 │ │ GND │ 12 AWG silicone wire to back wall
│ │ │ │
└────┬┘ └─┬────┘
│ │
+24V│ │GND
▼ ▼
┌─────────────────┐
│ Back wall │
│ ┌───────────┐ │
│ │ POGO+ │ │ Spring-loaded contact pin (+24V)
│ │ POGO- │ │ Spring-loaded contact pin (GND)
│ └────┬──────┘ │
│ │ │
│ ┌─────┴─────┐ │
│ │ LED 1-4 │ │ Red, Yellow, Blue, Green indicators
│ │ Resistors│ │ 1 kΩ limiting resistors (×4)
│ │ [GPIO] │ │ Control from Jetson Orin NX I2C
│ └───────────┘ │
└─────┬───────────┘
═════╧════════ DOCK / ROBOT AIR GAP (≤50 mm) ═════════════
┌──────────────────┐
│ Robot Receiver │
│ ┌────────────┐ │
│ │ Contact + │ │ Brass pad (Ø12×2 mm) [+24V]
│ │ Contact - │ │ Brass pad (Ø12×2 mm) [GND]
│ └──┬──┬──────┘ │
│ │ │ │
│ 12 AWG wires │ Red/black to BMS
│ │ │ │
│ ┌──▼──▼──┐ │
│ │ Robot │ │
│ │ BMS │ │
│ │Battery │ │ Charging current: 015A (typical)
│ └────────┘ │
└──────────────────┘
OPTIONAL — CURRENT SENSING (Diagnostic)
│ +24V
┌────┴────┐
│[INA219] │ I2C current monitor (0.1Ω sense resistor)
│ I2C 0x40│ Jetson reads dock current → state machine
└────┬────┘
│ GND
LED STATE MACHINE CONTROL (from docking_node.py):
State GPIO/Signal LED Output
─────────────────────────────────────────
SEARCHING GPIO H Red LED ON (20 mA, 1 kΩ)
ALIGNED GPIO H Yellow LED ON (pre-charge active)
CHARGING GPIO H Blue LED ON (>1 A charging)
FULL/COMPLETE GPIO H Green LED ON (float mode)
GPIO driven via Jetson Orin NX I2C LED driver (e.g., PCA9685)
or direct GPIO if firmware implements bitbang logic.
```
---
## Integration with ROS2 Docking Node (#489)
**Docking node location:** `./jetson/ros2_ws/src/saltybot_docking/docking_node.py`
### MQTT Topics
**Status reporting (outbound):**
```
saltybot/docking/status → { state, robot_id, contact_voltage, charge_current }
saltybot/docking/led → { red, yellow, blue, green } [0=OFF, 1=ON, blink_hz]
```
**Command subscriptions (inbound):**
```
saltybot/docking/reset → trigger dock reset (clear fault)
saltybot/docking/park → move robot out of dock (e.g., after full charge)
```
### Firmware Integration
**State machine (4 states):**
1. **SEARCHING** — No robot contact; dock waits for approach (ArUco marker detection via Jetson camera)
2. **ALIGNED** — Contact made (BMS pre-charge active); dock supplies trickle current (~100 mA) while robot capacitors charge
3. **CHARGING** — Main charge active; dock measures current via INA219, feedback to BMS
4. **FULL** — Target voltage reached (≥23.5 V, <100 mA draw); dock holds float voltage
**Current sensing feedback:**
- INA219 I2C shunt on 24V rail monitors dock-to-robot current
- Jetson polls at 10 Hz; state transitions trigger LED updates & MQTT publish
- Hysteresis prevents flickering (state valid for ≥2 sec)
---
## Testing Checklist
- [ ] **Electrical safety**
- [ ] 24V output isolated from mains AC (< 2.5 kV isolation @ 60 Hz)
- [ ] Fuse 25A blocks short-circuit (verify blow @ >30 A)
- [ ] Varistor clamps transient overvoltage (check 28V limit)
- [ ] All crimps are soldered + crimped (pull test: no slippage @ 10 lbf)
- [ ] **Mechanical**
- [ ] Base level on 4 rubber feet (no rocking)
- [ ] V-rails parallel within ±2 mm across 250 mm length
- [ ] Back wall perpendicular to base (level ±1°)
- [ ] Pogo pins extend 4 mm from back wall face (spring preload correct)
- [ ] **Contact alignment**
- [ ] Robot receiver pads contact pogo pins with ≥3 mm contact face overlap
- [ ] Contact resistance < 50 (measure with multimeter on lowest ohm scale during light press)
- [ ] No visible arcing or pitting (inspect pads after 10 charge cycles)
- [ ] **Power delivery**
- [ ] 24V output at PSU: 23.524.5 V (under load)
- [ ] 24V at pogo pins: ≥23.5 V (< 0.5 V droop @ 10 A)
- [ ] Robot receives 24V ± 1 V (measure at BMS input)
- [ ] **LED status**
- [ ] Red (SEARCHING) steady on before robot approach
- [ ] Yellow (ALIGNED) turns on when pads make contact
- [ ] Blue (CHARGING) turns on when charge current > 500 mA
- [ ] Green (FULL) turns on when current drops < 100 mA (float mode)
- [ ] **ArUco marker**
- [ ] Marker ID 42 is readable by Jetson camera from 1.5 m @ 90° angle
- [ ] No glare or shadow on marker (add diffuse lighting if needed)
- [ ] Marker detected by cv2.aruco in < 100 ms
- [ ] **MQTT integration**
- [ ] Dock publishes status every 5 sec (or on state change)
- [ ] LED state matches reported dock state
- [ ] Current sensing (INA219) reads within ±2% of true dock current
---
## Firmware/Software Requirements
### Jetson Orin NX (Docking controller)
**Python dependencies:**
```bash
pip install opencv-contrib-python # ArUco marker detection
pip install adafruit-circuitpython-ina219 # Current sensing
pip install rclpy # ROS2
pip install paho-mqtt # MQTT status reporting
```
**Key Python modules:**
- `cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)` → ArUco ID 42 detection
- `Adafruit_INA219` → I2C current monitoring @ 0x40
- GPIO library → LED control (via I2C LED driver or direct GPIO)
**ROS2 node:** `saltybot_docking/docking_node.py` (already present, Issue #489)
- Subscribes to `/docking/approach_request`
- Publishes to `/docking/status`, `/docking/led_state`
- MQTT gateway for legacy systems
---
## Files to Commit
**New files for Issue #505:**
```
chassis/
├── charging_dock_505.scad [Main dock 24V design]
├── charging_dock_receiver_505.scad [Robot receiver 24V variant]
├── ISSUE_505_CHARGING_DOCK_24V_DESIGN.md [This file]
├── charging_dock_505_BOM.csv [Excel-friendly BOM export]
└── charging_dock_505_WIRING_DIAGRAM.md [Detailed wiring guide]
docs/
└── Issue_505_Assembly_Guide.md [Step-by-step assembly photos + text]
```
---
## Revision History
| Date | Version | Changes |
|------|---------|---------|
| 2026-03-06 | 1.0 | Initial design (24V upgrade from Issue #159) |
---
## Next Steps
1. ✅ Design specification (this document)
2. ⏳ OpenSCAD CAD files (`charging_dock_505.scad`, `charging_dock_receiver_505.scad`)
3. ⏳ BOM export (CSV format for procurement)
4. ⏳ 3D-printed prototype testing
5. ⏳ Electrical integration with Jetson docking node
6. ⏳ ArUco marker calibration & documentation
7. ⏳ PR submission & merge to `main`
---
**Designer:** sl-mechanical
**Date:** 2026-03-06
**Status:** Design Specification Complete — Awaiting CAD Implementation

View File

@ -1,410 +0,0 @@
// ============================================================
// battery_holder.scad 6S LiPo Battery Holder for 2020 T-Slot Chassis
// Issue: #588 Agent: sl-mechanical Date: 2026-03-14
// ============================================================
//
// Parametric bracket holding a 6S 5000 mAh LiPo pack on 2020 aluminium
// T-slot rails. Designed for low centre-of-gravity mounting: pack sits
// flat between the two chassis rails, as close to ground as clearance
// allows. Quick-release via captive Velcro straps battery swap in
// under 60 s without tools.
//
// Architecture:
// Tray flat floor + perimeter walls, battery sits inside
// Rail saddles two T-nut feet drop onto 2020 rails, thumbscrew locks
// Strap slots four pairs of slots for 25 mm Velcro strap loops
// XT60 window cut-out in rear wall for XT60 connector exit
// Balance port open channel in front wall for balance lead routing
// QR tab front-edge pull tab for one-handed battery extraction
//
// Part catalogue:
// Part 1 battery_tray() Main tray body (single-piece print)
// Part 2 rail_saddle() T-nut saddle foot (print x2 per tray)
// Part 3 strap_guide() 25 mm Velcro strap guide (print x4)
// Part 4 assembly_preview()
//
// Hardware BOM:
// 2× M3 × 16 mm SHCS + M3 hex nut T-nut rail clamp thumbscrews
// 2× 25 mm × 250 mm Velcro strap battery retention (hook + loop)
// 1× XT60 female connector (mounted on ESC/PDB harness)
// battery slides in from front, Velcro strap over top
//
// 6S LiPo target pack (verify with calipers packs vary by brand):
// BATT_L = 155 mm (length, X axis in tray)
// BATT_W = 48 mm (width, Y axis in tray)
// BATT_H = 52 mm (height, Z axis in tray)
// Clearance 1 mm each side added automatically (BATT_CLEAR)
//
// Mounting:
// Rail span : RAIL_SPAN distance between 2020 rail centrelines
// Default 80 mm; adjust to chassis rail spacing
// Saddle height: SADDLE_H total height of saddle (tray floor above rail)
// Keep low for CoG; default 8 mm
//
// RENDER options:
// "assembly" full assembly preview (default)
// "tray_stl" Part 1 battery tray
// "saddle_stl" Part 2 rail saddle (print x2)
// "strap_guide_stl" Part 3 strap guide (print x4)
//
// Export commands:
// openscad battery_holder.scad -D 'RENDER="tray_stl"' -o bh_tray.stl
// openscad battery_holder.scad -D 'RENDER="saddle_stl"' -o bh_saddle.stl
// openscad battery_holder.scad -D 'RENDER="strap_guide_stl"' -o bh_strap_guide.stl
//
// Print settings (all parts):
// Material : PETG
// Perimeters : 5 (tray, saddle), 3 (strap_guide)
// Infill : 40 % gyroid (tray floor, saddle), 20 % (strap_guide)
// Orientation:
// tray floor flat on bed (no supports needed)
// saddle flat face on bed (no supports)
// strap_guide flat face on bed (no supports)
// ============================================================
$fn = 64;
e = 0.01;
// Battery pack dimensions (verify with calipers)
BATT_L = 155.0; // pack length (X)
BATT_W = 48.0; // pack width (Y)
BATT_H = 52.0; // pack height (Z)
BATT_CLEAR = 1.0; // per-side fit clearance
// Tray geometry
TRAY_FLOOR_T = 4.0; // tray floor thickness
TRAY_WALL_T = 4.0; // tray perimeter wall thickness
TRAY_WALL_H = 20.0; // tray wall height (Z) cradles lower half of pack
TRAY_FILLET_R = 3.0; // inner corner radius
// Inner tray cavity (battery + clearance)
TRAY_INN_L = BATT_L + 2*BATT_CLEAR;
TRAY_INN_W = BATT_W + 2*BATT_CLEAR;
// Outer tray footprint
TRAY_OUT_L = TRAY_INN_L + 2*TRAY_WALL_T;
TRAY_OUT_W = TRAY_INN_W + 2*TRAY_WALL_T;
TRAY_TOTAL_H = TRAY_FLOOR_T + TRAY_WALL_H;
// Rail interface
RAIL_SPAN = 80.0; // distance between 2020 rail centrelines (Y)
RAIL_W = 20.0; // 2020 extrusion width
SLOT_NECK_H = 3.2; // T-slot neck height
SLOT_OPEN = 6.0; // T-slot opening width
SLOT_INN_W = 10.2; // T-slot inner width
SLOT_INN_H = 5.8; // T-slot inner height
// T-nut / saddle geometry
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
TNUT_NUT_AF = 5.5; // M3 hex nut across-flats
TNUT_NUT_H = 2.4;
TNUT_BOLT_D = 3.3; // M3 clearance
SADDLE_W = 30.0; // saddle foot width (X, along rail)
SADDLE_T = 8.0; // saddle body thickness (Z, above rail top face)
SADDLE_PAD_T = 2.0; // rubber-pad recess depth (optional anti-slip)
// Velcro strap slots
STRAP_W = 26.0; // 25 mm strap + 1 mm clearance
STRAP_T = 4.0; // slot through-thickness (tray wall)
// Four slot pairs: one near each end of tray (X), one each side (Y)
// Slots run through side walls (Y direction) strap loops over battery top
// XT60 connector window (rear wall)
XT60_W = 14.0; // XT60 body width
XT60_H = 18.0; // XT60 body height (with cable exit)
XT60_OFFSET_Z = 4.0; // height above tray floor
// Balance lead port (front wall)
BAL_W = 40.0; // balance lead bundle width (6S = 7 wires)
BAL_H = 6.0; // balance lead channel height
BAL_OFFSET_Z = 8.0; // height above tray floor
// Quick-release pull tab (front edge)
QR_TAB_W = 30.0; // tab width
QR_TAB_H = 12.0; // tab height above front wall top
QR_TAB_T = 4.0; // tab thickness
QR_HOLE_D = 10.0; // finger-loop hole diameter
// Strap guide clip
GUIDE_OD = STRAP_W + 6.0;
GUIDE_T = 3.0;
GUIDE_BODY_H = 14.0;
// Fasteners
M3_D = 3.3;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly_preview();
else if (RENDER == "tray_stl") battery_tray();
else if (RENDER == "saddle_stl") rail_saddle();
else if (RENDER == "strap_guide_stl") strap_guide();
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly_preview() {
// Ghost 2020 rails (Y direction, RAIL_SPAN apart)
for (ry = [-RAIL_SPAN/2, RAIL_SPAN/2])
%color("Silver", 0.28)
translate([-TRAY_OUT_L/2 - 30, ry - RAIL_W/2, -SADDLE_T - TNUT_H])
cube([TRAY_OUT_L + 60, RAIL_W, RAIL_W]);
// Rail saddles (left and right)
for (sy = [-RAIL_SPAN/2, RAIL_SPAN/2])
color("DimGray", 0.85)
translate([0, sy, -SADDLE_T])
rail_saddle();
// Battery tray (sitting on saddles)
color("OliveDrab", 0.85)
battery_tray();
// Battery ghost
%color("SaddleBrown", 0.35)
translate([-BATT_L/2, -BATT_W/2, TRAY_FLOOR_T])
cube([BATT_L, BATT_W, BATT_H]);
// Strap guides (4×, two each end)
for (sx = [-TRAY_OUT_L/2 + STRAP_W/2 + TRAY_WALL_T + 8,
TRAY_OUT_L/2 - STRAP_W/2 - TRAY_WALL_T - 8])
for (sy = [-1, 1])
color("SteelBlue", 0.75)
translate([sx, sy*(TRAY_OUT_W/2), TRAY_TOTAL_H + 2])
rotate([sy > 0 ? 0 : 180, 0, 0])
strap_guide();
}
// ============================================================
// PART 1 BATTERY TRAY
// ============================================================
// Single-piece tray: flat floor, four perimeter walls, T-nut saddle
// attachment bosses on underside, Velcro strap slots through side walls,
// XT60 window in rear wall, balance lead channel in front wall, and
// quick-release pull tab on front edge.
//
// Battery inserts from the front (X end) front wall is lower than
// rear wall so the pack slides in and the rear wall stops it.
// Velcro straps loop over the top of the pack through the side slots.
//
// Coordinate convention:
// X: along battery length (X = front/plug-end, +X = rear/balance-end)
// Y: across battery width (centred, ±TRAY_OUT_W/2)
// Z: vertical (Z=0 = tray floor top face; Z = underside saddles)
//
// Print: floor flat on bed, PETG, 5 perims, 40% gyroid. No supports.
module battery_tray() {
// Short rear wall height (XT60 connector exits here full wall height)
// Front wall is lower to allow battery slide-in
front_wall_h = TRAY_WALL_H * 0.55; // 55% height battery slides over
difference() {
union() {
// Floor
translate([-TRAY_OUT_L/2, -TRAY_OUT_W/2, -TRAY_FLOOR_T])
cube([TRAY_OUT_L, TRAY_OUT_W, TRAY_FLOOR_T]);
// Rear wall (+X, full height)
translate([TRAY_INN_L/2, -TRAY_OUT_W/2, 0])
cube([TRAY_WALL_T, TRAY_OUT_W, TRAY_WALL_H]);
// Front wall (X, lowered for slide-in)
translate([-TRAY_INN_L/2 - TRAY_WALL_T, -TRAY_OUT_W/2, 0])
cube([TRAY_WALL_T, TRAY_OUT_W, front_wall_h]);
// Side walls (±Y)
for (sy = [-1, 1])
translate([-TRAY_OUT_L/2,
sy*(TRAY_INN_W/2 + (sy>0 ? 0 : -TRAY_WALL_T)),
0])
cube([TRAY_OUT_L,
TRAY_WALL_T,
TRAY_WALL_H]);
// Quick-release pull tab (front wall top edge)
translate([-TRAY_INN_L/2 - TRAY_WALL_T - e,
-QR_TAB_W/2, front_wall_h])
cube([QR_TAB_T, QR_TAB_W, QR_TAB_H]);
// Saddle attachment bosses (underside, one per rail)
// Bosses drop into saddle sockets; M3 bolt through floor
for (sy = [-RAIL_SPAN/2, RAIL_SPAN/2])
translate([-SADDLE_W/2, sy - SADDLE_W/2, -TRAY_FLOOR_T - SADDLE_T/2])
cube([SADDLE_W, SADDLE_W, SADDLE_T/2 + e]);
}
// Battery cavity (hollow interior)
translate([-TRAY_INN_L/2, -TRAY_INN_W/2, -e])
cube([TRAY_INN_L, TRAY_INN_W, TRAY_WALL_H + 2*e]);
// XT60 connector window (rear wall)
// Centred on rear wall, low position so cable exits cleanly
translate([TRAY_INN_L/2 - e, -XT60_W/2, XT60_OFFSET_Z])
cube([TRAY_WALL_T + 2*e, XT60_W, XT60_H]);
// Balance lead channel (front wall)
// Wide slot for 6S balance lead (7-pin JST-XH ribbon)
translate([-TRAY_INN_L/2 - TRAY_WALL_T - e,
-BAL_W/2, BAL_OFFSET_Z])
cube([TRAY_WALL_T + 2*e, BAL_W, BAL_H]);
// Velcro strap slots (side walls, 2 pairs)
// Pair A: near front end (X), Pair B: near rear end (+X)
// Each slot runs through the wall in Y direction
for (sx = [-TRAY_INN_L/2 + STRAP_W*0.5 + 10,
TRAY_INN_L/2 - STRAP_W*0.5 - 10])
for (sy = [-1, 1]) {
translate([sx - STRAP_W/2,
sy*(TRAY_INN_W/2) - (sy > 0 ? TRAY_WALL_T + e : -e),
TRAY_WALL_H * 0.35])
cube([STRAP_W, TRAY_WALL_T + 2*e, STRAP_T]);
}
// QR tab finger-loop hole
translate([-TRAY_INN_L/2 - TRAY_WALL_T/2,
0, front_wall_h + QR_TAB_H * 0.55])
rotate([0, 90, 0])
cylinder(d = QR_HOLE_D, h = QR_TAB_T + 2*e, center = true);
// Saddle bolt holes (M3 through floor into saddle boss)
for (sy = [-RAIL_SPAN/2, RAIL_SPAN/2])
translate([0, sy, -TRAY_FLOOR_T - e])
cylinder(d = M3_D, h = TRAY_FLOOR_T + 2*e);
// Floor lightening grid (non-structural area)
// 2D grid of pockets reduces weight without weakening battery support
for (gx = [-40, 0, 40])
for (gy = [-12, 12])
translate([gx, gy, -TRAY_FLOOR_T - e])
cylinder(d = 14, h = TRAY_FLOOR_T - 1.5 + e);
// Inner corner chamfers (battery slide-in guidance)
// 45° chamfers at bottom-front inner corners
translate([-TRAY_INN_L/2, -TRAY_INN_W/2 - e, -e])
rotate([0, 0, 45])
cube([4, 4, TRAY_WALL_H * 0.3 + e]);
translate([-TRAY_INN_L/2, TRAY_INN_W/2 + e, -e])
rotate([0, 0, -45])
cube([4, 4, TRAY_WALL_H * 0.3 + e]);
}
}
// ============================================================
// PART 2 RAIL SADDLE
// ============================================================
// T-nut foot that clamps to the top face of a 2020 T-slot rail.
// Battery tray boss drops into saddle socket; M3 bolt through tray
// floor and saddle body locks everything together.
// M3 thumbscrew through side of saddle body grips the rail T-groove
// (same thumbscrew interface as all other SaltyLab rail brackets).
//
// Saddle sits on top of rail (no T-nut tongue needed saddle clamps
// from the top using a T-nut inserted into the rail T-groove from the
// end). Low profile keeps battery CoG as low as possible.
//
// Print: flat base on bed, PETG, 5 perims, 50% gyroid.
module rail_saddle() {
sock_d = SADDLE_W - 4; // tray boss socket diameter
difference() {
union() {
// Main saddle body
translate([-SADDLE_W/2, -SADDLE_W/2, 0])
cube([SADDLE_W, SADDLE_W, SADDLE_T]);
// T-nut tongue (enters rail T-groove from above)
translate([-TNUT_W/2, -TNUT_L/2, -SLOT_NECK_H])
cube([TNUT_W, TNUT_L, SLOT_NECK_H + e]);
// T-nut inner body (locks in groove)
translate([-TNUT_W/2, -TNUT_L/2, -SLOT_NECK_H - (TNUT_H - SLOT_NECK_H)])
cube([TNUT_W, TNUT_L, TNUT_H - SLOT_NECK_H + e]);
}
// Rail channel clearance (bottom of saddle straddles rail)
// Saddle body has a channel that sits over the rail top face
translate([-RAIL_W/2 - e, -SADDLE_W/2 - e, -e])
cube([RAIL_W + 2*e, SADDLE_W + 2*e, 2.0]);
// M3 clamp bolt bore (through saddle body into T-nut)
translate([0, 0, -SLOT_NECK_H - TNUT_H - e])
cylinder(d = TNUT_BOLT_D, h = SADDLE_T + TNUT_H + 2*e);
// M3 hex nut pocket (top face of saddle for thumbscrew)
translate([0, 0, SADDLE_T - TNUT_NUT_H - 0.5])
cylinder(d = TNUT_NUT_AF / cos(30),
h = TNUT_NUT_H + 0.6, $fn = 6);
// Tray boss socket (top face of saddle, tray boss nests here)
// Cylindrical socket receives tray underside boss; M3 bolt centres
translate([0, 0, SADDLE_T - 3])
cylinder(d = sock_d + 0.4, h = 3 + e);
// M3 tray bolt bore (vertical, through saddle top)
translate([0, 0, SADDLE_T - 3 - e])
cylinder(d = M3_D, h = SADDLE_T + e);
// Anti-slip pad recess (bottom face, optional rubber adhesive)
translate([0, 0, -e])
cylinder(d = SADDLE_W - 8, h = SADDLE_PAD_T + e);
// Lightening pockets
for (lx = [-1, 1], ly = [-1, 1])
translate([lx*8, ly*8, -e])
cylinder(d = 5, h = SADDLE_T - 3 - 1 + e);
}
}
// ============================================================
// PART 3 STRAP GUIDE
// ============================================================
// Snap-on guide that sits on top of tray wall at each strap slot,
// directing the 25 mm Velcro strap from the side slot up and over
// the battery top. Four per tray, one at each slot exit.
// Curved lip prevents strap from cutting into PETG wall edge.
// Push-fit onto tray wall top; no fasteners required.
//
// Print: flat base on bed, PETG, 3 perims, 20% infill.
module strap_guide() {
strap_w_clr = STRAP_W + 0.5; // strap slot with clearance
lip_r = 3.0; // guide lip radius
difference() {
union() {
// Body (sits on tray wall top edge)
translate([-GUIDE_OD/2, 0, 0])
cube([GUIDE_OD, GUIDE_T, GUIDE_BODY_H]);
// Curved guide lip (top of body, strap bends around this)
translate([0, GUIDE_T/2, GUIDE_BODY_H])
rotate([0, 90, 0])
cylinder(r = lip_r, h = GUIDE_OD, center = true);
// Wall engagement tabs (snap over tray wall top)
for (sy = [0, -(TRAY_WALL_T + GUIDE_T)])
translate([-strap_w_clr/2 - 3, sy - GUIDE_T, 0])
cube([strap_w_clr + 6, GUIDE_T, GUIDE_BODY_H * 0.4]);
}
// Strap slot (through body)
translate([-strap_w_clr/2, -e, -e])
cube([strap_w_clr, GUIDE_T + 2*e, GUIDE_BODY_H + 2*e]);
// Wall clearance slot (body slides over tray wall)
translate([-strap_w_clr/2 - 3 - e,
-TRAY_WALL_T - GUIDE_T, -e])
cube([strap_w_clr + 6 + 2*e,
TRAY_WALL_T, GUIDE_BODY_H * 0.4 + 2*e]);
// Lightening pockets on side faces
for (lx = [-GUIDE_OD/4, GUIDE_OD/4])
translate([lx, GUIDE_T/2, GUIDE_BODY_H/2])
cube([6, GUIDE_T + 2*e, GUIDE_BODY_H * 0.5], center = true);
}
}

View File

@ -1,410 +0,0 @@
// ============================================================
// Cable Management Tray Issue #628
// Agent : sl-mechanical
// Date : 2026-03-15
// Part catalogue:
// 1. tray_body under-plate tray with snap-in cable channels, Velcro
// tie-down slots every 40 mm, pass-through holes, label slots
// 2. tnut_bracket 2020 T-nut rail mount bracket (×2, slide under tray)
// 3. channel_clip snap-in divider clip separating power / signal / servo zones
// 4. cover_panel hinged snap-on lid (living-hinge PETG flexure strip)
// 5. cable_saddle individual cable saddle / strain-relief clip (×n)
//
// BOM:
// 4 × M5×10 BHCS + M5 T-nuts (tnut_bracket × 2 to rail)
// 4 × M3×8 SHCS (tnut_bracket to tray body)
// n × 100 mm Velcro tie-down strips (through 6×2 mm slots, every 40 mm)
//
// Cable channel layout (X axis, inside tray):
// Zone A Power (2S6S LiPo, XT60/XT30): 20 mm wide, 14 mm deep
// Zone B Signal (JST-SH, PWM, I2C, UART): 14 mm wide, 10 mm deep
// Zone C Servo (JST-PH, thick servo leads): 14 mm wide, 12 mm deep
// Divider walls: 2.5 mm thick between zones
//
// Print settings (PETG):
// tray_body / tnut_bracket / channel_clip : 5 perimeters, 40 % gyroid, no supports
// cover_panel : 3 perimeters, 20 % gyroid, no supports
// (living-hinge print flat, thin strip flexes)
// cable_saddle : 3 perimeters, 30 % gyroid, no supports
//
// Export commands:
// openscad -D 'RENDER="tray_body"' -o tray_body.stl cable_tray.scad
// openscad -D 'RENDER="tnut_bracket"' -o tnut_bracket.stl cable_tray.scad
// openscad -D 'RENDER="channel_clip"' -o channel_clip.stl cable_tray.scad
// openscad -D 'RENDER="cover_panel"' -o cover_panel.stl cable_tray.scad
// openscad -D 'RENDER="cable_saddle"' -o cable_saddle.stl cable_tray.scad
// openscad -D 'RENDER="assembly"' -o assembly.png cable_tray.scad
// ============================================================
RENDER = "assembly"; // tray_body | tnut_bracket | channel_clip | cover_panel | cable_saddle | assembly
$fn = 48;
EPS = 0.01;
// 2020 rail constants
RAIL_W = 20.0;
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
SLOT_NECK_H = 3.2;
M5_D = 5.2;
M5_HEAD_D = 9.5;
M5_HEAD_H = 4.0;
// Tray geometry
TRAY_L = 280.0; // length along rail (7 × 40 mm tie-down pitch)
TRAY_W = 60.0; // width across rail (covers standard 40 mm rail pair)
TRAY_WALL = 2.5; // side / floor wall thickness
TRAY_DEPTH = 18.0; // interior depth (tallest zone + wall)
// Cable channel zones (widths must sum to TRAY_W - 2*TRAY_WALL - 2*DIV_T)
DIV_T = 2.5; // divider wall thickness
ZONE_A_W = 20.0; // Power
ZONE_A_D = 14.0;
ZONE_B_W = 14.0; // Signal
ZONE_B_D = 10.0;
ZONE_C_W = 14.0; // Servo
ZONE_C_D = 12.0;
// Total inner width used: ZONE_A_W + ZONE_B_W + ZONE_C_W + 2*DIV_T = 55 mm < TRAY_W - 2*TRAY_WALL = 55 mm
// Tie-down slots (Velcro strips)
TIEDOWN_PITCH = 40.0;
TIEDOWN_W = 6.0; // slot width (fits 6 mm wide Velcro)
TIEDOWN_T = 2.2; // slot through-thickness (floor)
TIEDOWN_CNT = 7; // 7 positions along tray
// Pass-through holes in floor
PASSTHRU_D = 12.0; // circular grommet-compatible pass-through
PASSTHRU_CNT = 3; // one per zone, at tray mid-length
// Label slots (rear outer wall)
LABEL_W = 24.0;
LABEL_H = 8.0;
LABEL_T = 1.0; // depth from outer face
// Snap ledge for cover
SNAP_LEDGE_H = 2.5;
SNAP_LEDGE_D = 1.5;
// T-nut bracket
BKT_L = 60.0;
BKT_W = TRAY_W;
BKT_T = 6.0;
BOLT_PITCH = 40.0;
M3_D = 3.2;
M3_HEAD_D = 6.0;
M3_HEAD_H = 3.0;
M3_NUT_W = 5.5;
M3_NUT_H = 2.4;
// Cover panel
CVR_T = 1.8; // panel thickness
HINGE_T = 0.6; // living-hinge strip thickness (printed in PETG)
HINGE_W = 3.0; // hinge strip width (flexes easily)
SNAP_HOOK_H = 3.5; // snap hook height
SNAP_HOOK_T = 2.2;
// Cable saddle
SAD_W = 12.0;
SAD_H = 8.0;
SAD_T = 2.5;
SAD_BORE_D = 7.0; // cable bundle bore
SAD_CLIP_T = 1.6; // snap arm thickness
// Utilities
module chamfer_cube(size, ch=1.0) {
hull() {
translate([ch, ch, 0]) cube([size[0]-2*ch, size[1]-2*ch, EPS]);
translate([0, 0, ch]) cube(size - [0, 0, ch]);
}
}
module hex_pocket(af, depth) {
cylinder(d=af/cos(30), h=depth, $fn=6);
}
// Part 1: tray_body
module tray_body() {
difference() {
// Outer shell
union() {
chamfer_cube([TRAY_L, TRAY_W, TRAY_DEPTH + TRAY_WALL], ch=1.5);
// Snap ledge along top of both long walls (for cover_panel)
for (y = [-SNAP_LEDGE_D, TRAY_W])
translate([0, y, TRAY_DEPTH])
cube([TRAY_L, TRAY_WALL + SNAP_LEDGE_D, SNAP_LEDGE_H]);
}
// Interior cavity
translate([TRAY_WALL, TRAY_WALL, TRAY_WALL])
cube([TRAY_L - 2*TRAY_WALL, TRAY_W - 2*TRAY_WALL,
TRAY_DEPTH + EPS]);
// Zone dividers (subtract from solid to leave walls)
// Zone A (Power) inner floor cut full depth A
translate([TRAY_WALL, TRAY_WALL, TRAY_WALL + (TRAY_DEPTH - ZONE_A_D)])
cube([TRAY_L - 2*TRAY_WALL, ZONE_A_W, ZONE_A_D + EPS]);
// Zone B (Signal) inner floor cut
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T,
TRAY_WALL + (TRAY_DEPTH - ZONE_B_D)])
cube([TRAY_L - 2*TRAY_WALL, ZONE_B_W, ZONE_B_D + EPS]);
// Zone C (Servo) inner floor cut
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T,
TRAY_WALL + (TRAY_DEPTH - ZONE_C_D)])
cube([TRAY_L - 2*TRAY_WALL, ZONE_C_W, ZONE_C_D + EPS]);
// Velcro tie-down slots (floor, every 40 mm)
for (i = [0:TIEDOWN_CNT-1]) {
x = TRAY_WALL + 20 + i * TIEDOWN_PITCH - TIEDOWN_W/2;
// Zone A slot
translate([x, TRAY_WALL + 2, -EPS])
cube([TIEDOWN_W, ZONE_A_W - 4, TRAY_WALL + 2*EPS]);
// Zone B slot
translate([x, TRAY_WALL + ZONE_A_W + DIV_T + 2, -EPS])
cube([TIEDOWN_W, ZONE_B_W - 4, TRAY_WALL + 2*EPS]);
// Zone C slot
translate([x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + 2, -EPS])
cube([TIEDOWN_W, ZONE_C_W - 4, TRAY_WALL + 2*EPS]);
}
// Pass-through holes in floor (centre of each zone at mid-length)
mid_x = TRAY_L / 2;
// Zone A
translate([mid_x, TRAY_WALL + ZONE_A_W/2, -EPS])
cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS);
// Zone B
translate([mid_x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2, -EPS])
cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS);
// Zone C
translate([mid_x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2, -EPS])
cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS);
// Label slots on front wall (y = 0) one per zone
zone_ctrs = [TRAY_WALL + ZONE_A_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2];
label_z = TRAY_WALL + 2;
for (yc = zone_ctrs)
translate([TRAY_L/2 - LABEL_W/2, -EPS, label_z])
cube([LABEL_W, LABEL_T + EPS, LABEL_H]);
// M3 bracket bolt holes in floor (4 corners)
for (x = [20, TRAY_L - 20])
for (y = [TRAY_W/4, 3*TRAY_W/4])
translate([x, y, -EPS])
cylinder(d=M3_D, h=TRAY_WALL + 2*EPS);
// Channel clip snap sockets (top of each divider, every 80 mm)
for (i = [0:2]) {
cx = 40 + i * 80;
for (dy = [ZONE_A_W, ZONE_A_W + DIV_T + ZONE_B_W])
translate([cx - 3, TRAY_WALL + dy - 1, TRAY_DEPTH - 4])
cube([6, DIV_T + 2, 4 + EPS]);
}
}
// Divider walls (positive geometry)
// Wall between Zone A and Zone B
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W, TRAY_WALL])
cube([TRAY_L - 2*TRAY_WALL, DIV_T,
TRAY_DEPTH - ZONE_A_D]); // partial height lower in A zone
// Wall between Zone B and Zone C
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W, TRAY_WALL])
cube([TRAY_L - 2*TRAY_WALL, DIV_T,
TRAY_DEPTH - ZONE_B_D]);
}
// Part 2: tnut_bracket
module tnut_bracket() {
difference() {
chamfer_cube([BKT_L, BKT_W, BKT_T], ch=1.5);
// M5 T-nut holes (2 per bracket, on rail centreline)
for (x = [BKT_L/2 - BOLT_PITCH/2, BKT_L/2 + BOLT_PITCH/2]) {
translate([x, BKT_W/2, -EPS]) {
cylinder(d=M5_D, h=BKT_T + 2*EPS);
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
translate([x - TNUT_L/2, BKT_W/2 - TNUT_W/2, BKT_T - TNUT_H])
cube([TNUT_L, TNUT_W, TNUT_H + EPS]);
}
// M3 tray-attachment holes (4 corners)
for (x = [10, BKT_L - 10])
for (y = [10, BKT_W - 10]) {
translate([x, y, -EPS])
cylinder(d=M3_D, h=BKT_T + 2*EPS);
// M3 hex nut captured pocket (from top)
translate([x, y, BKT_T - M3_NUT_H - 0.2])
hex_pocket(M3_NUT_W + 0.3, M3_NUT_H + 0.3);
}
// Weight relief
translate([15, 8, -EPS])
cube([BKT_L - 30, BKT_W - 16, BKT_T/2]);
}
}
// Part 3: channel_clip
// Snap-in clip that locks into divider-wall snap sockets;
// holds individual bundles in their zone and acts as colour-coded zone marker.
module channel_clip() {
clip_body_w = 6.0;
clip_body_h = DIV_T + 4.0;
clip_body_t = 8.0;
tab_h = 3.5;
tab_w = 2.5;
difference() {
union() {
// Body spanning divider
cube([clip_body_t, clip_body_w, clip_body_h]);
// Snap tabs (bottom, straddle divider)
for (s = [0, clip_body_w - tab_w])
translate([clip_body_t/2 - 1, s, -tab_h])
cube([2, tab_w, tab_h + 1]);
}
// Cable radius slot on each face
translate([-EPS, clip_body_w/2, clip_body_h * 0.6])
rotate([0, 90, 0])
cylinder(d=5.0, h=clip_body_t + 2*EPS);
// Snap tab undercut for flex
for (s = [0, clip_body_w - tab_w])
translate([clip_body_t/2 - 2, s - EPS, -tab_h + 1.5])
cube([4, tab_w + 2*EPS, 1.5]);
}
}
// Part 4: cover_panel
// Flat snap-on lid with living-hinge along one long edge.
// Print flat; PETG living hinge flexes ~90° to snap onto tray.
module cover_panel() {
total_w = TRAY_W + 2 * SNAP_HOOK_T;
difference() {
union() {
// Main panel
cube([TRAY_L, TRAY_W, CVR_T]);
// Living hinge strip along back edge (thin, flexes)
translate([0, TRAY_W - EPS, 0])
cube([TRAY_L, HINGE_W, HINGE_T]);
// Snap hooks along front edge (clips under tray snap ledge)
for (x = [20, TRAY_L/2 - 20, TRAY_L/2 + 20, TRAY_L - 20])
translate([x - SNAP_HOOK_T/2, -SNAP_HOOK_H + EPS, 0])
difference() {
cube([SNAP_HOOK_T, SNAP_HOOK_H, CVR_T + 1.5]);
// Hook nose chamfer
translate([-EPS, -EPS, CVR_T])
rotate([0, 0, 0])
cube([SNAP_HOOK_T + 2*EPS, 1.5, 1.5]);
}
}
// Ventilation slots (3 rows × 6 slots)
for (row = [0:2])
for (col = [0:5]) {
sx = 20 + col * 40 + row * 10;
sy = 10 + row * 12;
if (sx + 25 < TRAY_L && sy + 6 < TRAY_W)
translate([sx, sy, -EPS])
cube([25, 6, CVR_T + 2*EPS]);
}
// Zone label windows (align with tray label slots)
zone_ctrs = [TRAY_WALL + ZONE_A_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2];
for (yc = zone_ctrs)
translate([TRAY_L/2 - LABEL_W/2, yc - LABEL_H/2, -EPS])
cube([LABEL_W, LABEL_H, CVR_T + 2*EPS]);
}
}
// Part 5: cable_saddle
// Snap-in cable saddle / strain-relief clip; press-fits onto tray top edge.
module cable_saddle() {
arm_gap = TRAY_WALL + 0.4; // fits over tray wall
arm_len = 8.0;
difference() {
union() {
// Body
chamfer_cube([SAD_W, SAD_T * 2 + arm_gap, SAD_H], ch=1.0);
// Cable retaining arch
translate([SAD_W/2, SAD_T + arm_gap/2, SAD_H])
scale([1, 0.6, 1])
difference() {
cylinder(d=SAD_BORE_D + SAD_CLIP_T * 2, h=SAD_T);
translate([0, 0, -EPS])
cylinder(d=SAD_BORE_D, h=SAD_T + 2*EPS);
translate([-SAD_BORE_D, 0, -EPS])
cube([SAD_BORE_D * 2, SAD_BORE_D, SAD_T + 2*EPS]);
}
}
// Slot for tray wall (negative)
translate([0, SAD_T, -EPS])
cube([SAD_W, arm_gap, arm_len + EPS]);
// M3 tie-down hole
translate([SAD_W/2, SAD_T + arm_gap/2, -EPS])
cylinder(d=M3_D, h=SAD_H + 2*EPS);
}
}
// Assembly
module assembly() {
// Tray body (open face up for visibility)
color("SteelBlue")
tray_body();
// Two T-nut brackets underneath at 1/4 and 3/4 length
for (bx = [TRAY_L/4 - BKT_L/2, 3*TRAY_L/4 - BKT_L/2])
color("DodgerBlue")
translate([bx, 0, -BKT_T])
tnut_bracket();
// Channel clips (3 per divider position, every 80 mm)
for (i = [0:2]) {
cx = 40 + i * 80;
// Divider A/B
color("Tomato", 0.8)
translate([cx - 4, TRAY_WALL + ZONE_A_W - 2, TRAY_DEPTH - 3])
channel_clip();
// Divider B/C
color("Orange", 0.8)
translate([cx - 4,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W - 2,
TRAY_DEPTH - 3])
channel_clip();
}
// Cover panel (raised above tray to show interior)
color("LightSteelBlue", 0.5)
translate([0, 0, TRAY_DEPTH + SNAP_LEDGE_H + 4])
cover_panel();
// Cable saddles along front tray edge
for (x = [40, 120, 200])
color("SlateGray")
translate([x - SAD_W/2, -SAD_T * 2 - TRAY_WALL, 0])
cable_saddle();
}
// Dispatch
if (RENDER == "tray_body") tray_body();
else if (RENDER == "tnut_bracket") tnut_bracket();
else if (RENDER == "channel_clip") channel_clip();
else if (RENDER == "cover_panel") cover_panel();
else if (RENDER == "cable_saddle") cable_saddle();
else assembly();

View File

@ -1,265 +0,0 @@
// ============================================================
// canable_mount.scad CANable 2.0 USB-CAN Adapter Cradle
// Issue #654 / sl-mechanical 2026-03-16
// ============================================================
// Snap-fit cradle for CANable 2.0 PCB (~60 × 18 × 10 mm).
// Attaches to 2020 aluminium T-slot rail via 2× M5 T-nuts.
//
// Port access:
// USB-C port X end wall cutout (connector protrudes through)
// CAN terminal X+ end wall cutout (CANH / CANL / GND wire exit)
// LED status window slot in Y+ side wall, PCB top-face LEDs visible
//
// Retention: snap-fit cantilever lips on both side walls (PETG flex).
// Cable strain relief: zip-tie boss pair on X+ shelf (CAN wires).
//
// VERIFY WITH CALIPERS BEFORE PRINTING:
// PCB_L, PCB_W board outline
// USBC_W, USBC_H USB-C shell at X edge
// TERM_W, TERM_H 3-pos terminal block at X+ edge
// LED_X_CTR, LED_WIN_W LED window position on Y+ wall
//
// Print settings (PETG):
// 3 perimeters, 40 % gyroid infill, no supports, 0.2 mm layer
// Print orientation: open face UP (as modelled)
//
// BOM:
// 2 × M5×10 BHCS + 2 × M5 slide-in T-nut (2020 rail)
//
// Export commands:
// openscad -D 'RENDER="mount"' -o canable_mount.stl canable_mount.scad
// openscad -D 'RENDER="assembly"' -o canable_assembly.png canable_mount.scad
// ============================================================
RENDER = "assembly"; // mount | assembly
$fn = 48;
EPS = 0.01;
// Verify before printing
// CANable 2.0 PCB
PCB_L = 60.0; // board length (X: USB-C end terminal end)
PCB_W = 18.0; // board width (Y)
PCB_T = 1.6; // board thickness
COMP_H = 8.5; // tallest component above board (USB-C shell 3.5 mm;
// terminal block 8.5 mm)
// USB-C connector (at X end face of PCB)
USBC_W = 9.5; // connector outer width
USBC_H = 3.8; // connector outer height above board surface
USBC_Z0 = 0.0; // connector bottom offset above board surface
// CAN screw-terminal block (at X+ end face, 3-pos 5.0 mm pitch)
TERM_W = 16.0; // terminal block span (3 × 5 mm + housing)
TERM_H = 9.0; // terminal block height above board surface
TERM_Z0 = 0.5; // terminal bottom offset above board surface
// Status LED window (LEDs near USB-C end on PCB top face)
// Rectangular slot cut in Y+ side wall LEDs visible from the side
LED_X_CTR = 11.0; // LED zone centre measured from PCB X edge
LED_WIN_W = 14.0; // window width (X)
LED_WIN_H = 5.5; // window height (Z) opens top portion of side wall
// Cradle geometry
WALL_T = 2.5; // side/end wall thickness
FLOOR_T = 4.0; // floor plate thickness (accommodates M5 BHCS head pocket)
CL_SIDE = 0.30; // Y clearance per side (total 0.6 mm play)
CL_END = 0.40; // X clearance per end
// Interior cavity
INN_W = PCB_W + 2*CL_SIDE; // Y span
INN_L = PCB_L + 2*CL_END; // X span
INN_H = PCB_T + COMP_H + 1.2; // Z height (board + tallest comp + margin)
// Outer body
OTR_W = INN_W + 2*WALL_T; // Y
OTR_L = INN_L + 2*WALL_T; // X
OTR_H = FLOOR_T + INN_H; // Z
// PCB reference origin within body (lower-left corner of board)
PCB_X0 = WALL_T + CL_END; // board X start inside body
PCB_Y0 = WALL_T + CL_SIDE; // board Y start inside body
PCB_Z0 = FLOOR_T; // board bottom sits on floor
// Snap-fit lips
// Cantilever ledge on inner face of each side wall, at PCB-top Z.
// Tapered (chamfered) entry guides PCB in from above.
SNAP_IN = 0.8; // how far inward ledge protrudes over PCB edge
SNAP_T = 1.2; // snap-arm thickness (thin for PETG flex)
SNAP_H = 4.0; // cantilever arm height (root at OTR_H, tip near PCB_Z0+PCB_T)
SNAP_L = 18.0; // arm length along X (centred on PCB, shorter = more flex)
// Snap on Y wall protrudes in +Y direction; Y+ wall protrudes in Y direction
// M5 T-nut mount (2020 rail)
M5_D = 5.3; // M5 bolt clearance bore
M5_HEAD_D = 9.5; // M5 BHCS head pocket diameter (from bottom face)
M5_HEAD_H = 3.0; // BHCS head pocket depth
M5_SPAC = 20.0; // bolt spacing along X (centred on cradle)
// Standard M5 slide-in T-nuts used no T-nut pocket moulded in.
// Cable strain relief
// Two zip-tie anchor bosses on a shelf inside the X+ end, straddling
// the CAN terminal wires.
SR_BOSS_OD = 7.0; // boss outer diameter
SR_BOSS_H = 5.5; // boss height above floor
SR_SLOT_W = 3.5; // zip-tie slot width
SR_SLOT_T = 2.2; // zip-tie slot through-height
// Boss Y positions (straddle terminal block)
SR_Y1 = WALL_T + INN_W * 0.25;
SR_Y2 = WALL_T + INN_W * 0.75;
SR_X = OTR_L - WALL_T - SR_BOSS_OD/2 - 2.5; // just inside X+ end wall
//
module canable_mount() {
difference() {
// Outer solid body
union() {
cube([OTR_L, OTR_W, OTR_H]);
// Snap cantilever arms on Y wall (protrude inward +Y)
// Arms hang down from top of Y wall inner face.
// Root is flush with inner face (Y = WALL_T); tip at PCB level.
translate([OTR_L/2 - SNAP_L/2, WALL_T - SNAP_T, OTR_H - SNAP_H])
cube([SNAP_L, SNAP_T, SNAP_H]);
// Snap cantilever arms on Y+ wall (protrude inward Y)
translate([OTR_L/2 - SNAP_L/2, OTR_W - WALL_T, OTR_H - SNAP_H])
cube([SNAP_L, SNAP_T, SNAP_H]);
// Cable strain relief bosses (X+ end, inside)
for (sy = [SR_Y1, SR_Y2])
translate([SR_X, sy, 0])
cylinder(d=SR_BOSS_OD, h=SR_BOSS_H);
}
// Interior cavity
translate([WALL_T, WALL_T, FLOOR_T])
cube([INN_L, INN_W, INN_H + EPS]);
// USB-C cutout X end wall
// Centred on PCB width; opened from board surface upward
translate([-EPS,
PCB_Y0 + PCB_W/2 - (USBC_W + 1.5)/2,
PCB_Z0 + USBC_Z0 - 0.5])
cube([WALL_T + 2*EPS, USBC_W + 1.5, USBC_H + 2.5]);
// CAN terminal cutout X+ end wall
// Full terminal width + 2 mm margin for screwdriver access;
// height clears terminal block + wire bend radius
translate([OTR_L - WALL_T - EPS,
PCB_Y0 + PCB_W/2 - (TERM_W + 2.0)/2,
PCB_Z0 + TERM_Z0 - 0.5])
cube([WALL_T + 2*EPS, TERM_W + 2.0, TERM_H + 5.0]);
// LED status window Y+ side wall
// Rectangular slot; LEDs at top-face of PCB are visible through it
translate([PCB_X0 + LED_X_CTR - LED_WIN_W/2,
OTR_W - WALL_T - EPS,
OTR_H - LED_WIN_H])
cube([LED_WIN_W, WALL_T + 2*EPS, LED_WIN_H + EPS]);
// M5 BHCS head pockets (from bottom face of floor)
for (mx = [OTR_L/2 - M5_SPAC/2, OTR_L/2 + M5_SPAC/2])
translate([mx, OTR_W/2, -EPS]) {
// Clearance bore through full floor
cylinder(d=M5_D, h=FLOOR_T + 2*EPS);
// BHCS head pocket from bottom face
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
// Snap-arm ledge slot Y arm (hollow out to thin arm)
// Arm is SNAP_T thick; cut away material behind arm
translate([OTR_L/2 - SNAP_L/2 - EPS, EPS, OTR_H - SNAP_H])
cube([SNAP_L + 2*EPS, WALL_T - SNAP_T - EPS, SNAP_H + EPS]);
// Snap-arm ledge slot Y+ arm
translate([OTR_L/2 - SNAP_L/2 - EPS, OTR_W - WALL_T + SNAP_T, OTR_H - SNAP_H])
cube([SNAP_L + 2*EPS, WALL_T - SNAP_T - EPS, SNAP_H + EPS]);
// Snap-arm inward ledge notch (entry chamfer removed)
// Chamfer top of snap arm so PCB slides in easily
// Y arm: chamfer on upper-inner edge 45° wedge on +Y/+Z corner
translate([OTR_L/2 - SNAP_L/2 - EPS,
WALL_T - SNAP_T - EPS,
OTR_H - SNAP_IN])
rotate([0, 0, 0])
rotate([45, 0, 0])
cube([SNAP_L + 2*EPS, SNAP_IN * 1.5, SNAP_IN * 1.5]);
// Y+ arm: chamfer on upper-inner edge
translate([OTR_L/2 - SNAP_L/2 - EPS,
OTR_W - WALL_T + SNAP_T - SNAP_IN * 1.5 + EPS,
OTR_H - SNAP_IN])
rotate([45, 0, 0])
cube([SNAP_L + 2*EPS, SNAP_IN * 1.5, SNAP_IN * 1.5]);
// Snap ledge cutout on Y arm inner tip
// Creates inward nub: remove top portion of arm inner tip
// leaving bottom SNAP_IN height as the retaining ledge
translate([OTR_L/2 - SNAP_L/2 - EPS,
WALL_T - SNAP_T - EPS,
PCB_Z0 + PCB_T + SNAP_IN])
cube([SNAP_L + 2*EPS, SNAP_T + 2*EPS,
OTR_H - (PCB_Z0 + PCB_T + SNAP_IN) + EPS]);
// Snap ledge cutout on Y+ arm inner tip
translate([OTR_L/2 - SNAP_L/2 - EPS,
OTR_W - WALL_T - EPS,
PCB_Z0 + PCB_T + SNAP_IN])
cube([SNAP_L + 2*EPS, SNAP_T + 2*EPS,
OTR_H - (PCB_Z0 + PCB_T + SNAP_IN) + EPS]);
// Zip-tie slots through strain relief bosses
for (sy = [SR_Y1, SR_Y2])
translate([SR_X, sy,
SR_BOSS_H/2 - SR_SLOT_T/2])
rotate([0, 90, 0])
cube([SR_SLOT_T, SR_SLOT_W,
SR_BOSS_OD + 2*EPS],
center=true);
// Weight relief pocket in floor (underside)
translate([WALL_T + 8, WALL_T + 3, -EPS])
cube([OTR_L - 2*WALL_T - 16, OTR_W - 2*WALL_T - 6,
FLOOR_T - 1.5 + EPS]);
}
}
// Assembly preview
if (RENDER == "assembly") {
color("DimGray", 0.93) canable_mount();
// Phantom PCB
color("MidnightBlue", 0.35)
translate([PCB_X0, PCB_Y0, PCB_Z0])
cube([PCB_L, PCB_W, PCB_T]);
// Phantom component block (top of PCB)
color("DarkSlateGray", 0.25)
translate([PCB_X0, PCB_Y0, PCB_Z0 + PCB_T])
cube([PCB_L, PCB_W, COMP_H]);
// USB-C port highlight
color("Gold", 0.8)
translate([-1,
PCB_Y0 + PCB_W/2 - USBC_W/2,
PCB_Z0 + USBC_Z0])
cube([WALL_T + 2, USBC_W, USBC_H]);
// Terminal block highlight
color("Tomato", 0.7)
translate([OTR_L - WALL_T - 1,
PCB_Y0 + PCB_W/2 - TERM_W/2,
PCB_Z0 + TERM_Z0])
cube([WALL_T + 2, TERM_W, TERM_H]);
// LED zone highlight
color("LimeGreen", 0.9)
translate([PCB_X0 + LED_X_CTR - LED_WIN_W/2,
OTR_W - WALL_T - 0.5,
OTR_H - LED_WIN_H + 1])
cube([LED_WIN_W, 1, LED_WIN_H - 2]);
} else {
canable_mount();
}

View File

@ -1,531 +0,0 @@
// ============================================================
// charging_dock_505.scad 24V Charging Dock Station
// Issue: #505 Agent: sl-mechanical Date: 2026-03-06
// ============================================================
//
// 24V upgraded dock (forked from Issue #159 5V design).
// Robot drives forward into V-guide funnel; spring-loaded pogo pins
// make contact with the robot receiver plate (charging_dock_receiver.scad).
//
// Power: 24 V / 10 A (240 W) via 2× high-current pogo pins (+/-)
// Alignment tolerance: ±20 mm lateral (V-guide funnels to centre)
//
// Dock architecture (top view):
//
// back wall (robot stops here)
// PSU shelf
// [PSU] [LED ×4]
// [POGO+][POGO-] pogo face (robot contact)
// \ /
// \ V-guide rails /
// \ /
// dock entry, ±20 mm funnel
//
// Components (this file):
// Part A dock_base() weighted base plate with ballast pockets
// Part B back_wall() upright back panel + pogo housing + LED bezel
// Part C guide_rail(side) V-funnel guide rail, L/R (print 2×)
// Part D aruco_mount() ArUco marker frame at dock entrance
// Part E psu_bracket() PSU retention bracket (rear of base)
// Part F led_bezel() 4-LED status bezel
//
// Robot-side receiver see charging_dock_receiver.scad
//
// Coordinate system:
// Z = 0 at dock floor (base plate top face)
// Y = 0 at back wall front face (robot approaches from +Y)
// X = 0 at dock centre
// Robot drives in -Y direction to dock.
//
// RENDER options:
// "assembly" full dock preview (default)
// "base_stl" base plate (print 1×)
// "back_wall_stl" back wall + pogo housing (print 1×)
// "guide_rail_stl" V-guide rail (print 2×, mirror for R side)
// "aruco_mount_stl" ArUco marker frame (print 1×)
// "psu_bracket_stl" PSU mounting bracket (print 1×)
// "led_bezel_stl" LED status bezel (print 1×)
//
// Export commands (Issue #505 24V variant):
// openscad charging_dock_505.scad -D 'RENDER="base_stl"' -o dock_505_base.stl
// openscad charging_dock_505.scad -D 'RENDER="back_wall_stl"' -o dock_505_back_wall.stl
// openscad charging_dock_505.scad -D 'RENDER="guide_rail_stl"' -o dock_505_guide_rail.stl
// openscad charging_dock_505.scad -D 'RENDER="aruco_mount_stl"' -o dock_505_aruco_mount.stl
// openscad charging_dock_505.scad -D 'RENDER="psu_bracket_stl"' -o dock_505_psu_bracket.stl
// openscad charging_dock_505.scad -D 'RENDER="led_bezel_stl"' -o dock_505_led_bezel.stl
// ============================================================
$fn = 64;
e = 0.01;
// Base plate dimensions
// NOTE: Enlarged for 24V PSU (IRM-240-24: 210×108×56 mm vs. IRM-30-5: 63×45×28 mm)
BASE_W = 340.0; // base width (X) increased for larger PSU bracket
BASE_D = 320.0; // base depth (Y, extends behind and in front of back wall)
BASE_T = 12.0; // base thickness
BASE_R = 10.0; // corner radius
// Ballast pockets (for steel hex bar / bolt weights):
// 4× pockets in base underside, accept M20 hex nuts (30 mm AF) stacked
BALLAST_N = 4;
BALLAST_W = 32.0; // pocket width (hex nut AF + 2 mm)
BALLAST_D = 32.0; // pocket depth
BALLAST_T = 8.0; // pocket depth ( BASE_T/2)
BALLAST_INSET_X = 50.0;
BALLAST_INSET_Y = 40.0;
// Floor bolt holes (M8, for bolting dock to bench/floor optional)
FLOOR_BOLT_D = 8.5;
FLOOR_BOLT_INSET_X = 30.0;
FLOOR_BOLT_INSET_Y = 25.0;
// Back wall (upright panel)
WALL_W = 250.0; // wall width (X) same as guide entry span
WALL_H = 85.0; // wall height (Z)
WALL_T = 10.0; // wall thickness (Y)
// Back wall Y position relative to base rear edge
// Wall sits at Y=0 (its front face); base extends behind it (-Y) and in front (+Y)
BASE_REAR_Y = -80.0; // base rear edge Y coordinate
// Pogo pin housing (in back wall front face)
// High-current pogo pins: Ø5.5 mm body, 20 mm long (compressed), 4 mm spring travel
// Rated 5 A each; 2× pins for +/- power
POGO_D = 5.5; // pogo pin body OD
POGO_BORE_D = 5.7; // bore diameter (0.2 mm clearance)
POGO_L = 20.0; // pogo full length (uncompressed)
POGO_TRAVEL = 4.0; // spring travel
POGO_FLANGE_D = 8.0; // pogo flange / retention shoulder OD
POGO_FLANGE_T = 1.5; // flange thickness
POGO_SPACING = 20.0; // CL-to-CL spacing between + and - pins
POGO_Z = 35.0; // pogo CL height above dock floor
POGO_PROTRUDE = 8.0; // pogo tip protrusion beyond wall face (uncompressed)
// Wiring channel behind pogo (runs down to base)
WIRE_CH_W = 8.0;
WIRE_CH_H = POGO_Z + 5;
// LED bezel (4 status LEDs in back wall, above pogo pins)
// LED order (left to right): Searching | Aligned | Charging | Full
// Colours (suggested): Red | Yellow | Blue | Green
LED_D = 5.0; // 5 mm through-hole LED
LED_BORE_D = 5.2; // bore diameter
LED_BEZEL_W = 80.0; // bezel plate width
LED_BEZEL_H = 18.0; // bezel plate height
LED_BEZEL_T = 4.0; // bezel plate thickness
LED_SPACING = 16.0; // LED centre-to-centre
LED_Z = 65.0; // LED centre height above floor
LED_INSET_D = 2.0; // LED recess depth (LED body recessed for protection)
// V-guide rails
// Robot receiver width (contact block): 30 mm.
// Alignment tolerance: ±20 mm entry gap = 30 + 2×20 = 70 mm.
// Guide rail tapers from 70 mm entry (at Y = GUIDE_L) to 30 mm exit (at Y=0).
// Each rail is a wedge-shaped wall.
GUIDE_L = 100.0; // guide rail length (Y depth, from back wall)
GUIDE_H = 50.0; // guide rail height (Z)
GUIDE_T = 8.0; // guide rail wall thickness
RECV_W = 30.0; // robot receiver contact block width
ENTRY_GAP = 70.0; // guide entry gap (= RECV_W + 2×20 mm tolerance)
EXIT_GAP = RECV_W + 2.0; // guide exit gap (2 mm clearance on each side)
// Derived: half-gap at entry = 35 mm, at exit = 16 mm; taper = 19 mm over 100 mm
// Half-angle = atan(19/100) 10.8° gentle enough for reliable self-alignment
// ArUco marker mount
// Mounted at dock entry arch (forward of guide rails), tilted 15° back.
// Robot camera acquires marker for coarse approach alignment.
// ArUco marker ID 42 (DICT_4X4_250), 100×100 mm (printed/laminated on paper).
ARUCO_MARKER_W = 100.0;
ARUCO_MARKER_H = 100.0;
ARUCO_FRAME_T = 3.0; // frame plate thickness
ARUCO_FRAME_BDR = 10.0; // frame border around marker
ARUCO_SLOT_T = 1.5; // marker slip-in slot depth
ARUCO_MAST_H = 95.0; // mast height above base (centres marker at camera height)
ARUCO_MAST_W = 10.0;
ARUCO_TILT = 15.0; // backward tilt (degrees) faces approaching robot
ARUCO_Y = GUIDE_L + 60; // mast Y position (in front of guide entry)
// PSU bracket
// Mean Well IRM-240-24 (24V 10A 240W): 210×108×56 mm body Issue #505 upgrade
// Bracket sits behind back wall, on base plate.
PSU_W = 220.0; // bracket internal width (+5 mm clearance per side for 210 mm PSU)
PSU_D = 118.0; // bracket internal depth (+5 mm clearance per side for 108 mm PSU)
PSU_H = 66.0; // bracket internal height (+5 mm top clearance for 56 mm PSU + ventilation)
PSU_T = 4.0; // bracket wall thickness (thicker for larger PSU mass)
PSU_Y = BASE_REAR_Y + PSU_D/2 + PSU_T + 10; // PSU Y centre
// Fasteners
M3_D = 3.3;
M4_D = 4.3;
M5_D = 5.3;
M8_D = 8.5;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly();
else if (RENDER == "base_stl") dock_base();
else if (RENDER == "back_wall_stl") back_wall();
else if (RENDER == "guide_rail_stl") guide_rail("left");
else if (RENDER == "aruco_mount_stl") aruco_mount();
else if (RENDER == "psu_bracket_stl") psu_bracket();
else if (RENDER == "led_bezel_stl") led_bezel();
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly() {
// Base plate
color("SaddleBrown", 0.85) dock_base();
// Back wall
color("Sienna", 0.85)
translate([0, 0, BASE_T])
back_wall();
// Left guide rail
color("Peru", 0.85)
translate([0, 0, BASE_T])
guide_rail("left");
// Right guide rail (mirror in X)
color("Peru", 0.85)
translate([0, 0, BASE_T])
mirror([1, 0, 0])
guide_rail("left");
// ArUco mount
color("DimGray", 0.85)
translate([0, 0, BASE_T])
aruco_mount();
// PSU bracket
color("DarkSlateGray", 0.80)
translate([0, PSU_Y, BASE_T])
psu_bracket();
// LED bezel
color("LightGray", 0.90)
translate([0, -WALL_T/2, BASE_T + LED_Z])
led_bezel();
// Ghost robot receiver approaching from +Y
%color("SteelBlue", 0.25)
translate([0, GUIDE_L + 30, BASE_T + POGO_Z])
cube([RECV_W, 20, 8], center = true);
// Ghost pogo pins
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
%color("Gold", 0.60)
translate([px, -POGO_PROTRUDE, BASE_T + POGO_Z])
rotate([90, 0, 0])
cylinder(d = POGO_D, h = POGO_L);
}
// ============================================================
// PART A DOCK BASE PLATE
// ============================================================
module dock_base() {
difference() {
// Main base block (rounded rect)
linear_extrude(BASE_T)
minkowski() {
square([BASE_W - 2*BASE_R,
BASE_D - 2*BASE_R], center = true);
circle(r = BASE_R);
}
// Ballast pockets (underside)
// 4× pockets: 2 front, 2 rear
for (bx = [-1, 1])
for (by = [-1, 1])
translate([bx * (BASE_W/2 - BALLAST_INSET_X),
by * (BASE_D/2 - BALLAST_INSET_Y),
-e])
cube([BALLAST_W, BALLAST_D, BALLAST_T + e], center = true);
// Floor bolt holes (M8, 4 corners)
for (bx = [-1, 1])
for (by = [-1, 1])
translate([bx * (BASE_W/2 - FLOOR_BOLT_INSET_X),
by * (BASE_D/2 - FLOOR_BOLT_INSET_Y), -e])
cylinder(d = FLOOR_BOLT_D, h = BASE_T + 2*e);
// Back wall attachment slots (M4, top face)
for (bx = [-WALL_W/2 + 30, 0, WALL_W/2 - 30])
translate([bx, -BASE_D/4, BASE_T - 3])
cylinder(d = M4_D, h = 4 + e);
// Guide rail attachment holes (M4)
for (side = [-1, 1])
for (gy = [20, GUIDE_L - 20])
translate([side * (EXIT_GAP/2 + GUIDE_T/2), gy, BASE_T - 3])
cylinder(d = M4_D, h = 4 + e);
// Cable routing slot (from pogo wires to PSU, through base)
translate([0, -WALL_T - 5, -e])
cube([WIRE_CH_W, 15, BASE_T + 2*e], center = true);
// Anti-skid texture (front face chamfer)
// Chamfer front-bottom edge for easy robot approach
translate([0, BASE_D/2 + e, -e])
rotate([45, 0, 0])
cube([BASE_W + 2*e, 5, 5], center = true);
}
}
// ============================================================
// PART B BACK WALL (upright panel)
// ============================================================
module back_wall() {
difference() {
union() {
// Wall slab
translate([-WALL_W/2, -WALL_T, 0])
cube([WALL_W, WALL_T, WALL_H]);
// Pogo pin housing bosses (front face)
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
translate([px, -WALL_T, POGO_Z])
rotate([90, 0, 0])
cylinder(d = POGO_FLANGE_D + 6,
h = POGO_PROTRUDE);
// Wiring channel reinforcement (inside wall face)
translate([-WIRE_CH_W/2 - 2, -WALL_T, 0])
cube([WIRE_CH_W + 4, 4, WIRE_CH_H]);
}
// Pogo pin bores (through wall into housing boss)
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
translate([px, POGO_PROTRUDE + e, POGO_Z])
rotate([90, 0, 0]) {
// Main bore (full depth through wall + boss)
cylinder(d = POGO_BORE_D,
h = WALL_T + POGO_PROTRUDE + 2*e);
// Flange shoulder counterbore (retains pogo from pulling out)
translate([0, 0, WALL_T + POGO_PROTRUDE - POGO_FLANGE_T - 1])
cylinder(d = POGO_FLANGE_D + 0.4,
h = POGO_FLANGE_T + 2);
}
// Wiring channel (vertical slot, inside face base cable hole)
translate([-WIRE_CH_W/2, 0 + e, 0])
cube([WIRE_CH_W, WALL_T/2, WIRE_CH_H]);
// LED bezel recess (in front face, above pogo)
translate([-LED_BEZEL_W/2, -LED_BEZEL_T, LED_Z - LED_BEZEL_H/2])
cube([LED_BEZEL_W, LED_BEZEL_T + e, LED_BEZEL_H]);
// M4 base attachment bores (3 through bottom of wall)
for (bx = [-WALL_W/2 + 30, 0, WALL_W/2 - 30])
translate([bx, -WALL_T/2, -e])
cylinder(d = M4_D, h = 8 + e);
// Cable tie slots (in wall body, for neat wire routing)
for (cz = [15, POGO_Z - 15])
translate([WIRE_CH_W/2 + 3, -WALL_T/2, cz])
cube([4, WALL_T + 2*e, 3], center = true);
// Lightening cutout (rear face pocket)
translate([-WALL_W/2 + 40, 0, 20])
cube([WALL_W - 80, WALL_T/2 + e, WALL_H - 30]);
}
}
// ============================================================
// PART C V-GUIDE RAIL
// ============================================================
// Print 2×; mirror in X for right side.
// Rail tapers from ENTRY_GAP/2 (at Y=GUIDE_L) to EXIT_GAP/2 (at Y=0).
// Inner (guiding) face is angled; outer face is vertical.
module guide_rail(side = "left") {
// Inner face X at back wall = EXIT_GAP/2
// Inner face X at entry = ENTRY_GAP/2
x_back = EXIT_GAP/2; // 16 mm
x_entry = ENTRY_GAP/2; // 35 mm
difference() {
union() {
// Main wedge body
// Hull between two rectangles: narrow at Y=0, wide at Y=GUIDE_L
hull() {
// Back end (at Y=0, flush with back wall)
translate([x_back, 0, 0])
cube([GUIDE_T, e, GUIDE_H]);
// Entry end (at Y=GUIDE_L)
translate([x_entry, GUIDE_L, 0])
cube([GUIDE_T, e, GUIDE_H]);
}
// Entry flare (chamfered lip at guide entry for bump-entry)
hull() {
translate([x_entry, GUIDE_L, 0])
cube([GUIDE_T, e, GUIDE_H]);
translate([x_entry + 15, GUIDE_L + 20, 0])
cube([GUIDE_T, e, GUIDE_H * 0.6]);
}
}
// M4 base attachment bores
for (gy = [20, GUIDE_L - 20])
translate([x_back + GUIDE_T/2, gy, -e])
cylinder(d = M4_D, h = 8 + e);
// Chamfer on inner top corner (smooth robot entry)
translate([x_back - e, -e, GUIDE_H - 5])
rotate([0, -45, 0])
cube([8, GUIDE_L + 30, 8]);
}
}
// ============================================================
// PART D ArUco MARKER MOUNT
// ============================================================
// Free-standing mast at dock entry. Mounts to base plate.
// Marker face tilted 15° toward approaching robot.
// Accepts 100×100 mm printed/laminated paper marker in slot.
module aruco_mount() {
frame_w = ARUCO_MARKER_W + 2*ARUCO_FRAME_BDR;
frame_h = ARUCO_MARKER_H + 2*ARUCO_FRAME_BDR;
mast_y = ARUCO_Y;
union() {
// Mast column
translate([-ARUCO_MAST_W/2, mast_y - ARUCO_MAST_W/2, 0])
cube([ARUCO_MAST_W, ARUCO_MAST_W, ARUCO_MAST_H]);
// Marker frame (tilted back ARUCO_TILT°)
translate([0, mast_y, ARUCO_MAST_H])
rotate([-ARUCO_TILT, 0, 0]) {
difference() {
// Frame plate
translate([-frame_w/2, -ARUCO_FRAME_T, -frame_h/2])
cube([frame_w, ARUCO_FRAME_T, frame_h]);
// Marker window (cutout for marker visibility)
translate([-ARUCO_MARKER_W/2, -ARUCO_FRAME_T - e,
-ARUCO_MARKER_H/2])
cube([ARUCO_MARKER_W,
ARUCO_FRAME_T + 2*e,
ARUCO_MARKER_H]);
// Marker slip-in slot (insert from side)
translate([-frame_w/2 - e,
-ARUCO_SLOT_T - 0.3,
-ARUCO_MARKER_H/2])
cube([frame_w + 2*e,
ARUCO_SLOT_T + 0.3,
ARUCO_MARKER_H]);
}
}
// Mast base foot (M4 bolts to dock base)
difference() {
translate([-20, mast_y - 20, 0])
cube([40, 40, 5]);
for (fx = [-12, 12]) for (fy = [-12, 12])
translate([fx, mast_y + fy, -e])
cylinder(d = M4_D, h = 6 + e);
}
}
}
// ============================================================
// PART E PSU BRACKET
// ============================================================
// Open-top retention bracket for PSU module.
// PSU slides in from top; 2× M3 straps or cable ties retain it.
// Bracket bolts to base plate via 4× M4 screws.
module psu_bracket() {
difference() {
union() {
// Outer bracket box (open top)
_box_open_top(PSU_W + 2*PSU_T,
PSU_D + 2*PSU_T,
PSU_H + PSU_T);
// Base flange
translate([-(PSU_W/2 + PSU_T + 8),
-(PSU_D/2 + PSU_T + 8), -PSU_T])
cube([PSU_W + 2*PSU_T + 16,
PSU_D + 2*PSU_T + 16, PSU_T]);
}
// PSU cavity
translate([0, 0, PSU_T])
cube([PSU_W, PSU_D, PSU_H + e], center = true);
// Ventilation slots (sides)
for (a = [0, 90, 180, 270])
rotate([0, 0, a])
translate([0, (PSU_D/2 + PSU_T)/2, PSU_H/2 + PSU_T])
for (sz = [-PSU_H/4, 0, PSU_H/4])
translate([0, 0, sz])
cube([PSU_W * 0.5, PSU_T + 2*e, 5],
center = true);
// Cable exit slot (bottom)
translate([0, 0, -e])
cube([15, PSU_D + 2*PSU_T + 2*e, PSU_T + 2*e],
center = true);
// Base flange M4 bolts
for (fx = [-1, 1]) for (fy = [-1, 1])
translate([fx * (PSU_W/2 + PSU_T + 4),
fy * (PSU_D/2 + PSU_T + 4),
-PSU_T - e])
cylinder(d = M4_D, h = PSU_T + 2*e);
// Cable tie slots
for (sz = [PSU_H/3, 2*PSU_H/3])
translate([0, 0, PSU_T + sz])
cube([PSU_W + 2*PSU_T + 2*e, 4, 4], center = true);
}
}
module _box_open_top(w, d, h) {
difference() {
cube([w, d, h], center = true);
translate([0, 0, PSU_T + e])
cube([w - 2*PSU_T, d - 2*PSU_T, h], center = true);
}
}
// ============================================================
// PART F LED STATUS BEZEL
// ============================================================
// 4 × 5 mm LEDs in a row. Press-fits into recess in back wall.
// LED labels (LR): SEARCHING | ALIGNED | CHARGING | FULL
// Suggested colours: Red | Yellow | Blue | Green
module led_bezel() {
difference() {
// Bezel plate
cube([LED_BEZEL_W, LED_BEZEL_T, LED_BEZEL_H], center = true);
// 4× LED bores
for (i = [-1.5, -0.5, 0.5, 1.5])
translate([i * LED_SPACING, -LED_BEZEL_T - e, 0])
rotate([90, 0, 0]) {
// LED body bore (recess, not through)
cylinder(d = LED_BORE_D + 1,
h = LED_INSET_D + e);
// LED pin bore (through bezel)
translate([0, 0, LED_INSET_D])
cylinder(d = LED_BORE_D,
h = LED_BEZEL_T + 2*e);
}
// Label recesses between LEDs (for colour-dot stickers or printed inserts)
for (i = [-1.5, -0.5, 0.5, 1.5])
translate([i * LED_SPACING, LED_BEZEL_T/2, LED_BEZEL_H/2 - 3])
cube([LED_SPACING - 3, 1 + e, 5], center = true);
// M3 mounting holes (2× into back wall)
for (mx = [-LED_BEZEL_W/2 + 6, LED_BEZEL_W/2 - 6])
translate([mx, -LED_BEZEL_T - e, 0])
rotate([90, 0, 0])
cylinder(d = M3_D, h = LED_BEZEL_T + 2*e);
}
}

View File

@ -1,41 +0,0 @@
Item,Description,Specification,Quantity,Unit Cost,Total Cost,Source Notes
E1,Power Supply,Mean Well IRM-240-24 / Hi-Link HLK-240M24 (24V 10A 240W),1,$50.00,$50.00,Digi-Key / Amazon
E2,12 AWG Silicone Wire,Red + Black 600V rated 5m spool,1,$15.00,$15.00,McMaster-Carr / AliExpress
E3,PG7 Cable Gland,M20 IP67 5-8mm cable,2,$3.00,$6.00,AliExpress / Heilind
E4,Varistor (MOV),18-28V 1kA,1,$1.00,$1.00,Digi-Key
E5,Fuse 25A,T25 Slow-blow 5x20mm,1,$0.50,$0.50,Digi-Key
E6,Fuse Holder,5x20mm inline 20A rated,1,$2.00,$2.00,Amazon
E7,Crimp Ring Terminals,M3 12 AWG tin-plated,8,$0.20,$1.60,Heilind / AliExpress
E8,Strain Relief Sleeve,5mm ID silicone 1m,1,$5.00,$5.00,McMaster-Carr
C1,Pogo Pin Assembly,"Spring-loaded Ø5.5mm 20mm 20A 4mm travel",2,$10.00,$20.00,Preci-Dip / Jst / AliExpress
C2,Brass Contact Pad,Ø12x2mm H68 brass bare,2,$3.00,$6.00,OnlineMetals / Metals USA
C3,Solder Lug M3,Copper ring tin-plated,4,$0.40,$1.60,Heilind / Amazon
L1,5mm LED Red,2.0V 20mA diffuse,1,$0.30,$0.30,Digi-Key
L2,5mm LED Yellow,2.1V 20mA diffuse,1,$0.30,$0.30,Digi-Key
L3,5mm LED Blue,3.2V 20mA diffuse,1,$0.50,$0.50,Digi-Key
L4,5mm LED Green,2.1V 20mA diffuse,1,$0.30,$0.30,Digi-Key
R1-R4,Resistor 1kΩ 1/4W,Metal film 1% tolerance,4,$0.10,$0.40,Digi-Key
J1,Pin Header 2.54mm,1x6 right-angle,1,$0.50,$0.50,Digi-Key
S1,INA219 I2C Shunt Monitor,16-bit I2C 0x40 26V max (Optional),1,$5.00,$5.00,Adafruit / Digi-Key
S2,SMD Resistor 0.1Ω,1206 1W (Optional current sense),1,$1.00,$1.00,Digi-Key
M1,M20 Hex Nut,Steel DIN 934 ~86g,8,$0.80,$6.40,Grainger / Home Depot
M2,M4x16 SHCS,Stainless A4 DIN 912,16,$0.30,$4.80,Grainger
M3,M4x10 BHCS,Stainless A4 DIN 7380,8,$0.25,$2.00,Grainger
M4,M4 Heat-Set Insert,Brass threaded,20,$0.15,$3.00,McMaster-Carr
M5,M3x16 SHCS,Stainless,4,$0.20,$0.80,Grainger
M6,M3 Hex Nut,DIN 934,4,$0.10,$0.40,Grainger
M7,M8x40 BHCS,Zinc-plated floor anchor,4,$0.50,$2.00,Grainger
M8,Rubber Foot,Ø20x5mm self-adhesive,4,$0.80,$3.20,Amazon
A1,ArUco Marker Print,"100x100mm ID=42 DICT_4X4_250 glossy photo (qty 2)",2,$1.50,$3.00,Print locally / AliExpress
A2,Lamination Pouch,A4 80µm,2,$0.40,$0.80,Amazon / Staples
A3,Acrylic Cover Sheet,Clear 3mm 150x150mm,1,$3.00,$3.00,McMaster-Carr
X1,Solder Wire,63/37 Sn/Pb lead-free 1m,1,$3.00,$3.00,Digi-Key
X2,Flux Paste,No-clean 25mL,1,$4.00,$4.00,Digi-Key
X3,Loctite 243,Thread-locker 10mL,1,$4.00,$4.00,Grainger
X4,Epoxy Adhesive,Two-part 25mL,1,$6.00,$6.00,Home Depot
P1,PETG Filament (3D Print),"Natural/White 1kg ±15% waste factor",2.5,$20.00,$50.00,Prusament / Overture
,,,,,
SUBTOTAL (Electrical + Hardware + Consumables),,,,,,$234.00,excludes 3D printing
SUBTOTAL (With 3D filament @ $20/kg),,,,,,$284.00,all materials
LABOR ESTIMATE (Assembly ~4-6 hrs),,,,,,$150-225,tecnico time
TOTAL PROJECT COST (Material + Labor),,,,,,$434-509,per dock
Can't render this file because it has a wrong number of fields in line 37.

View File

@ -1,332 +0,0 @@
// ============================================================
// charging_dock_receiver_505.scad Robot-Side Charging Receiver (24V)
// Issue: #505 Agent: sl-mechanical Date: 2026-03-06
// ============================================================
//
// Robot-side contact plate that mates with the 24V charging dock pogo pins.
// Forked from Issue #159 receiver (contact geometry unchanged; 12 AWG wire bore).
// Each robot variant has a different mounting interface; the contact
// geometry is identical across all variants (same pogo pin spacing).
//
// Variants:
// A lab_receiver() SaltyLab mounts to underside of stem base ring
// B rover_receiver() SaltyRover mounts to chassis belly (M4 deck holes)
// C tank_receiver() SaltyTank mounts to skid plate / hull floor
//
// Contact geometry (common across variants):
// 2× brass contact pads, Ø12 mm × 2 mm (press-fit into PETG housing)
// Pad spacing: 20 mm CL-to-CL (matches dock POGO_SPACING exactly)
// Contact face Z height matches dock pogo pin Z when robot is level
// Polarity: marked + on top pin (conventional: positive = right when
// facing dock; negative = left) must match dock wiring.
//
// Approach guide nose:
// A chamfered V-nose on the forward face guides the receiver block
// into the dock's V-funnel. Taper half-angle 14° matches guide rails.
// Nose width = RECV_W = 30 mm (matches dock EXIT_GAP - 2 mm clearance).
//
// Coordinate convention:
// Z = 0 at receiver mounting face (against robot chassis/deck underside).
// +Z points downward (toward dock floor).
// Contact pads face +Y (toward dock back wall when docked).
// Receiver centred on X = 0 (robot centreline).
//
// RENDER options:
// "assembly" all 3 receivers side by side
// "lab_stl" SaltyLab receiver (print 1×)
// "rover_stl" SaltyRover receiver (print 1×)
// "tank_stl" SaltyTank receiver (print 1×)
// "contact_pad_2d" DXF Ø12 mm brass pad profile (order from metal shop)
//
// Export (Issue #505 24V variant):
// openscad charging_dock_receiver_505.scad -D 'RENDER="lab_stl"' -o receiver_505_lab.stl
// openscad charging_dock_receiver_505.scad -D 'RENDER="rover_stl"' -o receiver_505_rover.stl
// openscad charging_dock_receiver_505.scad -D 'RENDER="tank_stl"' -o receiver_505_tank.stl
// openscad charging_dock_receiver_505.scad -D 'RENDER="contact_pad_2d"' -o contact_pad_505.dxf
// ============================================================
$fn = 64;
e = 0.01;
// Contact geometry (must match charging_dock.scad)
POGO_SPACING = 20.0; // CL-to-CL (dock POGO_SPACING)
PAD_D = 12.0; // contact pad OD (brass disc)
PAD_T = 2.0; // contact pad thickness
PAD_RECESS = 1.8; // pad pressed into housing (0.2 mm proud for contact)
PAD_PROUD = 0.2; // pad face protrudes from housing face
// Common receiver body geometry
RECV_W = 30.0; // receiver body width (X) matches dock EXIT_GAP inner
RECV_D = 25.0; // receiver body depth (Y, docking direction)
RECV_H = 12.0; // receiver body height (Z, from mount face down)
RECV_R = 3.0; // corner radius
// V-nose geometry (front Y face faces dock back wall)
NOSE_CHAMFER = 10.0; // chamfer depth on X corners of front face
// Polarity indicator slot (on top/mount face: + on right, - on left)
POL_SLOT_W = 4.0;
POL_SLOT_D = 8.0;
POL_SLOT_H = 1.0;
// Fasteners
M2_D = 2.4;
M3_D = 3.3;
M4_D = 4.3;
// Mounting patterns
// SaltyLab stem base ring (Ø25 mm stem, 4× M3 in ring at Ø40 mm BC)
LAB_BC_D = 40.0;
LAB_BOLT_D = M3_D;
LAB_COLLAR_H = 15.0; // collar height above receiver body
// SaltyRover deck (M4 grid pattern, 30.5×30.5 mm matching FC pattern on deck)
// Receiver uses 4× M4 holes at ±20 mm from centre (clear of deck electronics)
ROVER_BOLT_SPC = 40.0;
// SaltyTank skid plate (M4 holes matching skid plate bolt pattern)
// Uses 4× M4 at ±20 mm X, ±10 mm Y (inset from skid plate M4 positions)
TANK_BOLT_SPC_X = 40.0;
TANK_BOLT_SPC_Y = 20.0;
TANK_NOSE_L = 20.0; // extended nose for tank (wider hull)
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly();
else if (RENDER == "lab_stl") lab_receiver();
else if (RENDER == "rover_stl") rover_receiver();
else if (RENDER == "tank_stl") tank_receiver();
else if (RENDER == "contact_pad_2d") {
projection(cut = true) translate([0, 0, -0.5])
linear_extrude(1) circle(d = PAD_D);
}
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly() {
// SaltyLab receiver
color("RoyalBlue", 0.85)
translate([-80, 0, 0])
lab_receiver();
// SaltyRover receiver
color("OliveDrab", 0.85)
translate([0, 0, 0])
rover_receiver();
// SaltyTank receiver
color("SaddleBrown", 0.85)
translate([80, 0, 0])
tank_receiver();
}
// ============================================================
// COMMON RECEIVER BODY
// ============================================================
// Internal helper: the shared contact housing + V-nose.
// Orientation: mount face = +Z top; contact face = +Y front.
// All variant-specific modules call this, then add their mount interface.
module _receiver_body() {
difference() {
union() {
// Main housing block (rounded)
linear_extrude(RECV_H)
_recv_profile_2d();
// V-nose chamfer reinforcement ribs
// Two diagonal ribs at 45° reinforce the chamfered corners
for (sx = [-1, 1])
hull() {
translate([sx*(RECV_W/2 - NOSE_CHAMFER),
RECV_D/2, 0])
cylinder(d = 3, h = RECV_H * 0.6);
translate([sx*(RECV_W/2), RECV_D/2 - NOSE_CHAMFER, 0])
cylinder(d = 3, h = RECV_H * 0.6);
}
}
// Contact pad bores (2× Ø12 mm, press-fit)
// Pads face +Y; bores from Y face into housing
for (px = [-POGO_SPACING/2, POGO_SPACING/2])
translate([px, RECV_D/2 + e, RECV_H/2])
rotate([90, 0, 0]) {
// Pad press-fit bore
cylinder(d = PAD_D + 0.1,
h = PAD_RECESS + e);
// Wire bore (behind pad, to mount face)
translate([0, 0, PAD_RECESS])
cylinder(d = 3.0,
h = RECV_D + 2*e);
}
// Polarity indicator slots on top face
// "+" slot: right pad (+X side)
translate([POGO_SPACING/2, 0, -e])
cube([POL_SLOT_W, POL_SLOT_D, POL_SLOT_H + e], center = true);
// "-" indent: left pad (no slot = negative)
// Wire routing channel (on mount face / underside)
// Trough connecting both pad bores for neat wire run
translate([0, RECV_D/2 - POGO_SPACING/2, RECV_H - 3])
cube([POGO_SPACING + 6, POGO_SPACING, 4], center = true);
}
}
// 2D profile of receiver body with chamfered V-nose
module _recv_profile_2d() {
hull() {
// Rear corners (full width)
for (sx = [-1, 1])
translate([sx*(RECV_W/2 - RECV_R), -RECV_D/2 + RECV_R])
circle(r = RECV_R);
// Front corners (chamfered narrowed by NOSE_CHAMFER)
for (sx = [-1, 1])
translate([sx*(RECV_W/2 - NOSE_CHAMFER - RECV_R),
RECV_D/2 - RECV_R])
circle(r = RECV_R);
}
}
// ============================================================
// PART A SALTYLAB RECEIVER
// ============================================================
// Mounts to the underside of the SaltyLab chassis stem base ring.
// Split collar grips Ø25 mm stem; receiver body hangs below collar.
// Z height set so contact pads align with dock pogo pins when robot
// rests on flat surface (robot wheel-to-contact-pad height calibrated).
//
// Receiver height above floor: tune LAB_CONTACT_Z in firmware (UWB/ArUco
// approach). Mechanically: receiver sits ~35 mm above ground (stem base
// height), matching dock POGO_Z = 35 mm.
module lab_receiver() {
collar_od = 46.0; // matches sensor_rail.scad STEM_COL_OD
collar_h = LAB_COLLAR_H;
union() {
// Common receiver body
_receiver_body();
// Stem collar (split, 2 halves joined with M4 bolts)
// Only the front half printed here; rear half is mirror.
translate([0, -RECV_D/2, RECV_H])
difference() {
// Half-collar cylinder
rotate_extrude(angle = 180)
translate([collar_od/2 - 8, 0, 0])
square([8, collar_h]);
// Stem bore clearance
translate([0, 0, -e])
cylinder(d = 25.5, h = collar_h + 2*e);
// 2× M4 clamping bolt bores (through collar flanges)
for (cx = [-collar_od/2 + 4, collar_od/2 - 4])
translate([cx, 0, collar_h/2])
rotate([90, 0, 0])
cylinder(d = M4_D,
h = collar_od + 2*e,
center = true);
}
// M3 receiver-to-collar bolts
// 4× M3 holes connecting collar flange to receiver body top
// (These are mounting holes for assembly; not holes in the part)
}
}
// ============================================================
// PART B SALTYOVER RECEIVER
// ============================================================
// Mounts to the underside of the SaltyRover deck plate.
// 4× M4 bolts into deck underside (blind holes tapped in deck).
// Receiver sits flush with deck belly; contact pads protrude 5 mm below.
// Dock pogo Z = 35 mm must equal ground-to-deck-belly height for rover
// (approximately 60 mm chassis clearance shim with spacer if needed).
module rover_receiver() {
mount_h = 5.0; // mounting flange thickness
union() {
// Common receiver body
_receiver_body();
// Mounting flange (attaches to deck belly)
difference() {
translate([-(ROVER_BOLT_SPC/2 + 12),
-RECV_D/2 - 10,
RECV_H])
cube([ROVER_BOLT_SPC + 24,
RECV_D + 20,
mount_h]);
// 4× M4 bolt holes
for (fx = [-1, 1]) for (fy = [-1, 1])
translate([fx*ROVER_BOLT_SPC/2,
fy*(RECV_D/2 + 5),
RECV_H - e])
cylinder(d = M4_D,
h = mount_h + 2*e);
// Weight-reduction pockets
for (sx = [-1, 1])
translate([sx*(ROVER_BOLT_SPC/4 + 6),
0, RECV_H + 1])
cube([ROVER_BOLT_SPC/2 - 4, RECV_D - 4, mount_h],
center = true);
}
}
}
// ============================================================
// PART C SALTYTANK RECEIVER
// ============================================================
// Mounts to SaltyTank hull floor or replaces a section of skid plate.
// Extended front nose (TANK_NOSE_L) for tank's wider hull approach.
// Contact pads exposed through skid plate via a 30×16 mm slot.
// Ground clearance: tank chassis = 90 mm; dock POGO_Z = 35 mm.
// Use ramp shim (see BOM) under dock base to elevate pogo pins to 90 mm
// OR set POGO_Z = 90 in dock for a tank-specific dock configuration.
// Cross-variant dock: set POGO_Z per robot if heights differ.
// Compromise: POGO_Z = 60 mm with 25 mm ramp for tank, 25 mm spacer for lab.
module tank_receiver() {
mount_h = 4.0;
nose_l = RECV_D/2 + TANK_NOSE_L;
union() {
// Common receiver body
_receiver_body();
// Extended nose for tank approach
// Additional chamfered wedge ahead of standard receiver body
hull() {
// Receiver front face corners
for (sx = [-1, 1])
translate([sx*(RECV_W/2 - NOSE_CHAMFER), RECV_D/2, 0])
cylinder(d = 2*RECV_R, h = RECV_H * 0.5);
// Extended nose tip (narrowed to 20 mm)
for (sx = [-1, 1])
translate([sx*10, RECV_D/2 + TANK_NOSE_L, 0])
cylinder(d = 2*RECV_R, h = RECV_H * 0.4);
}
// Mounting flange (bolts to tank skid plate)
difference() {
translate([-(TANK_BOLT_SPC_X/2 + 10),
-RECV_D/2 - 8,
RECV_H])
cube([TANK_BOLT_SPC_X + 20,
RECV_D + 16,
mount_h]);
// 4× M4 bolt holes
for (fx = [-1, 1]) for (fy = [-1, 1])
translate([fx*TANK_BOLT_SPC_X/2,
fy*TANK_BOLT_SPC_Y/2,
RECV_H - e])
cylinder(d = M4_D,
h = mount_h + 2*e);
}
}
}

View File

@ -8,9 +8,9 @@
// Requirements: // Requirements:
// - 600mm wheelbase // - 600mm wheelbase
// - 2x hoverboard hub motors (170mm OD) // - 2x hoverboard hub motors (170mm OD)
// - ESP32-S3 ESP32-S3 BALANCE FC mount (30.5x30.5mm pattern) // - STM32 MAMBA F722S FC mount (30.5x30.5mm pattern)
// - Battery tray (24V 4Ah ~180x70x50mm pack) // - Battery tray (24V 4Ah ~180x70x50mm pack)
// - Jetson Orin Nano Super B01 mount plate (100x80mm, M3 holes) // - Jetson Nano B01 mount plate (100x80mm, M3 holes)
// - Front/rear bumper brackets // - Front/rear bumper brackets
// ============================================================================= // =============================================================================
@ -37,7 +37,7 @@ MOTOR_FORK_H = 80; // mm, total height of motor fork bracket
MOTOR_FORK_T = 8; // mm, fork plate thickness MOTOR_FORK_T = 8; // mm, fork plate thickness
AXLE_HEIGHT = 310; // mm, axle CL above ground (motor radius + clearance) AXLE_HEIGHT = 310; // mm, axle CL above ground (motor radius + clearance)
// FC mount (ESP32-S3 BALANCE 30.5 × 30.5 mm M3 pattern) // FC mount (MAMBA F722S 30.5 × 30.5 mm M3 pattern)
FC_MOUNT_SPACING = 30.5; // mm, hole pattern pitch FC_MOUNT_SPACING = 30.5; // mm, hole pattern pitch
FC_MOUNT_HOLE_D = 3.2; // mm, M3 clearance FC_MOUNT_HOLE_D = 3.2; // mm, M3 clearance
FC_STANDOFF_H = 6; // mm, standoff height FC_STANDOFF_H = 6; // mm, standoff height
@ -52,7 +52,7 @@ BATT_FLOOR = 4; // mm, tray floor thickness
BATT_STRAP_W = 20; // mm, Velcro strap slot width BATT_STRAP_W = 20; // mm, Velcro strap slot width
BATT_STRAP_T = 2; // mm, strap slot depth BATT_STRAP_T = 2; // mm, strap slot depth
// Jetson Orin Nano Super B01 mount plate // Jetson Nano B01 mount plate
// B01 carrier board hole pattern: 58 x 58 mm M3 (inner) + corner pass-throughs // B01 carrier board hole pattern: 58 x 58 mm M3 (inner) + corner pass-throughs
JETSON_HOLE_PITCH = 58; // mm, M3 mounting hole pattern JETSON_HOLE_PITCH = 58; // mm, M3 mounting hole pattern
JETSON_HOLE_D = 3.2; // mm JETSON_HOLE_D = 3.2; // mm
@ -210,7 +210,7 @@ module battery_tray() {
// FC mount holes helper // FC mount holes helper
module fc_mount_holes(z_offset=0, depth=10) { module fc_mount_holes(z_offset=0, depth=10) {
// ESP32-S3 BALANCE: 30.5×30.5 mm M3 pattern, centred at origin // MAMBA F722S: 30.5×30.5 mm M3 pattern, centred at origin
for (x = [-FC_MOUNT_SPACING/2, FC_MOUNT_SPACING/2]) for (x = [-FC_MOUNT_SPACING/2, FC_MOUNT_SPACING/2])
for (y = [-FC_MOUNT_SPACING/2, FC_MOUNT_SPACING/2]) for (y = [-FC_MOUNT_SPACING/2, FC_MOUNT_SPACING/2])
translate([x, y, z_offset]) translate([x, y, z_offset])
@ -247,7 +247,7 @@ module fc_mount_plate() {
} }
} }
// Jetson Orin Nano Super B01 mount plate // Jetson Nano B01 mount plate
// Positioned rear of deck, elevated on standoffs // Positioned rear of deck, elevated on standoffs
module jetson_mount_plate() { module jetson_mount_plate() {
jet_x = 60; // offset toward rear jet_x = 60; // offset toward rear

View File

@ -1,599 +0,0 @@
// ============================================================
// gimbal_camera_mount.scad Pan/Tilt Gimbal Mount for RealSense D435i
// Issue: #552 Agent: sl-mechanical Date: 2026-03-14
// ============================================================
//
// Parametric gimbal bracket system mounting an Intel RealSense D435i
// (or similar box camera) on a 2-axis pan/tilt gimbal driven by
// ST3215 serial bus servos (25T spline, Feetech/Waveshare).
//
// Architecture:
// Pan axis base T-nut clamps to 2020 rail; pan servo rotates yoke
// Tilt axis tilt servo horn plate bolts to ST3215 horn; camera cradle
// rocks on tilt axis
// Camera D435i captured via 1/4-20 UNC hex nut in cradle floor
// Damping PETG flexure ribs on camera contact faces (or TPU pads)
// Wiring USB-C cable routed through channel in cradle arm
//
// Part catalogue:
// Part 1 tnut_rail_base() 2020 rail T-nut base + pan servo seat
// Part 2 pan_yoke() U-yoke connecting pan servo to tilt axis
// Part 3 tilt_horn_plate() Plate bolting to ST3215 tilt servo horn
// Part 4 camera_cradle() D435i cradle with 1/4-20 captured nut
// Part 5 vibe_pad() PETG flexure vibration-damping pad (×2)
// Part 6 assembly_preview() Full assembly preview
//
// Hardware BOM (per gimbal):
// 2× ST3215 serial bus servo (pan + tilt)
// 2× servo horn (25T spline, Ø36 mm, 4× M3 bolt holes on Ø24 mm BC)
// 2× M3 × 8 mm SHCS horn-to-plate bolts (×4 each horn = 8 total)
// 1× M3 × 16 mm SHCS + nut T-nut rail clamp thumbscrew
// 1× 1/4-20 UNC × 8 mm SHCS camera retention bolt (or existing tripod screw)
// 1× 1/4-20 UNC hex nut captured in cradle floor
// 4× M3 × 12 mm SHCS yoke-to-tilt-plate pivot axle bolts
// 2× M3 × 25 mm SHCS pan yoke attachment to servo body
// (optional) 2× vibe_pad printed in TPU 95A
//
// ST3215 servo interface (caliper-verified Feetech ST3215):
// Body footprint : 40.0 × 20.0 mm (W × D), 36.5 mm tall
// Shaft centre H : 28.5 mm from mounting face
// Shaft spline : 25T, centre Ø5.8 mm, D-cut
// Mount holes : 4× M3 on 32 × 10 mm rectangular pattern (18 mm offset)
// Horn bolt circle: Ø24 mm, 4× M3
// Horn OD : ~36 mm
//
// D435i camera interface (caliper-verified):
// Body : 90 × 25 × 25 mm (W × D × H)
// Tripod thread : 1/4-20 UNC, centred bottom face, 9 mm from front
// USB-C connector: right rear, 8 × 5 mm opening, 4 mm from edge
//
// Parametric camera size (override to adapt to other cameras):
// CAM_W, CAM_D, CAM_H body envelope
// CAM_MOUNT_X tripod hole X offset from camera centre
// CAM_MOUNT_Y tripod hole Y offset from front face
//
// Coordinate convention:
// Camera looks in +Y direction (forward)
// Pan axis is Z (vertical); tilt axis is X (lateral)
// Rail runs along Z; T-nut base at Z=0
// All parts at assembly origin; translate for assembly_preview
//
// RENDER options:
// "assembly" full assembly preview (default)
// "tnut_rail_base_stl" Part 1
// "pan_yoke_stl" Part 2
// "tilt_horn_plate_stl" Part 3
// "camera_cradle_stl" Part 4
// "vibe_pad_stl" Part 5
//
// Export commands:
// openscad gimbal_camera_mount.scad -D 'RENDER="tnut_rail_base_stl"' -o gcm_tnut_base.stl
// openscad gimbal_camera_mount.scad -D 'RENDER="pan_yoke_stl"' -o gcm_pan_yoke.stl
// openscad gimbal_camera_mount.scad -D 'RENDER="tilt_horn_plate_stl"' -o gcm_tilt_horn_plate.stl
// openscad gimbal_camera_mount.scad -D 'RENDER="camera_cradle_stl"' -o gcm_camera_cradle.stl
// openscad gimbal_camera_mount.scad -D 'RENDER="vibe_pad_stl"' -o gcm_vibe_pad.stl
// ============================================================
$fn = 64;
e = 0.01; // epsilon for boolean clearance
// Parametric camera envelope
// Override these for cameras other than D435i
CAM_W = 90.0; // camera body width (X)
CAM_D = 25.0; // camera body depth (Y)
CAM_H = 25.0; // camera body height (Z)
CAM_MOUNT_X = 0.0; // tripod hole X offset from camera body centre
CAM_MOUNT_Y = 9.0; // tripod hole from front face (Y) [D435i: 9 mm]
CAM_USBC_X = CAM_W/2 - 4; // USB-C connector X (right side)
CAM_USBC_Z = CAM_H/2; // USB-C connector Z (mid-height rear)
CAM_USBC_W = 9.0; // USB-C opening width (X)
CAM_USBC_H = 5.0; // USB-C opening height (Z)
// Rail geometry (matches sensor_rail.scad / sensor_rail_brackets.scad)
RAIL_W = 20.0;
SLOT_OPEN = 6.0;
SLOT_INNER_W = 10.2;
SLOT_INNER_H = 5.8;
SLOT_NECK_H = 3.2;
// T-nut geometry (matches sensor_rail_brackets.scad)
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
TNUT_M3_NUT_AF = 5.5;
TNUT_M3_NUT_H = 2.5;
TNUT_BOLT_D = 3.3; // M3 clearance
// T-nut base plate geometry
BASE_W = 44.0; // wide enough for pan servo body (40 mm)
BASE_H = 40.0; // height along rail (Z)
BASE_T = SLOT_NECK_H + 2.0; // plate depth (Y), rail-face side
// ST3215 servo geometry
SERVO_W = 40.0; // servo body width (X)
SERVO_D = 20.0; // servo body depth (Y)
SERVO_H = 36.5; // servo body height (Z)
SERVO_SHAFT_Z = 28.5; // shaft centre height from mounting face
SERVO_HOLE_X = 16.0; // mount hole half-span X (32 mm span)
SERVO_HOLE_Y = 5.0; // mount hole half-span Y (10 mm span)
SERVO_M3_D = 3.3; // M3 clearance
// Servo horn geometry
HORN_OD = 36.0; // horn outer diameter
HORN_SPLINE_D = 5.9; // 25T spline bore clearance (5.8 + 0.1)
HORN_BC_D = 24.0; // bolt circle diameter (4× M3)
HORN_BOLT_D = 3.3; // M3 clearance through horn plate
HORN_PLATE_T = 5.0; // tilt horn plate thickness
// Yoke geometry
YOKE_WALL_T = 5.0; // yoke arm wall thickness
YOKE_ARM_H = 50.0; // yoke arm height (Z) clears servo body + camera
YOKE_INNER_W = CAM_W + 8.0; // yoke inner span (camera + pad clearance)
YOKE_BASE_T = 8.0; // yoke base plate thickness
// Tilt pivot
PIVOT_D = 4.3; // M4 pivot axle bore
PIVOT_BOSS_D = 10.0; // boss OD around pivot bore
PIVOT_BOSS_L = 6.0; // boss protrusion from yoke wall
// Camera cradle geometry
CRADLE_WALL_T = 4.0; // cradle side wall thickness
CRADLE_FLOOR_T = 5.0; // cradle floor thickness (holds 1/4-20 nut)
CRADLE_LIP_T = 3.0; // front retaining lip thickness
CRADLE_LIP_H = 8.0; // front lip height
CABLE_CH_W = 12.0; // USB-C cable channel width
CABLE_CH_H = 8.0; // USB-C cable channel height
// 1/4-20 UNC tripod thread
QTR20_D = 6.6; // 1/4-20 clearance bore
QTR20_NUT_AF = 11.1; // 1/4-20 hex nut across-flats (standard)
QTR20_NUT_H = 5.5; // 1/4-20 hex nut height
// Vibration-damping pad geometry
PAD_W = CAM_W - 2*CRADLE_WALL_T - 2;
PAD_H = CAM_H + 4;
PAD_T = 2.5; // pad body thickness
RIB_H = 1.5; // flexure rib height
RIB_W = 1.2; // rib width
RIB_PITCH = 5.0; // rib pitch
// Fastener sizes
M3_D = 3.3;
M4_D = 4.3;
M3_NUT_AF = 5.5;
M3_NUT_H = 2.4;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly_preview();
else if (RENDER == "tnut_rail_base_stl") tnut_rail_base();
else if (RENDER == "pan_yoke_stl") pan_yoke();
else if (RENDER == "tilt_horn_plate_stl") tilt_horn_plate();
else if (RENDER == "camera_cradle_stl") camera_cradle();
else if (RENDER == "vibe_pad_stl") vibe_pad();
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly_preview() {
asm_rail_z = 0;
// Rail section ghost (200 mm)
%color("Silver", 0.25)
translate([-RAIL_W/2, -RAIL_W/2, asm_rail_z])
cube([RAIL_W, RAIL_W, 200]);
// T-nut rail base
color("OliveDrab", 0.85)
translate([0, 0, asm_rail_z + 80])
tnut_rail_base();
// Pan servo ghost (sitting in base seat)
%color("DimGray", 0.4)
translate([-SERVO_W/2, BASE_T, asm_rail_z + 80 + (BASE_H - SERVO_H)/2])
cube([SERVO_W, SERVO_D, SERVO_H]);
// Pan yoke rising from servo shaft
color("SteelBlue", 0.85)
translate([0, BASE_T + SERVO_D, asm_rail_z + 80 + BASE_H/2])
pan_yoke();
// Tilt horn plate (tilt axis left yoke wall)
color("DarkOrange", 0.85)
translate([-YOKE_INNER_W/2 - YOKE_WALL_T - HORN_PLATE_T,
BASE_T + SERVO_D + YOKE_BASE_T,
asm_rail_z + 80 + BASE_H/2 + YOKE_ARM_H/2])
rotate([0, 90, 0])
tilt_horn_plate();
// Camera cradle (centred in yoke)
color("DarkSlateGray", 0.85)
translate([0, BASE_T + SERVO_D + YOKE_BASE_T + CRADLE_FLOOR_T,
asm_rail_z + 80 + BASE_H/2 + YOKE_ARM_H/2 - CAM_H/2])
camera_cradle();
// D435i ghost
%color("Black", 0.4)
translate([-CAM_W/2,
BASE_T + SERVO_D + YOKE_BASE_T + CRADLE_FLOOR_T + PAD_T,
asm_rail_z + 80 + Base_H_mid() - CAM_H/2])
cube([CAM_W, CAM_D, CAM_H]);
// Vibe pads (front + rear camera face)
color("DimGray", 0.80) {
translate([-CAM_W/2 + CRADLE_WALL_T + 1,
Base_T + SERVO_D + YOKE_BASE_T + CRADLE_FLOOR_T,
asm_rail_z + 80 + Base_H_mid() - PAD_H/2])
rotate([90, 0, 0])
vibe_pad();
}
}
// helper (avoids recomputing same expression)
function Base_T() = BASE_T;
function Base_H_mid() = BASE_H/2 + YOKE_ARM_H/2;
// ============================================================
// PART 1 T-NUT RAIL BASE (pan servo seat + rail clamp)
// ============================================================
// Mounts to 2020 rail via standard T-nut tongue.
// Front face (+Y side) provides flat seat for pan ST3215 servo body.
// Servo body recessed 1 mm into seat for positive lateral registration.
// Pan servo shaft axis = Z (vertical) pan rotation about Z.
//
// Print: PETG, 5 perims, 50 % gyroid. Orient face-plate down (flat).
module tnut_rail_base() {
difference() {
union() {
// Face plate (against rail outer face, -Y side)
translate([-BASE_W/2, -BASE_T, 0])
cube([BASE_W, BASE_T, BASE_H]);
// T-nut neck (enters rail slot, +Y side of face plate)
translate([-TNUT_W/2, 0, (BASE_H - TNUT_L)/2])
cube([TNUT_W, SLOT_NECK_H + e, TNUT_L]);
// T-nut inner body (wider, locks inside T-groove)
translate([-TNUT_W/2, SLOT_NECK_H - e, (BASE_H - TNUT_L)/2])
cube([TNUT_W, TNUT_H - SLOT_NECK_H + e, TNUT_L]);
// Pan servo seat boss (front face, +Y side)
// Proud pad that servo body sits on; 1 mm registration recess
translate([-BASE_W/2, -BASE_T, 0])
cube([BASE_W, BASE_T + 6, BASE_H]);
}
// Rail clamp bolt bore (M3 through face plate)
translate([0, -BASE_T - e, BASE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_BOLT_D, h = BASE_T + TNUT_H + 2*e);
// M3 hex nut pocket (inside T-nut body)
translate([0, SLOT_NECK_H + 0.3, BASE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_M3_NUT_AF / cos(30),
h = TNUT_M3_NUT_H + 0.3, $fn = 6);
// Servo body recess (1 mm registration pocket in seat face)
translate([-SERVO_W/2 - 0.3, -BASE_T + 6 - 1.0,
(BASE_H - SERVO_H)/2 - 0.3])
cube([SERVO_W + 0.6, 1.2, SERVO_H + 0.6]);
// Pan servo mount holes (4× M3 in rectangular pattern)
for (sx = [-SERVO_HOLE_X, SERVO_HOLE_X])
for (sy = [-SERVO_HOLE_Y, SERVO_HOLE_Y])
translate([sx, -BASE_T + 6 + e, BASE_H/2 + sy])
rotate([90, 0, 0])
cylinder(d = SERVO_M3_D, h = BASE_T + 2*e);
// Pan servo shaft bore (passes shaft through base if needed)
// Centre of shaft at Z = BASE_H/2, no bore needed (shaft exits top)
// Lightening pockets
translate([0, -BASE_T/2 + 3, BASE_H/2])
cube([BASE_W - 14, BASE_T - 4, BASE_H - 14], center = true);
}
}
// ============================================================
// PART 2 PAN YOKE
// ============================================================
// U-shaped yoke that attaches to pan servo horn (below) and carries
// the tilt axis (above). Two vertical arms straddle the camera cradle.
// Tilt servo sits on top of one arm; tilt pivot boss on the other.
//
// Yoke base bolts to pan servo horn (4× M3 on HORN_BC_D bolt circle).
// Pan servo horn spline bore passes through yoke base centre.
// Tilt axis: M4 pivot axle through boss on each arm (X-axis rotation).
//
// Print: upright (yoke in final orientation), PETG, 5 perims, 40% gyroid.
module pan_yoke() {
arm_z_total = YOKE_ARM_H + YOKE_BASE_T;
inner_w = YOKE_INNER_W;
difference() {
union() {
// Yoke base plate (bolts to pan servo horn)
translate([-inner_w/2 - YOKE_WALL_T, 0, 0])
cube([inner_w + 2*YOKE_WALL_T, YOKE_BASE_T, YOKE_BASE_T]);
// Left arm
translate([-inner_w/2 - YOKE_WALL_T, 0, 0])
cube([YOKE_WALL_T, YOKE_BASE_T, arm_z_total]);
// Right arm (tilt servo side)
translate([inner_w/2, 0, 0])
cube([YOKE_WALL_T, YOKE_BASE_T, arm_z_total]);
// Tilt pivot bosses (both arms, X-axis)
// Left pivot boss (plain pivot M4 bolt)
translate([-inner_w/2 - YOKE_WALL_T - PIVOT_BOSS_L,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2])
rotate([0, 90, 0])
cylinder(d = PIVOT_BOSS_D, h = PIVOT_BOSS_L + YOKE_WALL_T);
// Right pivot boss (tilt servo horn seat)
translate([inner_w/2,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2])
rotate([0, 90, 0])
cylinder(d = PIVOT_BOSS_D + 4, h = PIVOT_BOSS_L + YOKE_WALL_T);
// Tilt servo body seat on right arm top
translate([inner_w/2, 0, arm_z_total - SERVO_H - 4])
cube([YOKE_WALL_T + SERVO_D + 2, YOKE_BASE_T, SERVO_H + 4]);
}
// Pan horn spline bore (centre of yoke base)
translate([0, YOKE_BASE_T/2, YOKE_BASE_T/2])
rotate([90, 0, 0])
cylinder(d = HORN_SPLINE_D, h = YOKE_BASE_T + 2*e,
center = true);
// Pan horn bolt holes (4× M3 on HORN_BC_D)
for (a = [45, 135, 225, 315])
translate([HORN_BC_D/2 * cos(a),
YOKE_BASE_T/2,
HORN_BC_D/2 * sin(a) + YOKE_BASE_T/2])
rotate([90, 0, 0])
cylinder(d = HORN_BOLT_D, h = YOKE_BASE_T + 2*e,
center = true);
// Left tilt pivot bore (M4 clearance)
translate([-inner_w/2 - YOKE_WALL_T - PIVOT_BOSS_L - e,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2])
rotate([0, 90, 0])
cylinder(d = PIVOT_D, h = PIVOT_BOSS_L + YOKE_WALL_T + 2*e);
// Right tilt pivot bore (larger tilt horn plate seats here)
translate([inner_w/2 - e,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2])
rotate([0, 90, 0])
cylinder(d = HORN_SPLINE_D,
h = PIVOT_BOSS_L + YOKE_WALL_T + 2*e);
// Tilt servo mount holes in right arm seat
for (sz = [-SERVO_HOLE_Y, SERVO_HOLE_Y])
translate([inner_w/2 + YOKE_WALL_T + SERVO_D/2,
YOKE_BASE_T/2,
arm_z_total - SERVO_H/2 + sz])
rotate([90, 0, 0])
cylinder(d = SERVO_M3_D, h = YOKE_BASE_T + 2*e,
center = true);
// M3 nut pockets (tilt servo mount, rear of arm seat)
for (sz = [-SERVO_HOLE_Y, SERVO_HOLE_Y])
translate([inner_w/2 + YOKE_WALL_T + SERVO_D/2,
YOKE_BASE_T - M3_NUT_H - 0.5,
arm_z_total - SERVO_H/2 + sz])
rotate([90, 0, 0])
cylinder(d = M3_NUT_AF / cos(30), h = M3_NUT_H + 0.5,
$fn = 6);
// Lightening slots in yoke arms
translate([-inner_w/2 - YOKE_WALL_T/2,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2 - 10])
cube([YOKE_WALL_T - 2, YOKE_BASE_T - 2, YOKE_ARM_H - 24],
center = true);
translate([inner_w/2 + YOKE_WALL_T/2,
YOKE_BASE_T/2,
YOKE_BASE_T + YOKE_ARM_H/2 - 10])
cube([YOKE_WALL_T - 2, YOKE_BASE_T - 2, YOKE_ARM_H - 30],
center = true);
}
}
// ============================================================
// PART 3 TILT HORN PLATE
// ============================================================
// Disc plate bolting to tilt ST3215 servo horn on the right yoke arm.
// Servo horn spline centres into disc bore (captured, no free rotation).
// Camera cradle attaches to opposite face via 2× M3 bolts.
//
// Tilt range: ±45° limited by yoke arm geometry.
// Plate thickness HORN_PLATE_T provides stiffness for cantilevered cradle.
//
// Print: flat (disc face down), PETG, 5 perims, 50 % infill.
module tilt_horn_plate() {
plate_od = HORN_OD + 8; // plate OD (4 mm rim outside horn BC)
difference() {
union() {
// Main disc
cylinder(d = plate_od, h = HORN_PLATE_T);
// Cradle attachment arm (extends to camera cradle)
// Rectangular boss on top of disc toward camera
translate([-CAM_W/2, HORN_PLATE_T - e, -CAM_H/2])
cube([CAM_W, HORN_PLATE_T + 4, CAM_H]);
}
// Servo horn spline bore (centre)
translate([0, 0, -e])
cylinder(d = HORN_SPLINE_D, h = HORN_PLATE_T + 2*e);
// Horn bolt holes (4× M3 on HORN_BC_D)
for (a = [45, 135, 225, 315])
translate([HORN_BC_D/2 * cos(a),
HORN_BC_D/2 * sin(a), -e])
cylinder(d = HORN_BOLT_D, h = HORN_PLATE_T + 2*e);
// Pivot axle bore (M4, coaxial with horn centre)
translate([0, 0, -e])
cylinder(d = PIVOT_D, h = HORN_PLATE_T + 2*e);
// Cradle attachment bolts (2× M3 in arm boss)
for (cz = [-CAM_H/2 + 6, CAM_H/2 - 6])
translate([0, HORN_PLATE_T + 2, cz])
rotate([90, 0, 0])
cylinder(d = M3_D, h = HORN_PLATE_T + 6 + 2*e);
// M3 hex nut pockets (rear of disc face)
for (cz = [-CAM_H/2 + 6, CAM_H/2 - 6])
translate([0, M3_NUT_H + 0.5, cz])
rotate([90, 0, 0])
cylinder(d = M3_NUT_AF / cos(30),
h = M3_NUT_H + 0.5, $fn = 6);
// Weight-relief arcs (between horn bolt holes)
for (a = [0, 90, 180, 270])
translate([(plate_od/2 - 5) * cos(a),
(plate_od/2 - 5) * sin(a), -e])
cylinder(d = 6, h = HORN_PLATE_T + 2*e);
}
}
// ============================================================
// PART 4 CAMERA CRADLE
// ============================================================
// Open-front U-cradle holding D435i via captured 1/4-20 hex nut.
// Front lip retains camera from sliding forward (+Y).
// Vibration-damping pads seat in recessed pockets on inner faces.
// USB-C cable routing channel exits cradle right rear wall.
//
// 1/4-20 captured nut in cradle floor tighten with standard
// tripod screw or M61/4-20 adapter from camera bottom.
//
// Print: cradle-floor-down (flat), PETG, 5 perims, 40 % gyroid.
// No supports needed (overhangs < 45°).
module camera_cradle() {
outer_w = CAM_W + 2*CRADLE_WALL_T;
outer_h = CAM_H + CRADLE_FLOOR_T;
difference() {
union() {
// Cradle body
translate([-outer_w/2, 0, 0])
cube([outer_w, CAM_D + CRADLE_WALL_T, outer_h]);
// Front retaining lip
translate([-outer_w/2, CAM_D + CRADLE_WALL_T - CRADLE_LIP_T, 0])
cube([outer_w, CRADLE_LIP_T, CRADLE_LIP_H]);
// Cable channel boss (right rear, exits +X side)
translate([CAM_W/2 + CRADLE_WALL_T - e,
0,
CRADLE_FLOOR_T + CAM_H/2 - CABLE_CH_H/2])
cube([CABLE_CH_W + CRADLE_WALL_T, CAM_D * 0.6, CABLE_CH_H]);
// Tilt horn attachment tabs (left + right, bolt to horn plate)
for (sx = [-outer_w/2 - 4, outer_w/2])
translate([sx, CAM_D/2, CRADLE_FLOOR_T + CAM_H/2 - 6])
cube([4, 12, 12]);
}
// Camera pocket (hollow interior)
translate([-CAM_W/2, 0, CRADLE_FLOOR_T])
cube([CAM_W, CAM_D + CRADLE_WALL_T + e, CAM_H + e]);
// 1/4-20 UNC clearance bore (camera tripod thread, bottom)
translate([CAM_MOUNT_X, CAM_MOUNT_Y, -e])
cylinder(d = QTR20_D, h = CRADLE_FLOOR_T + 2*e);
// 1/4-20 hex nut pocket (captured in cradle floor)
translate([CAM_MOUNT_X, CAM_MOUNT_Y, CRADLE_FLOOR_T - QTR20_NUT_H - 0.5])
cylinder(d = QTR20_NUT_AF / cos(30),
h = QTR20_NUT_H + 0.6, $fn = 6);
// USB-C cable channel (exit through right rear wall)
translate([CAM_W/2 - e,
0,
CRADLE_FLOOR_T + CAM_H/2 - CABLE_CH_H/2])
cube([CABLE_CH_W + CRADLE_WALL_T + 2*e,
CAM_D * 0.6 + e, CABLE_CH_H]);
// Vibe pad recesses on inner camera-contact faces
// Rear wall recess (camera front face +Y side of rear wall)
translate([-CAM_W/2 + CRADLE_WALL_T, CRADLE_WALL_T, CRADLE_FLOOR_T])
cube([CAM_W, PAD_T, CAM_H]);
// Tilt horn bolt holes in attachment tabs
for (sx = [-outer_w/2 - 4 - e, outer_w/2 - e])
translate([sx, CAM_D/2 + 6, CRADLE_FLOOR_T + CAM_H/2])
rotate([0, 90, 0])
cylinder(d = M3_D, h = 6 + 2*e);
// M3 nut pockets in attachment tabs
translate([outer_w/2 + 4 - M3_NUT_H - 0.4,
CAM_D/2 + 6,
CRADLE_FLOOR_T + CAM_H/2])
rotate([0, 90, 0])
cylinder(d = M3_NUT_AF / cos(30),
h = M3_NUT_H + 0.4, $fn = 6);
translate([-outer_w/2 - 4 - e,
CAM_D/2 + 6,
CRADLE_FLOOR_T + CAM_H/2])
rotate([0, 90, 0])
cylinder(d = M3_NUT_AF / cos(30),
h = M3_NUT_H + 0.4, $fn = 6);
// Lightening pockets in cradle walls
for (face_x = [-CAM_W/2 - CRADLE_WALL_T - e, CAM_W/2 - e])
translate([face_x, CAM_D * 0.2, CRADLE_FLOOR_T + 3])
cube([CRADLE_WALL_T + 2*e, CAM_D * 0.55, CAM_H - 6]);
}
}
// ============================================================
// PART 5 VIBRATION-DAMPING PAD
// ============================================================
// Flat pad with transverse PETG flexure ribs pressing against camera body.
// Rib geometry (thin fins ~1.5 mm tall) deflects under camera vibration,
// attenuating high-frequency input from motor/drive-train.
// For superior damping: print in TPU 95A (no infill changes needed).
// Pads seat in recessed pockets in camera cradle inner wall.
// Optional M2 bolt-through at corners or adhesive-back foam tape.
//
// Print: pad-back-face-down, PETG or TPU 95A, 3 perims, 20 % infill.
module vibe_pad() {
rib_count = floor((PAD_W - RIB_W) / RIB_PITCH);
union() {
// Base plate
translate([-PAD_W/2, -PAD_T, -PAD_H/2])
cube([PAD_W, PAD_T, PAD_H]);
// Flexure ribs (parallel to Z, spaced RIB_PITCH apart)
for (i = [0 : rib_count - 1]) {
rx = -PAD_W/2 + RIB_PITCH/2 + i * RIB_PITCH + RIB_W/2;
if (rx <= PAD_W/2 - RIB_W/2)
translate([rx, 0, 0])
cube([RIB_W, RIB_H, PAD_H - 6], center = true);
}
// Corner nubs (M2 bolt-through retention, optional)
for (px = [-PAD_W/2 + 5, PAD_W/2 - 5])
for (pz = [-PAD_H/2 + 5, PAD_H/2 - 5])
translate([px, -PAD_T/2, pz])
difference() {
cylinder(d = 5, h = PAD_T, center = true);
cylinder(d = 2.4, h = PAD_T + 2*e, center = true);
}
}
}

View File

@ -104,11 +104,7 @@ IP54-rated enclosures and sensor housings for all-weather outdoor robot operatio
| Component | Thermal strategy | Max junction | Enclosure budget | | Component | Thermal strategy | Max junction | Enclosure budget |
|-----------|-----------------|-------------|-----------------| |-----------|-----------------|-------------|-----------------|
| Jetson Orin NX | Al pad → lid → fan forced convection | 95 °C Tj | Target ≤ 60 °C case | | Jetson Orin NX | Al pad → lid → fan forced convection | 95 °C Tj | Target ≤ 60 °C case |
<<<<<<< HEAD | FC (MAMBA F722S) | Passive; FC has own EMI shield | 85 °C | <60 °C ambient OK |
| FC (ESP32 BALANCE) | Passive; FC has own EMI shield | 85 °C | <60 °C ambient OK |
=======
| FC (ESP32-S3 BALANCE) | Passive; FC has own EMI shield | 85 °C | <60 °C ambient OK |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| ESC × 2 | Al pad → lid | 100 °C Tj | Target ≤ 60 °C | | ESC × 2 | Al pad → lid | 100 °C Tj | Target ≤ 60 °C |
| D435i | Passive; housing vent gap on rear cap | 45 °C surface | — | | D435i | Passive; housing vent gap on rear cap | 45 °C surface | — |

View File

@ -1,386 +0,0 @@
// ============================================================
// Jetson Orin Nano Carrier Board Mount Issue #612
// Agent : sl-mechanical
// Date : 2026-03-15
// Part catalogue:
// 1. tnut_base 2020 T-slot rail interface plate, M5 T-nut captive pockets
// 2. standoff_post M2.5 captive-nut standoff post (×4), 10 mm airflow gap
// 3. side_brace lateral stiffening brace with port-access cutouts (×2)
// 4. duct_shroud optional top heatsink duct / fan-exhaust channel
// 5. cable_clip snap-on cable management clip for brace edge
//
// BOM:
// 4 × M5×10 BHCS + M5 T-nuts (tnut_base to rail, 2 per rail)
// 4 × M2.5×20 SHCS (board to standoff posts)
// 4 × M2.5 hex nuts (captured in standoff posts)
// 4 × M3×8 SHCS + washers (side_brace to tnut_base)
// 2 × M3×16 SHCS (duct_shroud to side_brace tops)
//
// Jetson Orin Nano carrier board (Seeed reComputer / official dev kit):
// Board dims : 100 × 80 mm
// Mounting hole pattern : 86 × 58 mm (centre-to-centre), M2.5, Ø3.5 pad
// PCB thickness: 1.6 mm
// Connector side: -Y (USB-A, USB-C, HDMI, DP, GbE, SD on one long edge)
// Fan header & PWM header: +X short edge
// M.2 / NVMe: bottom face
//
// Print settings (PETG):
// tnut_base / standoff_post / side_brace / duct_shroud : 5 perimeters, 40 % gyroid, no supports
// cable_clip : 3 perimeters, 30 % gyroid, no supports
//
// Export commands:
// openscad -D 'RENDER="tnut_base"' -o tnut_base.stl jetson_orin_mount.scad
// openscad -D 'RENDER="standoff_post"' -o standoff_post.stl jetson_orin_mount.scad
// openscad -D 'RENDER="side_brace"' -o side_brace.stl jetson_orin_mount.scad
// openscad -D 'RENDER="duct_shroud"' -o duct_shroud.stl jetson_orin_mount.scad
// openscad -D 'RENDER="cable_clip"' -o cable_clip.stl jetson_orin_mount.scad
// openscad -D 'RENDER="assembly"' -o assembly.png jetson_orin_mount.scad
// ============================================================
// Render selector
RENDER = "assembly"; // tnut_base | standoff_post | side_brace | duct_shroud | cable_clip | assembly
// Global constants
$fn = 64;
EPS = 0.01;
// 2020 rail
RAIL_W = 20.0;
SLOT_NECK_H = 3.2;
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
M5_D = 5.2;
M5_HEAD_D = 9.5;
M5_HEAD_H = 4.0;
// Jetson Orin Nano carrier board
BOARD_L = 100.0; // board X
BOARD_W = 80.0; // board Y
BOARD_T = 1.6; // PCB thickness
MH_SX = 86.0; // mounting hole span X (centre-to-centre)
MH_SY = 58.0; // mounting hole span Y
M25_D = 2.7; // M2.5 clearance bore
M25_NUT_W = 5.0; // M2.5 hex nut across-flats
M25_NUT_H = 2.0; // M2.5 hex nut height
M25_HEAD_D = 5.0; // M2.5 SHCS head diameter
M25_HEAD_H = 2.5;
// Base plate
BASE_L = 120.0; // length along X (covers board + overhang for braces)
BASE_W = 50.0; // width along Y (rail mount footprint)
BASE_T = 6.0; // plate thickness
BOLT_PITCH = 40.0; // M5 rail bolt pitch (per rail, 2 rails at Y=0 & Y=BASE_W)
M3_D = 3.2;
M3_HEAD_D = 6.0;
M3_HEAD_H = 3.0;
// Standoff posts
POST_H = 12.0; // airflow gap + PCB seating (>= 10 mm clearance spec)
POST_OD = 8.0; // outer diameter
POST_BASE_D = 11.0; // flange diameter
POST_BASE_H = 3.0; // flange height
NUT_TRAP_H = M25_NUT_H + 0.3;
NUT_TRAP_W = M25_NUT_W + 0.4;
// Side braces
BRACE_T = 5.0; // brace thickness (X)
BRACE_H = POST_H + POST_BASE_H + BOARD_T + 4.0; // full height
BRACE_W = BASE_W; // same width as base
// Port-access cutouts (connector side -Y)
USB_CUT_W = 60.0; // wide cutout for USB-A stack + HDMI + DP
USB_CUT_H = 22.0;
GBE_CUT_W = 20.0; // GbE jack
GBE_CUT_H = 18.0;
// Duct shroud
DUCT_T = 3.0; // wall thickness
DUCT_FLANGE = 6.0; // side tab width for M3 attachment
FAN_W = 40.0; // standard 40 mm blower clearance cutout
FAN_H = 10.0; // duct outlet height
// Cable clip
CLIP_OD = 12.0;
CLIP_ID = 7.0;
CLIP_GAP = 7.5;
CLIP_W = 10.0;
SNAP_T = 1.8;
// Utilities
module chamfer_cube(size, ch=1.0) {
hull() {
translate([ch, ch, 0]) cube([size[0]-2*ch, size[1]-2*ch, EPS]);
translate([0, 0, ch]) cube(size - [0, 0, ch]);
}
}
module hex_pocket(af, depth) {
cylinder(d=af/cos(30), h=depth, $fn=6);
}
// Part 1: tnut_base
module tnut_base() {
difference() {
union() {
chamfer_cube([BASE_L, BASE_W, BASE_T], ch=1.5);
// Raised mounting bosses for M3 brace attachment (4 corners)
for (x = [8, BASE_L-8])
for (y = [8, BASE_W-8])
translate([x, y, BASE_T])
cylinder(d=10, h=2.5);
}
// T-nut pockets and M5 bolts front rail (y = BASE_W/4)
for (x = [BASE_L/2 - BOLT_PITCH/2, BASE_L/2 + BOLT_PITCH/2]) {
translate([x, BASE_W/4, -EPS]) {
cylinder(d=M5_D, h=BASE_T + 2*EPS);
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
translate([x - TNUT_L/2, BASE_W/4 - TNUT_W/2, BASE_T - TNUT_H])
cube([TNUT_L, TNUT_W, TNUT_H + EPS]);
}
// T-nut pockets and M5 bolts rear rail (y = 3*BASE_W/4)
for (x = [BASE_L/2 - BOLT_PITCH/2, BASE_L/2 + BOLT_PITCH/2]) {
translate([x, 3*BASE_W/4, -EPS]) {
cylinder(d=M5_D, h=BASE_T + 2*EPS);
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
translate([x - TNUT_L/2, 3*BASE_W/4 - TNUT_W/2, BASE_T - TNUT_H])
cube([TNUT_L, TNUT_W, TNUT_H + EPS]);
}
// M3 boss bolt holes (corner braces)
for (x = [8, BASE_L-8])
for (y = [8, BASE_W-8])
translate([x, y, -EPS])
cylinder(d=M3_D, h=BASE_T + 2.5 + 2*EPS);
// M3 boss counterbores (head from bottom)
for (x = [8, BASE_L-8])
for (y = [8, BASE_W-8])
translate([x, y, -EPS])
cylinder(d=M3_HEAD_D, h=M3_HEAD_H + EPS);
// Standoff post seating holes (board hole pattern, centred on plate)
bx0 = BASE_L/2 - MH_SX/2;
by0 = BASE_W/2 - MH_SY/2;
for (dx = [0, MH_SX])
for (dy = [0, MH_SY])
translate([bx0+dx, by0+dy, -EPS])
cylinder(d=POST_BASE_D + 0.4, h=BASE_T + 2*EPS);
// Weight relief grid (2 pockets)
translate([20, 12, -EPS]) cube([30, BASE_W-24, BASE_T/2]);
translate([BASE_L-50, 12, -EPS]) cube([30, BASE_W-24, BASE_T/2]);
// Cable pass-through slot
translate([BASE_L/2 - 8, BASE_W/2 - 3, -EPS])
cube([16, 6, BASE_T + 2*EPS]);
}
}
// Part 2: standoff_post
module standoff_post() {
difference() {
union() {
// Flange
cylinder(d=POST_BASE_D, h=POST_BASE_H);
// Post body
translate([0, 0, POST_BASE_H])
cylinder(d=POST_OD, h=POST_H);
}
// M2.5 through bore
translate([0, 0, -EPS])
cylinder(d=M25_D, h=POST_BASE_H + POST_H + 2*EPS);
// Captured hex nut trap (from top)
translate([0, 0, POST_BASE_H + POST_H - NUT_TRAP_H])
hex_pocket(NUT_TRAP_W, NUT_TRAP_H + EPS);
// Anti-rotation flat on nut pocket
translate([-M25_NUT_W/2 - 0.2, -POST_OD/2 - EPS,
POST_BASE_H + POST_H - NUT_TRAP_H])
cube([M25_NUT_W + 0.4, 2.0, NUT_TRAP_H + EPS]);
}
}
// Part 3: side_brace
// Printed as +X face. Mirror for -X side.
module side_brace() {
difference() {
union() {
chamfer_cube([BRACE_T, BRACE_W, BRACE_H], ch=1.0);
// Top lip to retain board edge
translate([0, 0, BRACE_H])
cube([BRACE_T + 8.0, BRACE_W, 2.5]);
}
// M3 bolt holes at base (attach to tnut_base bosses)
for (y = [8, BRACE_W-8])
translate([-EPS, y, 4])
rotate([0, 90, 0])
cylinder(d=M3_D, h=BRACE_T + 2*EPS);
// M3 counterbore from outer face
for (y = [8, BRACE_W-8])
translate([-EPS, y, 4])
rotate([0, 90, 0])
cylinder(d=M3_HEAD_D, h=M3_HEAD_H + EPS);
// Port-access cutout USB/HDMI/DP cluster (centred on brace face)
translate([-EPS, BRACE_W/2 - USB_CUT_W/2, POST_BASE_H + 2.0])
cube([BRACE_T + 2*EPS, USB_CUT_W, USB_CUT_H]);
// GbE cutout (offset toward +Y)
translate([-EPS, BRACE_W/2 + USB_CUT_W/2 - GBE_CUT_W - 2, POST_BASE_H + 2.0])
cube([BRACE_T + 2*EPS, GBE_CUT_W, GBE_CUT_H]);
// M3 duct attachment holes (top edge)
for (y = [BRACE_W/4, 3*BRACE_W/4])
translate([BRACE_T/2, y, BRACE_H - 2])
cylinder(d=M3_D, h=10);
// Ventilation slots (3 tall slots for airflow)
for (i = [0:2])
translate([-EPS,
(BRACE_W - 3*8 - 2*4) / 2 + i*(8+4),
POST_BASE_H + USB_CUT_H + 6])
cube([BRACE_T + 2*EPS, 8, BRACE_H - POST_BASE_H - USB_CUT_H - 10]);
}
}
// Part 4: duct_shroud
// Top cap that channels fan exhaust away from board; optional print.
module duct_shroud() {
duct_l = BASE_L - 2*BRACE_T - 1.0; // span between inner brace faces
duct_w = BRACE_W;
difference() {
union() {
// Top plate
cube([duct_l, duct_w, DUCT_T]);
// Front wall (fan inlet side)
translate([0, 0, -FAN_H])
cube([DUCT_T, duct_w, FAN_H + DUCT_T]);
// Rear wall (exhaust side open centre)
translate([duct_l - DUCT_T, 0, -FAN_H])
cube([DUCT_T, duct_w, FAN_H + DUCT_T]);
// Side flanges for M3 attachment
translate([-DUCT_FLANGE, 0, -FAN_H])
cube([DUCT_FLANGE, duct_w, FAN_H + DUCT_T]);
translate([duct_l, 0, -FAN_H])
cube([DUCT_FLANGE, duct_w, FAN_H + DUCT_T]);
}
// Fan cutout on top plate (centred)
translate([duct_l/2 - FAN_W/2, duct_w/2 - FAN_W/2, -EPS])
cube([FAN_W, FAN_W, DUCT_T + 2*EPS]);
// Fan screw holes (40 mm fan, Ø3.2 at 32 mm BC)
for (dx = [-16, 16])
for (dy = [-16, 16])
translate([duct_l/2 + dx, duct_w/2 + dy, -EPS])
cylinder(d=M3_D, h=DUCT_T + 2*EPS);
// Exhaust slot on rear wall (full width minus corners)
translate([duct_l - DUCT_T - EPS, 4, -FAN_H + 2])
cube([DUCT_T + 2*EPS, duct_w - 8, FAN_H - 2]);
// M3 flange attachment holes
for (y = [duct_w/4, 3*duct_w/4]) {
translate([-DUCT_FLANGE - EPS, y, -FAN_H/2])
rotate([0, 90, 0])
cylinder(d=M3_D, h=DUCT_FLANGE + 2*EPS);
translate([duct_l + DUCT_T - EPS, y, -FAN_H/2])
rotate([0, 90, 0])
cylinder(d=M3_D, h=DUCT_FLANGE + 2*EPS);
}
}
}
// Part 5: cable_clip
module cable_clip() {
difference() {
union() {
// Snap-wrap body
difference() {
cylinder(d=CLIP_OD + 2*SNAP_T, h=CLIP_W);
translate([0, 0, -EPS])
cylinder(d=CLIP_ID, h=CLIP_W + 2*EPS);
// Front gap
translate([-CLIP_GAP/2, 0, -EPS])
cube([CLIP_GAP, CLIP_OD, CLIP_W + 2*EPS]);
}
// Mounting tab for brace edge
translate([CLIP_OD/2 + SNAP_T - EPS, -SNAP_T, 0])
cube([8, SNAP_T*2, CLIP_W]);
}
// Tab screw hole
translate([CLIP_OD/2 + SNAP_T + 4, 0, CLIP_W/2])
rotate([90, 0, 0])
cylinder(d=M3_D, h=SNAP_T*2 + 2*EPS, center=true);
}
}
// Assembly
module assembly() {
// Base plate
color("SteelBlue")
tnut_base();
// Standoff posts (board hole pattern)
bx0 = BASE_L/2 - MH_SX/2;
by0 = BASE_W/2 - MH_SY/2;
for (dx = [0, MH_SX])
for (dy = [0, MH_SY])
color("DodgerBlue")
translate([bx0+dx, by0+dy, BASE_T])
standoff_post();
// Side braces (left and right)
color("CornflowerBlue")
translate([0, 0, BASE_T])
side_brace();
color("CornflowerBlue")
translate([BASE_L, BRACE_W, BASE_T])
mirror([1, 0, 0])
mirror([0, 1, 0])
side_brace();
// Board silhouette (translucent, for clearance visualisation)
color("ForestGreen", 0.25)
translate([BASE_L/2 - BOARD_L/2, BASE_W/2 - BOARD_W/2,
BASE_T + POST_BASE_H + POST_H])
cube([BOARD_L, BOARD_W, BOARD_T]);
// Duct shroud (above board)
color("LightSteelBlue", 0.7)
translate([BRACE_T + 0.5, 0,
BASE_T + POST_BASE_H + POST_H + BOARD_T + 2.0])
duct_shroud();
// Cable clips (on brace edge, 2×)
for (y = [BRACE_W/3, 2*BRACE_W/3])
color("SlateGray")
translate([BASE_L + 2, y, BASE_T + BRACE_H/2 - CLIP_W/2])
rotate([0, 90, 0])
cable_clip();
}
// Dispatch
if (RENDER == "tnut_base") tnut_base();
else if (RENDER == "standoff_post") standoff_post();
else if (RENDER == "side_brace") side_brace();
else if (RENDER == "duct_shroud") duct_shroud();
else if (RENDER == "cable_clip") cable_clip();
else assembly();

View File

@ -1,504 +0,0 @@
// ============================================================
// phone_mount_bracket.scad Spring-Loaded Phone Mount for T-Slot Rail
// Issue: #535 Agent: sl-mechanical Date: 2026-03-07
// ============================================================
//
// Parametric spring-loaded phone mount that clamps to the 2020 aluminium
// T-slot sensor rail. Adjustable phone width 6085 mm. Quick-release
// cam lever for tool-free phone swap. Vibration-damping flexure ribs
// on grip pads absorb motor/terrain vibration (PETG compliance).
//
// Design overview:
// - Fixed jaw + sliding jaw on a 40 mm guide rail (M4 rod)
// - Coil spring (Ø8 × 30 mm) compressed between jaw and end-stop
// spring pre-load keeps phone clamped at any width in range
// - Cam lever (printed PETG) rotates 90° to release / lock spring
// - Anti-vibration flexure ribs on both grip pad faces
// - Landscape or portrait orientation: bracket rotates on T-nut base
//
// Parts (STL exports):
// Part 1 tnut_base() Rail attachment base (universal)
// Part 2 fixed_jaw() Fixed bottom jaw + guide rail bosses
// Part 3 sliding_jaw() Spring-loaded sliding jaw
// Part 4 cam_lever() Quick-release cam lever
// Part 5 grip_pad() Flexure grip pad (print ×2, TPU optional)
// Part 6 assembly_preview() Full assembly
//
// Hardware BOM (per mount):
// 1× M4 × 60 mm SHCS guide rod + spring bolt
// 1× M4 hex nut end-stop on sliding jaw
// 1× Ø8 × 30 mm coil spring ~0.5 N/mm rate (spring clamping)
// 2× M3 × 16 mm SHCS T-nut base thumbscrew + arm bolts
// 1× M3 hex nut thumbscrew nut in T-nut
// 4× M2 × 8 mm SHCS grip pad retention bolts (optional)
//
// Dimensions:
// Phone width range : PHONE_W_MINPHONE_W_MAX (6085 mm) parametric
// Phone thickness : up to PHONE_THICK_MAX (12 mm) open-front jaw
// Phone height held : GRIP_SPAN (22 mm each jaw) portrait/landscape
// Overall bracket H : ~110 mm W: ~90 mm D: ~55 mm
//
// Print settings:
// Material : PETG (tnut_base, fixed_jaw, sliding_jaw, cam_lever)
// TPU 95A optional for grip_pad (or PETG for rigidity)
// Perimeters: 5 (structural parts), 3 (grip_pad)
// Infill : 40 % gyroid (jaws), 20 % (grip_pad)
// Supports : none needed (designed for FDM orientation)
// Layer ht : 0.2 mm
//
// Export commands:
// openscad phone_mount_bracket.scad -D 'RENDER="tnut_base_stl"' -o pm_tnut_base.stl
// openscad phone_mount_bracket.scad -D 'RENDER="fixed_jaw_stl"' -o pm_fixed_jaw.stl
// openscad phone_mount_bracket.scad -D 'RENDER="sliding_jaw_stl"' -o pm_sliding_jaw.stl
// openscad phone_mount_bracket.scad -D 'RENDER="cam_lever_stl"' -o pm_cam_lever.stl
// openscad phone_mount_bracket.scad -D 'RENDER="grip_pad_stl"' -o pm_grip_pad.stl
// ============================================================
$fn = 64;
e = 0.01; // epsilon for boolean clearance
// Phone parameters (adjust to target device)
PHONE_W_MIN = 60.0; // narrowest phone width supported (mm)
PHONE_W_MAX = 85.0; // widest phone width supported (mm)
PHONE_THICK_MAX = 12.0; // max phone body thickness incl. case (mm)
// Rail geometry (must match sensor_rail.scad)
RAIL_W = 20.0;
SLOT_OPEN = 6.0;
SLOT_INNER_W = 10.2;
SLOT_INNER_H = 5.8;
SLOT_NECK_H = 3.2;
// T-nut constants
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
TNUT_M3_NUT_AF = 5.5;
TNUT_M3_NUT_H = 2.5;
TNUT_BOLT_D = 3.3; // M3 clearance
// Base plate geometry
BASE_FACE_W = 30.0;
BASE_FACE_H = 25.0;
BASE_FACE_T = SLOT_NECK_H + 1.5;
// Jaw geometry
JAW_BODY_W = 88.0; // jaw outer width (> PHONE_W_MAX for rim)
JAW_BODY_H = 28.0; // jaw height (Z) phone grip span
JAW_BODY_T = 14.0; // jaw depth (Y) phone cradled this deep
JAW_WALL_T = 4.0; // jaw side wall thickness
JAW_LIP_T = 3.0; // front retaining lip thickness
JAW_LIP_H = 5.0; // front lip height (retains phone)
PHONE_POCKET_D = PHONE_THICK_MAX + 0.5; // pocket depth for phone
// Guide rod / spring system
GUIDE_ROD_D = 4.3; // M4 clearance bore in sliding jaw
GUIDE_BOSS_D = 10.0; // boss OD around guide bore
GUIDE_BOSS_T = 6.0; // boss length
SPRING_OD = 8.5; // coil spring OD pocket (spring is Ø8)
SPRING_L = 32.0; // spring pocket length (spring compressed ~22 mm)
SPRING_SEAT_T = 3.0; // spring seat wall at end-stop boss
JAW_TRAVEL = PHONE_W_MAX - PHONE_W_MIN + 4.0; // max jaw travel (mm)
ARM_SPAN = PHONE_W_MAX + 2 * JAW_WALL_T + 8; // fixed jaw total width
// Cam lever geometry
CAM_R_MIN = 5.0; // cam small radius (engaged / clamped)
CAM_R_MAX = 9.0; // cam large radius (released, spring compressed)
CAM_THICK = 8.0; // cam disc thickness
CAM_HANDLE_L = 45.0; // lever arm length
CAM_HANDLE_W = 8.0; // lever handle width
CAM_HANDLE_T = 5.0; // lever handle thickness
CAM_BORE_D = 4.3; // M4 pivot bore
CAM_DETENT_D = 3.0; // detent ball pocket (3 mm bearing)
// Grip pad geometry (vibration dampening flexure ribs)
PAD_W = JAW_BODY_W - 2*JAW_WALL_T - 2; // pad width
PAD_H = JAW_BODY_H - 2; // pad height
PAD_T = 2.5; // pad body thickness
RIB_H = 1.5; // flexure rib height above pad face
RIB_W = 1.2; // rib width
RIB_PITCH = 5.0; // rib pitch (centre-to-centre)
RIB_COUNT = floor(PAD_W / RIB_PITCH) - 1;
// Arm geometry (base to jaw body)
ARM_REACH = 38.0; // distance from rail face to jaw centreline (+Y)
ARM_T = 4.0; // arm thickness
ARM_H = BASE_FACE_H;
// Fasteners
M2_D = 2.4;
M3_D = 3.3;
M4_D = 4.3;
M4_NUT_AF = 7.0; // M4 hex nut across-flats
M4_NUT_H = 3.2; // M4 hex nut height
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") assembly_preview();
else if (RENDER == "tnut_base_stl") tnut_base();
else if (RENDER == "fixed_jaw_stl") fixed_jaw();
else if (RENDER == "sliding_jaw_stl") sliding_jaw();
else if (RENDER == "cam_lever_stl") cam_lever();
else if (RENDER == "grip_pad_stl") grip_pad();
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly_preview() {
// Ghost rail section (20 × 20 × 200)
%color("Silver", 0.30)
linear_extrude(200)
square([RAIL_W, RAIL_W], center = true);
// T-nut base at Z=80 on rail
color("OliveDrab", 0.85)
translate([0, 0, 80])
tnut_base();
// Fixed jaw assembly (centred, extending +Y from base)
color("DarkSlateGray", 0.85)
translate([0, SLOT_NECK_H + BASE_FACE_T + ARM_REACH, 80])
fixed_jaw();
// Sliding jaw shown at mid-travel (phone ~72 mm wide)
color("SteelBlue", 0.85)
translate([PHONE_W_MIN + (PHONE_W_MAX - PHONE_W_MIN)/2,
SLOT_NECK_H + BASE_FACE_T + ARM_REACH, 80])
sliding_jaw();
// Grip pads on both jaws
color("DimGray", 0.85) {
translate([0, SLOT_NECK_H + BASE_FACE_T + ARM_REACH, 80])
translate([JAW_WALL_T, JAW_BODY_T, JAW_BODY_H/2])
rotate([90, 0, 0])
grip_pad();
translate([PHONE_W_MIN + (PHONE_W_MAX - PHONE_W_MIN)/2,
SLOT_NECK_H + BASE_FACE_T + ARM_REACH, 80])
translate([-JAW_WALL_T - PAD_T, JAW_BODY_T, JAW_BODY_H/2])
rotate([90, 0, 180])
grip_pad();
}
// Cam lever shown in locked (clamped) position
color("OrangeRed", 0.85)
translate([ARM_SPAN/2 + 6,
SLOT_NECK_H + BASE_FACE_T + ARM_REACH + GUIDE_BOSS_D/2,
80 + JAW_BODY_H/2])
rotate([0, 0, 0])
cam_lever();
}
// ============================================================
// PART 1 T-NUT BASE
// ============================================================
// Standard 2020 T-slot rail attachment base.
// Identical interface to sensor_rail_brackets.scad universal_tnut_base().
// Arm extends in +Y; rail clamp bolt in -Y face.
//
// Print flat (face plate down), PETG, 5 perims, 60 % infill.
module tnut_base() {
difference() {
union() {
// Face plate (flush against rail outer face)
translate([-BASE_FACE_W/2, -BASE_FACE_T, 0])
cube([BASE_FACE_W, BASE_FACE_T, BASE_FACE_H]);
// T-nut neck (enters rail slot)
translate([-TNUT_W/2, 0, (BASE_FACE_H - TNUT_L)/2])
cube([TNUT_W, SLOT_NECK_H + e, TNUT_L]);
// T-nut body (wider, inside T-groove)
translate([-TNUT_W/2, SLOT_NECK_H - e, (BASE_FACE_H - TNUT_L)/2])
cube([TNUT_W, TNUT_H - SLOT_NECK_H + e, TNUT_L]);
// Arm stub (face plate jaw)
translate([-BASE_FACE_W/2, -BASE_FACE_T, 0])
cube([BASE_FACE_W, BASE_FACE_T + ARM_REACH, ARM_T]);
}
// M3 rail clamp bolt bore (centre of T-nut, through face plate)
translate([0, -BASE_FACE_T - e, BASE_FACE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_BOLT_D, h = BASE_FACE_T + TNUT_H + 2*e);
// M3 hex nut pocket (inside T-nut body)
translate([0, SLOT_NECK_H + 0.3, BASE_FACE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_M3_NUT_AF / cos(30),
h = TNUT_M3_NUT_H + 0.3,
$fn = 6);
// 2× M3 bolt holes for arm-to-jaw bolting
for (bx = [-10, 10])
translate([bx, ARM_REACH - BASE_FACE_T - e, ARM_T/2])
rotate([-90, 0, 0])
cylinder(d = M3_D, h = 8 + 2*e);
// Lightening slot in arm
translate([0, -BASE_FACE_T/2 + ARM_REACH/2, ARM_T/2])
cube([BASE_FACE_W - 12, ARM_REACH - 16, ARM_T + 2*e],
center = true);
}
}
// ============================================================
// PART 2 FIXED JAW
// ============================================================
// Fixed lower jaw of the clamping system. Phone sits in the pocket
// formed by the fixed jaw (bottom) and sliding jaw (top).
// Two guide bosses on the right wall carry the M4 guide rod + spring.
// The cam lever pivot boss is on the outer right face.
//
// Coordinate origin: centre-bottom of jaw body.
// Phone entry face: +Y (open front), phone pocket opens toward +Y.
// Fixed jaw left edge is at X = -JAW_BODY_W/2.
//
// Print jaw-pocket-face down, PETG, 5 perims, 40 % infill.
module fixed_jaw() {
difference() {
union() {
// Main jaw body
translate([-JAW_BODY_W/2, -JAW_BODY_T/2, 0])
cube([JAW_BODY_W, JAW_BODY_T, JAW_BODY_H]);
// Front retaining lip (keeps phone from falling forward)
translate([-JAW_BODY_W/2, JAW_BODY_T/2 - JAW_LIP_T, 0])
cube([JAW_BODY_W, JAW_LIP_T, JAW_LIP_H]);
// Guide boss right (outer, carries spring + end-stop)
translate([JAW_BODY_W/2, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = GUIDE_BOSS_D, h = GUIDE_BOSS_T);
// Cam lever pivot boss (right face, above guide boss)
translate([JAW_BODY_W/2, 0, JAW_BODY_H/2 + GUIDE_BOSS_D + 4])
rotate([0, 90, 0])
cylinder(d = CAM_THICK + 4, h = 6);
// Arm attachment bosses (left side, connect to tnut_base)
for (bx = [-10, 10])
translate([bx, -JAW_BODY_T/2 - 8, ARM_T/2])
cylinder(d = 8, h = 8);
}
// Phone pocket (open-top U channel centred in jaw)
// Pocket opens toward +Y (front), phone drops in from above.
translate([0, -JAW_BODY_T/2 - e,
JAW_LIP_H])
cube([JAW_BODY_W - 2*JAW_WALL_T,
PHONE_POCKET_D + JAW_WALL_T,
JAW_BODY_H - JAW_LIP_H + e],
center = [true, false, false]);
// Guide rod bore (M4 clearance, through both guide bosses)
translate([-JAW_BODY_W/2 - e, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = GUIDE_ROD_D,
h = JAW_BODY_W + GUIDE_BOSS_T + 2*e);
// Spring pocket (coaxial with guide rod, in right boss)
translate([JAW_BODY_W/2 + e, 0, JAW_BODY_H/2])
rotate([0, -90, 0])
cylinder(d = SPRING_OD, h = SPRING_L);
// M4 hex nut pocket in spring-seat wall (end-stop nut)
translate([JAW_BODY_W/2 + GUIDE_BOSS_T + e, 0, JAW_BODY_H/2])
rotate([0, -90, 0])
cylinder(d = M4_NUT_AF / cos(30), h = M4_NUT_H + 0.5,
$fn = 6);
// Cam pivot bore (M4 pivot, through pivot boss)
translate([JAW_BODY_W/2 - e, 0, JAW_BODY_H/2 + GUIDE_BOSS_D + 4])
rotate([0, 90, 0])
cylinder(d = CAM_BORE_D, h = 6 + 2*e);
// Arm attachment bolt holes (M3, to tnut_base arm stubs)
for (bx = [-10, 10])
translate([bx, -JAW_BODY_T/2 - 8 - e, ARM_T/2])
rotate([-90, 0, 0])
cylinder(d = M3_D, h = 12 + 2*e);
// Grip pad seats (recessed Ø1.5 mm, 2 mm deep, optional)
for (pz = [JAW_BODY_H * 0.3, JAW_BODY_H * 0.7])
for (px = [-PAD_W/4, PAD_W/4])
translate([px, -JAW_BODY_T/2 + PHONE_POCKET_D + 1, pz])
rotate([-90, 0, 0])
cylinder(d = M2_D, h = 10);
// Lightening pockets (non-structural core removal)
translate([0, 0, JAW_BODY_H/2])
cube([JAW_BODY_W - 2*JAW_WALL_T - 4,
JAW_BODY_T - 2*JAW_WALL_T,
JAW_BODY_H - JAW_LIP_H - 4],
center = true);
}
}
// ============================================================
// PART 3 SLIDING JAW
// ============================================================
// Upper clamping jaw. Slides along the M4 guide rod.
// Spring pushes this jaw toward the phone (inward).
// M4 hex nut on the guide rod limits maximum travel (full open).
// Cam lever pushes on this jaw face to compress spring (release).
//
// Coordinate origin same convention as fixed_jaw() for assembly.
// Jaw slides in +X direction (away from fixed jaw left wall).
//
// Print jaw-pocket-face down, PETG, 5 perims, 40 % infill.
module sliding_jaw() {
difference() {
union() {
// Main jaw body
translate([-JAW_WALL_T, -JAW_BODY_T/2, 0])
cube([JAW_BODY_W/2 + JAW_WALL_T, JAW_BODY_T, JAW_BODY_H]);
// Front retaining lip
translate([-JAW_WALL_T, JAW_BODY_T/2 - JAW_LIP_T, 0])
cube([JAW_BODY_W/2 + JAW_WALL_T, JAW_LIP_T, JAW_LIP_H]);
// Guide boss (carries guide rod, spring butts against face)
translate([-JAW_WALL_T - GUIDE_BOSS_T, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = GUIDE_BOSS_D, h = GUIDE_BOSS_T);
// Cam follower ear (contacts cam lever)
translate([-JAW_WALL_T - 2, 0,
JAW_BODY_H/2 + GUIDE_BOSS_D + 4])
cube([4, CAM_THICK + 2, CAM_THICK + 2], center = true);
}
// Phone pocket (inner face, contacts phone side)
translate([-JAW_WALL_T - e, -JAW_BODY_T/2 - e, JAW_LIP_H])
cube([JAW_BODY_W/2 - JAW_WALL_T + e,
PHONE_POCKET_D + JAW_WALL_T + 2*e,
JAW_BODY_H - JAW_LIP_H + e]);
// Guide rod bore (M4 clearance through boss + jaw wall)
translate([-JAW_WALL_T - GUIDE_BOSS_T - e, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = GUIDE_ROD_D,
h = GUIDE_BOSS_T + JAW_WALL_T + 2*e);
// M4 nut pocket (end-stop nut, rear of guide boss)
translate([-JAW_WALL_T - GUIDE_BOSS_T - e, 0, JAW_BODY_H/2])
rotate([0, 90, 0])
cylinder(d = M4_NUT_AF / cos(30), h = M4_NUT_H + 1,
$fn = 6);
// Cam follower bore (M4 pivot passes through ear)
translate([-JAW_WALL_T - 2 - e, 0,
JAW_BODY_H/2 + GUIDE_BOSS_D + 4])
rotate([0, 90, 0])
cylinder(d = CAM_BORE_D, h = 6 + 2*e);
// Grip pad seats
for (pz = [JAW_BODY_H * 0.3, JAW_BODY_H * 0.7])
for (px = [JAW_BODY_W/8])
translate([px, -JAW_BODY_T/2 + PHONE_POCKET_D + 1, pz])
rotate([-90, 0, 0])
cylinder(d = M2_D, h = 10);
}
}
// ============================================================
// PART 4 CAM LEVER (QUICK-RELEASE)
// ============================================================
// Eccentric cam disc + integral handle lever.
// Rotates 90° on M4 pivot pin between CLAMPED and RELEASED states:
// CLAMPED : cam small radius (CAM_R_MIN) toward jaw spring pushes jaw
// RELEASED : cam large radius (CAM_R_MAX) toward jaw compresses spring
// by (CAM_R_MAX - CAM_R_MIN) = 4 mm, opening jaw
//
// Detent ball pocket (Ø3 mm) snaps into rail-dimple for each position.
// Handle points rearward (-Y) in clamped state for low profile.
//
// Print standing on cam edge (cam disc vertical), PETG, 5 perims, 40%.
module cam_lever() {
cam_offset = (CAM_R_MAX - CAM_R_MIN) / 2; // 2 mm eccentricity
union() {
difference() {
union() {
// Eccentric cam disc
// Offset so pivot bore is eccentric to disc profile
translate([cam_offset, 0, 0])
cylinder(r = CAM_R_MAX, h = CAM_THICK, center = true);
// Lever handle arm
hull() {
translate([cam_offset, 0, 0])
cylinder(r = CAM_R_MAX, h = CAM_THICK, center = true);
translate([cam_offset + CAM_HANDLE_L, 0, 0])
cylinder(r = CAM_HANDLE_W/2,
h = CAM_HANDLE_T, center = true);
}
}
// M4 pivot bore (through cam centre)
cylinder(d = CAM_BORE_D, h = CAM_THICK + 2*e, center = true);
// Detent pockets (2× Ø3 mm, at 0° and 90°)
// Pocket at 0° clamped detent
translate([CAM_R_MAX - 2, 0, CAM_THICK/2 - 1.5])
cylinder(d = CAM_DETENT_D + 0.2, h = 2);
// Pocket at 90° released detent
translate([0, CAM_R_MAX - 2, CAM_THICK/2 - 1.5])
cylinder(d = CAM_DETENT_D + 0.2, h = 2);
// Lightening recesses on cam disc face
for (a = [0, 60, 120, 180, 240, 300])
translate([cam_offset + (CAM_R_MAX - 4) * cos(a),
(CAM_R_MAX - 4) * sin(a), 0])
cylinder(d = 4, h = CAM_THICK + 2*e, center = true);
// Handle grip grooves
for (i = [0:4])
translate([cam_offset + 20 + i * 5, 0, 0])
rotate([90, 0, 0])
cylinder(d = 2.5, h = CAM_HANDLE_W + 2*e,
center = true);
}
}
}
// ============================================================
// PART 5 GRIP PAD (VIBRATION DAMPENING)
// ============================================================
// Flat pad with transverse flexure ribs that press against phone side.
// The rib profile (thin PETG fins) provides compliance in Z (vertical)
// absorbing vibration transmitted through the bracket.
// Optional: print in TPU 95A for superior damping.
// M2 bolts or adhesive-backed foam tape attach pad to jaw pocket face.
//
// Pad face (+Y) contacts phone. Mounting face (-Y) bonds to jaw.
// Ribs run parallel to Z axis (vertical).
//
// Print flat (mounting face down), PETG or TPU 95A, 3 perims, 20%.
module grip_pad() {
union() {
// Base plate
translate([-PAD_W/2, -PAD_T, -PAD_H/2])
cube([PAD_W, PAD_T, PAD_H]);
// Flexure ribs (transverse, dampening in Z)
// RIB_COUNT ribs spaced RIB_PITCH apart, centred on pad
for (i = [0 : RIB_COUNT - 1]) {
rx = -PAD_W/2 + RIB_PITCH/2 + i * RIB_PITCH;
if (abs(rx) <= PAD_W/2 - RIB_W/2) // stay within pad
translate([rx, 0, 0])
cube([RIB_W, RIB_H, PAD_H - 4], center = true);
}
// Corner retention nubs (M2 boss for optional bolt-through)
for (px = [-PAD_W/2 + 5, PAD_W/2 - 5])
for (pz = [-PAD_H/2 + 5, PAD_H/2 - 5])
translate([px, -PAD_T/2, pz])
difference() {
cylinder(d = 5, h = PAD_T, center = true);
cylinder(d = M2_D, h = PAD_T + 2*e, center = true);
}
}
}

View File

@ -65,7 +65,7 @@ CLAMP_ALIGN_D = 4.1; // Ø4 pin
// D-cut bore clearance // D-cut bore clearance
DCUT_CL = 0.3; DCUT_CL = 0.3;
// FC mount ESP32-S3 BALANCE 30.5 × 30.5 mm M3 // FC mount MAMBA F722S 30.5 × 30.5 mm M3
FC_PITCH = 30.5; FC_PITCH = 30.5;
FC_HOLE_D = 3.2; FC_HOLE_D = 3.2;
// FC is offset toward front of plate (away from stem) // FC is offset toward front of plate (away from stem)
@ -202,7 +202,7 @@ module base_plate() {
translate([STEM_FLANGE_BC/2, 0, -1]) translate([STEM_FLANGE_BC/2, 0, -1])
cylinder(d=M5, h=PLATE_THICK + 2); cylinder(d=M5, h=PLATE_THICK + 2);
// FC mount (ESP32-S3 BALANCE 30.5 × 30.5 M3) // FC mount (MAMBA F722S 30.5 × 30.5 M3)
for (x = [FC_X_OFFSET - FC_PITCH/2, FC_X_OFFSET + FC_PITCH/2]) for (x = [FC_X_OFFSET - FC_PITCH/2, FC_X_OFFSET + FC_PITCH/2])
for (y = [-FC_PITCH/2, FC_PITCH/2]) for (y = [-FC_PITCH/2, FC_PITCH/2])
translate([x, y, -1]) translate([x, y, -1])

View File

@ -11,7 +11,7 @@
// Ventilation slots all 4 walls + lid // Ventilation slots all 4 walls + lid
// //
// Shared mounting patterns (swappable with SaltyLab): // Shared mounting patterns (swappable with SaltyLab):
// FC : 30.5 × 30.5 mm M3 (ESP32-S3 BALANCE / Pixhawk) // FC : 30.5 × 30.5 mm M3 (MAMBA F722S / Pixhawk)
// Jetson: 58 × 49 mm M3 (Orin NX / Nano Devkit carrier) // Jetson: 58 × 49 mm M3 (Orin NX / Nano Devkit carrier)
// //
// Coordinate: bay centred at origin; Z=0 = deck top face. // Coordinate: bay centred at origin; Z=0 = deck top face.

View File

@ -1,343 +1,76 @@
// ============================================================ // ============================================================
// RPLIDAR A1 Mount Bracket Issue #596 // rplidar_mount.scad RPLIDAR A1M8 Anti-Vibration Ring Rev A
// Agent : sl-mechanical // Agent: sl-mechanical 2026-02-28
// Date : 2026-03-14 // ============================================================
// Part catalogue: // Flat ring sits between platform and RPLIDAR A1M8.
// 1. tnut_base 2020 T-slot rail interface plate with M5 T-nut captive pockets // Anti-vibration isolation via 4× M3 silicone grommets
// 2. column hollow elevation column, 120 mm tall, 3 stiffening ribs, cable bore // (same type as FC vibration mounts Ø6 mm silicone, M3).
// 3. scan_platform top plate with Ø40 mm BC M3 mounting pattern, vibration seats
// 4. vibe_ring silicone FC-grommet isolation ring for scan_platform bolts
// 5. cable_guide snap-on cable management clip for column body
// //
// BOM: // Bolt stack (bottom top):
// 2 × M5×10 BHCS + M5 T-nuts (tnut_base to rail) // M3×30 SHCS platform (8 mm) grommet (8 mm)
// 4 × M3×8 SHCS (scan_platform to RPLIDAR A1) // ring (4 mm) RPLIDAR bottom (threaded M3, ~6 mm engagement)
// 4 × M3 silicone FC grommets Ø8.5 OD / Ø3.2 bore (anti-vibe)
// 4 × M3 hex nuts (captured in scan_platform)
// //
// Print settings (PETG): // RENDER options:
// tnut_base / column / scan_platform : 5 perimeters, 40 % gyroid, no supports // "ring" print-ready flat ring (default)
// vibe_ring : 3 perimeters, 20 % gyroid, no supports // "assembly" ring in position on platform stub
// cable_guide : 3 perimeters, 30 % gyroid, no supports
//
// Export commands:
// openscad -D 'RENDER="tnut_base"' -o tnut_base.stl rplidar_mount.scad
// openscad -D 'RENDER="column"' -o column.stl rplidar_mount.scad
// openscad -D 'RENDER="scan_platform"' -o scan_platform.stl rplidar_mount.scad
// openscad -D 'RENDER="vibe_ring"' -o vibe_ring.stl rplidar_mount.scad
// openscad -D 'RENDER="cable_guide"' -o cable_guide.stl rplidar_mount.scad
// openscad -D 'RENDER="assembly"' -o assembly.png rplidar_mount.scad
// ============================================================ // ============================================================
// Render selector RENDER = "ring";
RENDER = "assembly"; // tnut_base | column | scan_platform | vibe_ring | cable_guide | assembly
// RPLIDAR A1M8
RPL_BODY_D = 70.0; // body diameter
RPL_BC = 58.0; // M3 mounting bolt circle
// Mount ring
RING_OD = 82.0; // outer diameter (RPL_BODY_D + 12 mm)
RING_ID = 30.0; // inner cutout (cable / airflow)
RING_H = 4.0; // ring thickness
BOLT_D = 3.3; // M3 clearance through-hole
GROMMET_D = 7.0; // silicone grommet OD (seat recess on bottom)
GROMMET_H = 1.0; // seating recess depth
// Global constants
$fn = 64; $fn = 64;
EPS = 0.01; e = 0.01;
// 2020 rail //
RAIL_W = 20.0; // extrusion cross-section module rplidar_ring() {
RAIL_H = 20.0;
SLOT_NECK_H = 3.2; // T-slot opening width
TNUT_W = 9.8; // M5 T-nut width
TNUT_H = 5.5; // T-nut height (depth into slot)
TNUT_L = 12.0; // T-nut body length
M5_D = 5.2; // M5 clearance bore
M5_HEAD_D = 9.5; // M5 BHCS head diameter
M5_HEAD_H = 4.0; // M5 BHCS head height
// Base plate
BASE_L = 60.0; // length along rail axis
BASE_W = 30.0; // width across rail
BASE_T = 8.0; // plate thickness
BOLT_PITCH = 40.0; // M5 bolt pitch along rail (centre-to-centre)
// Elevation column
COL_OD = 25.0; // column outer diameter
COL_ID = 17.0; // inner bore (cable routing)
ELEV_H = 120.0; // scan plane above rail top face
COL_WALL = (COL_OD - COL_ID) / 2;
RIB_W = 3.0; // stiffening rib width
RIB_H = 3.5; // rib radial height
CABLE_SLOT_W = 8.0; // cable entry slot width
CABLE_SLOT_H = 5.0; // cable entry slot height
// Scan platform
PLAT_D = 60.0; // platform disc diameter (clears RPLIDAR body Ø100 mm well)
PLAT_T = 6.0; // platform thickness
RPL_BC_D = 40.0; // RPLIDAR M3 bolt circle diameter (4 bolts at 45 °)
RPL_BORE_D = 36.0; // central pass-through for scan motor cable
M3_D = 3.2; // M3 clearance bore
M3_NUT_W = 5.5; // M3 hex nut across-flats
M3_NUT_H = 2.4; // M3 hex nut height
GROM_OD = 8.5; // FC silicone grommet OD
GROM_ID = 3.2; // grommet bore
GROM_H = 3.0; // grommet seat depth
CONN_SLOT_W = 12.0; // connector side-exit slot width
CONN_SLOT_H = 5.0; // connector slot height
// Vibe ring
VRING_OD = GROM_OD + 1.6; // printed retainer OD
VRING_ID = GROM_ID + 0.3; // pass-through with grommet seated
VRING_T = 2.0; // ring flange thickness
// Cable guide clip
CLIP_W = 14.0;
CLIP_T = 3.5;
CLIP_GAP = COL_OD + 0.4; // snap-fit gap (slight interference)
SNAP_T = 1.8;
CABLE_CH_W = 8.0;
CABLE_CH_H = 5.0;
// Utility modules
module chamfer_cube(size, ch=1.0) {
// simple chamfered box (bottom edge only for printability)
hull() {
translate([ch, ch, 0])
cube([size[0]-2*ch, size[1]-2*ch, EPS]);
translate([0, 0, ch])
cube(size - [0, 0, ch]);
}
}
module hex_pocket(af, depth) {
// hex nut pocket (flat-to-flat af)
cylinder(d = af / cos(30), h = depth, $fn = 6);
}
// Part 1: tnut_base
module tnut_base() {
difference() { difference() {
// Body cylinder(d = RING_OD, h = RING_H);
union() {
chamfer_cube([BASE_L, BASE_W, BASE_T], ch=1.5); // Central cutout
// Column socket boss centred on plate top face translate([0, 0, -e])
translate([BASE_L/2, BASE_W/2, BASE_T]) cylinder(d = RING_ID, h = RING_H + 2*e);
cylinder(d=COL_OD + 4.0, h=8.0);
// 4× M3 clearance holes on bolt circle
for (a = [45, 135, 225, 315]) {
translate([RPL_BC/2 * cos(a), RPL_BC/2 * sin(a), -e])
cylinder(d = BOLT_D, h = RING_H + 2*e);
} }
// M5 bolt holes (counterbored for BHCS heads from underneath) // Grommet seating recesses bottom face
for (x = [BASE_L/2 - BOLT_PITCH/2, BASE_L/2 + BOLT_PITCH/2]) for (a = [45, 135, 225, 315]) {
translate([x, BASE_W/2, -EPS]) { translate([RPL_BC/2 * cos(a), RPL_BC/2 * sin(a), -e])
cylinder(d=M5_D, h=BASE_T + 8.0 + 2*EPS); cylinder(d = GROMMET_D, h = GROMMET_H + e);
// counterbore from bottom
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
// T-nut captive pockets (accessible from bottom)
for (x = [BASE_L/2 - BOLT_PITCH/2, BASE_L/2 + BOLT_PITCH/2])
translate([x - TNUT_L/2, BASE_W/2 - TNUT_W/2, BASE_T - TNUT_H])
cube([TNUT_L, TNUT_W, TNUT_H + EPS]);
// Column bore into boss
translate([BASE_L/2, BASE_W/2, BASE_T - EPS])
cylinder(d=COL_OD + 0.3, h=8.0 + 2*EPS);
// Cable exit slot through base (offset 5 mm from column centre)
translate([BASE_L/2 - CABLE_SLOT_W/2, BASE_W/2 + COL_OD/4, -EPS])
cube([CABLE_SLOT_W, CABLE_SLOT_H, BASE_T + 8.0 + 2*EPS]);
// Weight relief pockets on underside
for (x = [BASE_L/2 - BOLT_PITCH/2 + 10, BASE_L/2 + BOLT_PITCH/2 - 10])
for (y = [7, BASE_W - 7])
translate([x - 5, y - 5, -EPS])
cube([10, 10, BASE_T/2]);
}
}
// Part 2: column
module column() {
// Actual column height: ELEV_H minus base boss engagement (8 mm) and platform seating (6 mm)
col_h = ELEV_H - 8.0 - PLAT_T;
difference() {
union() {
// Hollow tube
cylinder(d=COL_OD, h=col_h);
// Three 120°-spaced stiffening ribs along full height
for (a = [0, 120, 240])
rotate([0, 0, a])
translate([COL_OD/2 - EPS, -RIB_W/2, 0])
cube([RIB_H, RIB_W, col_h]);
// Bottom spigot (fits into base boss bore)
translate([0, 0, -6.0])
cylinder(d=COL_OD - 0.4, h=6.0 + EPS);
// Top spigot (seats into scan_platform recess)
translate([0, 0, col_h - EPS])
cylinder(d=COL_OD - 0.4, h=6.0);
}
// Inner cable bore
translate([0, 0, -6.0 - EPS])
cylinder(d=COL_ID, h=col_h + 12.0 + 2*EPS);
// Cable entry slot at bottom (aligns with base slot)
translate([-CABLE_SLOT_W/2, -COL_OD/2 - EPS, 2.0])
cube([CABLE_SLOT_W, CABLE_SLOT_H + EPS, CABLE_SLOT_H]);
// Cable exit slot at top (90° rotated for tidy routing)
rotate([0, 0, 90])
translate([-CABLE_SLOT_W/2, -COL_OD/2 - EPS, col_h - CABLE_SLOT_H - 4.0])
cube([CABLE_SLOT_W, CABLE_SLOT_H + EPS, CABLE_SLOT_H]);
// Cable clip snap groove (at mid-height)
translate([0, 0, col_h / 2])
difference() {
cylinder(d=COL_OD + 2*RIB_H + 0.8, h=4.0, center=true);
cylinder(d=COL_OD - 0.2, h=4.0 + 2*EPS, center=true);
} }
} }
} }
// Part 3: scan_platform //
module scan_platform() { // Render selector
//
if (RENDER == "ring") {
rplidar_ring();
} else if (RENDER == "assembly") {
// Platform stub
color("Silver", 0.5)
difference() { difference() {
union() { cylinder(d = 90, h = 8);
// Main disc translate([0, 0, -e]) cylinder(d = 25.4, h = 8 + 2*e);
cylinder(d=PLAT_D, h=PLAT_T);
// Rim lip for stiffness
translate([0, 0, PLAT_T])
difference() {
cylinder(d=PLAT_D, h=2.0);
cylinder(d=PLAT_D - 4.0, h=2.0 + EPS);
}
}
// Central cable pass-through
translate([0, 0, -EPS])
cylinder(d=RPL_BORE_D, h=PLAT_T + 4.0);
// Column spigot socket (bottom recess)
translate([0, 0, -EPS])
cylinder(d=COL_OD - 0.4 + 0.4, h=6.0);
// RPLIDAR M3 mounting holes 4× on Ø40 BC at 45°/135°/225°/315°
for (a = [45, 135, 225, 315])
rotate([0, 0, a])
translate([RPL_BC_D/2, 0, -EPS]) {
// Through bore
cylinder(d=M3_D, h=PLAT_T + 2*EPS);
// Grommet seat (countersunk from top)
translate([0, 0, PLAT_T - GROM_H])
cylinder(d=GROM_OD + 0.3, h=GROM_H + EPS);
// Captured M3 hex nut pocket (from bottom)
translate([0, 0, 1.5])
hex_pocket(M3_NUT_W + 0.3, M3_NUT_H + 0.2);
}
// Connector side-exit slots (2× opposing, at 0° and 180°)
for (a = [0, 180])
rotate([0, 0, a])
translate([-CONN_SLOT_W/2, PLAT_D/2 - CONN_SLOT_H, -EPS])
cube([CONN_SLOT_W, CONN_SLOT_H + EPS, PLAT_T + 2*EPS]);
// Weight relief pockets (2× lateral)
for (a = [90, 270])
rotate([0, 0, a])
translate([-10, 15, 1.5])
cube([20, 8, PLAT_T - 3.0]);
} }
// Ring floating 8 mm above (grommet gap)
color("SkyBlue", 0.9)
translate([0, 0, 8 + 8])
rplidar_ring();
} }
// Part 4: vibe_ring
// Printed silicone-grommet retainer ring press-fits over M3 bolt with grommet seated
module vibe_ring() {
difference() {
union() {
cylinder(d=VRING_OD, h=VRING_T + GROM_H);
// Flange
cylinder(d=VRING_OD + 2.0, h=VRING_T);
}
// Bore
translate([0, 0, -EPS])
cylinder(d=VRING_ID, h=VRING_T + GROM_H + 2*EPS);
}
}
// Part 5: cable_guide
// Snap-on cable clip for column mid-section
module cable_guide() {
arm_t = SNAP_T;
gap = CLIP_GAP;
difference() {
union() {
// Saddle body (U-shape wrapping column)
difference() {
cylinder(d=gap + 2*CLIP_T, h=CLIP_W);
translate([0, 0, -EPS])
cylinder(d=gap, h=CLIP_W + 2*EPS);
// Open front slot for snap insertion
translate([-gap/2, 0, -EPS])
cube([gap, gap/2 + CLIP_T + EPS, CLIP_W + 2*EPS]);
}
// Snap arms
for (s = [-1, 1])
translate([s*(gap/2 - arm_t), 0, 0])
mirror([s < 0 ? 1 : 0, 0, 0])
translate([0, -arm_t/2, 0])
cube([arm_t + 1.5, arm_t, CLIP_W]);
// Cable channel bracket (side-mounted)
translate([gap/2 + CLIP_T, -(CABLE_CH_W/2 + CLIP_T), 0])
cube([CLIP_T + CABLE_CH_H, CABLE_CH_W + 2*CLIP_T, CLIP_W]);
}
// Cable channel cutout
translate([gap/2 + CLIP_T + CLIP_T - EPS, -CABLE_CH_W/2, -EPS])
cube([CABLE_CH_H + EPS, CABLE_CH_W, CLIP_W + 2*EPS]);
// Snap tip undercut (both arms)
for (s = [-1, 1])
translate([s*(gap/2 + CLIP_T + 1.0), -arm_t, -EPS])
rotate([0, 0, s*30])
cube([2, arm_t*2, CLIP_W + 2*EPS]);
}
}
// Assembly / render dispatch
module assembly() {
// tnut_base at origin
color("SteelBlue")
tnut_base();
// column rising from base boss
color("DodgerBlue")
translate([BASE_L/2, BASE_W/2, BASE_T + 8.0 - 6.0])
column();
// scan_platform at top of column
col_h_actual = ELEV_H - 8.0 - PLAT_T;
color("CornflowerBlue")
translate([BASE_L/2, BASE_W/2, BASE_T + 8.0 - 6.0 + col_h_actual + 6.0 - EPS])
scan_platform();
// vibe rings (4×) seated in platform holes
for (a = [45, 135, 225, 315])
color("Gray", 0.7)
translate([BASE_L/2, BASE_W/2,
BASE_T + 8.0 - 6.0 + col_h_actual + 6.0 + PLAT_T - GROM_H])
rotate([0, 0, a])
translate([RPL_BC_D/2, 0, 0])
vibe_ring();
// cable_guide clipped at column mid-height
color("LightSteelBlue")
translate([BASE_L/2, BASE_W/2,
BASE_T + 8.0 - 6.0 + (ELEV_H - 8.0 - PLAT_T)/2 - CLIP_W/2])
cable_guide();
}
// Dispatch
if (RENDER == "tnut_base") tnut_base();
else if (RENDER == "column") column();
else if (RENDER == "scan_platform") scan_platform();
else if (RENDER == "vibe_ring") vibe_ring();
else if (RENDER == "cable_guide") cable_guide();
else assembly();

Some files were not shown because too many files have changed in this diff Show More