chore: merge CAD files and design docs from seb/saltylab seed repo

Consolidating seb/saltylab into saltylab-firmware before deleting the seed repo.
- 16 OpenSCAD CAD models → cad/
- Design docs (SALTYLAB.md, PLATFORM.md, AGENTS.md, board-viz.html) → docs/
This commit is contained in:
salty 2026-03-07 10:04:24 -05:00
parent 6d316514da
commit 02217443ea
21 changed files with 2768 additions and 0 deletions

118
cad/assembly.scad Normal file
View File

@ -0,0 +1,118 @@
// ============================================
// 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 Nano
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"));

77
cad/battery_shelf.scad Normal file
View File

@ -0,0 +1,77 @@
// ============================================
// 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();

75
cad/bumper.scad Normal file
View File

@ -0,0 +1,75 @@
// ============================================
// 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();

73
cad/dimensions.scad Normal file
View File

@ -0,0 +1,73 @@
// ============================================
// 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 Nano ---
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

70
cad/esc_mount.scad Normal file
View File

@ -0,0 +1,70 @@
// ============================================
// 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();

57
cad/esp32c3_mount.scad Normal file
View File

@ -0,0 +1,57 @@
// ============================================
// 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();

86
cad/fc_mount.scad Normal file
View File

@ -0,0 +1,86 @@
// ============================================
// 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();
}
}

59
cad/handle.scad Normal file
View File

@ -0,0 +1,59 @@
// ============================================
// 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();

69
cad/jetson_shelf.scad Normal file
View File

@ -0,0 +1,69 @@
// ============================================
// SaltyLab Jetson Nano Shelf
// 120×100×15mm PETG
// Mounts Jetson Nano 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

@ -0,0 +1,56 @@
// ============================================
// 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

@ -0,0 +1,53 @@
// ============================================
// 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();

61
cad/lidar_standoff.scad Normal file
View File

@ -0,0 +1,61 @@
// ============================================
// 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

@ -0,0 +1,94 @@
// ============================================
// 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

@ -0,0 +1,64 @@
// ============================================
// 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();

58
cad/sensor_tower_top.scad Normal file
View File

@ -0,0 +1,58 @@
// ============================================
// 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();

46
cad/tether_anchor.scad Normal file
View File

@ -0,0 +1,46 @@
// ============================================
// 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();

281
docs/AGENTS.md Normal file
View File

@ -0,0 +1,281 @@
# AGENTS.md — SaltyLab Agent Onboarding
You're working on **SaltyLab**, a self-balancing two-wheeled indoor robot. Read this entire file before touching anything.
## Project Overview
A hoverboard-based balancing robot with two compute layers:
1. **FC (Flight Controller)** — MAMBA F722S (STM32F722RET6 + 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 Nano** — 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)
MAMBA F722S (MPU6000 IMU, PID balance)
▼ UART2
Hoverboard ESC (FOC) → 2× 8" hub motors
```
## ⚠️ 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
```
firmware/ # STM32 HAL firmware (PlatformIO)
├── src/
│ ├── main.c # Entry point, clock config, main loop
│ ├── icm42688.c # ICM-42688-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 CDC 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
### MAMBA F722S Flight Controller
| Spec | Value |
|------|-------|
| MCU | STM32F722RET6 (Cortex-M7, 216MHz, 512KB flash, 256KB RAM) |
| 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 | ICM-42688-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 Nano | 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 Nano | 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.
2. **DCache breaks SPI on STM32F7** — disable DCache or use cache-aligned DMA buffers with clean/invalidate. We disable it.
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 CDC 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 CDC
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
5. If magic found: clears it, remaps system memory, jumps to STM32 bootloader at `0x1FF00000`
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 CDC 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
- **ICM-42688-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 CDC (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)

222
docs/PLATFORM.md Normal file
View File

