Compare commits

..

No commits in common. "main" and "sl-controls/issue-506-launch-profiles" have entirely different histories.

754 changed files with 3504 additions and 73769 deletions

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

@ -7,11 +7,7 @@ The robot can now be armed and operated autonomously from the Jetson without req
### Jetson Autonomous Arming ### Jetson Autonomous Arming
- Command: `A\n` (single byte 'A' followed by newline) - Command: `A\n` (single byte 'A' followed by newline)
<<<<<<< HEAD - Sent via USB CDC to the STM32 firmware
- 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 - Robot arms after ARMING_HOLD_MS (~500ms) safety hold period
- Works even when RC is not connected or not armed - Works even when RC is not connected or not armed
@ -46,11 +42,7 @@ The robot can now be armed and operated autonomously from the Jetson without req
## Command Protocol ## Command Protocol
<<<<<<< HEAD ### From Jetson to STM32 (USB CDC)
### 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) A — Request arm (triggers safety hold, then motors enable)
D — Request disarm (immediate motor stop) D — Request disarm (immediate motor stop)
@ -60,11 +52,7 @@ H — Heartbeat (refresh timeout timer, every 500ms)
C<spd>,<str> — Drive command: speed, steer (also refreshes heartbeat) C<spd>,<str> — Drive command: speed, steer (also refreshes heartbeat)
``` ```
<<<<<<< HEAD ### From STM32 to Jetson (USB CDC)
### 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`: Motor commands are gated by `bal.state == BALANCE_ARMED`:
- When ARMED: Motor commands sent every 20ms (50 Hz) - When ARMED: Motor commands sent every 20ms (50 Hz)
- When DISARMED: Zero sent every 20ms (prevents ESC timeout) - When DISARMED: Zero sent every 20ms (prevents ESC timeout)

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

@ -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
difference() { //
union() { if (RENDER == "ring") {
// Main disc rplidar_ring();
cylinder(d=PLAT_D, h=PLAT_T);
// Rim lip for stiffness } else if (RENDER == "assembly") {
translate([0, 0, PLAT_T]) // Platform stub
difference() { color("Silver", 0.5)
cylinder(d=PLAT_D, h=2.0); difference() {
cylinder(d=PLAT_D - 4.0, h=2.0 + EPS); cylinder(d = 90, h = 8);
} translate([0, 0, -e]) cylinder(d = 25.4, h = 8 + 2*e);
} }
// Ring floating 8 mm above (grommet gap)
// Central cable pass-through color("SkyBlue", 0.9)
translate([0, 0, -EPS]) translate([0, 0, 8 + 8])
cylinder(d=RPL_BORE_D, h=PLAT_T + 4.0); rplidar_ring();
// 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]);
}
} }
// 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();

View File

@ -17,7 +17,7 @@
// Weight target: <2 kg frame (excl. motors/electronics) // Weight target: <2 kg frame (excl. motors/electronics)
// //
// Shared SaltyLab patterns (swappable electronics): // Shared SaltyLab patterns (swappable electronics):
// 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 carrier board) // Jetson: 58 × 49 mm M3 (Orin NX / Nano carrier board)
// Stem : Ø25 mm bore (sensor head unchanged) // Stem : Ø25 mm bore (sensor head unchanged)
// //
@ -87,7 +87,7 @@ STEM_COLLAR_OD = 50.0;
STEM_COLLAR_H = 20.0; // raised boss height above deck top STEM_COLLAR_H = 20.0; // raised boss height above deck top
STEM_FLANGE_BC = 40.0; // 4× M4 bolt circle for stem adapter STEM_FLANGE_BC = 40.0; // 4× M4 bolt circle for stem adapter
// FC mount ESP32-S3 BALANCE / Pixhawk (30.5 × 30.5 mm M3) // FC mount MAMBA F722S / Pixhawk (30.5 × 30.5 mm M3)
// Shared with SaltyLab swappable electronics // Shared with SaltyLab swappable electronics
FC_PITCH = 30.5; FC_PITCH = 30.5;
FC_HOLE_D = 3.2; FC_HOLE_D = 3.2;

View File

@ -1,341 +1,275 @@
// ============================================================ // ============================================================
// uwb_anchor_mount.scad Wall/Ceiling UWB Anchor Mount Bracket // uwb_anchor_mount.scad Stem-Mounted UWB Anchor Rev A
// Issue: #564 Agent: sl-mechanical Date: 2026-03-14 // Agent: sl-mechanical 2026-03-01
// (supersedes Rev A stem-collar mount see git history) // Closes issues #57, #62
// ============================================================ // ============================================================
// Clamp-on bracket for 2× MaUWB ESP32-S3 anchor modules on
// SaltyBot 25 mm OD vertical stem.
// Anchors spaced ANCHOR_SPACING = 250 mm apart.
// //
// Parametric wall or ceiling mount bracket for ESP32 UWB Pro anchor. // Features:
// Designed for fixed-infrastructure deployment: anchors screw into // Split D-collar with M4 clamping bolts + M4 set screw
// wall or ceiling drywall/timber with standard M4 or #6 wood screws, // Anti-rotation flat tab that keys against a small pin
// at a user-defined tilt angle so the UWB antenna faces the desired // OR printed key tab that registers on the stem flat (if stem
// coverage zone. // has a ground flat) see ANTI_ROT_MODE parameter
// Module bracket: faces outward, tilted 10° from vertical
// so antenna clears stem and faces horizon
// USB cable channel (power from Orin via USB-A) on collar
// Tool-free capture: M4 thumbscrews (slot-head, hand-tighten)
// UWB antenna area: NO material within 10 mm of PCB top face
// //
// Architecture: // Components per mount:
// Wall base -> flat backplate with 2x screw holes (wall or ceiling) // 2× collar_half print in PLA/PETG, flat-face-down
// Tilt knuckle -> single-axis articulating joint; 15deg detent steps // 1× module_bracket print in PLA/PETG, flat-face-down
// locked with M3 nyloc bolt; range 0-90deg
// Anchor cradle-> U-cradle holding ESP32 UWB Pro PCB on M2.5 standoffs
// USB-C channel-> routed groove on tilt arm + exit slot in cradle back wall
// Label slot -> rear window slot for printed anchor-ID card strip
//
// Part catalogue:
// Part 1 -- wall_base() Backplate + 2-ear pivot block + detent arc
// Part 2 -- tilt_arm() Pivoting arm with knuckle + cradle stub
// Part 3 -- anchor_cradle() PCB cradle, standoffs, USB-C slot, label window
// Part 4 -- cable_clip() Snap-on USB-C cable guide for tilt arm
// Part 5 -- assembly_preview()
//
// Hardware BOM:
// 2x M4 x 30mm wood screws (or #6 drywall screws) wall fasteners
// 1x M3 x 20mm SHCS + M3 nyloc nut tilt pivot bolt
// 4x M2.5 x 8mm SHCS PCB-to-cradle
// 4x M2.5 hex nuts captured in standoffs
// 1x USB-C cable anchor power
//
// ESP32 UWB Pro interface (verify with calipers):
// PCB size : UWB_L x UWB_W x UWB_H (55 x 28 x 10 mm default)
// Mounting holes : M2.5, 4x corners on UWB_HOLE_X x UWB_HOLE_Y pattern
// USB-C port : centred on short edge, UWB_USBC_W x UWB_USBC_H
// Antenna area : top face rear half -- 10mm keep-out of bracket material
//
// Tilt angles (15deg detent steps, set TILT_DEG before export):
// 0deg -> horizontal face-up (ceiling, antenna faces down)
// 30deg -> 30deg downward tilt (wall near ceiling) [default]
// 45deg -> diagonal (wall mid-height)
// 90deg -> vertical face-out (wall, antenna faces forward)
// //
// RENDER options: // RENDER options:
// "assembly" full assembly at TILT_DEG (default) // "assembly" single mount assembled (default)
// "wall_base_stl" Part 1 // "collar_front" front collar half for slicing (×2 per mount × 2 mounts = 4)
// "tilt_arm_stl" Part 2 // "collar_rear" rear collar half
// "anchor_cradle_stl" Part 3 // "bracket" module bracket (×2 mounts)
// "cable_clip_stl" Part 4 // "pair" both mounts on 350 mm stem section
//
// Export commands:
// openscad uwb_anchor_mount.scad -D 'RENDER="wall_base_stl"' -o uwb_wall_base.stl
// openscad uwb_anchor_mount.scad -D 'RENDER="tilt_arm_stl"' -o uwb_tilt_arm.stl
// openscad uwb_anchor_mount.scad -D 'RENDER="anchor_cradle_stl"' -o uwb_anchor_cradle.stl
// openscad uwb_anchor_mount.scad -D 'RENDER="cable_clip_stl"' -o uwb_cable_clip.stl
// ============================================================ // ============================================================
$fn = 64;
e = 0.01;
// -- Tilt angle (override per anchor, 0-90deg, 15deg steps) ------------------
TILT_DEG = 30;
// -- ESP32 UWB Pro PCB dimensions (verify with calipers) ---------------------
UWB_L = 55.0;
UWB_W = 28.0;
UWB_H = 10.0;
UWB_HOLE_X = 47.5;
UWB_HOLE_Y = 21.0;
UWB_USBC_W = 9.5;
UWB_USBC_H = 4.0;
UWB_ANTENNA_L = 20.0;
// -- Wall base geometry -------------------------------------------------------
BASE_W = 60.0;
BASE_H = 50.0;
BASE_T = 5.0;
BASE_SCREW_D = 4.5;
BASE_SCREW_HD = 8.5;
BASE_SCREW_HH = 3.5;
BASE_SCREW_SPC = 35.0;
KNUCKLE_T = BASE_T + 4.0;
// -- Tilt arm geometry --------------------------------------------------------
ARM_W = 12.0;
ARM_T = 5.0;
ARM_L = 35.0;
PIVOT_D = 3.3;
PIVOT_NUT_AF = 5.5;
PIVOT_NUT_H = 2.4;
DETENT_D = 3.2;
DETENT_R = 8.0;
// -- Anchor cradle geometry ---------------------------------------------------
CRADLE_WALL_T = 3.5;
CRADLE_BACK_T = 4.0;
CRADLE_FLOOR_T = 3.0;
CRADLE_LIP_H = 4.0;
CRADLE_LIP_T = 2.5;
STANDOFF_H = 3.0;
STANDOFF_OD = 5.5;
LABEL_W = UWB_L - 4.0;
LABEL_H = UWB_W * 0.55;
LABEL_T = 1.2;
// -- USB-C routing ------------------------------------------------------------
USBC_CHAN_W = 11.0;
USBC_CHAN_H = 7.0;
// -- Cable clip ---------------------------------------------------------------
CLIP_CABLE_D = 4.5;
CLIP_T = 2.0;
CLIP_BODY_W = 16.0;
CLIP_BODY_H = 10.0;
// -- Fasteners ----------------------------------------------------------------
M2P5_D = 2.7;
M3_D = 3.3;
M3_NUT_AF = 5.5;
M3_NUT_H = 2.4;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly"; RENDER = "assembly";
if (RENDER == "assembly") assembly_preview(); // Verify with calipers
else if (RENDER == "wall_base_stl") wall_base(); MAWB_L = 50.0; // PCB length
else if (RENDER == "tilt_arm_stl") tilt_arm(); MAWB_W = 25.0; // PCB width
else if (RENDER == "anchor_cradle_stl") anchor_cradle(); MAWB_H = 10.0; // PCB + components
else if (RENDER == "cable_clip_stl") cable_clip(); MAWB_HOLE_X = 43.0; // M2 mounting hole X span
MAWB_HOLE_Y = 20.0; // M2 mounting hole Y span
M2_D = 2.2; // M2 clearance
// ============================================================ // Stem
// ASSEMBLY PREVIEW STEM_OD = 25.0;
// ============================================================ STEM_BORE = 25.4; // +0.4 clearance
module assembly_preview() { WALL = 2.0; // wall thickness (used in thumbscrew recess)
%color("Wheat", 0.22)
translate([-BASE_W/2, -10, -BASE_H/2])
cube([BASE_W, 10, BASE_H + 40]);
color("OliveDrab", 0.85) wall_base();
color("SteelBlue", 0.85)
translate([0, KNUCKLE_T, 0]) rotate([TILT_DEG,0,0]) tilt_arm();
color("DarkSlateGray", 0.85)
translate([0, KNUCKLE_T, 0]) rotate([TILT_DEG,0,0])
translate([0, ARM_T, ARM_L]) anchor_cradle();
%color("ForestGreen", 0.38)
translate([0, KNUCKLE_T, 0]) rotate([TILT_DEG,0,0])
translate([-UWB_L/2, ARM_T+CRADLE_BACK_T,
ARM_L+CRADLE_FLOOR_T+STANDOFF_H])
cube([UWB_L, UWB_W, UWB_H]);
color("DimGray", 0.70)
translate([ARM_W/2, KNUCKLE_T, 0]) rotate([TILT_DEG,0,0])
translate([0, ARM_T+e, ARM_L/2]) rotate([0,-90,90]) cable_clip();
}
// ============================================================ // Collar
// PART 1 -- WALL BASE COL_OD = 52.0;
// ============================================================ COL_H = 30.0; // taller than sensor-head collar for rigidity
// Flat backplate, 2x countersunk M4/#6 wood screws on 35mm centres. COL_BOLT_X = 19.0; // M4 bolt CL from stem axis
// Two pivot ears straddle the tilt arm; M3 pivot bolt through both. COL_BOLT_D = 4.5; // M4 clearance
// Detent arc on +X ear inner face: 7 notches at 15deg steps (0-90deg). THUMB_HEAD_D= 8.0; // M4 thumbscrew head OD (slot for access)
// Shallow rear recess for installation-zone label strip. COL_NUT_W = 7.0; // M4 hex nut A/F
// Same part for wall mount and ceiling mount. COL_NUT_H = 3.4;
//
// Print: backplate flat on bed, PETG, 5 perims, 40% gyroid. // Anti-rotation flat tab: a 3 mm wall tab that protrudes radially
module wall_base() { // and bears against the bracket arm, preventing axial rotation
ear_h = ARM_W + 3.0; // without needing a stem flat.
ear_t = 6.0; ANTI_ROT_T = 3.0; // tab thickness (radial)
ear_sep = ARM_W + 1.0; ANTI_ROT_W = 8.0; // tab width (tangential)
ANTI_ROT_Z = 4.0; // distance from collar base
// USB cable channel: groove on collar outer surface, runs Z direction
// Cable routes from anchor module down to base
USB_CHAN_W = 9.0; // channel width (fits USB-A cable Ø6 mm)
USB_CHAN_D = 5.0; // channel depth
// Module bracket
ARM_L = 20.0; // arm length from collar OD to bracket face
ARM_W = MAWB_W + 6.0; // bracket width (Y, includes side walls)
ARM_H = 6.0; // arm thickness (Z)
BRKT_TILT = 10.0; // tilt outward from vertical (antenna faces horizon)
BRKT_BACK_T = 3.0; // bracket back wall (module sits against this)
BRKT_SIDE_T = 2.0; // bracket side walls
M2_STNDFF = 3.0; // M2 standoff height
M2_STNDFF_OD= 4.5;
// USB port access notch in bracket side wall (8×5 mm)
USB_NOTCH_W = 10.0;
USB_NOTCH_H = 7.0;
// Spacing
ANCHOR_SPACING = 250.0; // centre-to-centre Z separation
$fn = 64;
e = 0.01;
//
// collar_half(side)
// split at Y=0 plane. Bracket arm on front (+Y) half.
// Print flat-face-down.
//
module collar_half(side = "front") {
y_front = (side == "front");
difference() { difference() {
union() { union() {
translate([-BASE_W/2, -BASE_T, -BASE_H/2]) // D-shaped body
cube([BASE_W, BASE_T, BASE_H]); intersection() {
for (ex = [-(ear_sep/2 + ear_t), ear_sep/2]) cylinder(d=COL_OD, h=COL_H);
translate([ex, -BASE_T+e, -ear_h/2]) translate([-COL_OD/2, y_front ? 0 : -COL_OD/2, 0])
cube([ear_t, KNUCKLE_T+e, ear_h]); cube([COL_OD, COL_OD/2, COL_H]);
for (ex = [-(ear_sep/2 + ear_t), ear_sep/2]) }
hull() {
translate([ex, -BASE_T, -ear_h/4]) // Anti-rotation tab (front half only, at +X side)
cube([ear_t, BASE_T-1, ear_h/2]); if (y_front) {
translate([ex + (ex<0 ? ear_t*0.5 : 0), -BASE_T, -ear_h/6]) translate([COL_OD/2, -ANTI_ROT_W/2, ANTI_ROT_Z])
cube([ear_t*0.5, 1, ear_h/3]); cube([ANTI_ROT_T, ANTI_ROT_W,
} COL_H - ANTI_ROT_Z - 4]);
}
// Bracket arm attachment boss (front half only, top centre)
if (y_front) {
translate([-ARM_W/2, COL_OD/2, COL_H * 0.3])
cube([ARM_W, ARM_L, COL_H * 0.4]);
}
} }
for (sz = [-BASE_SCREW_SPC/2, BASE_SCREW_SPC/2]) {
translate([0, -BASE_T-e, sz]) rotate([-90,0,0]) // Stem bore
cylinder(d=BASE_SCREW_D, h=BASE_T+2*e); translate([0,0,-e])
translate([0, -BASE_T-e, sz]) rotate([-90,0,0]) cylinder(d=STEM_BORE, h=COL_H + 2*e);
cylinder(d1=BASE_SCREW_HD, d2=BASE_SCREW_D, h=BASE_SCREW_HH+e);
// M4 clamping bolt holes (Y direction)
for (bx=[-COL_BOLT_X, COL_BOLT_X]) {
translate([bx, y_front ? COL_OD/2 : 0, COL_H/2])
rotate([90,0,0])
cylinder(d=COL_BOLT_D, h=COL_OD/2 + e);
// Thumbscrew head recess on outer face (front only access side)
if (y_front) {
translate([bx, COL_OD/2 - WALL, COL_H/2])
rotate([90,0,0])
cylinder(d=THUMB_HEAD_D, h=8 + e);
}
}
// M4 hex nut pockets (rear half)
if (!y_front) {
for (bx=[-COL_BOLT_X, COL_BOLT_X]) {
translate([bx, -(COL_OD/4 + e), COL_H/2])
rotate([90,0,0])
cylinder(d=COL_NUT_W/cos(30), h=COL_NUT_H + e,
$fn=6);
}
}
// Set screw (height lock, front half)
if (y_front) {
translate([0, COL_OD/2, COL_H * 0.8])
rotate([90,0,0])
cylinder(d=COL_BOLT_D,
h=COL_OD/2 - STEM_BORE/2 + e);
}
// USB cable routing channel (rear half, X side)
if (!y_front) {
translate([-COL_OD/2, -USB_CHAN_W/2, -e])
cube([USB_CHAN_D, USB_CHAN_W, COL_H + 2*e]);
}
// M4 hole through arm boss (Z direction, for bracket bolt)
if (y_front) {
for (dx=[-ARM_W/4, ARM_W/4])
translate([dx, COL_OD/2 + ARM_L/2, COL_H * 0.35])
cylinder(d=COL_BOLT_D, h=COL_H * 0.35 + e);
} }
translate([-(ear_sep/2+ear_t+e), KNUCKLE_T*0.55, 0])
rotate([0,90,0]) cylinder(d=PIVOT_D, h=ear_sep+2*ear_t+2*e);
translate([ear_sep/2+ear_t-PIVOT_NUT_H-0.4, KNUCKLE_T*0.55, 0])
rotate([0,90,0])
cylinder(d=PIVOT_NUT_AF/cos(30), h=PIVOT_NUT_H+0.5, $fn=6);
for (da = [0 : 15 : 90])
translate([ear_sep/2-e,
KNUCKLE_T*0.55 + DETENT_R*sin(da),
DETENT_R*cos(da)])
rotate([0,90,0]) cylinder(d=DETENT_D, h=ear_t*0.45+e);
translate([0, -BASE_T-e, 0]) rotate([-90,0,0])
cube([BASE_W-12, BASE_H-16, 1.6], center=true);
translate([0, -BASE_T+1.5, 0])
cube([BASE_W-14, BASE_T-3, BASE_H-20], center=true);
} }
} }
// ============================================================ //
// PART 2 -- TILT ARM // module_bracket()
// ============================================================ // Bolts to collar arm boss. Holds MaUWB PCB facing outward.
// Pivoting arm linking wall_base ears to anchor_cradle. // Tilted BRKT_TILT° from vertical antenna clears stem.
// Knuckle (Z=0): M3 pivot bore + spring-plunger detent pocket (3mm). // Print flat-face-down (back wall on bed).
// Cradle end (Z=ARM_L): 2x M3 bolt attachment stub. //
// USB-C cable channel groove on outer +Y face, full arm length. module module_bracket() {
// bk = BRKT_BACK_T;
// Print: knuckle face flat on bed, PETG, 5 perims, 40% gyroid. sd = BRKT_SIDE_T;
module tilt_arm() {
total_h = ARM_L + 10;
difference() {
union() {
translate([-ARM_W/2, 0, 0]) cube([ARM_W, ARM_T, total_h]);
translate([0, ARM_T/2, 0]) rotate([90,0,0])
cylinder(d=ARM_W, h=ARM_T, center=true);
translate([-ARM_W/2, 0, ARM_L])
cube([ARM_W, ARM_T+CRADLE_BACK_T, ARM_T]);
}
translate([-ARM_W/2-e, ARM_T/2, 0]) rotate([0,90,0])
cylinder(d=PIVOT_D, h=ARM_W+2*e);
translate([0, ARM_T+e, 0]) rotate([90,0,0])
cylinder(d=3.2, h=4+e);
translate([-USBC_CHAN_W/2, ARM_T-e, ARM_T+4])
cube([USBC_CHAN_W, USBC_CHAN_H, ARM_L-ARM_T-8]);
for (bx = [-ARM_W/4, ARM_W/4])
translate([bx, ARM_T/2, ARM_L+ARM_T/2]) rotate([90,0,0])
cylinder(d=M3_D, h=ARM_T+CRADLE_BACK_T+2*e);
for (bx = [-ARM_W/4, ARM_W/4])
translate([bx, ARM_T/2, ARM_L+ARM_T/2]) rotate([-90,0,0])
cylinder(d=M3_NUT_AF/cos(30), h=M3_NUT_H+0.5, $fn=6);
translate([0, ARM_T/2, ARM_L/2])
cube([ARM_W-4, ARM_T-2, ARM_L-18], center=true);
}
}
// ============================================================
// PART 3 -- ANCHOR CRADLE
// ============================================================
// Open-front U-cradle for ESP32 UWB Pro PCB.
// 4x M2.5 standoffs on UWB_HOLE_X x UWB_HOLE_Y pattern.
// Back wall: USB-C exit slot + routing groove, label card slot,
// antenna keep-out cutout (material removed above antenna area).
// Front retaining lip prevents PCB sliding out.
// Two attachment tabs bolt to tilt_arm cradle stub via M3.
//
// Label card slot: insert paper/laminate strip to ID this anchor
// (e.g. "UWB-A3 NE-CORNER"), accessible from open cradle end.
//
// Print: back wall flat on bed, PETG, 5 perims, 40% gyroid.
module anchor_cradle() {
outer_l = UWB_L + 2*CRADLE_WALL_T;
outer_w = UWB_W + CRADLE_FLOOR_T;
pcb_z = CRADLE_FLOOR_T + STANDOFF_H;
total_z = pcb_z + UWB_H + 2;
difference() { difference() {
union() { union() {
translate([-outer_l/2, 0, 0]) cube([outer_l, outer_w, total_z]); // Back wall (mounts to collar arm boss)
translate([-outer_l/2, outer_w-CRADLE_LIP_T, 0]) cube([ARM_W, bk, MAWB_H + M2_STNDFF + 6]);
cube([outer_l, CRADLE_LIP_T, CRADLE_LIP_H]);
for (tx = [-ARM_W/4, ARM_W/4]) // Side walls
translate([tx-4, -CRADLE_BACK_T, 0]) for (sx=[0, ARM_W - sd])
cube([8, CRADLE_BACK_T+1, total_z]); translate([sx, bk, 0])
cube([sd, MAWB_L + 2, MAWB_H + M2_STNDFF + 6]);
// M2 standoff posts (PCB mounts to these)
for (hx=[0, MAWB_HOLE_X], hy=[0, MAWB_HOLE_Y])
translate([(ARM_W - MAWB_HOLE_X)/2 + hx,
bk + (MAWB_L - MAWB_HOLE_Y)/2 + hy,
0])
cylinder(d=M2_STNDFF_OD, h=M2_STNDFF);
} }
translate([-UWB_L/2, 0, pcb_z]) cube([UWB_L, UWB_W+1, UWB_H+4]);
translate([0, -CRADLE_BACK_T-e, pcb_z+UWB_H/2-UWB_USBC_H/2]) // M2 bores through standoffs
cube([UWB_USBC_W+2, CRADLE_BACK_T+2*e, UWB_USBC_H+2], for (hx=[0, MAWB_HOLE_X], hy=[0, MAWB_HOLE_Y])
center=[true,false,false]); translate([(ARM_W - MAWB_HOLE_X)/2 + hx,
translate([0, -CRADLE_BACK_T-e, -e]) bk + (MAWB_L - MAWB_HOLE_Y)/2 + hy,
cube([USBC_CHAN_W, USBC_CHAN_H, pcb_z+UWB_H/2+USBC_CHAN_H], -e])
center=[true,false,false]); cylinder(d=M2_D, h=M2_STNDFF + e);
translate([0, -CRADLE_BACK_T-e, pcb_z+UWB_H/2])
cube([LABEL_W, LABEL_T+0.3, LABEL_H], center=[true,false,false]); // Antenna clearance cutout in back wall
translate([0, -e, pcb_z+UWB_H-UWB_ANTENNA_L]) // Open slot near top of back wall so antenna is unobstructed
cube([UWB_L-4, CRADLE_BACK_T+2*e, UWB_ANTENNA_L+4], translate([sd, -e, M2_STNDFF + 2])
center=[true,false,false]); cube([ARM_W - 2*sd, bk + 2*e, MAWB_H]);
for (tx = [-ARM_W/4, ARM_W/4])
translate([tx, ARM_T/2-CRADLE_BACK_T, total_z/2]) // USB port access notch on one side wall
rotate([-90,0,0]) translate([-e, bk + 2, M2_STNDFF - 1])
cylinder(d=M3_D, h=ARM_T+CRADLE_BACK_T+2*e); cube([sd + 2*e, USB_NOTCH_W, USB_NOTCH_H]);
for (side_x = [-outer_l/2-e, outer_l/2-CRADLE_WALL_T-e])
translate([side_x, 2, pcb_z+2]) // Mounting holes to collar arm boss (×2)
cube([CRADLE_WALL_T+2*e, UWB_W-4, UWB_H-4]); for (dx=[-ARM_W/4, ARM_W/4])
translate([ARM_W/2 + dx, bk + ARM_L/2, -e])
cylinder(d=COL_BOLT_D, h=6 + e);
} }
for (hx = [-UWB_HOLE_X/2, UWB_HOLE_X/2])
for (hy = [(outer_w-UWB_W)/2 + (UWB_W-UWB_HOLE_Y)/2,
(outer_w-UWB_W)/2 + (UWB_W-UWB_HOLE_Y)/2 + UWB_HOLE_Y])
difference() {
translate([hx, hy, CRADLE_FLOOR_T-e])
cylinder(d=STANDOFF_OD, h=STANDOFF_H+e);
translate([hx, hy, CRADLE_FLOOR_T-2*e])
cylinder(d=M2P5_D, h=STANDOFF_H+4);
}
} }
// ============================================================ //
// PART 4 -- CABLE CLIP // single_anchor_assembly()
// ============================================================ //
// Snap-on C-clip retaining USB-C cable along tilt arm outer face. module single_anchor_assembly(show_phantom=false) {
// Presses onto ARM_T-wide arm with flexible PETG snap tongues. // Collar
// Print x2-3 per anchor, spaced 25mm along arm. color("SteelBlue", 0.9) collar_half("front");
// color("CornflowerBlue", 0.9) mirror([0,1,0]) collar_half("rear");
// Print: clip-opening face down, PETG, 3 perims, 20% infill.
module cable_clip() { // Bracket tilted BRKT_TILT° outward from top of arm boss
ch_r = CLIP_CABLE_D/2 + CLIP_T; color("LightSteelBlue", 0.85)
snap_t = 1.6; translate([0, COL_OD/2 + ARM_L, COL_H * 0.3])
difference() { rotate([BRKT_TILT, 0, 0])
union() { translate([-ARM_W/2, 0, 0])
translate([-CLIP_BODY_W/2, 0, 0]) module_bracket();
cube([CLIP_BODY_W, CLIP_T, CLIP_BODY_H]);
translate([0, CLIP_T+ch_r, CLIP_BODY_H/2]) rotate([0,90,0]) // Phantom UWB PCB
difference() { if (show_phantom)
cylinder(r=ch_r, h=CLIP_BODY_W, center=true); color("ForestGreen", 0.4)
cylinder(r=CLIP_CABLE_D/2, h=CLIP_BODY_W+2*e, center=true); translate([-MAWB_L/2,
translate([0, ch_r+e, 0]) COL_OD/2 + ARM_L + BRKT_BACK_T,
cube([CLIP_CABLE_D*0.85, ch_r*2+2*e, CLIP_BODY_W+2*e], COL_H * 0.3 + M2_STNDFF])
center=true); cube([MAWB_L, MAWB_W, MAWB_H]);
}
for (tx = [-CLIP_BODY_W/2+1.5, CLIP_BODY_W/2-1.5-snap_t])
translate([tx, -ARM_T-1, 0])
cube([snap_t, ARM_T+1+CLIP_T, CLIP_BODY_H]);
for (tx = [-CLIP_BODY_W/2+1.5, CLIP_BODY_W/2-1.5-snap_t])
translate([tx+snap_t/2, -ARM_T-1, CLIP_BODY_H/2])
rotate([0,90,0]) cylinder(d=2, h=snap_t, center=true);
}
translate([0, -ARM_T-1-e, CLIP_BODY_H/2])
cube([CLIP_BODY_W-6, ARM_T+2, CLIP_BODY_H-4], center=true);
}
} }
//
// Render selector
//
if (RENDER == "assembly") {
single_anchor_assembly(show_phantom=true);
} else if (RENDER == "collar_front") {
collar_half("front");
} else if (RENDER == "collar_rear") {
collar_half("rear");
} else if (RENDER == "bracket") {
module_bracket();
} else if (RENDER == "pair") {
// Both anchors at 250 mm spacing on a stem stub
color("Silver", 0.2)
translate([0, 0, -50])
cylinder(d=STEM_OD, h=ANCHOR_SPACING + COL_H + 100);
// Lower anchor (Z = 0)
single_anchor_assembly(show_phantom=true);
// Upper anchor (Z = ANCHOR_SPACING)
translate([0, 0, ANCHOR_SPACING])
single_anchor_assembly(show_phantom=true);
}

