← milestones

build steps

Bench Bot

The dry bench build: assemble and bring up all the avionics, then power-on and prove every subsystem before anything gets wet.

16 of 16 steps100%
📦 materials: all 30 in hand
  1. check

    All Group A parts in hand

    Before you start, make sure every Bench Bot part is physically here and accounted for. It is miserable to get halfway into an assembly and find a missing cable or screw.

    What good looks like

    1. Walk the Parts for this step list below (and the BOM grouped by Bench bot) against what is actually on your bench.
    2. Check the easy-to-forget small stuff: USB-C cables, the NVMe mounting screw, JST / DF13 sensor cables, and the bench PSU leads.
    3. Note anything still on the way or to order (amber or red dot) so you can plan around it.

    Done when

    Every Bench Bot part is in hand and inventoried, and you know exactly what (if anything) is still missing. You do not need literally everything to begin — just know the gaps before you commit to a sub-assembly that needs them.

  2. do

    Unbox & inventory Group A

    Open every Group A box and lay the parts out. The goal is a known-good inventory and zero surprises later.

    Steps

    1. Open each box and keep the packaging (especially anti-static bags) until you are sure you are keeping the part.
    2. Check each part against the BOM (Bench bot group) and confirm the exact variant — Jetson Orin Nano Super 8 GB, Pixhawk 6C, the right T200 cable length, and so on.
    3. Photograph anything damaged, bent, or wrong before you power or install it. You will want that photo for a return. Attach it to this step with the photo button.
    4. Bag and label the small parts (screws, jumpers, connectors) so they do not wander off.
    5. Set the bench essentials within reach: the current-limited bench PSU, the multimeter, the NVMe SSD, and USB-C cables.

    Watch out for

    • Do not connect the battery pack for any bench work. Everything runs off the bench supply so you can control the current.

    Done when

    Every Group A part is unboxed, checked against the BOM, undamaged, and laid out where you can find it.

  3. check

    Bench rig ready

    📦 all 4 in hand

    Your bench setup is what keeps a small wiring mistake from becoming a dead board. Confirm all of this before powering anything.

    What good looks like

    1. Anti-static: a mat plus wrist strap, or at minimum touch a grounded metal surface before handling any board.
    2. Light and magnification: you will be reading tiny silkscreen labels and connector pins.
    3. Bench PSU: adjustable and current-limited, and you know how to set the current limit (see the Power module bring-up step).
    4. Multimeter: working, set to DC volts, with continuity mode handy for finding shorts.
    5. Battery disconnected: bench power only.

    Done when

    You can power a board, read its rails on the multimeter, and kill power fast if something looks wrong.

  4. do

    Boot the Jetson Orin from NVMe

    📦 all 4 in hand

    The Jetson Orin Nano (the 8 GB Super) is the AI brain — it runs the vision and autonomy software. Two parts: install the operating system (Ubuntu, which NVIDIA calls JetPack / Jetson Linux) onto the fast NVMe SSD, then add the AI libraries. Newer JetPack makes the OS install easy — you build a bootable USB installer on any PC and run it on the Jetson. No Linux host and no recovery-mode jumper needed.

    What you'll need

    • The Jetson Orin Nano Super 8 GB dev kit (module on its carrier).
    • The NVMe SSD (M.2 stick) + its mounting screw — the OS goes here.
    • A USB stick (8 GB+) for the installer, plus a monitor + keyboard for the Jetson's first boot.
    • Any PC (Windows / Mac / Linux) to write the installer, and your free NVIDIA developer account.

    Step 1 — Install the NVMe SSD

    1. Power off. Fit the M.2 NVMe stick into the carrier's slot at a slight angle, press it flat, and fasten the screw. It should sit flush.

    Step 2 — Build the USB installer (on the PC)

    1. Download NVIDIA's latest JetPack image for the Orin Nano from the Jetson download page.
    2. Write it to the USB stick with a disk imager such as balenaEtcher.
    3. New kits usually boot the USB straight away. If yours won't, update its bootloader / QSPI firmware first per NVIDIA's Orin Nano init guide, then retry.

    Step 3 — Boot the Jetson from USB and install

    1. Plug in the USB stick, monitor, and keyboard, then power on the Jetson.
    2. It boots the installer — choose the NVMe as the install target.
    3. Let it finish, remove the USB stick, and reboot.

    Step 4 — First boot (Ubuntu setup)

    1. Walk through the Ubuntu wizard: username, password, locale, timezone, and Wi-Fi / Ethernet.
    2. If it offers Ubuntu Pro, skip it — on a Jetson the kernel and CUDA come from NVIDIA, so Pro adds nothing here.

    Step 5 — Add the AI libraries (the "SDK")

    The OS gives you Ubuntu; this adds CUDA, cuDNN, TensorRT, VPI — what the vision/autonomy code needs. On the Jetson:

    1. sudo apt update
    2. sudo apt install nvidia-jetpack
    3. Spot-check: nvcc --version (CUDA) and dpkg -l | grep -i tensorrt.

    Step 6 — Verify and set performance

    1. Install the monitor: sudo pip3 install -U jetson-stats, reboot, then run jtop. Its INFO tab confirms the JetPack version + CUDA / cuDNN / TensorRT.
    2. Set the power mode: sudo nvpmodel -q shows the current one; list options in jtop's CTRL tab. A stock flash maxes at 15 W — fine for the bench. The full 25 W "MAXN SUPER" mode (the 40 → ~67 TOPS boost) only appears after a bootloader/firmware update, so unlock that before the heavy AI/vision load, not for bench work. sudo jetson_clocks pins clocks high (doesn't persist a reboot).

    Step 7 — Get on the network

    1. On the Jetson, run ip a and note its IP address.
    2. From your PC: ssh username@that-ip. SSH is on by default; if it refuses, run sudo systemctl enable --now ssh on the Jetson.

    Watch out for

    • Don't blanket `sudo apt upgrade`. NVIDIA pins CUDA and the kernel; an aggressive upgrade is the classic way to break a working JetPack. Install nvidia-jetpack and apply updates deliberately.
    • No hardware video encoder (NVENC) on the Orin Nano. Let the cameras encode onboard (the exploreHD does H.264 / H.265) rather than encoding video on the Jetson.
    • Confirm it's on NVMe, not SD: lsblk should show the root filesystem on nvme0n1.
    • Fallback (older boards, or if the USB won't take): NVIDIA SDK Manager on an x86 Ubuntu PC with the Jetson in Force Recovery mode (jumper FC RECGND) — slower, but it always works.
    • Upgrade path: if the vision workload outgrows 8 GB, the Orin NX 16 GB module is a pin-compatible drop-in on this same carrier — swap the module, reflash. Roughly 2× the RAM and AI throughput, plus hardware encode.

    Done when

    The Jetson boots from NVMe, jtop shows JetPack with CUDA / TensorRT installed and MAXN power mode, and you can ssh in from your PC — which sets up the next check, Jetson is up.

    Guides

    notes from the build

    Couldn't flash the firmware with 256GB USB C jump drive (the jetson orin nano didnt recognize it as a boot option). Retried with 32GB USB A jump drive and it worked right away (no network connection needed). Once ubuntu was installed it walked me through wifi connection.

  5. check

    Jetson is up

    Confirm the Jetson is healthy before you build anything on top of it.

    What good looks like

    1. Boots to desktop or headless with no errors.
    2. Reachable over SSH from your host: ssh username@jetson-ip (find the address with ip a on the Jetson).
    3. Running from NVMe, not SD: lsblk shows the root filesystem on nvme0n1.
    4. Reports telemetry: tegrastats streams CPU, GPU, and temperature lines.
    5. AI libraries installed: jtopINFO tab shows CUDA, cuDNN, TensorRT with version numbers (or nvcc --version prints CUDA). If they read *missing*, run sudo apt install nvidia-jetpack.
    6. Power mode: sudo nvpmodel -q. A stock flash maxes at 15 W — fine for the bench. The 25 W "MAXN SUPER" mode (full ~67 TOPS) only shows up after a bootloader/firmware update; unlock it before the AI/vision load, not for bench bring-up. (If you only see 7W/15W, Super isn't unlocked yet.)

    Good to know

    • `OpenCV … with CUDA: NO` is normal. JetPack ships OpenCV CPU-only; your inference runs through TensorRT/CUDA, not OpenCV's GPU module. Only rebuild OpenCV with CUDA (a multi-hour compile) if you later lean on GPU classical-CV *and* profiling proves it's a bottleneck.
    • `nvpmodel` persists across reboots; `jetson_clocks` does not — re-run sudo jetson_clocks after a reboot (or add a startup service) if you want clocks pinned at max.

    If it fails

    • SSH refused: enable it with sudo systemctl enable --now ssh on the Jetson, then retry.
    • Not on nvme0n1: you likely flashed the wrong storage target — reflash with the Storage Device set to NVMe.
    • Libraries still *missing* after install: confirm the Jetson has internet, then sudo apt update && sudo apt install nvidia-jetpack again.

    Done when

    The Jetson boots from NVMe, you can ssh in, and tegrastats is printing live.

  6. do

    Mount the bench bot on an HDPE plate

    📦 all 2 in hand

    Give the bench bot a home. Now that the Jetson is verified, cut a bench board from the HDPE sheet (#65) and mount the Jetson on it. This plate becomes the base for everything that follows — the flight controller, power module, sensors, and thruster ESC all mount here — so a clean, reconfigurable layout now saves hours later.

    Cut the board - From the 1/2" HDPE sheet (#65, 24×48), cut a piece for the bench — ~12 × 18 in is a good start (room for the Jetson plus the parts coming next). You'll still have most of the sheet left for the frame, skids (#86), and fins (#33). - Deburr the edges. HDPE cuts cleanly with a circular/table saw or a jigsaw.

    Mount the Jetson — tap the plate Because the sheet is 1/2" thick, thread the standoffs directly into the HDPE — no screws from underneath. - Position the Jetson carrier, leaving clearance around the fan intake and the edge connectors (USB, HDMI, Ethernet, USB-C power). Mark its 4 mounting holes. - Drill the marks with the tap-drill size for your standoff thread (measure your standoffs and the carrier holes — the Orin Nano carrier is commonly M2.5). - Tap each hole to the standoff's thread and screw the standoffs in hand-tight. Thick HDPE holds threads well. - Set the Jetson on the standoffs and secure it with the small top screws into the standoffs. Snug, not gorilla-tight — these are plastic threads.

    Leave room to grow - Rough out where the Pixhawk (#1), power module (#88), Ethernet switch (#8), and sensors will sit — keep the fan clear and group parts so the wiring stays short. - Velcro or zip-tie the battery so it's easy to swap/remove. - Keep the board near bench power and label your leads as you go.

    Good to know - Nothing bonds to HDPE (glue/tape won't stick) — that's why we tap/screw. - Prefer nylon standoffs: non-conductive, so the Jetson's exposed underside (NVMe + Wi-Fi card) can't short on anything. - This is the bench mule, not the flight chassis — the in-hull electronics later ride a tray inside the 4" aluminum tube (#18).

  7. do

    Flash ArduSub onto the Pixhawk 6C

    📦 all 1 in hand

    ArduSub is the autopilot firmware that drives the thrusters, reads the IMU, compass, and depth sensor, and speaks MAVLink. You flash and configure it with QGroundControl (QGC) on your laptop.

    What you'll need

    • The Pixhawk 6C and a USB-C cable.
    • QGroundControl installed on your laptop (Windows, Mac, or Linux).

    Steps

    1. Install QGroundControl on your laptop.
    2. Connect the Pixhawk 6C over USB-C. Nothing else powered yet.
    3. In QGC open Vehicle Setup > Firmware, then unplug and replug the board when prompted so QGC catches it in its bootloader.
    4. Choose ArduSub and flash the latest stable release (verify it supports the 6C).
    5. After it reboots, set a frame: pick the standard Vectored 6-thruster frame for now — on the bench you only need it to arm and drive one motor. The real custom layout comes later.
    6. Calibrate the accelerometer (hold the board in each orientation as prompted) and the compass (you will redo the compass once it is mounted in the hull, away from magnets and motors).
    7. Skip RC / radio calibration — you are not driving this with a hand transmitter.

    Watch out for

    • The goal here is just: armed, talking MAVLink, sensors reading. The geometry does not need to be perfect yet.

    Done when

    QGC connects to the Pixhawk, shows a heartbeat, and the running firmware is ArduSub.

    Guides

  8. check

    Pixhawk heartbeat & sensors sane

    📦 all 4 in hand

    With the Pixhawk connected over USB in QGroundControl, confirm the basics.

    What good looks like

    1. Heartbeat: QGC connects and shows the vehicle (no "waiting for vehicle").
    2. IMU: the attitude indicator moves correctly when you tilt the board.
    3. Compass: heading changes as you rotate the board and roughly tracks reality after calibration.
    4. Depth / barometer (Bar30): reads a plausible pressure at rest; in air it shows about surface level.
    5. No red alarms in the QGC toolbar. Some "no RC" or "no GPS" messages are normal on the bench and are fine.

    If it fails

    • A wild or missing sensor: re-run the relevant calibration, and check the sensor's cable and I2C connection. Fix it now — everything downstream trusts these readings.

    Done when

    QGC shows a heartbeat and the IMU, compass, and depth sensor all read plausible values.

    Guide

  9. do

    Power module bring-up (current-limited)

    📦 all 3 in hand

    The smoke-test: bring up the Holybro PM08-CAN power module on the bench, current-limited, so a wiring mistake trips the current limit instead of frying a board. Go slow.

    What connects where The PM08-CAN is a DroneCAN module — it powers the flight controller AND reports voltage/current over the CAN bus (not the analog power pins). - Bench PSU → module INPUT (the fat XT90 / ring-terminal leads). Match polarity — red +, black −. Reversed is the one mistake the current limit will NOT save you from. - Module → 6C `POWER1` — the 6-pin power branch (2× 5V + 2× GND; the analog current/voltage pins go unused because sensing is over CAN). - Module → 6C `CAN1` — the 4-pin branch (CAN-H / CAN-L) that carries telemetry. - Use the module's own split cable. Do not splice the FC connectors — a mis-pinned power lead kills the board.

    Set up the bench PSU (Jesverty SPS-3030H) — output OFF The knobs are encoders: press to select a digit, rotate to change it. 1. Power on the PSU. Make sure OUT is OFF (terminals dead while you set up). 2. Voltage → ~15 V (the 4S pack range is ~14–16.8 V). 3. Current → 0.50 A — this is your safety net.

    Prove the current limit BEFORE connecting the module 1. Test leads in the + / − terminals; press OUT on. 2. Briefly touch the lead tips together (a dead short). The supply should snap to CC mode and hold at ~0.50 A with voltage near 0 — that is the protection working. 3. Separate the leads; OUT off.

    Bring it up 1. OUT off. Connect PSU + → module +, − → module − (double-check polarity). Unplug USB from the 6C so it is powered by the module only. 2. Press OUT and watch the A display: - Healthy: a small steady draw (~0.1 A idle), voltage holds, the 6C PWR LED lights. - Fault: current pegs at 0.50 A and voltage collapses → short. OUT off, find it, fix it, retry.

    Done when The 6C boots on module power alone at a sane idle current. On this build we measured 0.11 A @ 15 V — textbook. (Measure the exact rails + battery telemetry in the next check.)

    Guide - Holybro PM08-CAN power module - Jesverty SPS-3030H manual

  10. check

    Rails in range

    Confirm the power system is healthy — don't settle for "it didn't smoke." Measure and verify.

    What good looks like 1. FC runs on module power with USB unplugged, booting cleanly. 2. Idle current low and steady (~0.1 A at pack voltage) — no climbing or oscillating draw. 3. Nothing hot — touch-check the power module and the 6C after a few minutes. 4. QGroundControl reports the battery over DroneCAN (see below).

    Make QGC read the battery (DroneCAN) The PM08-CAN reports over the CAN bus, so QGC won't show it like an analog module. Set these three parameters, then reboot the flight controller — the CAN driver only initializes on boot: - BATT_MONITOR = 8 (DroneCAN-BatteryInfo) - CAN_P1_DRIVER = 1 (First driver — matches the CAN1 port) - CAN_D1_PROTOCOL = 1 (DroneCAN)

    After the reboot, the battery voltage should read your pack voltage. Nudge the PSU voltage knob a touch and watch QGC follow it — that proves it's reading the real module, not a placeholder.

    Done when Every check passes and QGC shows the battery voltage matching the PSU. On this build we saw 14.95 V against a 15 V setpoint — power subsystem verified.

    If it fails - No voltage after the reboot → check the module's CAN status LED, reseat the CAN1 connector, and confirm all three params saved. A mismatched CAN cable can silently swap CAN-H / CAN-L. - Any current spike or a hot part → power down and find the fault before continuing.

    notes from the build

    Lost usb connection to pixhawk after powering on PSU by itself. Turned out to be USB issue on PC (connection restored after reboot). After all came back up, battery value reported correctly.

  11. do

    Sensors on I²C

    📦 all 6 in hand

    This step brings up Danu's sensor suite — and, just as important, puts each sensor where the thing that reads it can actually reach it. There's one architecture rule that trips almost everyone up, so read this part first; it changes how you wire.

    The rule: two buses, two readers

    One hard fact and one deliberate choice decide the layout:

    • The Pixhawk (ArduSub) cannot read through an I²C multiplexer. ArduSub's depth and compass drivers scan the bus directly — they have no idea a mux exists. So the sensors ArduSub needs to *fly* live on the Pixhawk's own I²C port, with no mux in between.
    • **The two depth sensors have *different* addresses — the primary Bar30XT is a Keller unit at 0x40, the backup Bar30 is an MS5837 at 0x76** — so they *could* share one bus. We split them anyway, on purpose: ArduSub reads the primary; the Jetson reads its own backup, so a Pixhawk fault can't blind the supervisor on depth. *(Confirmed on the bench: the Bar30XT enumerates under the Keller bit of BARO_PROBE_EXT, not MS5837.)*

    So the suite splits cleanly:

    • Pixhawk I²C port → for ArduSub: the primary depth (Bar30XT) + the compass (RM3100) — the two sensors the flight controller navigates on. (You already brought these up in the heartbeat check; here we just confirm them.)
    • Jetson, through the TCA9548A mux → for monitoring: the backup depth (Bar30) now, plus water temp and the tube seal sensors later. This is the independent health layer — it cross-checks ArduSub *without being able to break it*.
    • The leak sensor is not I²C at all. It's a single digital "am I wet?" wire into a Pixhawk pin, feeding ArduSub's failsafe directly.

    Part A — ArduSub's sensors (Pixhawk I2C port): confirm

    One port, two sensors — how that physically works. I²C is a *shared bus*: every device hangs off the same four wires (power, ground, SDA, SCL) and is told apart by its address, not by owning its own socket. So the Bar30XT (0x40) and the RM3100 (0x20) genuinely belong on the *same* port. You do not splice or solder to join them — you plug both into a passive I²C splitter: a little board that just parallels that one port out to several JST-GH sockets. Pixhawk I2C port → splitter input; Bar30XT and RM3100 → two of the splitter's outputs. That's the whole trick. *(Good news: your Pixhawk 6C kit shipped with one — the small passive board with several identical JST-GH ports. Every port is wired the same, so any port is the "input" from the Pixhawk and the rest are outputs; there's nothing to buy.)*

    1. Primary Bar30XT and the RM3100 compass onto the splitter hanging off the 6C's `I2C` port (they were part of the heartbeat check — if you only validated the IMU there, wire them now; both share the bus fine, different addresses 0x40 (Keller) vs 0x20). The Bar30XT already ends in a JST-GH plug. The RM3100 you received is a bare-header FlyArm Meg_3D_3100 — solder its four I²C pins (4=3.3V, 3=GND, 7=SDA, 5=SCL) and set the mode/address straps (pin 1→I²C, SA0/SA1→GND for 0x20) per the pinout recorded on part #5, then run a JST-GH pigtail (PA-09) into a splitter socket. Only the 3.3V/GND pair is polarity-critical; no splicing *into* the bus either way.
    2. QGC → Parameters: confirm `BARO_PROBE_EXT` has the Keller bit enabled — the Bar30XT is a Keller sensor (0x40), so this is the bit that matters, *not* MS5837 (that one's for the backup Bar30). Change it → reboot (I²C is scanned only at boot). It then appears as `SCALED_PRESSURE2` in the MAVLink Inspector.
    3. In QGC the depth reads ≈ 0 m at rest (press the sensor → the reading twitches) and the heading tracks as you rotate the board. That's ArduSub's nav sensors — done.

    Part B — leak sensor → ArduSub failsafe (safety-critical, do it carefully)

    The SOS sensor is a digital wire that goes "wet/dry." On the Pixhawk 6C:

    1. Wire it to FMU PWM `AUX 6` (the SOS board's black/ground toward the top edge of the Pixhawk), power from the 5V side.
    2. ArduSub 4.5+ gotcha: AUX 6 won't act as an input until you free the pin — set `SERVO14_FUNCTION = -1` (GPIO) in Parameters, then reboot.
    3. QGC → Vehicle Setup → Safety: leak detector pin = `Pixhawk Aux6`, Logic when dry = `Low`.
    4. On the bench, set the failsafe action to Warn Only (an audible alert) — *not* Surface mode; there's no water and no reason to command a surface. You'll switch it to Surface before real dives.
    5. Trip it on purpose: bridge the two SOS probe pads with a damp finger or paper towel. QGC should announce the leak; dry it and it clears. A failsafe you've never fired isn't a failsafe yet.

    Part C — the monitoring sensors, on the Jetson via the mux

    The TCA9548A gives the Jetson-side health sensors one shared bus — and headroom for future *same-address* devices (e.g., several identical tube sensors). Right now that's the backup Bar30; the mux has spare channels for the water-temp and tube sensors when they arrive.

    1. Wire the mux: SDA / SCL / 3V3 / GND from a Jetson I²C bus to the TCA9548A, then each sensor onto a channel (07). Keep bench runs short. 2. Find the mux: i2cdetect -y <bus> (your Jetson bus number) — it shows at `0x70` (its A0/A1/A2 pins move it to 0x710x77). 3. Select a channel (write the channel bit to 0x70), then i2cdetect again to see that channel's device: - Backup Bar30 (MS5837) at `0x76` — here as the independent depth read (its 0x76 doesn't clash with the primary's Keller 0x40; it's on the Jetson for fault-tolerance, not because it had to be). - (later, spare channels: water-temp TSYS01 0x77, tube BME280s.) 4. Enumerate every populated channel and write the channel → sensor map into this step's notes — the Jetson's health code reads it.

    Watch out for

    • **The two depth sensors *don't* clash** (primary Bar30XT 0x40 Keller, backup Bar30 0x76 MS5837), so you *could* hang the backup on the Pixhawk bus and let ArduSub read both baros. We keep it on the Jetson for an independent read that survives a Pixhawk fault — a design choice now, not a hardware constraint. Pick one: ArduSub dual-baro *or* independent-supervisor backup.
    • The backup Bar30's gel element needs ~2 h/day of drying and isn't rated for long continuous submersion (the primary Bar30XT/Keller is — 316 stainless). Fine as a bench/short-dive backup; for long dives the backup should also be a BarXT.
    • 3.3 V logic. The Bar/RM3100 talk 3.3 V I²C — power on the Bar30XT is happy at 5 V, but don't feed 5 V *logic* onto SDA/SCL.
    • One set of pull-ups per bus. The Pixhawk and most breakouts include them; if a bus gets flaky as you add devices, doubled-up pull-ups are the first suspect.

    Done when

    • ArduSub (QGC): depth ≈ surface, a live heading, and the leak test trips and clears.
    • Jetson: every populated mux channel enumerates its sensor and returns live data, and the channel map is written in the notes.

    Guides

  12. check

    Each sensor reports

    The check that separates "the address showed up in a scan" from "the sensor actually works." An I²C device answering its address only means it's *alive* — not that it's returning real numbers. Half-working I²C (present but garbage) is more dangerous than a dead sensor, because the flight controller will happily trust the garbage. So here you make every sensor *prove* it, on both sides of the split you built in the last step.

    Side 1 — ArduSub's sensors (in QGroundControl)

    Depth (Bar30XT). Watch the depth/pressure readout: 1. At rest in air it reads ≈ 0 m (± a few cm of noise) and a plausible pressure (~1013 hPa at sea level; a bit lower up on the lakeshore). 2. Press on the sensor (or blow gently across the port) and the pressure/depth twitches, then settles back. That one motion proves it's reading the real transducer, not a cached constant. 3. Temperature from the same sensor reads roughly room temperature.

    Compass (RM3100). In the QGC compass/heading view: 1. Rotate the board slowly through a full circle — the heading sweeps through 360° and roughly matches reality (point the nose north, it says ~north). 2. No "compass variance" or "inconsistent compass" alarms in the toolbar. 3. Spin up a thruster later (step 13) and re-check — the RM3100 was chosen precisely because it shrugs off motor magnetics, so heading shouldn't wander when current flows.

    Leak (already tested in the last step). Confirm it still reads dry at rest and that your wet-finger trip is repeatable.

    Side 2 — the Jetson's sensors (through the mux)

    For each populated mux channel, don't stop at i2cdetectread the register data and sanity-check it:

    1. Select the channel (write the channel bit to the mux at 0x70), then talk to the sensor.
    2. Backup Bar30 (MS5837, `0x76`): read pressure + temperature. It should read a plausible pressure and room temperature, and — the real test — track the primary Bar30XT within a small offset when you press on both. Two depth sensors that disagree wildly means one is lying; find out which *now*.
    3. Use Blue Robotics' / Adafruit's example scripts to pull real values (not just i2cdetect); confirm the numbers change when you change the world (press, warm with a finger).
    4. Cross-check the map: the channel→sensor map you wrote down last step should match what actually answers on each channel. Fix any mismatch in the notes.

    Watch out for

    • Frozen values = failure. A number that never moves no matter what you do is a stuck read (bad wire, wrong channel, half-seated Qwiic). Treat it as offline.
    • Wild jumps / NaNs on the depth or compass = a marginal bus — usually a long run, a loose ground, or doubled pull-ups. Fix the wiring, don't average the noise away.
    • Silent = offline, never "probably fine." Danu's whole safety model treats a quiet sensor as failed. Hold this step to that same bar: no sensor gets a pass on "it was there a minute ago."
    • Primary vs backup disagreement. A small fixed offset between the two Bar30s is normal (mounting/thermal); a *growing* or *large* gap is a fault to chase before you trust depth underwater.

    Done when

    Every sensor — ArduSub's depth + compass in QGC, and each mux channel on the Jetson — returns believable, live, changing data, the two depth sensors agree, the leak trip is repeatable, and the channel map matches reality. When all of that is true, the sensor suite is trustworthy and you can move on to the first thruster spin.

  13. do

    Single-thruster test

    📦 all 1 in hand

    The first motion test — one thruster only, so a wiring or direction mistake is harmless.

    A T200 must run in water, never dry. The water cools and lubricates the bearing; running it dry damages it. Use a bucket or tub.

    What you'll need

    • One T200 thruster and one Basic ESC.
    • A tub of water and a clamp.

    Steps

    1. Wire one T200 to one Basic ESC. The ESC signal and ground go to a Pixhawk MAIN or AUX output; the ESC power comes from the current-limited bench supply.
    2. Submerge the thruster in the tub, clamped so it cannot jump out when it spins.
    3. Run it from QGC. On the Motors test page, arm, then nudge one motor to low throttle. The Basic ESC is bidirectional around a 1500 microsecond neutral.
    4. Confirm it spins, reverses when you go the other way, and draws a sane current on the PSU.

    Watch out for

    • Keep fingers and loose cables clear of the prop.
    • Low throttle only — you are proving function, not thrust. The T200 runs nominally at 12 to 16 V.

    Done when

    One thruster spins both directions, in water, under QGC control, at sane current.

    Guides

  14. do

    Cameras + gimbal

    📦 all 5 in hand

    Bring up the imaging on the Jetson (over USB) and exercise the pan/tilt gimbal from the Pixhawk.

    Steps

    1. Power the powered USB hub first. The cameras can exceed the Jetson's own port power budget, so run them through the powered hub.
    2. Plug in the cameras (primary, secondary, rear). On the Jetson, confirm they enumerate as /dev/video devices and list them with v4l2-ctl --list-devices, then grab a test frame.
    3. Gimbal: wire the pan and tilt servos to Pixhawk AUX PWM channels. In QGC set those outputs as servos and sweep them — confirm smooth pan and tilt, and set end-stops so the gimbal cannot bind.
    4. Record which video device is which camera in this step's notes — the USB order can shift between boots.

    Watch out for

    • If a camera does not appear, suspect power first (use the powered hub) and the cable second.

    Done when

    Every camera streams a frame on the Jetson, and the gimbal pans and tilts smoothly within its end-stops.

  15. do

    Comms bring-up

    📦 all 4 in hand

    Two links: LTE (primary, near shore, high bandwidth) and Iridium (last-resort, global, tiny messages).

    LTE modem

    1. Insert an activated SIM and connect the modem to the Jetson over USB.
    2. Bring up a data connection with ModemManager (mmcli) or the vendor tool.
    3. Confirm with ping.

    Iridium RockBLOCK

    1. Connect over serial / USB and attach the Iridium antenna with a clear view of the sky (outdoors or at a window for this test).
    2. AT-command handshake: send AT and expect OK; check signal with AT+CSQ.
    3. Send one test message and confirm it goes out. Iridium credits are metered, so a single test is enough.

    Watch out for

    • Record the modem's interface name and the RockBLOCK serial port in this step's notes.

    Done when

    LTE gets a data connection (ping works) and the RockBLOCK answers AT and sends one test message.

    Guides

    notes from the build

    2026-08-01 — LTE modem bring-up: hardware proven, waiting on the SIM. Full runbook in docs/COMMS-LTE.md.

    Sixfab Base HAT + Quectel EG25-G on the bench Jetson. Antennas: Antenova SRFL029 on MAIN, Pulse W3906 combo with its LTE lead on DIV and its GPS lead on GNSS. MAIN and DIV are deliberately on two *physically separate* antennas — two elements a few millimetres apart on one flex strip are too correlated to give real diversity.

    The order mattered. The ModemManager ignore rule for the Pixhawk's CP2102 bridge (10c4:ea60) went in *before* ModemManager was installed. It probes every USB serial device it finds with AT commands, and would otherwise have gone after the flight controller.

    Ports don't land where the guides say. The modem's AT port came up at /dev/ttyUSB3, not the ttyUSB2 everyone assumes — the CP2102 already owned slot 0 and pushed the modem's four ports up by one. Added a udev SYMLINK so the flight controller is /dev/pixhawk regardless of enumeration order. All MAVLink config has to use that name; the alternative is a reboot months from now that quietly points the flight-controller link at the modem.

    What worked: +CPIN: READY, +CSQ: 20 (−73 dBm), and AT+COPS=? found four LTE carriers — AT&T, T-Mobile, FirstNet, Verizon — with none marked forbidden. The antenna and RF chain are proven end to end, which was the part with actual risk in it.

    What didn't: +CEREG: 0,0 (not registered, not even searching), and a forced attach returned +CME ERROR: 30. A network you can hear at −73 dBm refusing a forced attach, with no reject cause recorded, is the signature of a SIM with no active subscription. AT+COPS=? only tells you which towers are *audible*, not which ones your subscription may use. Nothing left to debug on this end.

    Set nwscanmode to LTE-only permanently — US 2G and 3G were switched off in 2022, and a surfacing AUV has a short window in which it wants the fastest possible attach, not thirty seconds spent hunting for networks that no longer exist.

    Next: activate the SIM, then run a real coverage survey around the lake with the actual modem and antenna, then solve where the antenna lives — an aluminium pressure tube is a Faraday cage.

    ---

    Same evening — SIM activated, link came up. AT+COPS=0 to undo the manual lock, then a AT+CFUN=0/AT+CFUN=1 radio cycle:

    • +CEREG: 0,5 — registered, roaming. That's normal and permanent for a Super SIM: its home network is virtual, so every attach is a roaming attach.
    • +COPS: 0,2,"310260",7T-Mobile, LTE.
    • AT+CGACT=1,1+CGPADDR: 1,"100.90.x.x" — data context up.

    The address is the interesting part. 100.64.0.0/10 is carrier-grade NAT. There aren't enough IPv4 addresses to give every device its own, so carriers share one public address among many subscribers — your phone is behind the same thing. Universal, expected, and it won't change.

    What that actually costs us is narrower than it first looks: no _unsolicited_ inbound connection to the vehicle's cellular address. Nothing can port-scan Danu and nothing can dial it up cold.

    But a device behind NAT is still reachable through a connection it opened itself. Running Tailscale on the Jetson has it dial outward to a coordination server; from then on it holds a stable address on a private network and ssh danu works from anywhere. That's strictly better than buying a public IP — which some IoT providers sell — because the vehicle is submerged and offline ~95% of a mission, so a public address can't reach something with no radio link anyway, and an internet-facing IP gets port-scanned within minutes. That's a real attack surface on a vehicle carrying a drop-weight actuator.

    What the overlay doesn't fix: mission command-and-control still has to be vehicle-initiated queue-and-poll — but the reason is *submersion*, not NAT. Danu has no link at all underwater, and its surface windows are short and wave-shadowed. So the protocol has to be idempotent and resumable, assuming no single session completes. The overlay is for *operations* — debugging, pulling logs, intervening during a surface window.

    Worth separating those two clearly. Running them together first led to the conclusion that the vehicle could never be reached at all. It can — just never on someone else's initiative.

    Written up in docs/COMMS-LTE.md, along with a survey script (software/tools/lte-survey.py) that logs position + RSRP/RSRQ/SINR to CSV — the plan is a shoreline drive and a boat trip to build a real coverage map of the mission area, since published carrier maps show essentially nothing offshore.

    Step stays open until the Iridium half is done.

    ---

    The data connection took another two hours, and the cause was nowhere near where the symptoms pointed.

    nmcli connection up lte failed with "Disconnected by user". The logs showed ModemManager sending AT commands and getting nothing back — Serial command timed out, retrying every 6 seconds, then port ttyUSB3 timed out 10 consecutive times, marking modem as invalid. At which point mmcli insisted there was no modem, while lsusb showed it sitting right there with all five serial ports present.

    I assumed a power brownout — the EG25 pulls ~2 A bursts and it's sharing a USB hub with the flight controller. Wrong. Nothing ever disconnected.

    The real cause: NVIDIA's Tegra kernel doesn't ship the `qmi_wwan` driver. The modem defaults to QMI mode, which is right on most Linux hosts, but with no driver to bind, no network interface ever appears — so NetworkManager fell back to dialling PPP over the AT command port, and that's what was timing out. Every symptom pointed at a broken modem; the modem was fine the whole time.

    Fix was to check what the kernel *does* have before changing anything (ls /lib/modules/$(uname -r)/kernel/drivers/net/usb/), find cdc_ether.ko present and cdc_mbim absent, and switch the modem's USB composition to ECM: AT+QCFG="usbnet",1 then AT+CFUN=1,1.

    ECM is probably the better choice regardless. The modem manages the data context itself and hands Linux a DHCP lease, so the link is as boring as a wired connection with no ModemManager or libqmi in the data path. On something that has to come up unattended in a short surface window 20 km out, fewer moving parts beats peak throughput — and with Cat 4 sending thumbnails, the driver was never the bottleneck.

    Two traps on the way through: the ECM interface arrives named after its MAC address (pinned it to lte0 with a udev rule matching USB IDs), and NetworkManager auto-adopts it at route metric 100 while WiFi sits at 600 — so the Jetson quietly started routing everything over the metered SIM until the profile was rebuilt with a higher metric.

    Result: ping -I lte0 1.1.1.1 — 0% loss, ~160 ms.

    Then the test that mattered. Tailscale on the Jetson, WiFi taken down for a timed two-minute window, and ssh danu from a Windows laptop:

    • ssh danu@danu.local → could not resolve. mDNS is gone, so WiFi really is down. That's the control.
    • ssh danu@danuconnected, over Tailscale, over LTE.

    A vehicle behind carrier-grade NAT with no public address, reached from a laptop on the other side of the internet. That's the claim I'd earlier written off as impossible, and it turned out to be a solved problem rather than a constraint.

    Use systemd-run rather than a backgrounded shell job for that WiFi-off window, by the way — a background job dies with the SSH session that turning WiFi off is about to kill, which leaves the restore command unrun and the board unreachable until someone attaches a monitor.

    Step stays open until the Iridium half is done.

  16. do

    Supervisor MCU + heartbeats

    📦 all 1 in hand

    The STM32 supervisor is the independent watchdog — if the Jetson or the Pixhawk hangs, the supervisor is what brings the sub home. Bench bring-up is about flashing it and wiring the heartbeats.

    Steps

    1. Flash the supervisor firmware to the STM32 with STM32CubeProgrammer, over ST-Link/SWD or USB-DFU depending on your board (this is our custom firmware).
    2. Wire the heartbeat UARTs: a serial line from the Pixhawk to the supervisor, and one from the Jetson to the supervisor. The supervisor listens for a periodic "I am alive" from each.
    3. Power it from the bench rail and confirm it boots and logs over its serial console.
    4. With both brains running, confirm the supervisor sees both heartbeats (its log or LED shows both healthy).

    Watch out for

    • This sets up the failsafe dry-fire test next — do not skip the heartbeat wiring.

    Done when

    The supervisor is flashed, boots, and reports a healthy heartbeat from both the Pixhawk and the Jetson.

    Guide

    notes from the build

    2026-08-02 — both brains reporting. The deadman can now tell one dead computer from two.

    The gap we found first. The supervisor firmware had no heartbeat UARTs at all. usart.c implemented only USART2 — the ST-Link debug console — and every heartbeat test up to this point had been frames typed by hand into that one port. The pin map in SUPERVISOR-INTERFACE.md described the intended design, not what was built.

    Why one wire can't test this. The deadman fires only when *both* sources are overdue, so the failure it exists to survive is one link dying while the other lives — a pulled connector, a dead port, a crashed process on one side. With both sources sharing a wire, a single break silences both at once and looks exactly like both brains dying. The test cannot distinguish the case it exists to distinguish.

    So: hblink.c, two independent receive links. USART1 (PA10 / D2) from the Pixhawk's TELEM3, USART6 (PC7 / D9) from the Jetson's 40-pin UART.

    Two decisions worth recording:

    • The links are receive-only. TE is never set, so the supervisor physically cannot drive those lines — a firmware bug can't fight the Pixhawk or Jetson for the wire. It also means two wires per link instead of three.
    • Each link is bound to the source letter it may speak for. A frame claiming to be 'P' arriving on the Jetson's wire is rejected and logged. Without that, a miswired connector lets one brain silently speak for the other and cancel a countdown on its behalf — undoing in software exactly what the two wires were for.

    Pixhawk side is an ArduPilot Lua script rather than teaching the supervisor MAVLink. The safety brain runs one small CRC-checked ASCII parser that's been hardened against malformed input; adding MAVLink framing to it would double the surface area where a parsing bug can hide, to save one file.

    Two traps on the way through. SERIAL3 is not TELEM3 — on a Pixhawk 6C, TELEM3 is SERIAL5 and SERIAL3 is the GPS port, so the obvious guess would have disabled navigation while still delivering nothing. And the script can go on the SD card without opening the vehicle: MAVFTP over the existing MAVLink link (ftp put in MAVProxy) writes straight to /APM/scripts/.

    Result — the discrimination proven in both directions, both times by accident:

    `` [ARMED] P:OVERDUE J: 7s <- Pixhawk silent, Jetson alive. No countdown. [ARMED] P: 7s J:OVERDUE <- Jetson silent, Pixhawk alive. No countdown. [ARMED] P: 7s J: 7s <- both alive [link] Pixhawk lines=150 uart-errors=0 [link] Jetson lines=22 uart-errors=0 ``

    Either brain on its own is enough to hold the weight. That's the requirement, and it stayed stable across minutes in every combination. Zero UART errors on both links.

    Next: #16, the dry-fire — cut both heartbeats, watch the countdown expire, and confirm the full FIRE path into a test load.