@ -0,0 +1,222 @@
# SaltyRover — Modular Platform Design 🧂🛞
## Design Philosophy
- **Modular:** Standardized mounting points for swappable top decks
- **Printable:** Main structural brackets on Bambu X1C (256x256x256mm) and A1 (256x256x256mm)
- **Repairable:** Bolt-together, no permanent welds/glue on structural parts
- **Weatherproof-ish:** Splash resistant for outdoor use, not submarine
## Base Platform ("Skateboard")
### Frame
```
FRONT
┌─────────────────┐
│ ┌─M1─┐ ┌─M2─┐ │ M1-M4: 6.5" hub motors
│ │ │ │ │ │ ESC1 drives M1+M2 (front)
│ └────┘ └────┘ │ ESC2 drives M3+M4 (rear)
│ │
│ ┌──────────────┐ │
│ │ BATTERY │ │ Center-mounted battery bay
│ │ BAY │ │ Fits 2x hoverboard packs (2P)
│ └──────────────┘ │
│ │
│ ┌─ESC1─┐┌─ESC2─┐ │ ESCs flanking center
│ └──────┘└──────┘ │
│ ┌──5V──┐┌─12V──┐ │ DC-DC converters
│ └──────┘└──────┘ │
│ │
│ ┌─M3─┐ ┌─M4─┐ │
│ │ │ │ │ │
│ └────┘ └────┘ │
└─────────────────┘
REAR
Overall: ~600mm L × 450mm W × ~120mm H (base only)
```
### Dimensions
- **Length:** 600mm (motor center to motor center ~500mm, +50mm overhang each end)
- **Width:** 450mm (constrained by motor axle-to-axle, ~350mm inner + motor housings)
- **Ground clearance:** ~50mm (bottom of frame to ground)
- **Wheelbase:** 500mm (front axle to rear axle)
- **Track width:** 350mm (left wheel center to right wheel center)
### Frame Construction
- **Main rails (x2):** Aluminum extrusion 2040 V-slot, 600mm length
- Or: 40x20mm aluminum rectangular tube
- Or: 3D printed with steel rod reinforcement
- **Cross members (x3):** Front, center, rear — aluminum or printed
- **Motor mounts (x4):** 3D printed brackets, bolted to frame rails
- Must accommodate 6.5" hub motor axle (standard hoverboard M10 axle)
- Axle clamp style — two-piece with bolts for easy wheel swap
### Modular Top Deck Interface
```
┌─────────────────────┐
│ ○ ○ ○ ○ │ ← M5 threaded inserts, 100mm grid
│ │
│ ○ ○ ○ ○ │ Standard mounting pattern:
│ │ - 400mm × 300mm grid
│ ○ ○ ○ ○ │ - M5 bolt holes on 100mm centers
│ │ - 16 mount points total
│ ○ ○ ○ ○ │
└─────────────────────┘
```
**Top deck connector:**
- 16x M5 threaded inserts in frame top rails
- 100mm grid spacing
- Any top deck just needs matching bolt holes
- Power connector: XT30 (5V + 12V + GND) standardized position at rear-center
- Data connector: USB-A hub mounted to frame, accessible from top
## Top Deck Configurations
### Config 1: "Follow Bot" (Primary)
```
┌─────────────────────┐
│ [RPLIDAR A1M8] │ ← Top-mounted, unobstructed 360°
spinning╲ │ Raised on 100mm standoff
│ │
│ [RealSense D435i] │ ← Front-facing, angled down ~10°
│ │ Height: ~400mm from ground
│ [Jetson Nano] │ ← Center, in ventilated enclosure
│ [WiFi/4G module] │ Noctua fan draws air through
│ │
│ [Speaker] [LEDs] │ ← Rear: audio feedback + status
│ [E-STOP button] │ Big red mushroom button
└─────────────────────┘
```
**Parts:**
- Sensor tower: 3D printed, 100mm tall, mounts LIDAR on top
- RealSense bracket: 3D printed, adjustable tilt
- Jetson enclosure: 3D printed, ventilated, vibration dampened
- LED strip ring: NeoPixel/WS2812B around sensor tower (status indication)
### Config 2: "Cargo Hauler"
```
┌─────────────────────┐
│ ┌─────────────────┐ │
│ │ │ │ Flat cargo platform
│ │ CARGO AREA │ │ 400 × 300 × 150mm
│ │ (open top) │ │ With tie-down points
│ │ │ │
│ └─────────────────┘ │
│ [GPS] [Beacon] │ Minimal autonomy — follows beacon
└─────────────────────┘
```
### Config 3: "Camera Rig"
```
┌─────────────────────┐
│ [Gimbal] │ 2-axis stabilized camera mount
│ [Action Cam] │ GoPro / Insta360
│ │
│ [Jetson + storage] │ Records while following
│ [Large battery] │ Extended runtime for filming
└─────────────────────┘
```
### Config 4: "Security Patrol"
```
┌─────────────────────┐
│ [RPLIDAR] │ Autonomous waypoint patrol
│ [PTZ Camera] │ Pan-tilt-zoom camera
│ [Spotlight] │ High-power LED
│ [Jetson + 4G] │ Streams to Frigate
│ [Siren/Speaker] │
└─────────────────────┘
```
## 3D Printed Parts List (Config 1: Follow Bot)
All designed for Bambu X1C/A1 build plate (256x256mm max).
| Part | Size (mm) | Material | Infill | Qty |
|------|-----------|----------|--------|-----|
| Motor mount bracket | 80×60×40 | PETG/ASA | 60% | 4 |
| Motor mount clamp top | 80×40×15 | PETG/ASA | 60% | 4 |
| Cross member front | 350×40×20 | PETG/ASA | 80% | 1 |
| Cross member center | 350×60×20 | PETG/ASA | 80% | 1 |
| Cross member rear | 350×40×20 | PETG/ASA | 80% | 1 |
| Battery tray | 250×150×30 | PETG | 40% | 1 |
| Battery strap anchor | 40×20×15 | PETG | 100% | 4 |
| ESC mount tray | 150×100×15 | PETG | 40% | 2 |
| DC-DC mount | 80×60×15 | PETG | 40% | 2 |
| Sensor tower base | 120×120×10 | ASA | 80% | 1 |
| Sensor tower tube | Ø80×100 | ASA | 40% | 1 |
| LIDAR mount plate | Ø90×5 | ASA | 100% | 1 |
| RealSense bracket | 100×50×60 | PETG | 60% | 1 |
| Jetson enclosure bottom | 120×100×25 | PETG | 40% | 1 |
| Jetson enclosure top | 120×100×25 | PETG | 40% | 1 |
| E-stop mount | 50×50×30 | PETG | 60% | 1 |
| Wire management clips | 20×15×10 | PETG | 100% | 10 |
| Fender/splash guard | 200×80×60 | ASA | 30% | 4 |
**Material notes:**
- **ASA** for outdoor/exposed parts (UV resistant, weather resistant)
- **PETG** for structural internal parts (strong, slight flex)
- Avoid PLA — warps in summer sun
## Electrical Wiring
```
PACK1 ═╤═ PACK2 (parallel, XT60)
├──→ ESC1 ──→ M1 (front-left) + M2 (front-right)
│ │
│ └── UART TX/RX ──→ Jetson GPIO
├──→ ESC2 ──→ M3 (rear-left) + M4 (rear-right)
│ │
│ └── UART TX/RX ──→ Jetson GPIO
├──→ DC-DC 36V→5V ──→ Jetson Nano (barrel jack 5V/4A)
│ ──→ USB hub (sensors)
├──→ DC-DC 36V→12V ──→ LED strips
│ ──→ Speaker amp
│ ──→ 4G modem
└──→ E-STOP (normally closed, inline with main power)
```
### ESC UART Protocol (FOC firmware)
- Baud: 115200 (or 9600, configurable)
- Each ESC: `steer` + `speed` as int16 values (-1000 to +1000)
- ESC1 (front): Jetson UART1
- ESC2 (rear): Jetson UART2 (or USB-serial adapter)
### Differential Drive Control
```
Left speed = throttle - steering
Right speed = throttle + steering
For 4WD: front and rear ESCs get same commands
(or: rear slightly less for better turning)
```
## Assembly Order
1. Cut/prepare frame rails (aluminum extrusion or tube)
2. Print all brackets and mounts
3. Assemble frame with cross members
4. Mount motors to brackets, attach to frame
5. Install battery tray, strap packs
6. Mount ESCs and DC-DC converters
7. Wire power distribution (XT60 splitters)
8. Install E-stop inline
9. Mount top deck with sensor tower
10. Wire data connections (UART, USB)
11. First test: power on, spin motors manually via serial terminal
12. Flash follow-bot software to Jetson
13. Outdoor test in parking lot
## Next Steps
- [ ] Measure exact motor axle dimensions and spacing
- [ ] Choose frame material (aluminum extrusion vs printed vs hybrid)
- [ ] Design motor mount bracket in CAD (FreeCAD/Fusion360)
- [ ] Print test motor mount, verify fit
- [ ] Design and print sensor tower
- [ ] Bench test: Jetson → UART → ESC → single motor spinning

454
docs/SALTYLAB-DETAILED.md Normal file
View File

