Skip to content

fix(power): make a bench charge and discharge run tell the truth - #118

Open
fcatuhe wants to merge 5 commits into
mainfrom
fix/power-bench-runs
Open

fcatuhe wants to merge 5 commits into
mainfrom
fix/power-bench-runs

Conversation

@fcatuhe

@fcatuhe fcatuhe commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

The first two power runs on silicon (docs/bench/2026-10-05-power-run-*.md) showed four problems that stop the next charge and discharge run from being trustworthy:

  • After the cutoff, neither a charger nor the button woke the unit: the button was left unarmed on purpose, and the charger wake meant to re-arm it was swallowed by the factory bootloader. Only the reset pin helped.
  • On the cable, the divider reads the USB rail, so the charge went unrecorded and the cable went unseen.
  • The battery level flickered across Low and Critical for minutes at a time.
  • A bench run could only measure a parked unit.

This branch fixes the first three and adds a FLIGHT RUN capture for the fourth, within what ADS-L allows on air. One commit per change, in this order: #115, #114, #113 (two commits: the first let the cable through, the second, 3abc34e, replaces it with the behavior chosen in review), flight run.

Behavior changes

Before After
After the cutoff (#113) the button is left unarmed, and the charger wake meant to re-arm it is swallowed by the factory bootloader: only the reset pin brings the unit back the button is armed after every switch-off. A press on a flat cell is refused without a redraw and the button stays armed. A press on the cable starts the device
A charger plugged into a switched-off unit meant to wake the SoC and repaint the glass, swallowed by the bootloader in practice never wakes the device, by the bootloader's rule. FLAT BATTERY or CHARGE BATTERY stays on the glass while charging, the charger's red LED shows the charge
A refused boot that hangs stays awake until the reset pin, with no watchdog running the watchdog is armed before setup() and reboots it
Battery on the cable (#114) 0B1B2C refused every reading, so valid false, external_power false and the cable unseen. E68BD9 read the rail as the cell, so 100 % while charging from 45 % VBUS published every pass. The gauge says nothing on the cable: -- V --% CHG, battery_percent left out, charging equal to external_power
A refused reading dropped, counted nowhere counted in implausible, raw value kept in cell_mv with valid clear
Learned trim and charge curve in the code, but neither could ever work on these boards deleted
Battery level near a step (#115) three samples at or above a step climb it, so it flips back and forth for minutes climbing a step also needs the reading 35 mV above it. Going down is unchanged
CAPTURE page FULL, POWER RUN FULL, POWER RUN, FLIGHT RUN
FLIGHT RUN armed records like POWER RUN, and the radar and g-meter run a flight clock from arming. The air and the flight log stay grounded
power_budget.py charges the IMU row to every unit, cannot tell a session's profile IMU row only with the Inclinometer capability, reads the profile from boot, classes a flight run, prints the transmit not sent

Visual changes

The glass, drawn by the host build from each page's own test snapshot.

CAPTURE page, before CAPTURE page, after
capture before capture after
STATUS on the cable, before (E68BD9's 4.69 V rail read as 100 %) STATUS on the cable, after
status before status after

Implementation notes

#113, the button after the cutoff.

  • The units run LilyGO's UF2 Bootloader 0.6.1-2-g1224915 (factory/INFO_UF2.TXT). lyusupov's commit 1224915 sends any VBUS wake straight back to SYSTEM OFF when GPREGRET holds DFU_MAGIC_SKIP (0x6d), before MCUboot or the app runs. SoftRF, by the same author, writes 0x6d before every sleep, so for SoftRF ignoring the charger is the point.
  • We write 0x6d too, and must whenever the button is armed: the bootloader's DFU button is our only button (P1.10), so without it a waking press lands in USB DFU with no timeout.
  • Build 942 armed no button after the cutoff and counted on a charger wake to re-arm it, which the bootloader swallows. So after the cutoff only the reset pin worked, and waiting could not help: nothing wakes a SYSTEM OFF nRF52840 on a timer.
  • Now every switch-off arms the button and writes 0x6d (power::boot_magic_for_system_off, recovery's 0x57 still wins). ButtonWake, button_wake_after and button_wake_after_refusal are gone. A refused boot waits for the button to come up (kReleaseSettleMs, 100 ms) before arming it, or the held press would wake it again at once.
  • The app keeps its own refusal of a charger wake, for a bootloader that would let one through.
  • The watchdog is armed before setup() and fed in the refusal loop, which is capped at 6 s by kRefusalParkCeilingMs. The console line prints how long setup() took.

#114, the cable.

  • power::plausible_mv in core/power/battery.h is now the one 1800..4700 mV window, used by both platforms and the boot read.
  • board.h pushes every sample with VBUS, whatever the ADC returned.
  • The power record keeps its layout. trim_learned (bit 7) and bytes 13-14 are retired, written as zero and never reused, and scripts/blip_records.py still reads them from old captures.
  • core/power/README.md "The two curves" and "The trim" are rewritten to say what the divider sees.

#115, the levels. kRecoveryMarginMv = 35 in cutoff.h is the median half-hour spread of the gauge's median over E68BD9's 37 windows off the cable. A static_assert(kCriticalMv + kRecoveryMarginMv < kLowMv) keeps a cell climbing out of Critical inside Low.

Flight run.

  • ADS-L G.1.2 puts the flight state in the payload, and G.1.16 holds a unit on the ground to 0.1 Hz. So nothing that leaves the device changes: cadence, payload, NMEA, the offload and update gates and the flight log all keep reading the real flight state.
  • The one local seam is the flight clock (state.flight.running, seconds). Only the glass and the g-meter read it, and OwnshipService publishes a second one held airborne while the profile is armed.
  • Boot byte 14 names the profile, behind flag bit 2, so the two runs already in docs/bench/data/ decode with no profile key instead of as full.

Design decisions

  • Delete the trim and the charge curve, not fence them off. On the cable the divider reads the rail, about 4.7 V on both units, so neither can learn anything. A trim learned off a rail near 4.2 V would have moved the cutoff. settings.battery_offset_manual stays in the blob, because dropping it means a settings version bump.

  • The cable never wakes the device, the button always does. Considered: letting the cable through after the cutoff only (the first power: a charger on a unit the cutoff switched off neither repaints the glass nor arms the button #113 commit), which still left CHARGE BATTERY stuck and mixed two rules; and replacing the factory bootloader, which makes everything work but needs a bootloader flash on every unit, where a power loss mid-write leaves a unit only SWD can recover. The cost of the choice: the glass does not change on its own when a charger goes in.

  • A refused press is not cheap. boot_path needs the cell reading and the stored trim, so it runs after board_.begin(): every part is probed, the radio and the panel are set up, Bluetooth advertises, and an IMU unit hashes its stored image. Not measured yet. Moving read_stored_imu_image() after the refusal is a safe first cut, not done here.

  • One recovery margin for every step, because the spread belongs to the reading, not to the step. firmware/README.md said hysteresis "never becomes a constant". This adds an exception for a measured gap shared by every step of a ladder. Two named exit thresholds are the alternative if you prefer the old rule.

  • Flight run without the airborne cadence. Ground-flagged bursts at 1 Hz break G.1.16, and airborne-flagged ones would put a false aircraft on air. So the run models the transmit it does not send (54 bursts of 5 ms a minute at 90 mA, about +0.40 mA) on a separate line, outside the modelled total. Measuring it for real needs test firmware into a dummy load or a shielded box.

  • No flight log in a flight run. A 60-hour bench run would push real flights out of the ring, and nothing could tell a simulated flight from a real one. The cost left out is a 24-byte write every 4 s and an erase every 11 minutes.

Data and rollout

  • Diagnostics log: captures from before this branch decode as before. power gains no field, boot gains a flagged profile byte. The schemas and READMEs say "builds before 2026-10-05", because this change has no build number yet.
  • Bench: flash both units once 0B1B2C finishes its current run to cutoff. Then charge with the device on, and leave it to cutoff on POWER RUN or FLIGHT RUN.
  • Website, not touched here:
    • website/content/pages/power{,.fr}.html.erb still say three samples to leave a step.
    • The capture screenshot (website/content_images/pages/skyblip-go/capture.png) and the "two captures" prose in controls{,.fr} and displays{,.fr} need retaking with emscripten.

Risks and edge cases

  • A button held down in a bag keeps a flat unit awake. A refused boot waits for the release before arming the button, with the parts powered, and nothing bounds that wait. Build 942 avoided it by not arming the button at all after the cutoff, which is what trapped the unit. A bounded wait with the rails down is the follow-up if it matters.
  • The app's own charger refusal probably never fires on silicon. boot_path refuses on LowPowerWake with UsbVbus, but a VBUS wake sets only RESETREAS bit 20, which Zephyr's hwinfo folds into RESET_POR, so the chip reports UsbVbus | PowerOn. Hidden today because the bootloader takes VBUS wakes first. Worth one bench check before relying on it.
  • On silicon, after flashing: let a unit reach the cutoff, plug a charger (the glass stays, the red LED lights), then press the button: it should start the device. Unplugged and flat, a press should leave FLAT BATTERY on the glass without a refresh, and the next press should behave the same.
  • Bring-up must finish inside the 9 s software watchdog channel. No figure for it exists yet, so the reset-reason log line now prints the uptime when setup() returns. The static constructors before main are still unsupervised.
  • The 35 mV margin rests on one parked unit. A flying unit's spread under transmit load is not measured.
  • A hand-set trim near 4700 mV can make the boot read and the gauge disagree on a refusal, because the gauge judges the trimmed reading.
  • The flight run adds about one panel refresh a minute on an empty radar, so its discharge will read close to a power run's. A glass busy at 1 Hz in real flight comes from traffic or the sixpack page, which the profile does not invent.

Tests

  • Host suite, make -C firmware test: 1848 passed, 37 skipped.
  • Sanitizers, make -C firmware test-sanitize: pass.
  • Lint: make -C firmware tidy (clang-tidy 22.1.8) and clang-format 23.1.1 --dry-run --Werror over the tree: pass.
  • Scripts: check_test_coverage, check_adsl_clauses, check_tuning_names, check_status_schema, test_mkuf2, test_check_imu_image, check_no_dfu_package, test_blip, test_power_budget, test_link_budget: pass. node --test in simulator/: pass. docs/BEHAVIOR.md and docs/TUNING.md regenerate without a diff.
  • Not run here: the Zephyr image, twister and fuzz. CI builds the image.

Closes #114, closes #115. Closes #113. Refs #109.

Screenshots live on refs/assets/github under pr/118-fix-power-bench-runs/.

E68BD9's run to cutoff flipped Normal and Low five times in five minutes and Low and Critical three times in one, because three samples at or above a step were enough to climb back across a reading that spreads 35 mV (#115). Climbing a step now also needs the reading 35 mV above it, sized from that run's half-hour spread. Going down is unchanged.
…l as the cell

On the cable the divider reads the USB rail, about 4.7 V, not the cell: E68BD9 read 4670-4700 mV through a charge from 45 %, and 0B1B2C read above 4700 and refused every sample, hiding the cable with them (#114). VBUS now rides on every battery pass, a refused reading is counted in implausible and its raw value kept in cell_mv, and on the cable the gauge says nothing rather than 100 %. The charge curve and the learned trim are deleted: neither can work while the divider sees the rail, and a trim learned off it would move the cutoff. power_budget.py charges the IMU row only to a unit whose boot record has the Inclinometer capability.
…e cutoff switched off

LilyGO's UF2 bootloader (lyusupov fork, 1224915) sends any VBUS wake straight back to SYSTEM OFF when GPREGRET holds the skip magic, which system_off wrote before every switch-off, so a cable on a flat unit never reached the refusal that repaints the glass and re-arms the button (#113). The skip byte now follows the button: kept when the button is armed, because the bootloader's DFU button is ours, cleared when it is withheld. A retained charger_woke bit shows USB WOKE on the self-test page and in the diag reply, and the watchdog is armed before setup so a refused boot that hangs reboots.
A third capture on the CAPTURE page records like POWER RUN and runs a flight clock for the glass and the g-meter, so a bench discharge pays for a flying glass. What goes on air stays the truth: ADS-L G.1.2 carries the flight state and G.1.16 holds a grounded unit to 0.1 Hz, so the cadence and the payload stay the ground ones, and power_budget.py prints the transmit not sent beside the model. No flight log is written, so a bench run cannot push real flights out. The Boot record names the profile in a flagged byte, so older captures decode as before.
… the bootloader

A unit the cutoff switched off answered neither its button nor, on the factory bootloader, a cable: only the reset pin brought it back. Letting the cable through by writing 0x00 traded that for a bootloader that would put a waking press into USB DFU, so every SYSTEM OFF now arms the button and writes 0x6d, and a charger never wakes the device. A press on a flat cell is refused by the lockout, leaves the flat frame without a refresh and waits for the button to come up before arming it, and a press on the cable runs. The charger_woke note and its USB WOKE and diag field go with the cable they reported, and GPREGRET2 is back on its 0xa8 layout.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant