Skip to content

Charge Node

A small DroneCAN node on the aircraft that makes the hangar charge path safe to run unattended. It reads the six cells of the pack, the charge current, the voltage on the landing shoes and the pack temperature, and it drives the charge-enable switch and the balancer’s power. It runs ArduPilot’s AP_Periph firmware from one board definition, so every function is stock and the firmware flashes over the CAN bus. The reasoning is in hangar.md, “Charge path, revisited”. This page is the design. Written 2026-09-26. The schematic is drawn and checked; layout waits on the airframe fit.

Item Where
Board definition, bootloader, embedded defaults hw/charge-node/hwdef/TerraHawkChargeNode/
Build hw/charge-node/Makefile, make firmware ARDUPILOT=<private copy>
Interlock script on the FC tools/charge_interlock.lua
Firmware size 168 KB of the F405’s 1 MB, bootloader 27 KB (built 2026-09-26 at ArduPilot 4c98c92)
KiCad project terrahawk-eda/boards/charge-node/, five schematic sheets and two boards, ERC and DRC clean, netlist checked against the hwdef pin for pin on 2026-09-26, routed 2026-09-27 (EDA commit 59a5cfd)
Renders and gerbers terrahawk-eda/docs/renders/charge-*.png; gerber sets frozen in terrahawk-eda/fab/releases/ as rev A rc5 (silkscreen only over rc4; copper byte-identical). rc2 redid the silkscreen, rc3 ships the CAN terminator jumper open, rc4 fixes the bridge controller’s bottom gate wiring, rc5 follows a pre-order review of every IC pin against its datasheet (terrahawk-eda/docs/charge-node-preorder-review.md); do not build a power stage from rc1 to rc3
Thread with the EDA session docs/agent-threads/charge-node-eda/

The node is two boards joined by a five-wire lead, because the aft bay cannot hold it as one board beside the pusher ESC and the SiK radio. The split follows the circuit: the sense board (MCU, CAN, cell dividers, thermistor, LEDs) connects only to the pack’s balance lead, the thermistor and the CAN cable, all of which start at the pack. The power stage (bridge, fuse, charge switch, shunt) connects to the shoes and the pack leads. Both live in the battery bay. The same schematic builds as one board by leaving the two link connectors off; the nets are already joined.

Positions come from tools/space_map.py, a 2 mm voxel map of the free space inside the skin with the printed parts, spars, pack and FC stack removed, fitted for all four builds (Survey3 or stock, P50B or Tattu). Assembly frame, mm, and the picture is images/aft-bay-layout.png. The three positions below are pinned in the tool, which fails if one is not wholly free, so the page and the map cannot drift apart. The power stage sits 2 mm higher and 2 mm further port than the first fit put it: at Z 3 it touched the pocket’s lower corner within the sampling noise, and at X -58 its back face is 5.5 mm from the starboard wall, which gives the bracket a 2 mm wall plate and 3.5 mm bosses deep enough for heat-set inserts (one M2 × 4 insert, ruthex RX-M2x4, at each of the four holes; an M2 screw passes the board’s M2.5 hole) instead of screws threaded into 3.5 mm of plastic.

Board Hard outline P50B pack Tattu pack
Sense board 55 × 25 mm, 8 mm total height with parts on both sides Flat on top of the pack’s aft end under the battery hatch, centre (0, 237, 34), Z 30 to 38; 24 mm of spare length there On edge against the pack’s port face, centre (14, 204, 12), 8 × 55 × 25 (X × Y × Z); the component face looks port into a 30 mm pocket
Power stage 40 × 30 mm, 12 mm height On its 40 mm edge in the starboard pocket of the battery bay beside the pack, centre (-58, 180, 5), 30 mm side vertical; 4 mm spare length Same place, zero margin

Three consequences. The sense board sits where the pack slides in and out. With the P50B it must hang from the underside of the battery hatch, not from the airframe: the pack rests against the nose taper and the board covers its aft 18.5 mm with about 1 mm to spare, so with the board fixed the pack can neither be lowered, tilted (3 degrees lifts it into the board) nor slid into place. The board comes out with the hatch on four corner standoffs of 7 to 9 mm, sized per corner because the hatch underside curves. Its four cables need a 60 to 80 mm service loop so the hatch can be lifted off and set beside the aircraft while still connected, and they leave the housings bending down or aft, never up, because the front hatch lock turns 3.9 mm above the board. The balance lead’s disconnect is at the pack, between the pack’s XH plug and the GH-8 harness, so a pack swap never touches the board. With the Tattu, a printed fence on the tray beside the pack carries four standoffs to the component face, screwed from the back with low-head M2s, and the pack lifts out without disturbing the board. The power stage rests on a ledge in the starboard pocket, held by bosses from a wall plate at four holes set in from the edges, with a zip-tie anchor about 8 mm aft of the pads so cable pull never reaches the solder joints. Its connectors follow one rule that both placements satisfy, checked face by face against the free-space map: the cable housings (CAN in, CAN out, the 8-pin balance-plus-thermistor, the 5-pin link, all JST-GH side-entry, the only family under 8 mm) sit on one long edge, which faces aft with the P50B and up with the Tattu; the USB-C sits on one short end, port with the P50B and aft with the Tattu (the same board, unmirrored, standing on edge), with three solder pads for an LED strip inboard of it. Nothing vertical fits: there is about 4 mm above the board in the flat position. The face away from the connectors stays under 2 mm of parts, because in the Tattu position it lies 2 mm from the pack. The power stage’s XT30 pigtails and feet leads leave from one 30 mm end. And the wiring volume is not in the model: the starboard pocket and the pusher ESC and SiK stack under the FC tray have no spare room for 12 AWG runs, so the routes get planned before the boards are glued in. The VTOL sections turned out to be solid prints, not shells, so the feet wiring runs from the wing roots forward about 90 mm into the bay.

Link pin (J14 JST-GH 5-pin on the sense board, J15 JST-PH 5-pin on the power stage) Net Meaning
1 GND Reference, tied at the pack-side shunt pad on the power stage
2 3V3 Supply for the current-sense amplifier
3 SHOE_V_ADC Shoe voltage after its divider, 0 to 3 V
4 I_CHG_ADC Charge current, 0.25 V per A
5 CHARGE_EN 3.3 V logic to the switch gate driver

The aircraft has two CAN buses (see electronics-notes.md, “Our own flight controller”). This node is on bus 2, the sense bus: a line from the nose back through the airspeed sensor, the rangefinder node and this sense board in the battery bay, the elevon servo nodes at the wing roots and the FC. Bus 1 carries only the four lift ESCs, and both of its ends are terminator plugs beyond the last splice at each front pod, so every ESC’s terminator stays open and any ESC fits any pod. Each bus has a 120 Ω terminator at its two ends and nowhere else. On bus 2 both ends are terminator plugs (a JST-GH 4-pin with 120 Ω across CAN high and low): one in a three-way GH joiner beside the airspeed sensor, the other in the last connector of the harness. The airspeed module does not fit the nose cone; it stands in the Fuselage Front’s starboard lobe (th-blender and th-main, 2026-09-28) on a 60 mm stub from that joiner, with its own software terminator off, so replacing the module never touches the bus’s termination. The ends do not depend on which nodes are fitted: first flights without servo nodes and later ones with them use the same harness. Every node, this one included, has its terminator open and passes the bus through its two CAN connectors; the FC hangs off a short stub. Do not close a mid-bus jumper: a terminator in the middle of the bus and none at the end is the configuration that produces intermittent dropouts that look like a wiring fault. Static node ids: FC 10, charge node 20, airspeed 21, rangefinder node 22, lift ESC nodes 24 to 27 by motor number, elevon servo nodes 28 (port) and 29 (starboard), and the lights node 70 (th-avionics, hw/lights-node defaults.parm CAN_NODE 70; on the LUX it shares the one CAN port and is turned on by params/payload-lights.parm only when the lights are fitted; GH-4 connectors with pin 1, VCC, not used; its terminator is behind JP1, open as shipped: close it only if the lights node is the last node on the bus). Id 23 was the GPS node; the GPS now connects to the FC directly (2026-09-28), so 23 is unused. The bus runs at 1 Mbit/s on the four-wire Pixhawk cable: 5 V, CAN high, CAN low, ground. The node draws about 60 mA from the cable’s 5 V.

CAN bus 2 wiring: the airspeed module in the Fuselage Front’s starboard lobe on a 133 mm stub, its terminator off, to a three-way GH-4 joiner with a terminator plug; from there the bus runs through the charge node and the flight controller’s stub to a terminator plug in the last connector

Select the drawing to open it at full size.

flowchart LR
    SP[Starboard shoes<br/>8 pogo pins] --> A
    SS[Port shoes<br/>8 pogo pins] --> B
    A & B --> BR[Ideal-diode bridge<br/>LT4320 + 4 N-MOSFETs]
    BR -- "+" --> F1[Fuse 10 A] --> Q5[Charge-enable<br/>P-MOSFET] --> PP[Pack +]
    BR -- "−" --> SH[Shunt 5 mΩ] --> PN[Pack −]
    A & B -. diode-OR, 11:1 .-> ADC12[Shoe voltage]
    PP -. 11:1 .-> ADC10[Pack voltage]
    SH -. INA180A2 .-> ADC11[Charge current]
    BAL[Balance lead<br/>C1 to C6] -. 10:1 each .-> ADC1[Cell taps]
    NTC[NTC on the pack] --> ADC13[Pack temperature]
    N[Charge node<br/>STM32F405, AP_Periph] --> Q5
    N --> Q6[Balancer power switch]
    N <-- CAN --> FC[Flight controller, CAN bus 2]

Bridge. An LT4320 drives four N-channel MOSFETs as a full bridge. It accepts 9 to 72 V, so the 6S pack and a charger at up to 30 V are inside its range. One wiring detail is not obvious and cost a release candidate: the controller’s bottom gate outputs cross over, BG1 drives the bottom MOSFET on input 2 and BG2 the one on input 1, as in Analog Devices’ own LTspice example. Wired straight across, the controller shorts the live input to the return. rc4 has it right, checked against ADI’s LT4320 model in simulation. With no plate voltage the MOSFETs are off and their body diodes point away from the shoes, so the pack never reaches the shoes. Either plate polarity charges. Forward drop is about 20 mV per leg, so at 5 A the bridge dissipates a fraction of a watt.

Fuse. A 10 A Littelfuse NANO2 (0453010.MR, board-mounted, not field-replaceable) in the positive leg between the bridge and the switch. It protects the wiring if a bridge MOSFET fails short, since the charger itself is limited to 6 A.

Charge-enable switch. Two P-channel MOSFETs back to back: one with its drain on pack positive, the other with its drain on the fused bridge positive, sources tied, one gate. With the gate off nothing conducts in either direction. The first draft used a single MOSFET, which blocks bridge to pack but lets its body diode carry pack to bridge. That was thought harmless because the bridge blocks the path to the shoes, but it keeps the LT4320 powered from the pack, and the LT4320 draws about 1.5 mA, roughly 1 Ah a month from a pack sitting on the pad. The pair removes that drain; the LT4320 now boots from the plates through the bridge body diodes when a charger arrives, which is how it is designed to start. A 100 kΩ resistor holds the gate at the sources and a 12 V zener clamps it, so the switch is off with the node unpowered, in reset, or with the link unplugged (there is a second pull-down on the driver). A 2N7002 from the node’s CHARGE_EN pin pulls the gate down through 10 kΩ to turn the switch on. At 5 A the pair dissipates about 1 W with 20 mΩ parts; both tabs get copper.

Current. A 5 mΩ 2512 shunt in the negative leg, ground-referenced, read by an INA180A2 (50 V/V): 0.25 V per amp, 13 A full scale on a 3.3 V ADC. BATT_AMP_PERVLT is 4.0.

Voltages. Pack voltage is taken from the balance lead’s top tap through 1 MΩ over 100 kΩ, BATT_VOLT_MULT 11, so pack positive never crosses the link and the reading is valid whether the switch is open or closed. Shoe voltage through a BAV70 (a 70 V common-cathode diode pair; the BAT54 first chosen is only rated 30 V, which is the charger’s ceiling with no margin), one anode on each bridge input, into a 100 kΩ over 10 kΩ divider on the power stage. It reads whichever shoe is energised minus 0.7 V, without caring about polarity. This is the contact sense: it shows plate voltage before any current can flow, independent of the pack voltage reading. The 5 V probe reads about 4.3 V; the interlock’s threshold is 3 V.

Cells. Each balance-lead tap goes through a 910 kΩ over 100 kΩ divider, 0.1 %, to its own ADC channel (Viking ARG03BTC9103, LCSC C2686431; terrahawk-eda a1d96ca). The divider ratio is fixed in the firmware as SCALE(10.1) and there is no per-cell calibration parameter, so the resistor tolerance is the cell-voltage accuracy: 0.1 % on a 25 V tap is 25 mV. AP_Periph reads the taps as cumulative voltages from pack negative and reports the differences as the six cell voltages. 100 nF to ground at each ADC input; the capacitor supplies the sample charge, so the 100 kΩ source impedance does not trouble the ADC.

The values are high on purpose. The balance lead is plugged in whenever the pack is in the aircraft, node powered or not, and the dividers load the pack the whole time. Each tap’s divider draws from every cell below it, so cell 1 carries the sum of all six and cell 6 only its own. With 180 kΩ over 20 kΩ that was about 0.67 mA on cell 1 against 0.13 mA on cell 6, roughly half an amp-hour of imbalance a month on the 10 Ah pack. At 910 kΩ over 100 kΩ it is about a fifth of that. Do not fit 900 kΩ parts with this firmware: the ratio is then 10.0, and every cell reads 1 % high (about 0.04 V).

Temperature. A 10 kΩ NTC (B 3435) taped to the pack, with a 10 kΩ pull-up to 3.3 V. The analog temperature backend converts pin volts to degrees with a polynomial. For this thermistor and pull-up the fit over -10 to 70 °C is within 0.08 °C:

Parameter Value
TEMP1_A0 134.58
TEMP1_A1 -164.769
TEMP1_A2 126.473
TEMP1_A3 -62.6887
TEMP1_A4 16.4309
TEMP1_A5 -1.81505

Check three points after fitting the real part: 0 °C reads 2.447 V, 25 °C reads 1.650 V, 45 °C reads 1.077 V. A different thermistor needs its own fit; the script that produced these is a ten-line NumPy polyfit, reproduced in the git history of this page.

Balancer power. The active balancer’s negative lead goes through an N-channel MOSFET (AO3400) driven by the node’s BAL_EN pin, so the balancer only runs while the interlock says charging is under way. Check that the balancer chosen tolerates having its negative lead opened; most of the small capacitive ones do, since they only see the cell taps.

Node power. 3.3 V from the CAN cable’s 5 V through an LDO (AP2112K-3.3). A USB-C receptacle feeds the same rail through a Schottky so the node runs on the bench from USB alone, and the bootloader uses it for the first flash. The bus 5 V enters through a P-channel load switch whose gate is tied to USB VBUS: with no USB it is on and drops nothing, so the CAN transceiver sees the full 5 V; with USB present it turns off and its body diode points inward, so a node powered from USB can never push voltage back into the FC over the bus. The node is alive whenever the FC is, which is whenever the aircraft is on the pad with the pack connected.

Four printed PETG parts from tools/make_charge_brackets.py, in parts/, pictured in images/charge-brackets.png. Each is modelled oversize against the airframe and then has the airframe’s parts subtracted, so its glue faces follow the wall, floor or hatch exactly.