@ -0,0 +1,454 @@
# SaltyLab — Detailed Build Plan 🔬⚖️
Self-balancing two-wheeled indoor robot with AI brain.
---
## 1. Battery Analysis
### Pack Specs (Begode Master V1 packs)
- **Configuration per pack:** 10S (35V nominal, 42V full, 30V cutoff)
- **Chemistry:** Li-ion 18650 or 21700
- **Estimated capacity per pack:** ~450-500Wh (based on Master V1 total ~1800Wh ÷ 4 packs)
- If 10S4P with 21700 5000mAh cells: 36V × 20Ah = **720Wh**
- If 10S3P with 21700 5000mAh cells: 36V × 15Ah = **540Wh**
- If 10S4P with 18650 3500mAh cells: 36V × 14Ah = **504Wh**
- **Need to verify:** check cell count visible on pack, or weigh it
### SaltyLab Battery Config: Single Pack
- **Voltage:** 35V nominal (fits hoverboard ESC: designed for 36V/10S)
- **Capacity:** ~500Wh (conservative estimate)
- **Weight:** ~2-3kg per pack
### Why Single Pack is Enough
- SaltyLab is indoor-only, short missions
- One pack gives 2-4 hours runtime (see estimates below)
- Keep other 3 packs for SaltyRider and SaltyTank
---
## 2. Power Budget & Range Estimation
### Component Power Draw
| Component | Voltage | Current | Power (W) | Notes |
|-----------|---------|---------|-----------|-------|
| Jetson Nano | 5V | 2-4A | 10-20W | AI inference mode: ~15W avg |
| RealSense D435i | 5V (USB) | 0.7A | 3.5W | Depth + RGB streaming |
| RPLIDAR A1M8 | 5V | 0.5A | 2.5W | Spinning at 5.5Hz |
| BNO055 IMU | 3.3V | 0.01A | 0.04W | Negligible |
| ESC (idle/balance) | 36V | 0.3A | 10W | Maintaining balance, no movement |
| LEDs + misc | 12V | 0.5A | 6W | Status LEDs, speaker |
| DC-DC losses | — | — | ~5W | ~85% efficiency on converters |
| **Subtotal (idle/balancing)** | | | **~47W** | |
### Motor Power (Moving)
| Activity | Per Motor | Total (2 motors) | Notes |
|----------|-----------|-------------------|-------|
| Balancing in place | 5-15W | 10-30W | Continuous micro-corrections |
| Slow indoor movement (2 km/h) | 15-25W | 30-50W | Walking pace |
| Normal indoor (5 km/h) | 30-50W | 60-100W | Brisk walk |
| Fast / acceleration | 80-150W | 160-300W | Bursts, turning |
| Climbing threshold/ramp | 100-200W | 200-400W | Short duration |
### Total Power by Use Case
| Mode | Electronics | Motors | Total | Notes |
|------|-------------|--------|-------|-------|
| **Idle (balancing)** | 47W | 20W | **~67W** | Standing still |
| **Slow patrol** | 47W | 40W | **~87W** | Gentle movement |
| **Normal follow** | 47W | 80W | **~127W** | Following person around house |
| **Active (turning, accel)** | 47W | 200W | **~247W** | Bursts |
### Range Estimates (Single 500Wh Pack)
| Mode | Avg Power | Runtime | Distance |
|------|-----------|---------|----------|
| Idle (balancing) | 67W | **7.5 hours** | 0 km (stationary) |
| Slow patrol (2 km/h) | 87W | **5.7 hours** | ~11 km |
| Normal follow (5 km/h) | 127W | **3.9 hours** | ~20 km |
| Mixed indoor use | ~100W avg | **5 hours** | ~15 km |
| Aggressive (lots of turning) | 180W avg | **2.8 hours** | ~8 km |
**Bottom line: 3-5 hours of indoor use on a single pack.** More than enough.
### Weight Budget
| Component | Weight (g) | Notes |
|-----------|-----------|-------|
| Battery pack (1x) | 2500 | Estimated, weigh to verify |
| 2x 8" hub motors | 2400 | ~1200g each with tire |
| ESC board | 150 | Single board |
| Jetson Nano + heatsink | 280 | With Noctua fan |
| RealSense D435i | 72 | Very light |
| RPLIDAR A1M8 | 170 | With motor |
| BNO055 breakout | 5 | Tiny |
| DC-DC converters (2x) | 300 | 150g each |
| Frame + brackets | 1200 | Aluminum + 3D printed |
| Wiring + connectors | 200 | |
| Bumpers (TPU) | 150 | |
| **TOTAL** | **~7.4 kg** | Target: under 8 kg |
---
## 3. Detailed 2D Schematics
### 3.1 Base Plate — Top View
```
350mm
←─────────────────────────────────────→
┌─────────────────────────────────────┐ ─┬─
│ ○ ○ │ │
│ ┌───────────────────────────┐ │ │
│ │ MOTOR MOUNT PLATE │ │ │
│ │ │ │ │
│ │ ┌─────┐ ┌─────┐ │ │ │
│ │ │AXLE │ │AXLE │ │ │ │ 200mm
│ │ │ L │ │ R │ │ │ │
│ │ └─────┘ └─────┘ │ │ │
│ │ ↑ 250mm ↑ │ │ │
│ │ └─────────────┘ │ │ │
│ │ track width │ │ │
│ └───────────────────────────┘ │ │
│ ○ ○ │ │
└─────────────────────────────────────┘ ─┴─
○ = M5 mounting holes for vertical spine (4 corners)
Axle holes: Ø14mm (standard hoverboard axle)
Plate thickness: 6mm PETG
Motor mount detail:
┌──────────────┐
│ ┌────────┐ │
│ │ Ø14mm │ │ Two-piece clamp:
│ │ axle │ │ Bottom: part of base plate
│ │ hole │ │ Top: clamp plate with 2x M6 bolts
│ └────────┘ │
│ ○ ○ │ ○ = M6 clamp bolt holes
└──────────────┘
80mm
```
### 3.2 Side View — Full Assembly
```
FRONT →
550mm ┬ ┌─────────┐
│ │ RPLIDAR │ Ø80mm, 360° clear
│ │ A1M8 │
500mm │ ├─────────┤
│ │ │ ← LIDAR standoff tube (80mm tall)
│ │ │
420mm │ ├─────────┤
│ │RealSense│ ← Tilted down 10°, front-facing
│ │ D435i │ Adjustable bracket
380mm │ ├─────────┤
│ │ │
│ │ JETSON │ ← Noctua fan, ventilation slots
│ │ NANO │
300mm │ ├─────────┤
│ │ BNO055 │ ← IMU, vibration-isolated mount
280mm │ ├─────────┤
│ │ │
│ │ ESC + │ ← ESC board + DC-DC converters
│ │ DC-DCs │
200mm │ ├─────────┤
│ │ │
│ │ BATTERY │ ← Heaviest component, lowest position
│ │ PACK │ Strapped to spine with velcro
│ │ │
80mm │ ├─────────┤
│ │ BASE │ ← Motor mount plate (6mm)
│ │ PLATE │
40mm │ ├────┬────┤
│ │ │ │
┴ └────┘ └── 8" wheel (Ø203mm)
═════════════
GROUND (0mm)
Ground clearance: ~40mm (bottom of plate to ground)
Wheel contact to axle center: ~100mm (8" diameter / 2)
Axle height from ground: ~100mm
```
### 3.3 Front View
```
←── 350mm ──→
┌─────────┐ ─┬─ 550mm
│ RPLIDAR │ │
├─────────┤ │
│ ┃ │ ← spine │
│ ┃ │ (2020 │
│ ┃ │ extrusion│
│ ┃ │ or │
│ ┃ │ aluminum │
│ ┃ │ tube) │
│ ┃ │ │
├───┃─────┤ │
┌─────┐ │ ┃ │ ┌─────┐│
│ │ │ ┃ │ │ ││
│ 8" │ │ ┃ │ │ 8" ││ 100mm
│ L │───┤ ┃ ├───│ R ││ (axle)
│ │ │ ┃ │ │ ││
│ │ └───┃─────┘ │ │┴─ 0mm
└─────┘ ┃ └─────┘
═══════════════════════════════════
←65→←──── 250mm ────→←65→
mm (track width) mm
Total width with tires: ~380mm
Fits through standard doorway (760mm) ✓
```
### 3.4 Spine Detail — Side View
```
┌──┐ ← 20×20mm aluminum extrusion (2020 V-slot)
│ │ or 25×25mm square aluminum tube
│ │
│ │──── Shelf bracket (3D printed, bolts to T-slot)
│ │ Each shelf: 120mm wide × 100-150mm deep
│ │
│ │──── Shelf bracket
│ │
│ │──── Shelf bracket
│ │
│ │──── Shelf bracket
│ │
├──┤
│ │──── Base plate connection (L-brackets, 4x M5)
└──┘
Spine length: 470mm (from base plate to LIDAR mount)
Shelf positions (from base plate):
0mm — Base plate
30mm — Battery shelf (holds pack on its side)
150mm — ESC + DC-DC shelf
250mm — Jetson Nano shelf
300mm — BNO055 (attached to spine directly)
370mm — RealSense bracket (front-facing arm)
420mm — LIDAR standoff begins
500mm — LIDAR mount plate
```
### 3.5 Wiring Diagram
```
BATTERY PACK (35V nominal, 10S Li-ion)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
│(+) ()│
│ │
├──[E-STOP (NC)]───────────┤
│ │
XT60 ├────────┬────────┬────────┤ XT60
│ │ │ │
│ ┌───┴───┐ │ │
│ │DC-DC │ │ │
│ │36V→5V │ │ │
│ │ 4A │ │ │
│ └───┬───┘ │ │
│ 5V │ │ │
│ ┌───┴────┐ │ │
│ │USB Hub │ │ │
│ │Jetson │ │ │
│ │RealSns │ │ │
│ │RPLIDAR │ │ │
│ └────────┘ │ │
│ │ │
│ ┌────┴───┐ │ │
│ │DC-DC │ │ │
│ │36V→12V │ │ │
│ │ 2A │ │ │
│ └───┬────┘ │ │
│ 12V │ │ │
│ ┌───┴────┐ │ │
│ │LEDs │ │ │
│ │Speaker │ │ │
│ └────────┘ │ │
│ │ │
┌────┴─────────────────┴────────┴────┐
│ HOVERBOARD ESC │
│ (FOC firmware) │
│ │
│ I2C: SDA──BNO055──SCL │
│ UART: TX──Jetson──RX │
│ │
│ PHASE L: ─── 8" LEFT MOTOR │
│ HALL L: ─── (5 wire: hall A/B/C │
│ + 5V + GND) │
│ │
│ PHASE R: ─── 8" RIGHT MOTOR │
│ HALL R: ─── (5 wire) │
└────────────────────────────────────┘
```
---
## 4. Phased Build Plan
### Phase 1: Rolling Skeleton (Days 1-3)
**Goal:** Prove balance works.
**Tasks:**
- [ ] Measure 8" motor axle diameter with calipers
- [ ] Design motor mount plate in CAD (FreeCAD or TinkerCAD)
- [ ] Print motor mount plate on Bambu X1C (PETG, 80% infill, ~4h print)
- [ ] Print axle clamp tops (x2)
- [ ] Mount both 8" motors to plate
- [ ] Mount ESC to plate with standoffs
- [ ] Wire BNO055 to ESC I2C (4 wires: VCC, GND, SDA, SCL)
- [ ] Wire battery to ESC (XT60)
- [ ] Modify FOC firmware: add BNO055 I2C read, replace gyro board input
- [ ] Flash ESC with updated firmware
- [ ] **SAFETY:** Tie rope from ceiling to plate as fall catch
- [ ] Power on, tune PID (start with Kp=20, Ki=0, Kd=0, increase gradually)
- [ ] Achieve stable free-standing balance (no rope)
**Parts needed:** motor mount plate, 2x clamp tops, M6 bolts, M3 standoffs
**Risk:** PID tuning can take hours of iteration. Be patient.
### Phase 2: Spine + Brain (Days 4-7)
**Goal:** Add Jetson, achieve remote-controlled movement while balancing.
**Tasks:**
- [ ] Cut aluminum extrusion/tube to 470mm for spine
- [ ] Print shelf brackets (4x) and L-brackets for base connection
- [ ] Assemble spine onto base plate
- [ ] Mount battery to lowest shelf (velcro straps)
- [ ] Mount ESC + DC-DC converters
- [ ] Mount Jetson Nano on shelf, connect 5V power
- [ ] Wire Jetson UART → ESC UART
- [ ] Install JetPack 4.6 on Jetson (if not already)
- [ ] Write serial bridge: Jetson Python → ESC UART commands
- [ ] Test: keyboard control (WASD) → speed/steer commands → balanced movement
- [ ] Tune speed response (acceleration limits, max speed for indoor)
- [ ] Add E-stop button (inline with battery positive)
**Software deliverable:** `saltylab_teleop.py` — keyboard-controlled balancing bot
### Phase 3: Eyes + Ears (Days 8-12)
**Goal:** See the world. Map a room. Detect people.
**Tasks:**
- [ ] Print RealSense bracket (adjustable tilt)
- [ ] Print LIDAR standoff tube + mount plate
- [ ] Mount RealSense D435i (front-facing, ~10° down tilt)
- [ ] Mount RPLIDAR A1M8 (top of spine, 360° clear)
- [ ] Install ROS2 on Jetson
- [ ] Install and test `realsense-ros` (verify depth stream)
- [ ] Install and test `rplidar_ros` (verify laser scan)
- [ ] Run `slam_toolbox` — drive around room, build 2D map
- [ ] Test person detection with SSD-MobileNet-v2 (TensorRT)
- [ ] Implement follow mode:
- Detect person in RGB frame
- Get distance from depth frame
- PID to maintain 1.5m following distance
- LIDAR for obstacle avoidance
**Software deliverable:** `saltylab_follow.py` — person-following balanced bot
### Phase 4: Polish + Personality (Days 13-17)
**Goal:** Make it feel alive. Make it SaltyLab.
**Tasks:**
- [ ] Print proper enclosures for all electronics
- [ ] Print TPU bumpers (front + rear)
- [ ] Print carry handle
- [ ] Add NeoPixel LED ring around LIDAR mount (status indication)
- Blue breathing: idle/balancing
- Green: following
- Yellow: exploring/mapping
- Red: error/low battery
- [ ] Add small speaker (USB or I2S to amp)
- Boot sound
- Acknowledge commands with beeps/chirps
- Optional: TTS via Jetson ("I see you", "battery low")
- [ ] WiFi dashboard: live camera feed + map + battery status
- [ ] Battery voltage monitoring (ADC on ESC → Jetson via UART)
- [ ] Low battery return behavior (stop and beep)
- [ ] Integrate with Home Assistant (MQTT: location, battery, status)
### Phase 5: House Mapping + Autonomy (Days 18-24)
**Goal:** SaltyLab knows your house.
**Tasks:**
- [ ] Map every room (drive around manually, SLAM builds full floor plan)
- [ ] Save map, set up `nav2` for autonomous navigation
- [ ] Define waypoints: lab, living room, kitchen, hallway
- [ ] Patrol mode: visit waypoints on schedule
- [ ] Person detection + greeting ("hey Tee", "hi Inka")
- [ ] Integration with Bermuda BLE: know where people are, go to them
- [ ] Charging dock design (future: auto-dock when low)
---
## 5. Speed & Performance Specs
### Target Performance
| Parameter | Value | Notes |
|-----------|-------|-------|
| Max speed (indoor) | 5 km/h | Software limited for safety |
| Normal follow speed | 2-3 km/h | Walking pace |
| Turning radius | 0 (pivot) | Differential drive, spins in place |
| Ground clearance | 40mm | Clears door thresholds (~15mm) |
| Max incline | ~10° | Limited by motor torque + balance |
| Operating time | 3-5 hours | Single 500Wh pack |
| Charge time | ~2-3 hours | Using one of the existing chargers |
| Weight | ~7.5 kg | Easy to pick up with handle |
| Width | 380mm | Fits all doorways |
| Height | 550mm | Below table height |
### Motor Specs (8" hub motor, estimated)
| Parameter | Value |
|-----------|-------|
| Nominal voltage | 36V |
| Rated power | 250-350W per motor |
| No-load RPM | ~250 RPM |
| Wheel circumference | ~0.64m (Ø203mm) |
| Max wheel speed | 160 m/min = 9.6 km/h |
| Continuous torque | ~2-3 Nm |
| Stall torque | ~8-10 Nm |
---
## 6. Safety Considerations
| Hazard | Mitigation |
|--------|-----------|
| Falls over | TPU bumpers, max tilt cutoff (30°), low CoG |
| Runs away | Software speed limit (5 km/h), E-stop button |
| Pinches/crushes | No exposed gears, motor covers |
| Battery fire | BMS on pack, fused main power, no charging unattended |
| Hits furniture | LIDAR obstacle avoidance, bumper sensors (future) |
| Scares the cat | Slow acceleration, no sudden movements |
---
## 7. Shopping List (Items NOT in Inventory)
| Item | Price (CAD) | Source | Notes |
|------|------------|--------|-------|
| 2020 aluminum extrusion 500mm | ~$8 | Amazon/AliExpress | Spine |
| T-slot nuts + M5 bolts (pack) | ~$12 | Amazon | For shelf mounting |
| M6 bolts + nuts (axle clamps) | ~$5 | Hardware store | 4x sets |
| NeoPixel ring (24 LED) | ~$8 | Amazon | Status indication |
| Small speaker + amp (MAX98357A) | ~$10 | Amazon/Adafruit | I2S audio |
| E-stop mushroom button | ~$5 | Amazon | Safety |
| XT60 splitter/distribution | ~$8 | Amazon | Power wiring |
| Misc: heat shrink, zip ties, wire | ~$10 | — | Always need more |
| **TOTAL** | **~$66** | | Everything else: already owned |
---
*Last updated: 2026-02-27*
*Project: SaltyRover / SaltyLab*