View File

@ -1,296 +0,0 @@
// ============================================================
// vesc_mount.scad FSESC 6.7 Pro Mini Dual ESC Mount Cradle
// Issue #699 / sl-mechanical 2026-03-17
// ============================================================
// Open-top tray for Flipsky FSESC 6.7 Pro Mini Dual (~100 × 68 × 28 mm).
// Attaches to 2020 aluminium T-slot rail via 4× M5 T-nuts
// (2× per rail, two parallel rails, 60 mm bolt spacing in X,
// 20 mm bolt spacing in Y matching 2020 slot pitch).
//
// Connector access:
// XT60 battery inputs X end wall cutouts (2 connectors, side-by-side)
// XT30 motor outputs Y+ and Y side wall cutouts (2 per side wall)
// CAN/UART terminal X+ end wall cutout (screw terminal, wire exit)
//
// Ventilation:
// Open top face heatsink fins fully exposed
// Floor grille slots under-board airflow
// Side vent louvres 4 slots on each Y± wall at heatsink height
//
// Retention: 4× M3 heat-set insert boss in floor board screws down through
// ESC mounting holes via M3×8 FHCS. Board sits on 4 mm raised posts for
// under-board airflow.
//
// VERIFY WITH CALIPERS BEFORE PRINTING:
// PCB_L, PCB_W board outline
// XT60_W, XT60_H XT60 shell at X edge
// XT30_W, XT30_H XT30 shells at Y± edges
// TERM_W, TERM_H CAN screw terminal at X+ edge
// MOUNT_X1/X2, MOUNT_Y1/Y2 ESC board mounting hole pattern
//
// Print settings (PETG):
// 3 perimeters, 40 % gyroid infill, no supports, 0.2 mm layer
// Print orientation: open face UP (as modelled)
//
// BOM:
// 4 × M5×10 BHCS + 4 × M5 slide-in T-nut (2020 rail)
// 4 × M3 heat-set insert (Voron-style, OD 4.5 mm × 4 mm deep)
// 4 × M3×8 FHCS (board retention)
//
// Export commands:
// openscad -D 'RENDER="mount"' -o vesc_mount.stl vesc_mount.scad
// openscad -D 'RENDER="assembly"' -o vesc_assembly.png vesc_mount.scad
// ============================================================
RENDER = "assembly"; // mount | assembly
$fn = 48;
EPS = 0.01;
// Verify before printing
// FSESC 6.7 Pro Mini Dual PCB
PCB_L = 100.0; // board length (X: XT60 end CAN terminal end)
PCB_W = 68.0; // board width (Y)
PCB_T = 2.0; // board thickness (incl. bottom-side components)
COMP_H = 26.0; // tallest component above board top face (heatsink ~26 mm)
// XT60 battery connectors at X end (2 connectors, side-by-side)
XT60_W = 16.0; // each XT60 shell width (Y)
XT60_H = 16.0; // each XT60 shell height (Z) above board surface
XT60_Z0 = 0.0; // connector bottom offset above board surface
// Y centres of each XT60 measured from PCB Y edge
XT60_Y1 = 16.0;
XT60_Y2 = 52.0;
// XT30 motor output connectors at Y± sides (2 per side)
XT30_W = 10.5; // each XT30 shell width (X span)
XT30_H = 12.0; // each XT30 shell height (Z) above board surface
XT30_Z0 = 0.5; // connector bottom offset above board surface
// X centres measured from PCB X edge (same layout both Y and Y+ sides)
XT30_X1 = 22.0;
XT30_X2 = 78.0;
// CAN / UART screw terminal block at X+ end (3-pos 3.5 mm pitch)
TERM_W = 14.0; // terminal block Y span
TERM_H = 10.0; // terminal block height above board surface
TERM_Z0 = 0.5; // terminal bottom offset above board surface
TERM_Y_CTR = PCB_W / 2;
// ESC board mounting hole pattern
// 4 corner holes, 4 mm inset from each PCB edge
MOUNT_INSET = 4.0;
MOUNT_X1 = MOUNT_INSET;
MOUNT_X2 = PCB_L - MOUNT_INSET;
MOUNT_Y1 = MOUNT_INSET;
MOUNT_Y2 = PCB_W - MOUNT_INSET;
M3_INSERT_OD = 4.6; // Voron M3 heat-set insert press-fit OD
M3_INSERT_H = 4.0; // insert depth
M3_CLEAR_D = 3.4; // M3 clearance bore below insert
// Cradle geometry
WALL_T = 2.8; // side / end wall thickness
FLOOR_T = 4.5; // floor plate thickness (fits M5 BHCS head pocket)
POST_H = 4.0; // standoff post height (board lifts off floor for airflow)
CL_SIDE = 0.35; // Y clearance per side
CL_END = 0.40; // X clearance per end
INN_W = PCB_W + 2*CL_SIDE;
INN_L = PCB_L + 2*CL_END;
INN_H = POST_H + PCB_T + COMP_H + 1.5;
OTR_W = INN_W + 2*WALL_T;
OTR_L = INN_L + 2*WALL_T;
OTR_H = FLOOR_T + INN_H;
PCB_X0 = WALL_T + CL_END;
PCB_Y0 = WALL_T + CL_SIDE;
PCB_Z0 = FLOOR_T + POST_H;
// M5 T-nut mount (2020 rail)
// 4 bolts: 2 columns (X) × 2 rows (Y), centred on body
M5_D = 5.3;
M5_HEAD_D = 9.5;
M5_HEAD_H = 3.0;
M5_SPAC_X = 60.0; // X bolt spacing
M5_SPAC_Y = 20.0; // Y bolt spacing (2020 T-slot pitch)
// Floor ventilation grille
GRILLE_SLOT_W = 4.0;
GRILLE_SLOT_T = FLOOR_T - 1.5;
GRILLE_PITCH = 10.0;
GRILLE_X0 = WALL_T + 14;
GRILLE_X_LEN = OTR_L - 2*WALL_T - 28;
GRILLE_N = floor((INN_W - 10) / GRILLE_PITCH);
// Side vent louvres on Y± walls
LOUV_H = 5.0;
LOUV_W = 12.0;
LOUV_Z = FLOOR_T + POST_H + PCB_T + 4.0; // mid-heatsink height
LOUV_N = 4;
LOUV_PITCH = (OTR_L - 2*WALL_T - 20) / max(LOUV_N - 1, 1);
// CAN wire strain relief bosses (X+ end)
SR_BOSS_OD = 7.0;
SR_BOSS_H = 6.0;
SR_SLOT_W = 3.5;
SR_SLOT_T = 2.2;
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;
//
module m3_insert_boss() {
// Solid post with heat-set insert bore from top
post_h = FLOOR_T + POST_H;
difference() {
cylinder(d = M3_INSERT_OD + 3.2, h = post_h);
// Insert bore from top
translate([0, 0, post_h - M3_INSERT_H])
cylinder(d = M3_INSERT_OD, h = M3_INSERT_H + EPS);
// Clearance bore from bottom
translate([0, 0, -EPS])
cylinder(d = M3_CLEAR_D, h = post_h - M3_INSERT_H + EPS);
}
}
module vesc_mount() {
difference() {
union() {
// Main body
cube([OTR_L, OTR_W, OTR_H]);
// M3 insert bosses at board mounting corners
for (mx = [MOUNT_X1, MOUNT_X2])
for (my = [MOUNT_Y1, MOUNT_Y2])
translate([PCB_X0 + mx, PCB_Y0 + my, 0])
m3_insert_boss();
// CAN strain relief bosses on X+ end
for (sy = [SR_Y1, SR_Y2])
translate([SR_X, sy, 0])
cylinder(d = SR_BOSS_OD, h = SR_BOSS_H);
}
// Interior cavity (open top)
translate([WALL_T, WALL_T, FLOOR_T])
cube([INN_L, INN_W, INN_H + EPS]);
// XT60 cutouts at X end (2 connectors)
for (yc = [XT60_Y1, XT60_Y2])
translate([-EPS,
PCB_Y0 + yc - (XT60_W + 2.0)/2,
PCB_Z0 + XT60_Z0 - 0.5])
cube([WALL_T + 2*EPS, XT60_W + 2.0, XT60_H + 3.0]);
// XT30 cutouts at Y side (2 connectors)
for (xc = [XT30_X1, XT30_X2])
translate([PCB_X0 + xc - (XT30_W + 2.0)/2,
-EPS,
PCB_Z0 + XT30_Z0 - 0.5])
cube([XT30_W + 2.0, WALL_T + 2*EPS, XT30_H + 3.0]);
// XT30 cutouts at Y+ side (2 connectors)
for (xc = [XT30_X1, XT30_X2])
translate([PCB_X0 + xc - (XT30_W + 2.0)/2,
OTR_W - WALL_T - EPS,
PCB_Z0 + XT30_Z0 - 0.5])
cube([XT30_W + 2.0, WALL_T + 2*EPS, XT30_H + 3.0]);
// CAN terminal cutout at X+ end
translate([OTR_L - WALL_T - EPS,
PCB_Y0 + TERM_Y_CTR - (TERM_W + 3.0)/2,
PCB_Z0 + TERM_Z0 - 0.5])
cube([WALL_T + 2*EPS, TERM_W + 3.0, TERM_H + 5.0]);
// Floor ventilation grille
for (i = [0 : GRILLE_N - 1]) {
sy = WALL_T + 5 + i * GRILLE_PITCH;
translate([GRILLE_X0, sy, -EPS])
cube([GRILLE_X_LEN, GRILLE_SLOT_W, GRILLE_SLOT_T + EPS]);
}
// Side vent louvres Y wall
for (i = [0 : LOUV_N - 1]) {
lx = WALL_T + 10 + i * LOUV_PITCH;
translate([lx, -EPS, LOUV_Z])
cube([LOUV_W, WALL_T + 2*EPS, LOUV_H]);
}
// Side vent louvres Y+ wall
for (i = [0 : LOUV_N - 1]) {
lx = WALL_T + 10 + i * LOUV_PITCH;
translate([lx, OTR_W - WALL_T - EPS, LOUV_Z])
cube([LOUV_W, WALL_T + 2*EPS, LOUV_H]);
}
// M5 BHCS head pockets (4 bolts, bottom face)
for (mx = [OTR_L/2 - M5_SPAC_X/2, OTR_L/2 + M5_SPAC_X/2])
for (my = [OTR_W/2 - M5_SPAC_Y/2, OTR_W/2 + M5_SPAC_Y/2])
translate([mx, my, -EPS]) {
cylinder(d = M5_D, h = FLOOR_T + 2*EPS);
cylinder(d = M5_HEAD_D, h = M5_HEAD_H + EPS);
}
// Zip-tie slots through CAN 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 + 16, WALL_T + 6, -EPS])
cube([OTR_L - 2*WALL_T - 32, OTR_W - 2*WALL_T - 12,
FLOOR_T - 2.0 + EPS]);
}
}
// Assembly preview
if (RENDER == "assembly") {
color("DimGray", 0.93) vesc_mount();
// Phantom PCB
color("ForestGreen", 0.30)
translate([PCB_X0, PCB_Y0, PCB_Z0])
cube([PCB_L, PCB_W, PCB_T]);
// Phantom heatsink / component block
color("SlateGray", 0.22)
translate([PCB_X0, PCB_Y0, PCB_Z0 + PCB_T])
cube([PCB_L, PCB_W, COMP_H]);
// XT60 connector highlights (X end)
for (yc = [XT60_Y1, XT60_Y2])
color("Gold", 0.85)
translate([-2,
PCB_Y0 + yc - XT60_W/2,
PCB_Z0 + XT60_Z0])
cube([WALL_T + 3, XT60_W, XT60_H]);
// XT30 connector highlights Y side
for (xc = [XT30_X1, XT30_X2])
color("OrangeRed", 0.80)
translate([PCB_X0 + xc - XT30_W/2,
-2,
PCB_Z0 + XT30_Z0])
cube([XT30_W, WALL_T + 3, XT30_H]);
// XT30 connector highlights Y+ side
for (xc = [XT30_X1, XT30_X2])
color("OrangeRed", 0.80)
translate([PCB_X0 + xc - XT30_W/2,
OTR_W - WALL_T - 1,
PCB_Z0 + XT30_Z0])
cube([XT30_W, WALL_T + 3, XT30_H]);
// CAN terminal highlight
color("Tomato", 0.75)
translate([OTR_L - WALL_T - 1,
PCB_Y0 + TERM_Y_CTR - TERM_W/2,
PCB_Z0 + TERM_Z0])
cube([WALL_T + 3, TERM_W, TERM_H]);
} else {
vesc_mount();
}

View File

@ -1,323 +0,0 @@
# AGENTS.md — SaltyLab Agent Onboarding
You're working on **SaltyLab**, a self-balancing two-wheeled indoor robot. Read this entire file before touching anything.
## ⚠️ ARCHITECTURE — SAUL-TEE (finalised 2026-04-04)
<<<<<<< HEAD
Full hardware spec: `docs/SAUL-TEE-SYSTEM-REFERENCE.md` — **read it before writing firmware.**
| Board | Role |
|-------|------|
| **ESP32-S3 BALANCE** | Waveshare Touch LCD 1.28 (CH343 USB). QMI8658 IMU, PID loop, CAN→VESC L(68)/R(56), GC9A01 LCD |
| **ESP32-S3 IO** | Bare devkit (JTAG USB). TBS Crossfire RC (UART0), ELRS failover (UART2), BTS7960 motors, NFC/baro/ToF, WS2812, buzzer/horn/headlight/fan |
| **Jetson Orin** | CANable2 USB→CAN. Cmds on 0x3000x303, telemetry on 0x4000x401 |
```
Jetson Orin ──CANable2──► CAN 500kbps ◄───────────────────────┐
│ │
ESP32-S3 BALANCE ←─UART 460800─► ESP32-S3 IO
(QMI8658, PID loop) (BTS7960, RC, sensors)
│ CAN 500kbps
┌─────────┴──────────┐
VESC Left (ID 68) VESC Right (ID 56)
=======
A hoverboard-based balancing robot with two compute layers:
1. **ESP32-S3 BALANCE** — ESP32-S3 BALANCE (ESP32-S3RET6 + MPU6000 IMU). Runs a lean C balance loop at up to 8kHz. Talks UART to the hoverboard ESC. This is the safety-critical layer.
2. **Jetson Orin Nano Super** — AI brain. ROS2, SLAM, person tracking. Sends velocity commands to FC via UART. Not safety-critical — FC operates independently.
```
Jetson (speed+steer via UART1) ←→ ELRS RC (UART3, kill switch)
ESP32-S3 BALANCE (MPU6000 IMU, PID balance)
▼ UART2
Hoverboard ESC (FOC) → 2× 8" hub motors
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
```
Frame: `[0xAA][LEN][TYPE][PAYLOAD][CRC8]`
Legacy `src/` STM32 HAL code is **archived — do not extend.**
## ⚠️ SAFETY — READ THIS OR PEOPLE GET HURT
This is not a toy. 8" hub motors + 36V battery can crush fingers, break toes, and launch the frame. Every firmware change must preserve these invariants:
1. **Motors NEVER spin on power-on.** Requires deliberate arming: hold button 3s while upright.
2. **Tilt cutoff at ±25°** — motors to zero, require manual re-arm. No retry, no recovery.
3. **Hardware watchdog (50ms)** — if firmware hangs, motors cut.
4. **RC kill switch** — dedicated ELRS channel, checked every loop iteration. Always overrides.
5. **Jetson UART timeout (200ms)** — if Jetson disconnects, motors cut.
6. **Speed hard cap** — firmware limit, start at 10%. Increase only after proven stable.
7. **Never test untethered** until PID is stable for 5+ minutes on a tether.
**If you break any of these, you are removed from the project.**
## Repository Layout
```
<<<<<<< HEAD
firmware/ # Legacy ESP32/STM32 HAL firmware (PlatformIO, archived)
=======
firmware/ # ESP-IDF firmware (PlatformIO)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
├── src/
│ ├── main.c # Entry point, clock config, main loop
│ ├── icm42688.c # QMI8658-P SPI driver (backup IMU — currently broken)
│ ├── bmp280.c # Barometer driver (disabled)
│ └── status.c # LED + buzzer status patterns
├── include/
│ ├── config.h # Pin definitions, constants
│ ├── icm42688.h
│ ├── mpu6000.h # MPU6000 driver header (primary IMU)
│ ├── hoverboard.h # Hoverboard ESC UART protocol
│ ├── crsf.h # ELRS CRSF protocol
│ ├── bmp280.h
│ └── status.h
├── lib/USB_CDC/ # USB Serial (CH343) stack (serial over USB)
│ ├── src/ # CDC implementation, USB descriptors, PCD config
│ └── include/
└── platformio.ini # Build config
cad/ # OpenSCAD parametric parts (16 files)
├── dimensions.scad # ALL measurements live here — single source of truth
├── assembly.scad # Full robot assembly visualization
├── motor_mount_plate.scad
├── battery_shelf.scad
├── fc_mount.scad # Vibration-isolated FC mount
├── jetson_shelf.scad
├── esc_mount.scad
├── sensor_tower_top.scad
├── lidar_standoff.scad
├── realsense_bracket.scad
├── bumper.scad # TPU bumpers (front + rear)
├── handle.scad
├── kill_switch_mount.scad
├── tether_anchor.scad
├── led_diffuser_ring.scad
└── esp32c3_mount.scad
ui/ # Web UI (Three.js + WebSerial)
└── index.html # 3D board visualization, real-time IMU data
SALTYLAB.md # Master design doc — architecture, wiring, build phases
SALTYLAB-DETAILED.md # Power budget, weight budget, detailed schematics
PLATFORM.md # Hardware platform reference
```
## Hardware Quick Reference
<<<<<<< HEAD
### ESP32 BALANCE Flight Controller
| Spec | Value |
|------|-------|
| MCU | ESP32RET6 (Cortex-M7, 216MHz, 512KB flash, 256KB RAM) |
=======
### ESP32-S3 BALANCE Flight Controller
| Spec | Value |
|------|-------|
| MCU | ESP32-S3RET6 (Cortex-M7, 216MHz, 512KB flash, 256KB RAM) |
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
| Primary IMU | MPU6000 (WHO_AM_I = 0x68) |
| IMU Bus | SPI1: PA5=SCK, PA6=MISO, PA7=MOSI, CS=PA4 |
| IMU EXTI | PC4 (data ready interrupt) |
| IMU Orientation | CW270 (Betaflight convention) |
| Secondary IMU | QMI8658-P (on same SPI1, CS unknown — currently non-functional) |
| Betaflight Target | DIAT-MAMBAF722_2022B |
| USB | OTG FS (PA11/PA12), enumerates as /dev/cu.usbmodemSALTY0011 |
| VID/PID | 0x0483/0x5740 |
| LEDs | PC15 (LED1), PC14 (LED2), active low |
| Buzzer | PB2 (inverted push-pull) |
| Battery ADC | PC1=VBAT, PC3=CURR (ADC3) |
| DFU | Hold yellow BOOT button + plug USB (or send 'R' over CDC) |
### UART Assignments
| UART | Pins | Connected To | Baud |
|------|------|-------------|------|
| USART1 | PA9/PA10 | Jetson Orin Nano Super | 115200 |
| USART2 | PA2/PA3 | Hoverboard ESC | 115200 |
| USART3 | PB10/PB11 | ELRS Receiver | 420000 (CRSF) |
| UART4 | — | Spare | — |
| UART5 | — | Spare | — |
### Motor/ESC
- 2× 8" pneumatic hub motors (36V, hoverboard type)
- Hoverboard ESC with FOC firmware
- UART protocol: `{0xABCD, int16 speed, int16 steer, uint16 checksum}` at 115200
- Speed range: -1000 to +1000
### Physical Dimensions (from `cad/dimensions.scad`)
| Part | Key Measurement |
|------|----------------|
| FC mounting holes | 25.5mm spacing (NOT standard 30.5mm!) |
| FC board size | ~36mm square |
| Hub motor body | Ø200mm (~8") |
| Motor axle | Ø12mm, 45mm long |
| Jetson Orin Nano Super | 100×80×29mm, M2.5 holes at 86×58mm |
| RealSense D435i | 90×25×25mm, 1/4-20 tripod mount |
| RPLIDAR A1 | Ø70×41mm, 4× M2.5 on Ø67mm circle |
| Kill switch hole | Ø22mm panel mount |
| Battery pack | ~180×80×40mm |
| Hoverboard ESC | ~80×50×15mm |
| 2020 extrusion | 20mm square, M5 center bore |
| Frame width | ~350mm (axle to axle) |
| Frame height | ~500-550mm total |
| Target weight | <8kg (current estimate: 7.4kg) |
### 3D Printed Parts (16 files in `cad/`)
| Part | Material | Infill |
|------|----------|--------|
| motor_mount_plate (350×150×6mm) | PETG | 80% |
| battery_shelf | PETG | 60% |
| esc_mount | PETG | 40% |
| jetson_shelf | PETG | 40% |
| sensor_tower_top | ASA | 80% |
| lidar_standoff (Ø80×80mm) | ASA | 40% |
| realsense_bracket | PETG | 60% |
| fc_mount (vibration isolated) | TPU+PETG | — |
| bumper front + rear (350×50×30mm) | TPU | 30% |
| handle | PETG | 80% |
| kill_switch_mount | PETG | 80% |
| tether_anchor | PETG | 100% |
| led_diffuser_ring (Ø120×15mm) | Clear PETG | 30% |
| esp32c3_mount | PETG | 40% |
## Firmware Architecture
### Critical Lessons Learned (DON'T REPEAT THESE)
1. **SysTick_Handler with HAL_IncTick() is MANDATORY** — without it, HAL_Delay() and every HAL timeout hangs forever. This bricked us multiple times.
<<<<<<< HEAD
2. **DCache breaks SPI on ESP32** — disable DCache or use cache-aligned DMA buffers with clean/invalidate. We disable it.
=======
2. **DCache breaks SPI on ESP32-S3** — disable DCache or use cache-aligned DMA buffers with clean/invalidate. We disable it.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
3. **`-(int)0 == 0`** — checking `if (-result)` to detect errors doesn't work when result is 0 (success and failure look the same). Always use explicit error codes.
4. **NEVER auto-run untested code on_boot** — we bricked the NSPanel 3x doing this. Test manually first.
5. **USB Serial (CH343) needs ReceivePacket() primed in CDC_Init** — without it, the OUT endpoint never starts listening. No data reception.
### DFU Reboot (Betaflight Method)
The firmware supports reboot-to-DFU via USB command:
1. Send `R` byte over USB Serial (CH343)
2. Firmware writes `0xDEADBEEF` to RTC backup register 0
3. `NVIC_SystemReset()` — clean hardware reset
4. On boot, `checkForBootloader()` (called after `HAL_Init()`) reads the magic
<<<<<<< HEAD
5. If magic found: clears it, remaps system memory, jumps to ESP32 BALANCE bootloader at `0x1FF00000`
=======
5. If magic found: clears it, remaps system memory, jumps to ESP32-S3 bootloader at `0x1FF00000`
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
6. Board appears as DFU device, ready for `dfu-util` flash
### Build & Flash
```bash
cd firmware/
python3 -m platformio run # Build
dfu-util -a 0 -s 0x08000000:leave -D .pio/build/f722/firmware.bin # Flash
```
Dev machine: mbpm4 (seb@192.168.87.40), PlatformIO project at `~/Projects/saltylab-firmware/`
### Clock Configuration
```
HSE 8MHz → PLL (M=8, N=432, P=2, Q=9) → SYSCLK 216MHz
PLLSAI (N=384, P=8) → CLK48 48MHz (USB)
APB1 = HCLK/4 = 54MHz
APB2 = HCLK/2 = 108MHz
Fallback: HSI 16MHz if HSE fails (PLL M=16)
```
## Current Status & Known Issues
### Working
- USB Serial (CH343) serial streaming (50Hz JSON: `{"ax":...,"ay":...,"az":...,"gx":...,"gy":...,"gz":...}`)
- Clock config with HSE + HSI fallback
- Reboot-to-DFU via USB 'R' command
- LED status patterns (status.c)
- Web UI with WebSerial + Three.js 3D visualization
### Broken / In Progress
- **QMI8658-P SPI reads return all zeros** — was the original IMU target, but SPI communication completely non-functional despite correct pin config. May be dead silicon. Switched to MPU6000 as primary.
- **MPU6000 driver** — header exists but implementation needs completion
- **PID balance loop** — not yet implemented
- **Hoverboard ESC UART** — protocol defined, driver not written
- **ELRS CRSF receiver** — protocol defined, driver not written
- **Barometer (BMP280)** — I2C init hangs, disabled
### TODO (Priority Order)
1. Get MPU6000 streaming accel+gyro data
2. Implement complementary filter (pitch angle)
3. Write hoverboard ESC UART driver
4. Write PID balance loop with safety checks
5. Wire ELRS receiver, implement CRSF parser
6. Bench test (ESC disconnected, verify PID output)
7. First tethered balance test at 10% speed
8. Jetson UART integration
9. LED subsystem (ESP32-C3)
## Communication Protocols
### Jetson → FC (UART1, 50Hz)
```c
struct { uint8_t header=0xAA; int16_t speed; int16_t steer; uint8_t mode; uint8_t checksum; };
// mode: 0=idle, 1=balance, 2=follow, 3=RC
```
### FC → Hoverboard ESC (UART2, loop rate)
```c
struct { uint16_t start=0xABCD; int16_t speed; int16_t steer; uint16_t checksum; };
// speed/steer: -1000 to +1000
```
### FC → Jetson Telemetry (UART1 TX, 50Hz)
```
T:12.3,P:45,L:100,R:-80,S:3\n
// T=tilt°, P=PID output, L/R=motor commands, S=state (0-3)
```
### FC → USB Serial (CH343) (50Hz JSON)
```json
{"ax":123,"ay":-456,"az":16384,"gx":10,"gy":-5,"gz":3,"t":250,"p":0,"bt":0}
// Raw IMU values (int16), t=temp×10, p=pressure, bt=baro temp
```
## LED Subsystem (ESP32-C3)
ESP32-C3 eavesdrops on FC→Jetson telemetry (listen-only tap on UART1 TX). No extra FC UART needed.
| State | Pattern | Color |
|-------|---------|-------|
| Disarmed | Slow breathe | White |
| Arming | Fast blink | Yellow |
| Armed idle | Solid | Green |
| Turning | Sweep direction | Orange |
| Braking | Flash rear | Red |
| Fault | Triple flash | Red |
| RC lost | Alternating flash | Red/Blue |
## Printing (Bambu Lab)
- **X1C** (192.168.87.190) — for structural PETG/ASA parts
- **A1** (192.168.86.161) — for TPU bumpers and prototypes
- LAN access codes and MQTT details in main workspace MEMORY.md
- STL export from OpenSCAD, slice in Bambu Studio
## Rules for Agents
1. **Read SALTYLAB.md fully** before making any design decisions
2. **Never remove safety checks** from firmware — add more if needed
3. **All measurements go in `cad/dimensions.scad`** — single source of truth
4. **Test firmware on bench before any motor test** — ESC disconnected, verify outputs on serial
5. **One variable at a time** — don't change PID and speed limit in the same test
6. **Document what you change** — update this file if you add pins, change protocols, or discover hardware quirks
7. **Ask before wiring changes** — wrong connections can fry the FC ($50+ board)

View File

@ -1,10 +1,6 @@
# Face LCD Animation System (Issue #507) # Face LCD Animation System (Issue #507)
<<<<<<< HEAD Implements expressive face animations on an STM32 LCD display with 5 core emotions and smooth transitions.
Implements expressive face animations on an ESP32 LCD display with 5 core emotions and smooth transitions.
=======
Implements expressive face animations on an ESP32-S3 LCD display with 5 core emotions and smooth transitions.
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
## Features ## Features
@ -86,11 +82,7 @@ STATUS → Echo current emotion + idle state
- Colors: Monochrome (1-bit) or RGB565 - Colors: Monochrome (1-bit) or RGB565
### Microcontroller ### Microcontroller
<<<<<<< HEAD - STM32F7xx (Mamba F722S)
- ESP32xx (ESP32 BALANCE)
=======
- ESP32-S3xx (ESP32-S3 BALANCE)
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
- Available UART: USART3 (PB10=TX, PB11=RX) - Available UART: USART3 (PB10=TX, PB11=RX)
- Clock: 216 MHz - Clock: 216 MHz

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