Part Mass What it does
Charge Power Bracket 6.6 g Wall plate glued to the flat starboard wall at X -68, bosses with M2 × 4 heat-set inserts at the four holes, a ledge under the bottom edge at Z -10 with lips into the part-free band, and a zip-tie tunnel aft at Y 206 to 210 where the shoe and pack pigtails are strain-relieved
Charge Sense Floor Tray 4.5 g For either pack (Ryan, 2026-10-04): the sense board lies flat on the floor under the FC deck, at X -12.5 to 12.5, Y 255 to 310. Glue the tray to the floor across the joint between the Fuselage Front and Mid B. Its underside follows the floor and its top is flat. Four 4.5 mm bosses lift the board 2 mm over its back-side parts. Drive M2 thread-forming screws down through the board into the bosses. The USB-C end is aft, under the open middle of the deck. The GH plugs mate toward -X, with 7.8 mm of room for the plugs and their bends. Clearances: 0.86 mm to the FC Mount, 2.3 mm to the front spar, 1 mm to the 155 x 50 x 58 pack at its aft-most position
Charge Sense Hatch Mount 5.1 g Superseded by the floor tray (2026-10-04). It was for the P50B: two rails at X ±25 and a forward crossbar under the front hatch, with four standoffs down to the board’s connector face
Charge Sense Fence 12.2 g Superseded by the floor tray (2026-10-04). It was for the Tattu only: a vertical plate on the tray beside the pack, with the pack against the starboard wall (X -36 to 6.5)
Channel Drill Guide, starboard and port cancelled 2026-10-10, do not print Aimed the motor-wire exit drill at (±68, 208, 2); that bore is the wing spar’s seat, and the motor leads use the wire channels instead: on Fuselage C, the channels ahead of the front spar (th-blender, th-main)

Note: This section gives the Fuselage B harness. On Fuselage C v2.4B the lift motor leads go through the wire channels ahead of the front spar (20 x 10 mm at Y 232.5 to 252.5, mouths at X ±78.5), not through the Fuselage Mid stub. th-main’s v2.4B harness pass replaces this section. Until then, do not cut leads to the lengths below.

Routes from tools/wire_routes.py: a shortest path on the 2 mm grid for each cable radius, around the fitted items, the pack and the brackets, with one hard rule: no route crosses the column above the pack, so the pack can always lift out. Steps along a wall cost less, so the runs hug walls where they can be tied. Cut length is path × 1.15 plus 20 mm. images/charge-wiring.png shows the first-flight harness: the 18 leads of the first build on Fuselage B with the LUX F765, which has no charge node (2026-10-04), the lift 4-in-1 flat in the belly between the spars and the pusher ESC beside it (2026-10-05). The script also gives each power lead’s closest approach to the Q43’s base, against the compass rule: 60 mm for a twisted pair or bundle, 150 mm for a lone conductor. The router keeps every power lead outside that limit. The one exception is the pusher ESC’s three phase leads to the tail: the fuselage narrows to 57 mm either side at about Y 430, so 57 mm is the most any path can keep. th-telemetry accepts 57 mm for that triple (2026-10-05) with a twist of 1 turn in 22 mm or tighter from Y 330 to 480: three phase leads carry no net DC, and the field of a twisted bundle falls with distance divided by pitch. The bench compass fit is the final check. The charge-node routes come back with the charge node (wire_routes.py --charge-node).

Warning: do not drill. The exit is under revision (th-blender, 2026-10-10). The bore that ends at X ±101 is the seat of the 8 mm wing spar, swept 28 degrees out to the Winglet, not a wire channel: a drill removes the spar’s inboard stop. Do not print the Channel Drill Guides. On Fuselage C v2.4B the motor leads go through the wire channels ahead of the front spar, which are already open into the bay. The text below stays only until the revised routes replace it.

The shoe leads travel with the lift motor wires. The kit runs each side’s motor wires inside the 14 mm VTOL spar, out through the spar’s 45 degree wire slot, and into the kit’s own 25 x 10 mm wire channel through the Fuselage Mid’s wing-root stub, Y 283 to 308, which opens into the bay at X about ±62 beside the Lucid (th-blender and th-main, 2026-10-10). Until 2026-10-10 this section described a 10 mm hole drilled at (±68, 208, 2) into what was taken for the end of a moulded 8 x 8 mm channel; that bore is the seat of the 8 mm wing spar, not a wire channel, and the drill is cancelled (the warning above). The shoe leads share the kit channel: the two feet on a side are the same plate polarity, so one 16 AWG per side (or two, joined in the bay) runs in the spar and the channel. The first build flies the kit’s 4-in-1 lift ESC (Ryan, 2026-09-29), so each channel carries its two motors’ phase leads (6 x 18 AWG), the shoe lead and the keel antenna coax: about 41 mm² on the port side, which carries two coax runs, and 36 mm² on the starboard side (2.3 mm over 18 AWG silicone), 14 to 16 % of the 250 mm² channel. The DroneCAN pod path that follows puts the two pod power feeds (4 x 14 AWG) and CAN bus 1 there instead, about 58 and 53 mm², 21 to 23 %; the earlier “too full to pull” verdict was against the 8 x 8 mm channel that does not exist. The shoe lead ends and the power bracket’s zip-tie tunnel, which sat at the old exit, need a new place at the channel mouth (th-blender). Nothing is cut in a kit part.

Route P50B, cut Tattu, cut Where it goes
4-in-1 feed, XT60, 2 x 12 AWG 210 166 From the power junction to the 4-in-1 on the starboard bay wall
Phase leads, 6 x 18 AWG a side port 262, starboard 59 port 264, starboard 59 Out of the cradle’s two aft notches (th-blender, 3d353be): the lower, Z -7 to 1, for the starboard bundle; the upper, Z 10 to 18, for the port bundle, which crosses the bay. In the bay only; then the channel and the spar to the motors
Starboard shoe, 16 AWG 137 (bay path 102) not re-run From the starboard channel mouth to the power stage (th-main 6f7e740, 2026-10-10, ESTIMATE)
Port shoe, 16 AWG 232 (bay path 185) not re-run From the port channel mouth across the bay to the power stage (th-main 6f7e740, ESTIMATE)
Pack tap, T-branch, XT30 155 129 From the main feed’s junction to the power stage
Link, PH-5 to GH-5 216 with loop 245 Up the pocket to the sense board on the hatch (the loop is for the P50B’s hatch-mounted board)
CAN bus 2, GH-4 232 with loop 201 The charge node’s CAN to the FC, with the 6C Mini turned so its ports face aft

In-bay cut lengths, from th-blender’s rebuild of the bay model (6f16815 and 5edf520, P50B pack, Survey3 build), re-run for the 4-in-1 on 2026-09-29 (tools/wire_routes.py, both packs); the full list of record is in agent-threads/harness/. The shoe leads and the phase leads (or the pod feeds, on the pod path) also need the spar run from each tip to the slot at Y about 295.5 (about 229 mm front, 190 mm rear; the earlier 177 and 239 were measured to the wing spar bore) plus about 130 mm through the kit channel, measured from the stub mouth (th-main, 2026-10-10; was 140 through the cancelled drill). The in-bay shoe rows below are th-main’s 6f7e740 run from the channel mouths (P50B; Tattu not re-run); wire_routes.py --charge-node also prints the PH-5 link’s parallel length within 20 mm of hub B’s board, its BATT tap and its 5 V pair (0, 56 and 54 mm with the sense board on top of the pack; the board is the one that must stay clear, the two DC leads are tolerable at that length, see the sense board’s filtering in the circuit section), and the compass distances (126 and 120 mm).

Warning: do not drill (see above; th-blender, 2026-10-10).

Order of work for the exit, superseded 2026-10-10: the drilled exit and its guide are cancelled (see the warning). The wires enter the bay from the kit channel’s mouth at X about ±62, Y 283 to 308; the power bracket is glued where th-blender’s revised bay model puts it (moved 3.5 mm aft for hub B, 2026-10-09), entering through the hatch opening and sliding about 10 mm outboard under the pocket’s side lip. Routes are laid biggest first, each reserving its own volume, so a shared passage holds all of its cables. Tie points: the tunnel on the power bracket for the shoes and the pigtail, the starboard wall along the pocket, and the spar top.

Two endpoints are assumed until confirmed. The pack’s lead leaves its aft face near the centreline; both packs are specified that way. The FC’s CAN ports are on the 6C Mini’s aft face, because the board is turned 180 degrees (Ryan, 2026-09-28).

The power-stage pigtails and their mates follow one rule: the side that is live when unplugged carries the female XT30, with its recessed contacts, and the power stage carries the male on all three, because with the switch off and the bridge unpowered its pads are dead. The plates are live when the pad is energised, so the shoe leads end female. The pack is always live, so its tap ends female. That also means neither shoe pigtail can be plugged into the pack tap by mistake: the two shoe pigtails and the pack pigtail are all male, and only female ends exist on the airframe side. Each shoe carries one conductor, so both pins of a shoe XT30 are paralleled on both sides of the joint, on the board and in the lead.

Cable Ends Wire Cut length Source
Shoe pigtail, port, J4 Board pads to XT30 male 2 × 16 AWG silicone, both the same conductor 40 mm Pre-made Amass XT30U-M 16 AWG pigtail, cut
Shoe pigtail, starboard, J5 Board pads to XT30 male same 40 mm same
Pack pigtail, J6 Board pads to XT30 male, + on pad 1 2 × 16 AWG 136 mm (P50B), 118 mm (Tattu) Pre-made Amass XT30U-M 16 AWG pigtail, extended or cut
Shoe lead, starboard Feet (both shoes on the side joined at the spar) to XT30 female at the zip-tie tunnel 16 AWG silicone, one conductor into both pins spar run 229 (front) or 190 mm (rear) plus about 130 mm through the kit channel from the stub mouth, plus 137 mm in the bay (th-main 6f7e740) XT30U-F housing, crimped
Shoe lead, port same, across the bay behind the pack same as starboard but 232 mm (P50B) in the bay; Tattu not re-run same
Pack tap A T-branch off the pack side of the main feed (10 AWG since 2026-09-28; an XT90-S male on the aircraft side and the pack’s XT90 female since 2026-10-04) to an XT30 female; charge current only, never in series with the main feed 16 AWG branch 155 mm branch Soldered into the main feed’s junction by th-main’s harness, XT30U-F on the branch
Servo node DC tap (when the servo nodes are fitted) A fused tap and its own negative wire, both from the pack junction; never from a point on a lift pod’s feed as the servo node needs from the junction th-eda and th-avionics, 2026-09-28. A tap on a pod feed puts the pod’s return drop (about 0.13 V at 50 A, ESTIMATE) across the servo node’s 26 AWG CAN ground, about 0.8 A in it; from the junction on its own negative, about 0.1 A
Link GH-5 at the sense board to PH-5 at the power stage, pin 1 to pin 1 5 × 26 AWG 197 mm (P50B, with the 80 mm loop); Tattu not re-run JST-GH pre-crimped leads into a PH housing with PH crimps
CAN in, CAN out GH-4 to GH-4, Pixhawk pinout 4 × 26 AWG 236 mm (P50B, with loop), to the FC; Tattu not re-run The cable supplied with the airspeed sensor, or crimped from parts-list.md H13 to H15; the H4 kit’s pre-crimped leads are 150 mm
Balance and thermistor XH-7 mating the pack’s balance plug to GH-8 at the sense board; NTC on pins 1 and 8 7 × 22 AWG plus the thermistor pair 150 mm plus the 80 mm loop A 6S JST-XH balance extension lead, one end cut and re-terminated GH, thermistor soldered in
Balancer power Two 1.27 mm pads on the sense board to the balancer’s negative lead 22 AWG as the balancer needs Soldered
LED strip Three 1.27 mm pads to the strip 3 × 26 AWG as fitted Soldered
Shoe lead chokes One snap-on ferrite on each shoe lead where it leaves the keel’s rear core, before it enters the spar fits 16 AWG none A ferrite specified for impedance at 900 MHz, not only at HF. th-telemetry’s NEC2 model (8837eed) shows the unchoked lead in the keel lowers the SiK antenna’s gain from -1.7 to -3.3 dBi

The XT30s are not on the board bill of materials because they are wire parts; they and the rest of this table are the harness bill of materials, and it lives here.

Manufacturer part numbers. LCSC numbers get filled in when the BOM is placed, and the genuine TDK-style clone problem does not arise here: none of these parts has a common counterfeit.

Ref Part Qty Notes
U1 STM32F405RGT6 1 LQFP64, 8 MHz crystal, 2 × 12 pF
U2 TJA1051T/3 1 CAN transceiver, 3.3 V logic, 5 V supply
U3 LT4320IDD 1 Ideal-diode bridge controller
Q1 to Q4 PSMN7R0-60YS 4 N-MOSFET, 60 V, 6.4 mΩ, LFPAK56 (stocked; the 4 mΩ part was not)
Q5, Q9 SUD50P06-15L (VBsemi 60 V 20 mΩ part as the alternate) 2 P-MOSFET pair, back to back, DPAK
Q6 AO3400 1 Balancer power switch
Q7 2N7002 1 Gate driver for Q5
U4 INA180A2IDBVR 1 Current-shunt amplifier, 50 V/V
U5 AP2112K-3.3 1 LDO, 600 mA
R_sh RLP25FEGMR005 1 5 mΩ, 3 W, 2512
R 910 kΩ (Viking ARG03BTC9103), 100 kΩ, 0.1 % 6 + 6 Cell dividers, ratio 10.1
R 1 MΩ over 100 kΩ; 100 kΩ over 10 kΩ, 1 % 2 + 2 Pack divider (from the balance lead top tap); shoe divider
R 10 kΩ 1 %, 10 kΩ NTC B3435 1 + 1 Thermistor and pull-up
D1 BAV70 1 Shoe sense diode-OR, 70 V common-cathode pair
D3 12 V zener 1 Q5 gate clamp
R_t 120 Ω 1 CAN terminator behind solder jumper JP1, which ships open because this node sits mid-bus on bus 2
F1 NANO2 SMD fuse, 10 A (Littelfuse 0453010 class) 1 Not field-replaceable; it only opens on a hard fault
C C32 10 µF 50 V X7R 1210 (Murata GRM32ER71H106KA12L, LCSC C77102) plus C31 1 µF across the bridge output 1 + 1 The LT4320 needs at least 10 µF there. No 22 µF 1210 rated well above the 30 V charger ceiling is in stock, so 10 µF (Ryan, 2026-10-01; terrahawk-eda a1d96ca). The frozen rc6 schematic still prints 22 µF; the BOM and the fitted part are 10 µF
U6 74AHCT1G125 1 Level shifter so the WS2812 sees a 5 V data signal
D4 B5819W 1 USB VBUS into the 5 V rail
Q8 AO3401A 1 Load switch on the bus 5 V input, off while USB is present
D6 NUP2105L 1 TVS across CAN high and low
J12 USB-C receptacle, 16-pin 1 Bootloader and bench power
J1, J2 JST-GH 1.25 mm 4-pin 2 CAN in and out, Pixhawk pinout
J4, J5 XT30 pigtails on 16 AWG through-holes 2 Bridge inputs from the shoes (one per side); pigtails, not board-mount, where the wiring vibrates
J6 XT30 pigtail 1 To the pack’s main leads
J14, J15 JST-GH 5-pin at the sense board, JST-PH 2.0 mm 5-pin at the power stage 2 The link between the two boards; GH for height on the sense board, PH for vibration at the power stage
J3 JST-GH 8-pin 1 Balance lead on pins 1 to 7 (1 = pack negative) and the thermistor on pin 8, through an adapter pigtail from the pack’s XH-7
J13 Two 1.27 mm pads, no part 1 Balancer negative lead, switched by Q6
J8 Three 1.27 mm solder pads (5V, DAT, GND) 1 WS2812 strip; a connector did not fit on the 25 mm end beside the USB-C and the corner screws
J9 Tag-Connect TC2030 footprint, unpopulated 1 SWD
J10, J11 1.27 mm pads, unpopulated 2 Debug UART, I2C
LED WS2812B 2 Status, visible through the hatch

Estimate for five sets from a prototype service: about $40 in parts per set and $70 each assembled including setup, two boards per set. Estimate, not a quote. Manufacturer part numbers above are the ones the stockist carries; the EDA repo’s docs/charge-node-parts.md records the originals and the reasons for each swap.

From hwdef.dat, checked against the schematic netlist on 2026-09-26. Analog pin numbers are what the parameters use.

MCU pin Function Analog pin Parameter
PC0, PC1, PC2, PC3, PA0, PA1 Cell taps 1 to 6, SCALE(10.1) 1 to 6 BAL_CELL1_PIN 1
PA6 Pack voltage 10 BATT_VOLT_PIN
PA5 Charge current 11 BATT_CURR_PIN
PA4 Shoe voltage 12 BATT2_VOLT_PIN
PA7 Pack thermistor 13 TEMP1_PIN
PB0 CHARGE_EN GPIO 50 RELAY1_PIN
PB1 BAL_EN GPIO 51 RELAY2_PIN
PA15 WS2812 data, OUT1 GPIO 58 OUT1_FUNCTION 120
PB8, PB9 CAN1 RX, TX
PC13 CAN silent
PA9, PA10 Debug UART
PB6, PB7 I2C1, spare header
PA13, PA14 SWD
PA11, PA12 USB, bootloader only
PC12 Activity LED

Embedded in the firmware from defaults.parm. Battery ids are what the FC matches on.

Parameter Value Why
BATT_MONITOR 4 Pack: analog voltage and current
BATT_SERIAL_NUM 10 Reported as battery id 10
BATT2_MONITOR 3 Shoe side: analog voltage only
BATT2_SERIAL_NUM 11 Reported as battery id 11
BAL_NUM_CELLS, BAL_ID 6, 10 Cell voltages attach to battery 10
TEMP1_TYPE, TEMP1_PIN 5, 13 Analog thermistor
TEMP1_SRC, TEMP1_SRC_ID 3, 1 Temperature goes into battery 1’s report
RELAY1_FUNCTION, RELAY2_FUNCTION 10, 11 DroneCAN hardpoint 0 and 1
RELAY1_DEFAULT, RELAY2_DEFAULT 0, 0 Both off at boot
OUT1_FUNCTION, NTF_LED_LEN 120, 2 Two NeoPixels on OUT1
Parameter Value Why
CAN_P1_DRIVER 1 Enable CAN port 1
CAN_D1_PROTOCOL 1 DroneCAN
ARSPD_TYPE 8 DroneCAN airspeed sensor
BATT2_MONITOR, BATT2_SERIAL_NUM 8, 10 Node pack data as battery 2
BATT3_MONITOR, BATT3_SERIAL_NUM 8, 11 Node shoe voltage as battery 3
BATT2_LOW_VOLT, BATT2_CRT_VOLT, BATT3_LOW_VOLT, BATT3_CRT_VOLT 0 No failsafes from these; the FC’s own sensor keeps them
BATT2_FS_LOW_ACT, BATT2_FS_CRT_ACT, BATT3_FS_LOW_ACT, BATT3_FS_CRT_ACT 0 Same
RELAY3_FUNCTION, RELAY3_PIN 1, 1000 Charge enable, hardpoint 0
RELAY4_FUNCTION, RELAY4_PIN 1, 1001 Balancer power, hardpoint 1
RELAY3_DEFAULT, RELAY4_DEFAULT 0, 0 Off at boot
SCR_ENABLE 1 Lua, then reboot and copy charge_interlock.lua to APM/scripts/ on the SD card

tools/charge_interlock.lua runs on the FC every 500 ms. It closes the charge-enable switch only while all of these hold, and opens it as soon as one fails:

  • Disarmed and not flying.
  • Node pack and shoe data healthy.
  • Shoe voltage above 3 V: the pad is probing or charging.
  • Pack temperature known, between 2 and 40 °C to start; once charging, it may continue to 45 °C and resumes below 40 °C again (hysteresis, so a warm pack does not chatter). 45 °C is Ryan’s ceiling (“must not charge above 45 °C”, 2026-10-06). A reading at either ADC rail (below -20 or above 80 °C after the polynomial, or INT16_MAX in BATTERY_STATUS) is an open or shorted thermistor: “unknown”, no charge. For reference, the Molicel P50B data sheet (80122) allows -20 to 60 °C ambient and a 70 °C cell-surface cut-off while charging at up to 25 A, so there is room to loosen the band if Ryan wants it; the dock (docs/hangar.md, th-hangar) and the interlock script use the same numbers.
  • All six cells above 2.8 V, and their sum within 0.6 V of the pack voltage.
  • No cell above 4.20 V. Once a cell trips, the switch stays open until that cell is below 4.15 V.

The balancer power follows the charge current: on above 0.2 A, off otherwise. On load the script opens both switches before its first check. State changes and blocks are announced to the GCS, which the dock controller reads over the SiK link.

The script also resets the FC’s battery count after a full charge. Battery instance 0 counts the mAh used since boot and does not count the charge. Thus, after a dock charge with no reboot, the capacity triggers (BATT_LOW_MAH, BATT_CRT_MAH) still see the last flight, and the pre-arm check refuses to arm. The script sets instance 0 to 100 % when all of these hold for 30 s:

  • A charge current above 0.2 A flowed since boot or since the last reset.
  • Disarmed, and the node pack data healthy.
  • All six cells at 4.15 V or more.
  • The charge current below 0.5 A (C/20 for the 10 Ah P50B).

A partial charge does not reset the count. The count then shows more use than is true, which is the safe direction. The GCS shows “charge: full, min cell …, battery count reset” when the script resets the count.

The ground-side sequence in hangar.md changes in two places, both because the node now sees the shoe side.

  1. Aircraft reports landed and disarmed. Roof closed.
  2. Probe: the supply goes to 5.0 V with a 50 mA limit. This is below the pack voltage, so the bridge passes nothing, but the shoe sense sees it.
  3. Within 2 s the FC reports battery 3 above 3 V. If not, a shoe is off its plate: alert, do not raise the voltage.
  4. The interlock now closes the switch (shoe voltage is one of its conditions).
  5. Ramp the supply upward under a 100 mA limit until the node reports charge current above 50 mA, with a ceiling 1 V above the reported pack voltage. If the ceiling is reached with no current, the aircraft has refused: read the interlock’s reason from the GCS messages.
  6. Raise the current limit to the charge rate. Set the voltage ceiling from the chemistry: 23.1 V storage or 25.2 V full for LiPo, 21.6 to 22.2 V storage or 25.2 V full for the Li-ion pack.
  7. Stop on target voltage, on any cell at 4.20 V as reported by the FC, or on loss of telemetry for 10 s. De-energise the plates before the roof opens.
  8. Launch: with the roof at its open limit and the people guard clear, the supply goes to 12.0 V with a 50 mA limit. This is the “site clear” signal for the pre-arm check on the aircraft (clear-to-launch.md): battery 3 between 11 and 13 V. The interlock does not close the switch at this level; it only closes on the ramp in step 4. The supply returns to 0 V when the aircraft reports armed and airborne, or at once if the guard trips.

WARNING: Remove all five props before the test. A servo output or an arming fault can start a motor.