420
docs/SALTYLAB.md Normal file
View File

@ -0,0 +1,420 @@
# SaltyLab — Self-Balancing Indoor Bot 🔬
Two-wheeled, self-balancing robot for indoor AI/SLAM experiments.
## ⚠️ SAFETY — TOP PRIORITY
**This robot can cause serious injury.** 8" hub motors with 36V power can crush toes, break fingers, and launch the frame if control is lost. Every design decision must prioritize safety.
### Mandatory Safety Systems
1. **Hardware kill switch** — physical big red button, wired inline with battery. Cuts ALL power instantly. Must be reachable without approaching the wheels.
2. **Software tilt cutoff** — if pitch exceeds ±25° (not 30°), motors go to zero immediately. No retry, no recovery. Requires manual re-arm.
3. **Startup arming sequence** — motors NEVER spin on power-on. Requires deliberate arming: hold button for 3 seconds while robot is upright and stable.
4. **Watchdog timeout** — if FC firmware hangs or crashes, hardware watchdog resets to safe state (motors off) within 50ms.
5. **Current limiting** — hoverboard ESC max current set conservatively. Start low, increase gradually.
6. **Tether during development** — ceiling rope/strap during ALL balance testing. No free-standing tests until PID is proven stable for 5+ minutes tethered.
7. **Speed limiting** — firmware hard cap on max speed. Start at 10% throttle, increase in 10% increments only after stable testing.
8. **Remote kill** — Jetson can send emergency stop via UART. If Jetson disconnects (UART timeout >200ms), FC cuts motors automatically.
9. **Bumpers** — TPU bumpers on all sides, mandatory before any untethered operation.
10. **Test area** — clear 3m radius, no pets/kids/cables. Shoes mandatory.
11. **RC kill channel** — ELRS receiver connected to FC UART. Dedicated switch on radio = instant disarm. Works independently of Jetson. Always have radio in hand during testing.
### Safety Rules for Development
- **Never reach near wheels while powered** — even "stopped" motors can spike
- **Never test new firmware untethered** — tether FIRST, always
- **Never increase speed and change PID in the same test** — one variable at a time
- **Log everything** — FC sends telemetry (pitch, PID output, motor commands) to Jetson for post-crash analysis
- **Two people for early tests** — one at the kill switch, one observing
## Parts
| Part | Status |
|------|--------|
| 2x 8" pneumatic hub motors (36 PSI) | ✅ Have |
| 1x hoverboard ESC (FOC firmware) | ✅ Have |
| 1x Drone FC (STM32F745 + MPU-6000) | ✅ Have — balance brain |
| 1x Jetson Nano + Noctua fan | ✅ Have |
| 1x RealSense D435i | ✅ Have |
| 1x RPLIDAR A1M8 | ✅ Have |
| 1x battery pack (36V) | ✅ Have |
| 1x DC-DC 5V converter | ✅ Have |
| 1x DC-DC 12V converter | ✅ Have |
| 1x ESP32-C3 (LED controller) | ⬜ Need (~$3) |
| WS2812B LED strip (60/m) | ⬜ Need |
| BNO055 9-DOF IMU | ✅ Have (spare/backup) |
| MPU6050 | ✅ Have (spare/backup) |
| 1x Big red kill switch (NC, inline with battery) | ⬜ Need |
| 1x Arming button (momentary, with LED) | ⬜ Need |
| 1x Ceiling tether strap + carabiner | ⬜ Need |
| 1x BetaFPV ELRS 2.4GHz 1W TX module | ✅ Have — RC control + kill switch |
| 1x ELRS receiver (matching) | ✅ Have — mounts on FC UART |
### Drone FC Details — GEPRC GEP-F7 AIO
- **MCU:** STM32F722RET6 (216MHz Cortex-M7, 512KB flash, 256KB RAM)
- **IMU:** TDK ICM-42688-P (6-axis, 32kHz gyro, ultra-low noise, SPI) ← the good one!
- **Flash:** 8MB Winbond W25Q64 (blackbox, unused)
- **OSD:** AT7456E (unused)
- **4-in-1 ESC:** Built into AIO board (unused — we use hoverboard ESC)
- **DFU mode:** Hold yellow BOOT button while plugging USB
- **Firmware:** Custom balance firmware (PlatformIO + STM32 HAL)
- **UART pads (confirmed from silkscreen):**
- T1/R1 (bottom) → USART1 (PA9/PA10) → Jetson
- T2/R2 (right top) → USART2 (PA2/PA3) → Hoverboard ESC
- T3/R3 (bottom) → USART3 (PB10/PB11) → ELRS receiver
- T4/R4 (bottom) → UART4 → spare
- T5/R5 (right bottom) → UART5 → spare
## Architecture
```
┌──────────────┐
│ RPLIDAR A1 │ ← 360° scan, top-mounted
└──────┬───────┘
┌──────┴───────┐
│ RealSense │ ← Forward-facing depth+RGB
│ D435i │
├──────────────┤
│ Jetson Nano │ ← AI brain: navigation, person tracking
│ │ Sends velocity commands via UART
├──────────────┤
│ Drone FC │ ← Balance brain: IMU + PID @ 8kHz
│ F745+MPU6000 │ Custom firmware, UART out to ESC
├──────────────┤
│ Battery 36V │
│ + DC-DCs │
├──────┬───────┤
┌─────┤ ESC (FOC) ├─────┐
│ │ Hoverboard │ │
│ └──────────────┘ │
┌──┴──┐ ┌──┴──┐
│ 8" │ │ 8" │
│ LEFT│ │RIGHT│
└─────┘ └─────┘
```
## Self-Balancing Control — Custom Firmware on Drone FC
### Why a Drone FC?
The F745 board is just a premium STM32 dev board with a high-quality IMU (MPU-6000) already soldered on, proper voltage regulation, and multiple UARTs broken out. We write a lean custom balance firmware (~50 lines of C).
### Architecture
```
Jetson (speed+steer via UART1)
Drone FC (F745 + MPU-6000)
│ - Reads IMU @ 8kHz (SPI)
│ - Runs PID balance loop
│ - Mixes balance correction + Jetson commands
│ - Outputs speed+steer via UART2
Hoverboard ESC (FOC firmware)
│ - Receives UART commands
│ - Drives hub motors
Left + Right wheels
```
- **No motor outputs used** — FC talks UART directly to hoverboard ESC
- **Custom firmware only** — no third-party flight software
- **Dead motor output irrelevant** — not using any PWM channels
### Wiring
```
Jetson UART1 Drone FC (UART1)
──────────── ────────────────
TX (Pin 8) ──→ RX
RX (Pin 10) ──→ TX
GND ──→ GND
Drone FC (UART2) Hoverboard ESC
──────────────── ──────────────
TX ──→ RX (serial input)
GND ──→ GND
5V (BEC) ←── ESC 5V out (powers FC)
ELRS Receiver Drone FC (UART3)
───────────── ────────────────
TX ──→ RX
RX ←── TX (for telemetry/binding)
GND ──→ GND
5V ←── 5V
```
### Custom Firmware (STM32 C)
```c
// Core balance loop — runs in timer interrupt @ 1-8kHz
void balance_loop(void) {
// 1. Read pitch angle from MPU-6000 (complementary filter)
float pitch = get_pitch_angle(); // SPI read + filter
// 2. Get velocity command from Jetson (updated async via UART1 RX)
float target_speed = jetson_cmd.speed; // -1000 to 1000
float target_steer = jetson_cmd.steer; // -1000 to 1000
// 3. PID on pitch error
// Target angle shifts with speed command (lean forward = go forward)
float target_angle = target_speed * SPEED_TO_ANGLE_FACTOR;
float error = target_angle - pitch;
integral += error * dt;
integral = clamp(integral, -MAX_I, MAX_I); // anti-windup
float derivative = (error - prev_error) / dt;
prev_error = error;
float output = Kp * error + Ki * integral + Kd * derivative;
// 4. Mix balance + steering → hoverboard ESC UART command
int16_t left = clamp(output + target_steer, -1000, 1000);
int16_t right = clamp(output - target_steer, -1000, 1000);
// 5. Send to hoverboard ESC via UART2
send_hoverboard_cmd(left, right);
// 6. Safety: kill motors if tipped beyond recovery
if (fabs(pitch) > MAX_TILT_DEG) {
send_hoverboard_cmd(0, 0);
disarm();
}
// 7. Safety: RC kill switch (ELRS channel, checked every loop)
if (rc_channels.arm_switch == DISARMED) {
send_hoverboard_cmd(0, 0);
disarm();
}
// 8. Safety: kill if Jetson UART heartbeat lost
if (millis() - jetson_last_rx > JETSON_TIMEOUT_MS) {
send_hoverboard_cmd(0, 0);
disarm();
}
// 8. Safety: clamp output to max allowed speed
left = clamp(left, -max_speed_limit, max_speed_limit);
right = clamp(right, -max_speed_limit, max_speed_limit);
}
```
### Hoverboard ESC UART Protocol
```c
typedef struct {
uint16_t start; // 0xABCD
int16_t speed; // -1000 to 1000 (left)
int16_t steer; // -1000 to 1000 (right)
uint16_t checksum; // XOR of all bytes
} HoverboardCmd;
// 115200 baud, send at loop rate
```
### Jetson → FC Protocol (simple custom)
```c
typedef struct {
uint8_t header; // 0xAA
int16_t speed; // -1000 to 1000
int16_t steer; // -1000 to 1000
uint8_t mode; // 0=idle, 1=balance, 2=follow, 3=RC
uint8_t checksum;
} JetsonCmd;
// 115200 baud, ~50Hz from Jetson is plenty
```
### PID Tuning
| Param | Starting Value | Notes |
|-------|---------------|-------|
| Kp | 30-50 | Main balance response |
| Ki | 0.5-2 | Drift correction |
| Kd | 0.5-2 | Damping oscillation |
| Loop rate | 1-8 kHz | Start at 1kHz, increase if needed |
| Max tilt | ±25° | Beyond this = cut motors, require re-arm |
| JETSON_TIMEOUT_MS | 200 | Kill motors if Jetson stops talking |
| max_speed_limit | 100 | Start at 10% (100/1000), increase gradually |
| SPEED_TO_ANGLE_FACTOR | 0.01-0.05 | How much lean per speed unit |
## LED Subsystem (ESP32-C3)
### Architecture
The ESP32-C3 eavesdrops on the FC→Jetson telemetry UART line (listen-only, one wire).
No extra UART needed on the FC — zero firmware change.
```
FC UART1 TX ──┬──→ Jetson RX
└──→ ESP32-C3 RX (listen-only, same wire)
└──→ WS2812B strip (via RMT peripheral)
```
### Telemetry Format (already sent by FC at 50Hz)
```
T:12.3,P:45,L:100,R:-80,S:3\n
^-- State byte: 0=disarmed, 1=arming, 2=armed, 3=fault
```
ESP32-C3 parses the `S:` field and `L:/R:` for turn detection.
### LED Patterns
| State | Pattern | Color |
|-------|---------|-------|
| Disarmed | Slow breathe | White |
| Arming | Fast blink | Yellow |
| Armed idle | Solid | Green |
| Turning left | Sweep left | Orange |
| Turning right | Sweep right | Orange |
| Braking | Flash rear | Red |
| Fault | Triple flash | Red |
| RC signal lost | Alternating flash | Red/Blue |
### Turn/Brake Detection (on ESP32-C3)
```
if (L - R > threshold) → turning right
if (R - L > threshold) → turning left
if (L < -threshold && R < -threshold) braking
```
### Wiring
```
FC UART1 TX pin ──→ ESP32-C3 GPIO RX (e.g. GPIO20)
ESP32-C3 GPIO8 ──→ WS2812B data in
ESC 5V BEC ──→ ESP32-C3 5V + WS2812B 5V
GND ──→ Common ground
```
### Dev Tools
- **Flashing:** STM32CubeProgrammer via USB (DFU mode) or SWD
- **IDE:** PlatformIO + STM32 HAL, or STM32CubeIDE
- **Debug:** SWD via ST-Link (or use FC's USB as virtual COM for printf debug)
## Physical Design
### Frame: Vertical Tower
```
SIDE VIEW FRONT VIEW
┌───────────┐ ┌─────────────────┐
│ RPLIDAR │ ~500mm │ RPLIDAR │
├───────────┤ ├─────────────────┤
│ RealSense │ ~400mm │ [RealSense] │
├───────────┤ ├─────────────────┤
│ Jetson │ ~300mm │ [Jetson] │
├───────────┤ ├─────────────────┤
│ Drone FC │ ~200mm │ [Drone FC] │
├───────────┤ ├─────────────────┤
│ Battery │ ~100mm │ [Battery] │
│ + ESC │ LOW! │ [ESC+DCDC] │
├─────┬─────┤ ├──┬──────────┬───┤
│ │ │ │ │ │ │
─┘ └─────┘─ ─┘ 8" 8" └──┘─
═══════════════ ═══ ═══
GROUND L R
```
### Key Dimensions
- **Height:** ~500-550mm total (sensor tower top)
- **Width:** ~350mm (axle to axle, constrained by motors)
- **Depth:** ~150-200mm (thin profile for doorways)
- **Weight target:** <10kg including battery
- **Center of gravity:** AS LOW AS POSSIBLE — battery + ESC at bottom
### Critical: Center of Mass
- Battery is the heaviest component → mount at axle height or below
- Jetson + sensors are light → can go higher
- Lower CoG = easier to balance, less aggressive PID needed
- If CoG is too high → oscillations, falls easily
### Frame Material
- **Main spine:** Aluminum extrusion 2020, vertical
- **Motor mount plate:** 3D printed PETG, 6mm thick, reinforced
- **Component shelves:** 3D printed PETG, bolt to spine
- **Fender/bumper:** 3D printed TPU (flexible, absorbs falls)
### 3D Printed Parts
| Part | Size (mm) | Material | Qty |
|------|-----------|----------|-----|
| Motor mount plate | 350×150×6 | PETG 80% | 1 |
| Battery shelf | 200×100×40 | PETG 60% | 1 |
| ESC mount | 150×100×15 | PETG 40% | 1 |
| Jetson shelf | 120×100×15 | PETG 40% | 1 |
| Sensor tower top | 120×120×10 | ASA 80% | 1 |
| LIDAR standoff | Ø80×80 | ASA 40% | 1 |
| RealSense bracket | 100×50×40 | PETG 60% | 1 |
| FC mount (vibration isolated) | 30×30×15 | TPU+PETG | 1 |
| Bumper front | 350×50×30 | TPU 30% | 1 |
| Bumper rear | 350×50×30 | TPU 30% | 1 |
| Handle (for carrying) | 150×30×30 | PETG 80% | 1 |
| Kill switch mount | 60×60×40 | PETG 80% | 1 |
| Tether anchor point | 50×50×20 | PETG 100% | 1 |
| LED diffuser ring | Ø120×15 | Clear PETG 30% | 1 |
| ESP32-C3 mount | 30×25×10 | PETG 40% | 1 |
## Software Stack
### Jetson Nano
- **OS:** JetPack 4.6.1 (Ubuntu 18.04)
- **ROS2 Humble** (or Foxy) for:
- `nav2` — navigation stack
- `slam_toolbox` — 2D SLAM from LIDAR
- `realsense-ros` — depth camera
- `rplidar_ros` — LIDAR driver
- **Person following:** SSD-MobileNet-v2 via TensorRT (~20 FPS)
- **Balance commands:** ROS topic → UART bridge to drone FC
### Modes
1. **Idle** — self-balancing in place, waiting for command
2. **RC** — manual control via ELRS radio (primary testing mode)
3. **Follow** — tracks person with RealSense, follows at set distance
4. **Explore** — autonomous SLAM mapping, builds house map
5. **Patrol** — follows waypoints on saved map
6. **Dock** — returns to charging station (future)
**Mode priority:** RC override always wins. If radio sends stick input, it overrides Jetson commands. Kill switch overrides everything.
## Build Order
### Phase 1: Balance (Week 1)
**Safety first — no motor spins without kill switch + tether in place.**
- [ ] Install hardware kill switch inline with 36V battery (NC — press to kill)
- [ ] Set up ceiling tether point above test area (rated for >15kg)
- [ ] Clear test area: 3m radius, no loose items, shoes on
- [ ] Set up PlatformIO project for STM32F745 (STM32 HAL)
- [ ] Write MPU-6000 SPI driver (read gyro+accel, complementary filter)
- [ ] Write PID balance loop with ALL safety checks:
- ±25° tilt cutoff → disarm, require manual re-arm
- Watchdog timer (50ms hardware WDT)
- Speed limit at 10% (max_speed_limit = 100)
- Arming sequence (3s hold while upright)
- [ ] Write hoverboard ESC UART output (speed+steer protocol)
- [ ] Flash firmware via USB DFU (boot0 jumper on FC)
- [ ] Write ELRS CRSF receiver driver (UART3, parse channels + arm switch)
- [ ] Bind ELRS TX ↔ RX, verify channel data on serial monitor
- [ ] Map radio: CH1=steer, CH2=speed, CH5=arm/disarm switch
- [ ] **Bench test first** — FC powered but ESC disconnected, verify IMU reads + PID output + RC channels on serial monitor
- [ ] Wire FC UART2 → hoverboard ESC UART
- [ ] Build minimal frame: motor plate + battery + ESC + FC
- [ ] Power FC from ESC 5V BEC
- [ ] **First balance test — TETHERED, kill switch in hand, 10% speed limit**
- [ ] Tune PID at 10% speed until stable tethered for 5+ minutes
- [ ] Gradually increase speed limit (10% increments, 5 min stable each)
### Phase 2: Brain (Week 2)
- [ ] Mount Jetson + power (DC-DC 5V)
- [ ] Set up JetPack + ROS2
- [ ] Add Jetson UART RX to FC firmware (receive speed+steer commands)
- [ ] Wire Jetson UART1 → FC UART1
- [ ] Python serial bridge: send speed+steer, read telemetry
- [ ] Test: keyboard teleoperation while balancing
### Phase 3: Senses (Week 3)
- [ ] Mount RealSense + RPLIDAR
- [ ] SLAM mapping of a room
- [ ] Person detection + tracking (SSD-MobileNet-v2 via TensorRT)
- [ ] Follow mode: maintain 1.5m distance from person
### Phase 4: Polish (Week 4)
- [ ] Print proper enclosures, bumpers, diffuser ring
- [ ] Wire ESP32-C3 to FC telemetry TX line (listen-only tap)
- [ ] Flash ESP32-C3: parse telemetry, drive WS2812B via RMT
- [ ] Mount LED strip around frame with diffuser
- [ ] Test all LED patterns: disarmed/arming/armed/turning/fault
- [ ] Speaker for audio feedback
- [ ] WiFi status dashboard (ESP32-C3 can serve this too)
- [ ] Emergency stop button

275
docs/board-viz.html Normal file
View File

@ -0,0 +1,275 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GEPRC GEP-F722-45A AIO — Board Layout</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; color: #eee; font-family: 'Courier New', monospace; display: flex; flex-direction: column; align-items: center; padding: 20px; }
h1 { color: #e94560; margin-bottom: 5px; font-size: 1.4em; }
.subtitle { color: #888; margin-bottom: 20px; font-size: 0.85em; }
.container { display: flex; gap: 30px; align-items: flex-start; flex-wrap: wrap; justify-content: center; }
.board-wrap { position: relative; }
.board { width: 400px; height: 340px; background: #1a472a; border: 3px solid #333; border-radius: 8px; position: relative; box-shadow: 0 0 20px rgba(0,0,0,0.5); }
.board::before { content: 'GEPRC GEP-F722-45A AIO'; position: absolute; top: 8px; left: 50%; transform: translateX(-50%); color: #fff3; font-size: 10px; letter-spacing: 2px; }
/* Mounting holes */
.mount { width: 10px; height: 10px; background: #111; border: 2px solid #555; border-radius: 50%; position: absolute; }
.mount.tl { top: 15px; left: 15px; }
.mount.tr { top: 15px; right: 15px; }
.mount.bl { bottom: 15px; left: 15px; }
.mount.br { bottom: 15px; right: 15px; }
/* MCU */
.mcu { width: 80px; height: 80px; background: #222; border: 1px solid #555; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; align-items: center; justify-content: center; font-size: 9px; color: #aaa; text-align: center; line-height: 1.3; }
.mcu .dot { width: 5px; height: 5px; background: #666; border-radius: 50%; position: absolute; top: 4px; left: 4px; }
/* IMU */
.imu { width: 32px; height: 32px; background: #333; border: 1px solid #e94560; position: absolute; top: 85px; left: 60px; display: flex; align-items: center; justify-content: center; font-size: 7px; color: #e94560; }
.imu::after { content: 'CW90°'; position: absolute; bottom: -14px; color: #e94560; font-size: 8px; white-space: nowrap; }
/* Arrow showing CW90 rotation */
.rotation-arrow { position: absolute; top: 72px; left: 55px; color: #e94560; font-size: 18px; }
/* Pads */
.pad { position: absolute; display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer; }
.pad .dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid; display: flex; align-items: center; justify-content: center; font-size: 7px; font-weight: bold; }
.pad:hover .label { color: #fff; }
.pad .label { transition: color 0.2s; }
.pad .sublabel { font-size: 8px; color: #888; }
/* UART colors */
.uart1 .dot { background: #2196F3; border-color: #64B5F6; }
.uart2 .dot { background: #FF9800; border-color: #FFB74D; }
.uart3 .dot { background: #9C27B0; border-color: #CE93D8; }
.uart4 .dot { background: #4CAF50; border-color: #81C784; }
.uart5 .dot { background: #F44336; border-color: #EF9A9A; }
/* Component dots */
.comp { position: absolute; font-size: 9px; display: flex; align-items: center; gap: 4px; }
.comp .icon { width: 10px; height: 10px; border-radius: 2px; }
/* LED */
.led-blue { position: absolute; width: 8px; height: 8px; background: #2196F3; border-radius: 50%; box-shadow: 0 0 8px #2196F3; top: 45px; right: 50px; }
.led-label { position: absolute; top: 36px; right: 30px; font-size: 8px; color: #64B5F6; }
/* Boot button */
.boot-btn { position: absolute; width: 16px; height: 10px; background: #b8860b; border: 1px solid #daa520; border-radius: 2px; bottom: 45px; right: 40px; }
.boot-label { position: absolute; bottom: 32px; right: 30px; font-size: 8px; color: #daa520; }
/* USB */
.usb { position: absolute; width: 30px; height: 14px; background: #444; border: 2px solid #777; border-radius: 3px; bottom: -3px; left: 50%; transform: translateX(-50%); }
.usb-label { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); font-size: 8px; color: #999; }
/* Connector pads along edges */
/* Bottom row: T1 R1 T3 R3 */
.pad-t1 { bottom: 20px; left: 40px; }
.pad-r1 { bottom: 20px; left: 80px; }
.pad-t3 { bottom: 20px; left: 140px; }
.pad-r3 { bottom: 20px; left: 180px; }
/* Right side: T2 R2 */
.pad-t2 { right: 20px; top: 80px; flex-direction: row-reverse; }
.pad-r2 { right: 20px; top: 110px; flex-direction: row-reverse; }
/* Top row: T4 R4 T5 R5 */
.pad-t4 { top: 30px; left: 40px; }
.pad-r4 { top: 30px; left: 80px; }
.pad-t5 { top: 30px; right: 100px; flex-direction: row-reverse; }
.pad-r5 { top: 30px; right: 55px; flex-direction: row-reverse; }
/* ESC pads (motor outputs - not used) */
.esc-pads { position: absolute; left: 20px; top: 140px; }
.esc-pads .esc-label { font-size: 8px; color: #555; }
/* Legend */
.legend { background: #16213e; padding: 15px 20px; border-radius: 8px; min-width: 280px; }
.legend h2 { color: #e94560; font-size: 1.1em; margin-bottom: 10px; border-bottom: 1px solid #333; padding-bottom: 5px; }
.legend-item { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; }
.legend-item .swatch { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
.legend-item .arrow { color: #888; font-size: 10px; }
.legend-section { margin-top: 12px; padding-top: 8px; border-top: 1px solid #333; }
.legend-section h3 { font-size: 0.9em; color: #888; margin-bottom: 6px; }
/* Orientation guide */
.orient { margin-top: 20px; background: #16213e; padding: 15px 20px; border-radius: 8px; width: 100%; max-width: 710px; }
.orient h2 { color: #4CAF50; font-size: 1.1em; margin-bottom: 10px; }
.orient-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.orient-item { font-size: 12px; padding: 6px 10px; background: #1a1a2e; border-radius: 4px; }
.orient-item .dir { color: #4CAF50; font-weight: bold; }
/* Axis overlay */
.axis { position: absolute; }
.axis-x { top: 50%; right: -60px; color: #F44336; font-size: 12px; font-weight: bold; }
.axis-y { bottom: -30px; left: 50%; transform: translateX(-50%); color: #4CAF50; font-size: 12px; font-weight: bold; }
.axis-arrow-x { position: absolute; top: 50%; right: -45px; transform: translateY(-50%); width: 30px; height: 2px; background: #F44336; }
.axis-arrow-x::after { content: '▶'; position: absolute; right: -12px; top: -8px; color: #F44336; }
.axis-arrow-y { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); width: 2px; height: 20px; background: #4CAF50; }
.axis-arrow-y::after { content: '▼'; position: absolute; bottom: -14px; left: -5px; color: #4CAF50; }
.note { margin-top: 15px; color: #888; font-size: 11px; text-align: center; max-width: 710px; }
.note em { color: #e94560; font-style: normal; }
</style>
</head>
<body>
<h1>🤖 GEPRC GEP-F722-45A AIO — SaltyLab Pinout</h1>
<p class="subtitle">STM32F722RET6 + ICM-42688-P | Betaflight target: GEPR-GEPRC_F722_AIO</p>
<div class="container">
<div class="board-wrap">
<div class="board">
<!-- Mounting holes -->
<div class="mount tl"></div>
<div class="mount tr"></div>
<div class="mount bl"></div>
<div class="mount br"></div>
<!-- MCU -->
<div class="mcu"><div class="dot"></div>STM32<br>F722RET6<br>216MHz</div>
<!-- IMU -->
<div class="imu">ICM<br>42688</div>
<div class="rotation-arrow"></div>
<!-- LED -->
<div class="led-blue"></div>
<div class="led-label">LED PC4</div>
<!-- Boot button -->
<div class="boot-btn"></div>
<div class="boot-label">BOOT 🟡</div>
<!-- USB -->
<div class="usb"></div>
<div class="usb-label">USB-C (DFU)</div>
<!-- UART Pads - Bottom -->
<div class="pad pad-t1 uart1">
<div class="dot">T</div>
<span class="label">T1<br><span class="sublabel">PA9</span></span>
</div>
<div class="pad pad-r1 uart1">
<div class="dot">R</div>
<span class="label">R1<br><span class="sublabel">PA10</span></span>
</div>
<div class="pad pad-t3 uart3">
<div class="dot">T</div>
<span class="label">T3<br><span class="sublabel">PB10</span></span>
</div>
<div class="pad pad-r3 uart3">
<div class="dot">R</div>
<span class="label">R3<br><span class="sublabel">PB11</span></span>
</div>
<!-- UART Pads - Right -->
<div class="pad pad-t2 uart2">
<span class="label">T2<br><span class="sublabel">PA2</span></span>
<div class="dot">T</div>
</div>
<div class="pad pad-r2 uart2">
<span class="label">R2<br><span class="sublabel">PA3</span></span>
<div class="dot">R</div>
</div>
<!-- UART Pads - Top -->
<div class="pad pad-t4 uart4">
<div class="dot">T</div>
<span class="label">T4<br><span class="sublabel">PC10</span></span>
</div>
<div class="pad pad-r4 uart4">
<div class="dot">R</div>
<span class="label">R4<br><span class="sublabel">PC11</span></span>
</div>
<div class="pad pad-t5 uart5">
<span class="label">T5<br><span class="sublabel">PC12</span></span>
<div class="dot">T</div>
</div>
<div class="pad pad-r5 uart5">
<span class="label">R5<br><span class="sublabel">PD2</span></span>
<div class="dot">R</div>
</div>
<!-- ESC motor pads label -->
<div class="esc-pads">
<div class="esc-label">M1-M4 (unused)<br>PC6-PC9</div>
</div>
<!-- Board axes -->
<div class="axis-arrow-x"></div>
<div class="axis axis-x">X →<br><span style="font-size:9px;color:#888">board right</span></div>
<div class="axis-arrow-y"></div>
<div class="axis axis-y">Y ↓ (board forward = tilt axis)</div>
</div>
</div>
<div class="legend">
<h2>🔌 UART Assignments</h2>
<div class="legend-item">
<div class="swatch" style="background:#2196F3"></div>
<span><b>USART1</b> T1/R1 → Jetson Nano</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#FF9800"></div>
<span><b>USART2</b> T2 → Hoverboard ESC (TX only)</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#9C27B0"></div>
<span><b>I2C2</b> T3/R3 → Baro/Mag (reserved)</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#4CAF50"></div>
<span><b>UART4</b> T4/R4 → ELRS RX (CRSF)</span>
</div>
<div class="legend-item">
<div class="swatch" style="background:#F44336"></div>
<span><b>UART5</b> T5/R5 → Debug/spare</span>
</div>
<div class="legend-section">
<h3>📡 SPI Bus</h3>
<div class="legend-item">
<span>SPI1: PA5/PA6/PA7 → IMU (CS: <em style="color:#e94560">PA15</em>)</span>
</div>
<div class="legend-item">
<span>SPI2: PB13-15 → OSD MAX7456</span>
</div>
<div class="legend-item">
<span>SPI3: PB3-5 → Flash W25Q128</span>
</div>
</div>
<div class="legend-section">
<h3>⚡ Other</h3>
<div class="legend-item">
<span>🔵 LED: PC4 | 📢 Beeper: PC15</span>
</div>
<div class="legend-item">
<span>🔋 VBAT: PC2 | ⚡ Current: PC1</span>
</div>
<div class="legend-item">
<span>💡 LED Strip: PA1 (WS2812)</span>
</div>
<div class="legend-item">
<span>📍 EXTI (IMU data-ready): PA8</span>
</div>
</div>
</div>
</div>
<div class="orient">
<h2>🧭 IMU Orientation (CW90° from chip to board)</h2>
<div class="orient-grid">
<div class="orient-item"><span class="dir">Board Forward</span> (tilt for balance) = Chip's +Y axis</div>
<div class="orient-item"><span class="dir">Board Right</span> = Chip's -X axis</div>
<div class="orient-item"><span class="dir">Board Pitch Rate</span> = -Gyro X (raw)</div>
<div class="orient-item"><span class="dir">Board Accel Forward</span> = Accel Y (raw)</div>
</div>
</div>
<p class="note">
⚠️ Pad positions are <em>approximate</em> — check the physical board silkscreen for exact locations.
The CW90 rotation is handled in firmware (mpu6000.c). USB-C at bottom edge for DFU flashing.
</p>
</body>
</html>