Do this once with a lab supply standing in for the pad, before any pad exists.

  1. Connect the node to the FC’s CAN port. Power the FC from USB and feed 5 V to the node’s CAN connector J1 as well: on USB alone the node’s 5 V rail sits at about 4.65 V behind its Schottky, marginal for the CAN transceiver’s 4.5 V minimum, so a USB-only node may not talk on the bus.
  2. In Mission Planner’s DroneCAN tab, confirm the node appears and its parameters read back.
  3. Connect a 6S pack through the balance lead only. Check the six cell voltages in the FC’s battery 2 status against a multimeter. Each must be within 30 mV.
  4. Tape the thermistor to the pack. Check battery 2’s temperature against a thermometer.
  5. Connect the pack’s main leads to J6. Connect the lab supply, off, to J4 with either polarity.
  6. Set the supply to 5.0 V, 50 mA limit, and turn it on. Battery 3’s voltage must rise above 3 V and the interlock must report “enabled, waiting”.
  7. Ramp the supply toward the pack voltage under a 100 mA limit. Current must appear in battery 2 at about the pack voltage. Set the limit to 1 A and hold for 5 min. The bridge MOSFETs and Q5 must stay cool to the touch.
  8. Swap the supply polarity at J4 and repeat step 7.
  9. Warm the thermistor above 45 °C with a finger and a hot cloth. Current must stop within 1 s and the GCS must show “pack hot”.
  10. Disconnect one cell tap. Current must stop and the GCS must show “cell missing”.
  11. Arm the aircraft with the props off. Current must stop and the GCS must show “armed”.
  12. Turn the supply off. Battery 3’s voltage must fall to 0 and the shoes must read 0 V.

If every step passes, the pad can be wired.

  • The pack. The design assumes a 6S pack with a balance lead. The Li-ion recommendation in battery.md needs a pack that exposes one; check before ordering.
  • The balancer part, and whether opening its negative lead is a clean off.
  • The board id. 20401 is not registered with ArduPilot. It must match between the bootloader and the firmware. This page is public, and another project can register the same number. To get a registered id, open a pull request to Tools/AP_Bootloader/board_types.txt. That is Ryan’s decision.
  • Layout. Done: both boards are at rev A rc6 in terrahawk-eda/boards/charge-sense/ and charge-power/, frozen 2026-09-29 (see Ordering below).
  • Ordering. Both boards are routed and DRC clean (power stage 165 tracks, 91 vias, 2 oz outer copper for the 10 A return; sense board 1025 tracks, 97 vias, 1 oz). The recommended order is five of each from JLCPCB, 2-layer 1.6 mm, economic assembly for the sense board, the power stage assembled with the LT4320 consigned or hand-built. The order is Ryan’s decision. The bill of materials is terrahawk-eda/docs/charge-node-bom.md.

The source files this page describes, as they are in the repo.

hw/charge-node/hwdef/TerraHawkChargeNode/hwdef.dat · 133 lines · open · download
# TerraHawk charge node: AP_Periph DroneCAN node for the hangar charge path.
# Reads the pack's balance lead (six cells), charge current, shoe-side voltage
# and pack temperature; drives the charge-enable and balancer switches; one
# status LED strip. STM32F405RGT6, 8 MHz crystal. See docs/charge-node.md.
MCU STM32F4xx STM32F405xx
# bootloader in the first 64k, parameters in the next two 16k sectors
FLASH_RESERVE_START_KB 64
FLASH_SIZE_KB 1024
STORAGE_FLASH_PAGE 2
define HAL_STORAGE_SIZE 15360
# Private board id. It only has to agree between hwdef.dat and hwdef-bl.dat.
# IDs 1000 to 19999 are allocated upstream by PR; this board is not registered.
APJ_BOARD_ID 20401
env AP_PERIPH 1
define STM32_ST_USE_TIMER 5
define CH_CFG_ST_RESOLUTION 32
OSCILLATOR_HZ 8000000
STDOUT_SERIAL SD1
STDOUT_BAUDRATE 57600
define SERIAL_BUFFERS_SIZE 512
define PORT_INT_REQUIRED_STACK 64
define DMA_RESERVE_SIZE 0
define HAL_DEVICE_THREAD_STACK 768
define AP_PARAM_MAX_EMBEDDED_PARAM 512
env ROMFS_UNCOMPRESSED True
# activity LED
PC12 LED OUTPUT LOW
define HAL_LED_ON 0
# SWD stays available: this is a home-made board
PA13 JTMS-SWDIO SWD
PA14 JTCK-SWCLK SWD
# ---------------------- CAN bus -------------------------
PB8 CAN1_RX CAN1
PB9 CAN1_TX CAN1
PC13 GPIO_CAN1_SILENT OUTPUT PUSHPULL SPEED_LOW LOW
# ---------------------- UARTs ---------------------------
# USART1: debug console on a 3-pin header
SERIAL_ORDER USART1
PA9 USART1_TX USART1 SPEED_HIGH NODMA
PA10 USART1_RX USART1 SPEED_HIGH NODMA
# ---------------------- I2C1 ----------------------------
# spare header for a digital current monitor or temperature sensor later
I2C_ORDER I2C1
PB6 I2C1_SCL I2C1
PB7 I2C1_SDA I2C1
define HAL_I2C_CLEAR_ON_TIMEOUT 0
define HAL_I2C_INTERNAL_MASK 0
# ---------------------- ADC -----------------------------
define HAL_USE_ADC TRUE
define STM32_ADC_USE_ADC1 TRUE
# Balance lead taps, cumulative from pack negative, through 10.1:1 dividers
# (910k over 100k, 0.1 %, Viking ARG03BTC9103, high so the permanently plugged
# balance lead does not drain the pack unevenly; terrahawk-eda a1d96ca, Ryan
# 2026-10-01). SCALE(10.1) is fixed in firmware, so the dividers must be
# accurate; there is no per-cell calibration parameter. Never fit 900k parts
# with SCALE(10.1), or 910k with SCALE(10): each reads 1 % wrong, 0.04 V a cell.
PC0 CELL1 ADC1 SCALE(10.1) ANALOG(1)
PC1 CELL2 ADC1 SCALE(10.1) ANALOG(2)
PC2 CELL3 ADC1 SCALE(10.1) ANALOG(3)
PC3 CELL4 ADC1 SCALE(10.1) ANALOG(4)
PA0 CELL5 ADC1 SCALE(10.1) ANALOG(5)
PA1 CELL6 ADC1 SCALE(10.1) ANALOG(6)
# Pack voltage (1M over 100k from the balance lead top tap), charge current
# (INA180A2 on a 5 mOhm shunt, 0.25 V/A, over the link from the power stage),
# shoe-side voltage at the bridge input (11:1 after a BAV70 diode-OR, over the
# link), pack thermistor (10k NTC, 10k pull-up to 3V3)
PA6 BATT_VOLTAGE_SENS ADC1 SCALE(1) ANALOG(10)
PA5 BATT_CURRENT_SENS ADC1 SCALE(1) ANALOG(11)
PA4 BATT2_VOLTAGE_SENS ADC1 SCALE(1) ANALOG(12)
PA7 PACK_TEMP ADC1 SCALE(1) ANALOG(13)
# ---------------------- Battery -------------------------
define AP_PERIPH_BATTERY_ENABLED 1
define AP_BATT_MONITOR_MAX_INSTANCES 2
# BATT: the pack, voltage and charge current
define HAL_BATT_MONITOR_DEFAULT 4
define HAL_BATT_VOLT_PIN 10
define HAL_BATT_CURR_PIN 11
define HAL_BATT_VOLT_SCALE 11.0
define HAL_BATT_CURR_SCALE 4.0
# BATT2: shoe-side voltage, voltage only. Reports contact before any current flows.
define HAL_BATT2_MONITOR_DEFAULT 3
define HAL_BATT2_VOLT_PIN 12
define HAL_BATT2_VOLT_SCALE 11.0
# per-cell voltages from the balance lead
define AP_PERIPH_BATTERY_BALANCE_ENABLED 1
define AP_PERIPH_BATTERY_BALANCE_NUMCELLS_DEFAULT 6
define AP_PERIPH_BATTERY_BALANCE_RATE_DEFAULT 2
define AP_PERIPH_BATTERY_BALANCE_CELL1_PIN_DEFAULT 1
define AP_PERIPH_BATTERY_BALANCE_ID_DEFAULT 10
# pack temperature: analog NTC, attached to BATT by TEMP1_SRC in defaults.parm
define AP_PERIPH_DEVICE_TEMPERATURE_ENABLED 1
define AP_TEMPERATURE_SENSOR_ENABLED 1
define AP_TEMPERATURE_SENSOR_ANALOG_ENABLED 1
# ---------------------- Relays --------------------------
# RELAY1 = charge-enable P-MOSFET gate driver, RELAY2 = balancer power.
# Both default off. Driven from the FC by DroneCAN hardpoint 0 and 1.
PB0 CHARGE_EN OUTPUT LOW GPIO(50)
PB1 BAL_EN OUTPUT LOW GPIO(51)
define AP_PERIPH_RELAY_ENABLED 1
define AP_RELAY_ENABLED 1
define AP_RELAY_NUM_RELAYS 2
define RELAY1_PIN_DEFAULT 50
define RELAY2_PIN_DEFAULT 51
# ---------------------- Status LEDs ---------------------
# WS2812 strip visible through the hatch, on OUT1
PA15 TIM2_CH1 TIM2 PWM(1) GPIO(58)
define AP_PERIPH_RC_OUT_ENABLED 1
define AP_PERIPH_NOTIFY_ENABLED 1
# built-in LED plus NeoPixel
define DEFAULT_NTF_LED_TYPES 257
hw/charge-node/hwdef/TerraHawkChargeNode/hwdef-bl.dat · 28 lines · open · download
# TerraHawk charge node bootloader. USB for the first flash, CAN afterwards.
MCU STM32F4xx STM32F405xx
OSCILLATOR_HZ 8000000
# must match hwdef.dat
APJ_BOARD_ID 20401
env AP_PERIPH 1
FLASH_RESERVE_START_KB 0
FLASH_SIZE_KB 1024
FLASH_BOOTLOADER_LOAD_KB 64
PC12 LED_BOOTLOADER OUTPUT LOW GPIO(0)
define HAL_LED_ON 0
SERIAL_ORDER OTG1
PA11 OTG_FS_DM OTG1
PA12 OTG_FS_DP OTG1
PA9 USART1_TX USART1
PA10 USART1_RX USART1
PB8 CAN1_RX CAN1
PB9 CAN1_TX CAN1
PC13 GPIO_CAN1_SILENT OUTPUT PUSHPULL SPEED_LOW LOW
hw/charge-node/hwdef/TerraHawkChargeNode/defaults.parm · 26 lines · open · download
# Embedded defaults for the TerraHawk charge node. Change with the DroneCAN
# GUI tool or Mission Planner's DroneCAN tab; they persist in flash.
# battery ids the FC matches with BATTn_SERIAL_NUM. BAL_ID must equal BATT_SERIAL_NUM.
BATT_SERIAL_NUM 10
BATT2_SERIAL_NUM 11
BAL_ID 10
BAL_NUM_CELLS 6
# pack thermistor on analog pin 13, attached to battery 1.
# TEMP1_A0..A5 are the polynomial from ADC volts to degC; fit them to the
# thermistor actually fitted (docs/charge-node.md). All zero reads 0 degC.
TEMP1_TYPE 5
TEMP1_PIN 13
TEMP1_SRC 3
TEMP1_SRC_ID 1
# relays driven by DroneCAN hardpoint 0 (charge enable) and 1 (balancer)
RELAY1_FUNCTION 10
RELAY2_FUNCTION 11
RELAY1_DEFAULT 0
RELAY2_DEFAULT 0
# status strip: two NeoPixels on OUT1
OUT1_FUNCTION 120
NTF_LED_LEN 2
tools/charge_interlock.lua · 174 lines · open · download
-- TerraHawk charge interlock. Runs on the flight controller (Pixhawk 6C Mini first, then our own H743).
--
-- The aircraft carries a charge-enable switch between the landing-shoe
-- rectifier and the pack, driven by the charge node over DroneCAN. This
-- script closes that switch only while every condition below holds, and
-- opens it the moment one fails. The ground-side charger has its own
-- limits; both controllers must agree before current flows.
--
-- Battery instances on the FC (0-based here, BATT1.. in parameters):
-- 0 the FC's own sensor on the main leads
-- 1 charge node, pack: voltage, charge current, cell voltages, temperature (id 10)
-- 2 charge node, shoe side of the rectifier: voltage only (id 11)
-- Relays on the FC (0-based here, RELAY1.. in parameters):
-- 2 charge enable -> DroneCAN hardpoint 0 (RELAY3_PIN 1000)
-- 3 balancer power -> DroneCAN hardpoint 1 (RELAY4_PIN 1001)
-- See docs/charge-node.md for the parameters that go with this script.
--
-- Full-charge reset: instance 0 counts mAh used since boot and never counts the charge, so after a
-- dock charge with no reboot the capacity triggers still see the last flight and refuse to arm
-- (th-testpilot, 2026-10-01: the dock harness hung on "Battery 1 low capacity failsafe"). When a
-- charge seen in this session tapers off with every cell at or above FULL_CELL, the script sets
-- instance 0 back to 100 %. A partial charge does not reset it: the count then overstates what was
-- used, which is the safe side.
local PACK = 1
local SHOE = 2
local RLY_CHARGE = 2
local RLY_BAL = 3
local NUM_CELLS = 6
local CELL_MAX = 4.20 -- V, open above this
local CELL_REARM = 4.15 -- V, close again only below this
local CELL_MIN = 2.80 -- V, do not charge a cell below this
-- Pack temperature band (docs/charge-node.md, th-eda 00ba24c; Ryan via th-hangar: never above 45 C): start only
-- from 2 to 40 C; once charging, continue up to 45 C; after a trip, close again only below 40 C. A reading below
-- -20 or above 80 C (an ADC rail, or INT16_MAX) is an open or shorted thermistor: unknown, so the switch stays open.
-- The P50B data sheet allows more (70 C cell surface while charging); 40/45 is a pack-life margin.
local TEMP_MIN = 2.0 -- degC, never below
local TEMP_START = 40.0 -- degC, highest temperature to start, and to close again after a trip
local TEMP_MAX = 45.0 -- degC, open above this while charging
local TEMP_VALID_LO, TEMP_VALID_HI = -20.0, 80.0
local SHOE_MIN = 3.0 -- V on the shoe side means the pad is probing us
local SUM_TOL = 0.6 -- V, cells must add up to the pack voltage
local BAL_ON_AMPS = 0.2 -- balancer runs while this much charge current flows
local MAIN = 0 -- the FC's own sensor, which the failsafes use
local FULL_CELL = 4.15 -- V, every cell at or above this for a full charge
local FULL_AMPS = 0.5 -- A, charge current tapered below this (C/20 on the 10 Ah P50B)
local FULL_HOLD_MS = 30000 -- ms the full condition must hold
local PERIOD_MS = 500
local MSG_MIN_MS = 5000
local cell_tripped = false
local temp_tripped = false
local last_reason = ""
local last_msg_ms = 0
local charged = false -- a charge flowed since boot or since the last reset
local full_since = nil
local function say(reason)
local now = millis()
if reason ~= last_reason or (now - last_msg_ms) > MSG_MIN_MS then
gcs:send_text(6, "charge: " .. reason) -- 6 = MAV_SEVERITY_INFO
last_reason = reason
last_msg_ms = now
end
end
-- returns ok, reason
local function conditions()
if arming:is_armed() then return false, "armed" end
if vehicle:get_likely_flying() then return false, "flying" end
if not battery:healthy(PACK) then return false, "node pack data stale" end
if not battery:healthy(SHOE) then return false, "node shoe data stale" end
local shoe_v = battery:voltage(SHOE)
if shoe_v < SHOE_MIN then return false, "no pad contact" end
local temp = battery:get_temperature(PACK)
if temp == nil then return false, "no pack temperature" end
if temp < TEMP_VALID_LO or temp > TEMP_VALID_HI then
return false, string.format("pack temperature unknown (%.0fC: thermistor open or shorted)", temp)
end
if temp < TEMP_MIN then return false, string.format("pack cold %.1fC", temp) end
local charging = relay:get(RLY_CHARGE) ~= 0
if temp > TEMP_MAX then temp_tripped = true end
if temp_tripped and temp < TEMP_START then temp_tripped = false end
if temp_tripped then return false, string.format("pack hot %.1fC, waiting for %.0fC", temp, TEMP_START) end
if not charging and temp > TEMP_START then return false, string.format("pack warm %.1fC, start below %.0fC", temp, TEMP_START) end
local sum = 0
local vmax = 0
for c = 0, NUM_CELLS - 1 do
local v = battery:get_cell_voltage(PACK, c)
if v == nil then return false, string.format("cell %d missing", c + 1) end
if v < CELL_MIN then return false, string.format("cell %d low %.2fV", c + 1, v) end
if v > vmax then vmax = v end
sum = sum + v
end
local pack_v = battery:voltage(PACK)
if math.abs(sum - pack_v) > SUM_TOL then
return false, string.format("cells %.2fV vs pack %.2fV", sum, pack_v)
end
if vmax > CELL_MAX then cell_tripped = true end
if cell_tripped and vmax < CELL_REARM then cell_tripped = false end
if cell_tripped then return false, string.format("cell high %.3fV", vmax) end
return true, "ok"
end
local function set_relay(idx, on)
if on then
if relay:get(idx) == 0 then relay:on(idx) end
else
if relay:get(idx) ~= 0 then relay:off(idx) end
end
end
local function min_cell()
local vmin = 99
for c = 0, NUM_CELLS - 1 do
local v = battery:get_cell_voltage(PACK, c)
if v == nil then return nil end
if v < vmin then vmin = v end
end
return vmin
end
-- after a charge in this session tapers off with every cell full, set the FC's count to 100 %
local function full_reset(amps)
if not charged or arming:is_armed() or not battery:healthy(PACK) or amps == nil then
full_since = nil
return
end
local vmin = min_cell()
if vmin == nil or vmin < FULL_CELL or amps >= FULL_AMPS then
full_since = nil
return
end
local now = millis()
full_since = full_since or now
if now - full_since >= FULL_HOLD_MS then
battery:reset_remaining(MAIN, 100)
charged, full_since = false, nil
gcs:send_text(6, string.format("charge: full, min cell %.2fV, battery count reset", vmin))
end
end
function update()
local ok, reason = conditions()
set_relay(RLY_CHARGE, ok)
local amps = battery:current_amps(PACK)
local charging = ok and amps ~= nil and amps > BAL_ON_AMPS
set_relay(RLY_BAL, charging)
if charging then charged = true end
full_reset(amps)
if ok then
if charging then say(string.format("charging %.1fA", amps)) else say("enabled, waiting") end
else
say("blocked: " .. reason)
end
return update, PERIOD_MS
end
-- fail safe on start: both switches open until the first good check
relay:off(RLY_CHARGE)
relay:off(RLY_BAL)
gcs:send_text(6, "charge interlock loaded")
return update, PERIOD_MS
hw/charge-node/Makefile · 25 lines · open · download
# Build the TerraHawk charge node firmware in ArduPilot's Docker image.
# ARDUPILOT must be a checkout with submodules that nothing else is using.
ARDUPILOT ?= $(abspath ../../../ardupilot-chargenode)
# Pinned by digest (Ryan, 2026-10-03: tracked builds). This is the image :latest named on 2026-06-25; a tag
# can move to a new compiler without notice. SITL uses the same digest through mcardupilot.
IMAGE ?= ardupilot/ardupilot-dev-chibios@sha256:8bb0f850fb3fe1c170cb12dd577ab64f3708be8a828f9eafd28d73559766e81f
BOARD := TerraHawkChargeNode
HERE := $(abspath .)
.PHONY: firmware clean
firmware: ## Build bootloader and AP_Periph firmware, outputs in build/
@test -d "$(ARDUPILOT)/libraries/AP_HAL_ChibiOS/hwdef" || { echo "no ArduPilot tree at $(ARDUPILOT); set ARDUPILOT="; exit 1; }
rm -rf "$(ARDUPILOT)/libraries/AP_HAL_ChibiOS/hwdef/$(BOARD)"
cp -r hwdef/$(BOARD) "$(ARDUPILOT)/libraries/AP_HAL_ChibiOS/hwdef/"
docker run --rm -v "$(ARDUPILOT)":"$(ARDUPILOT)" -w "$(ARDUPILOT)" -u "$$(id -u):$$(id -g)" -e HOME=/tmp $(IMAGE) \
bash -c 'git config --global --add safe.directory "*" && Tools/scripts/build_bootloaders.py $(BOARD) && ./waf configure --board $(BOARD) && ./waf AP_Periph'
mkdir -p build
cp "$(ARDUPILOT)/build/$(BOARD)/bin/AP_Periph.bin" "$(ARDUPILOT)/build/$(BOARD)/bin/AP_Periph_with_bl.hex" build/
cp "$(ARDUPILOT)/Tools/bootloaders/$(BOARD)_bl.bin" build/
@ls -la build/
clean:
rm -rf build
hw/charge-node/README.md · 34 lines · open · download
# TerraHawk charge node
An ArduPilot AP_Periph DroneCAN node for the hangar charge path. The design, the
circuit, the parts and the test plan are in `docs/charge-node.md`. This directory holds
the firmware inputs:
| Path | What |
| --- | --- |
| `hwdef/TerraHawkChargeNode/hwdef.dat` | Board definition: pins, ADC channels, features |
| `hwdef/TerraHawkChargeNode/hwdef-bl.dat` | Bootloader definition (USB for the first flash, CAN after) |
| `hwdef/TerraHawkChargeNode/defaults.parm` | Parameters embedded in the firmware |
| `../../tools/charge_interlock.lua` | The interlock script that runs on the flight controller |
## Build
The build runs in ArduPilot's own Docker image against an ArduPilot checkout. Do not
point it at a checkout another tool is using (the SITL tree beside this repo): waf keeps
one configuration per tree, and configuring this board there breaks the SITL build until
it is configured back. Use a copy.
```bash
make firmware ARDUPILOT=/path/to/a/private/ardupilot/copy
```
The target copies `hwdef/TerraHawkChargeNode` into the tree, builds the bootloader, then
the firmware, and copies the outputs to `build/`:
| File | Use |
| --- | --- |
| `AP_Periph_with_bl.hex` | First flash, over USB DFU (hold BOOT0 at power-up) or SWD |
| `AP_Periph.bin` | Later updates over CAN, from Mission Planner's DroneCAN tab |
| `TerraHawkChargeNode_bl.bin` | Bootloader alone, if it ever needs replacing |
The `build/` directory is not tracked.

Source: docs/charge-node.md in the repo. Edit it there; this page is generated.

Comments on

    Comments are reviewed before they appear. Please leave out places, addresses and other personal details.

    A Supported Systems Flight

    This build log is written and kept by Supported Systems, a small studio that makes thoughtful, user-first technology. The airframe, the hangar and the simulation are ours.

    Visit supported.systems