diff --git a/docs/BEHAVIOR.md b/docs/BEHAVIOR.md index 283ab5a7..f64bfcc3 100644 --- a/docs/BEHAVIOR.md +++ b/docs/BEHAVIOR.md @@ -1,6 +1,6 @@ # Behavior -1843 host test cases over 202 subjects in 167 files, as the doctest suite states them. A test name is a claim, and the file above it is where the claim is checked. Quoted prose is the comment the test file or the case carries. +1852 host test cases over 203 subjects in 168 files, as the doctest suite states them. A test name is a claim, and the file above it is where the claim is checked. Quoted prose is the comment the test file or the case carries. Generated by `scripts/behavior_index.py` from the run itself, and diffed in CI. Do not edit: rename the test case, or rewrite the comment it carries. @@ -328,6 +328,17 @@ When the board reads the barometer: that interval is what a vertical speed is ta - triggers stay on the PPS phases - with no PPS the barometer keeps its own second and its own quarters +### test/boards/test_board_battery.cpp + +What the board publishes off the divider: every pass, read or refused, with the cable beside it. + +**board battery** + +- a believable reading off the cable goes out as it was read +- a refused reading still publishes the cable and what was read + > 0B1B2C's divider sat over the window for a whole charge, and hid the cable with it (#114). +- an ADC that gave nothing still publishes the cable, at zero + ### test/boards/test_board_glass.cpp Where a pixel the product draws lands on the glass each platform is fitted with. @@ -1169,12 +1180,12 @@ What an EFB is told, in the FLARM sentences it already speaks. The geometry is t ### test/core/test_power.cpp -One terminal voltage, two meanings: on the cable the charger holds the cell above its own resting voltage, so the same reading is a far emptier cell. These pin down that the gauge says which curve it read, never walks the wrong way, and ignores the sag of a 14 dBm burst. A percentage that jumps when the radio keys is a gauge a pilot stops believing. +One curve, read off the cable only: on it the divider reads the USB rail and not the cell. These pin down that the gauge never walks the wrong way, ignores the sag of a 14 dBm burst, and shows no cell it cannot see. A percentage that jumps when the radio keys is a gauge a pilot stops believing. **battery** -- charging reads lower than resting at the same voltage - > The point of the whole module: the same reading means two different states of charge, because on charge the terminal sits above the cell's own voltage. +- a reading is the cell only between the floor and the ceiling + > The window the gauge believes: a floating divider under it, a rail over it. - the curve is monotonic and clamps at both ends - the flat middle of the cell is not a straight line @@ -1186,8 +1197,12 @@ One terminal voltage, two meanings: on the cable the charger holds the cell abov - a cell that recovers far climbs every step it cleared at once - a first reading at or above low is a sound cell, one under it waits for three - a floating ADC cannot power the device off -- a level comes back up on three samples above its step, not on one +- a level comes back up on three samples clear of its step by the margin > The cell sits on a step for the minutes it takes to cross it, and noise straddles the line. +- a level goes down on three samples under its step, with no margin + > A falling cell is not slowed: the margin is only on the way up. +- a parked cell drifting across a step changes level once + > E68BD9's run to cutoff, its cell_mv every 30 s on 5 Oct, 02:39-02:45 and 03:39-03:43 UTC. - critical comes before flat, never instead of it - every step is a boundary, not a range > Exactly on a step is the step above it, one millivolt under is the step itself. @@ -1203,13 +1218,16 @@ One terminal voltage, two meanings: on the cable the charger holds the cell abov **gauge** -- a full cell on the cable is charged, not charging -- charging never walks backwards +- a refused reading is counted, and its value is what the gauge shows + > 0B1B2C's rail sat above the ceiling for 7 h, and nothing said which bound it crossed (#114). - nothing is reported before the first reading +- off the cable the percentage only falls +- on the cable the divider reads the rail, so there is no cell to show + > E68BD9 read 4670 to 4700 mV for 3.5 h of charging from 45 %, and called it 100 % (#114). - one transmit burst does not move the gauge -- plugging in re-seats on the charge curve, unplugging on the other -- the percentage only moves the way the current flows - the reading the median threw out is still the reading the bench sees +- unplugging starts the median over on the charged cell + > Charged on the cable, the cell is where the first reading off it says, not under the old median. **refresh gate** @@ -1228,21 +1246,6 @@ One terminal voltage, two meanings: on the cable the charger holds the cell abov > It refuses writes; it does not power the device off. POFCON warns about VDD, well under any healthy cell, so there is no orderly shutdown left to run: the panel park alone is kParkMs and the brownout reset is milliseconds away. The voltage rule keeps the shutdown, where three consecutive samples are the evidence. - the monitor answers it from the samples it already has -### test/core/test_power_trim.cpp - -A charger in constant voltage holds a known value, so the difference is this unit's error. - -**trim** - -- a plateau it did not climb into teaches it nothing - > A topped-off pack drifting down under its own load is flat too, and is no reference. -- a reading that is still moving is not a plateau -- a unit reading low learns the millivolts it is out by -- no cable, no reference -- the trim it learns is the one the settings bound accepts -- two minutes of plateau, and not a second less -- unplugging forgets the climb - ### test/core/test_radio_log.cpp The station log: newest first, and nothing kept that a screen could not show. @@ -1315,9 +1318,9 @@ The road from running to SYSTEM OFF: what starts a shutdown, and the order the r **power down** -- a low-battery shutdown drops the rails without arming the button - > A press on a cell this flat is the ratchet, so only a cable brings it back. - every step runs once, in the order the table declares +- every switch-off asks the factory bootloader to skip DFU + > The bootloader's DFU button is ours: a press waking on any other byte lands in USB DFU. - the GNSS is switched off, and before its supply goes - the external flash is told to sleep while it still has a rail > The bug this whole sequence exists to prevent: 0xB9 arrives at a part that has already lost its supply, the command does nothing, and an external flash draws its full standby current for the whole night the device spends in a flight bag looking switched off. @@ -1329,8 +1332,8 @@ The road from running to SYSTEM OFF: what starts a shutdown, and the order the r **shutdown** - a device woken by the button does not switch itself off again -- a low-battery shutdown does not wait for a button it will not arm - > A bag on the button must not hold the rails up on the cell that asked to go. +- a low-battery shutdown waits for the button it arms + > The cutoff arms the button like every switch-off, and a held one would wake it at once. - a pad never seen up since boot is not a stow > A pad line that reads touched at rest would turn every power-off into a stow (#60). - a pad that was not held throughout is an ordinary power-off @@ -1641,13 +1644,13 @@ The reset cause and the cell at boot decide whether a wake gets a running device **wake** -- a boot refused for a flat cell leaves the button unarmed - > SENSE is a level detect: a button held in a bag re-wakes what it just refused. - a cell nobody read never refuses a boot > The lockout's own catastrophic failure: a unit bricked by a divider nobody read. - a charger plugged into a sleeping device does not switch it on > The one the item exists for. - a charger wake is named without hiding a fault that came with it +- a charger wake that reaches the refusal takes the word back off the glass + > A bootloader that let a charger wake through would get the frame of every armed device. - a flat cell on the cable gets a device when a person asks for one > A refusal with no way out is a brick, and this way out also fills the cell. - a flat cell refused under the low word names itself instead @@ -1662,8 +1665,6 @@ The reset cause and the cell at boot decide whether a wake gets a running device - on a cell that can run, the button and the reset pin ask for a device - the boot a flat cell refuses names it for the unit that never shut down > A cell emptied on a shelf ran no shutdown, so the refusal is the only thing left to name it. -- the charger that wakes a flat device takes the word back off the glass - > The cable arms the button again (button_wake_after_refusal), so the mark is the instruction. ## diag @@ -1673,6 +1674,8 @@ What the device decided: the build it booted, the dwell, the flight state, the a **diag record** +- a boot older than its profile byte reads as unrecorded, not as full + > A capture from before the byte has a zero there, and zero is Full: the flag says it was never set. - a durable write keeps where in the second it was placed - a duty counter past 16 bits wraps, because a clamp would lose the delta - a dwell carries the plan the second ran under and its refusal @@ -1698,6 +1701,7 @@ Every enum a diagnostics payload writes as a byte, pinned to the code the corpus - dfu::ImageState's codes are the boot image_state byte, and a code changed here changes IMAGE_STATE in scripts/blip_records.py and the schema's image_state enum - diag::LinkAction's codes are the link action byte, and a code changed here changes LINK_ACTION in scripts/blip_records.py and the schema's action enum +- diag::Profile's codes are the boot profile byte, and a code changed here changes PROFILE in scripts/blip_records.py and the schema's profile enum with it - diag::Refusal's codes are the dwell refusal byte, and a code changed here changes REFUSAL in scripts/blip_records.py and the schema's refusal enum with it - diag::SwitchKind's codes are the switch change byte, and a code changed here changes CHANGE in scripts/blip_records.py and the schema's change enum with it - events::Contact's codes are the contact byte, and a code changed here changes CONTACT in scripts/blip_records.py and the schema's contact enum with it @@ -1740,6 +1744,7 @@ The RAM ring a capture fills: disarmed at boot, drained by the writer, honest ab - a boot comes up disarmed and records nothing - a burst carries its own instant off the tape - a disarmed recorder refuses whatever profile it last held +- a flight run records what a power run records, and nothing more - a full capture records every subject the device decides - a full ring refuses the new record rather than the history - a gap costs the slot that freed it, so a ring still full drops again @@ -1778,6 +1783,7 @@ What the device sensed: a fix, a PPS edge, a burst, the barometer, the hub, the - motion keeps the ball and both ends of the g-meter - power keeps the cell, the verdict on it and the warnings counted - the knee rides inside the level, and the bit it used to ride stays clear +- the retired trim bit and bytes are written clear and never reused ## hardware - one folder per part, driver against model @@ -2220,7 +2226,8 @@ The page that states the price of a capture before it takes it, and what it took - an armed device names the capture that is running - an armed device shows what it wrote, what it lost and what it holds - the capture in focus is the one under the bar -- the two captures quote spans two orders of magnitude apart +- the flight run records power and duty, as the power run does +- the full capture and the two runs quote spans two orders apart ### test/products/pages/test_confirm.cpp @@ -2639,7 +2646,7 @@ The status page pins the units a pilot reads first, what each sensor and the rec - every value reads in the aeronautical unit first, then SI - the IMU field reads the hub's own bring-up word and the ball it feeds - the barometer row reads what the sensor resolves -- the battery row states the voltage, the charge and which curve +- the battery row states the voltage and the charge, and on the cable only CHG - the callsign shares the header with the address, and never crowds it > B4. ADS-L carries no callsign, so the setting has exactly one job: telling three devices on a bench apart. It shares the header with the identity that does go on the air. - the page reports whether own-ship is transmitting, not the PPS pin @@ -2828,14 +2835,14 @@ The capture end to end: what arming writes, what the floor refuses, what reads b - a boot comes up disarmed even with a capture on the flash - a device with no partition offers no capture to arm -- a second tap hands the page back to the menu it was opened from +- a tap off the last capture hands the page back to the menu it came from > The pad walks off the last capture rather than trapping the thumb on the page. - a windowed read answers the chunks it was asked for, and stops at eof - both stores are offloaded in one session, each under its own selector - records the ring had to drop reach the flash as a gap - the diagnostics page states the price, and one press does not arm it - the double press arms the capture in focus, and no other -- the page prices both captures, and the pad picks between them +- the page prices every capture, and the pad picks between them - what an armed device recorded comes back through the offload - what is still queued reaches the flash on the way to power off > The queue dies with the rails: the drain has to run after the radio is asleep. @@ -2976,7 +2983,7 @@ One case per record type, at the service that decides the fact it carries. ### test/products/test_diag_taps_power.cpp -The power and duty records: the cell on its cadence, the knee, the trim, and the duty pair. +The power and duty records: the cell on its cadence, the knee, the cable, and the duty pair. **diag duty** @@ -2987,8 +2994,8 @@ The power and duty records: the cell on its cadence, the knee, the trim, and the **diag power** -- a trim the charger taught the unit reaches the corpus, not only settings - > Two minutes on a charger holding its float voltage, which is the only bench this unit gets. +- on the cable the record carries the cable and what the divider read + > 0B1B2C charged for 7 h with every record reading no cable and no cell (#114). - the cell is recorded on the cadence it is sampled at - the knee is recorded on both sides of it, as the level - the reading the median threw out rides beside it, to the millivolt @@ -3153,6 +3160,22 @@ The flight log as a tablet takes it off the device: listed, read back a chunk at - the tablet lists a flight and reads it back five records at a time - with no storage the device flies and logs nothing +### test/products/test_flight_run.cpp + +A flight run: the glass flies while the unit sits on the bench, and nothing false goes on air. + +**flight run** + +- a parked unit keeps the ground cadence on air while the glass flies + > G.1.16: on ground the rate shall be 0.1 Hz, so the glass flies and the transmitter does not. +- a power run and a full capture leave the glass on the ground +- a stop lands the glass, and leaves no flight time behind +- every burst on air says on ground, at the real position, standing still +- records the pair every 30 s, and its boot names the profile +- the glass refreshes as a flown one does, and a parked one does not +- the radar flies, the flight log writes nothing, the gates stay grounded + > A simulated flight in the pilot's log could not be told from a real one, so it writes none. + ### test/products/test_formation.cpp What a formation the device found by itself does to the annunciator, and what takes it back. @@ -3406,25 +3429,27 @@ The cell on the glass: the word the ring wears, the words under the mark, and th > The first step already winks the lamp red, so the glass has to carry that word too. - a cell that takes the device down names itself on the way out > Nobody pressed anything, so the frame is the only thing that can say why the device stopped. +- a charger wake past the bootloader takes the word back off the glass + > The factory bootloader swallows a charger wake: one that got through would get the armed frame. - a cutoff leaves the note for the next boot, an ordinary boot has none - a cutoff too hot to park a frame still notes the flat cell > The note is the cell's, not the frame's: a panel held off in the heat parks nothing. +- a press after a cutoff is refused and leaves the flat frame where it is + > The cutoff left FLAT BATTERY on the glass, and the press that follows needs no other answer. +- a press on the cable after a cutoff starts the device and clears the word + > The lockout spares external power, so the cable and a press are the way out of a flat cell. - a refused boot pushes no frame the glass is already wearing > A frame is seconds of panel rail, and the cell paying for it is the flat one. +- a refused press goes dark only once the button has come up + > SENSE is a level detect: armed under the finger it refused, the wake pin fires at once. - a switch-off on a cell resting at the critical step still asks for the charger > A cell reading 5% sat on the critical step, and one sample above it parked the bare mark. - a switch-off on a critical cell asks for the charger under the mark > The lamp's warning dies with the rails, so the glass carries it to whoever picks the device up. - a switch-off on the cable wears the plain mark, however low the cell > A cable in says the charge is already coming, so the mark alone is the frame. -- the boot after a flat cell names it, though the cable took the word away - > The support half of a flat cell: the glass forgets it on the cable, the boot that runs does not. -- the cable after a cutoff puts the mark back and arms the button - > The whole road, on the one bit that survives the rails: the cutoff writes it, the cable reads it. -- the cable after a low switch-off puts the plain mark back - > The same road out as the flat word: the cable is the instruction the word gave. -- the charger that wakes a flat device takes the word back off the glass - > The cable re-arms the button, so the mark is the whole instruction again. +- the press on the cable after a flat cell names it on the self-test page + > The support half of a flat cell: the boot the cable lets run says so, once. ### test/products/test_panel_protection.cpp @@ -3513,9 +3538,8 @@ H. The gauge is uncalibrated per unit. The divider ratio is a devicetree fact a **product** -- a charge held at the float voltage trims the unit that watched it - > The bench step, done by a charger: a unit that never saw a supply still gets a trim. -- a trim somebody measured outranks the one the charger offers +- a charge however long leaves the trim where the line set it + > On the cable the divider reads the USB rail: a plateau there says nothing of this unit (#114). ### test/products/test_power_run.cpp @@ -3550,6 +3574,8 @@ The acceptance invariant on the host: the real product (board, services, drivers - a charger plugged into a sleeping device is not a boot > D1. A VBUS event is a wake source on this SoC, so plugging a charger into a device that was switched off brings the SoC up. What must not happen is that it brings the DEVICE up: a unit that charges itself awake in a flight bag arrives flat, and the e-paper holds its last image with the rails down, so nothing about it would look wrong until a pilot needed it. - a missing optional capability is degraded, a required one refuses +- on USB power the gauge shows the cable and no state of charge + > E68BD9 told its phone 100 % for 3.5 h of charging from 45 %, off the USB rail (#114). - persisted settings are loaded on setup - settings changed over the link are persisted - setup brings the radio to Rx and reports its capabilities @@ -3570,7 +3596,6 @@ The acceptance invariant on the host: the real product (board, services, drivers - the range gate's refusals leave the device over the link > J. The range gate refuses a reception whose claimed position is further away than this radio could have heard it. Whether that fires once a week or once a second is the first question a support case asks, so the counter leaves the device: read off the table that keeps it, on its own reply. - the reset reason is read once at boot and kept -- the same cell on USB power reports a lower state of charge - the same charger with the button held is a boot ### test/products/test_product_ownship.cpp diff --git a/docs/TUNING.md b/docs/TUNING.md index f9ddc6a3..70b69f5c 100644 --- a/docs/TUNING.md +++ b/docs/TUNING.md @@ -108,7 +108,6 @@ in `firmware/products/skyblip_go/settings.h`. | `kLongPressMs` | 2000 (2 s) | milliseconds | - | Long enough that it cannot be the page press, short enough to do with gloves on. | | `kParkMs` | 3000 (3 s) | milliseconds | - | A power-off parks the panel with a full refresh, which the SSD1681 clocks out in about 2.5 s, and an install or a recovery through black, about a second. | | `kReleaseSettleMs` | 100 | milliseconds | Settle | nRF52 SENSE is a level detect, not an edge, so arming the wake pin while the button is still down wakes the device the instant SYSTEM OFF latches. | -| `kPlateauHoldMs` | 120000 (2 min) | milliseconds | Hold | [README](../firmware/core/power/README.md) argues it | ## [`firmware/core/protocol`](../firmware/core/protocol/README.md) @@ -267,4 +266,4 @@ in `firmware/products/skyblip_go/settings.h`. | `kBaroConversionCeilingMs` | 150 | milliseconds | Ceiling | Zephyr's BME280_MEASUREMENT_TIMEOUT_MS, over 3x the DS 9.1 worst case | | `kBatteryPeriodMs` | 1000 (1 s) | milliseconds | Period | A cell moves over minutes. The gauge needs three readings before it can throw out a transient, so a second between them is the slowest cadence that still shows the state of charge on the first screen a pilot sees. | -162 constants over 18 folders. +161 constants over 18 folders. diff --git a/firmware/README.md b/firmware/README.md index d4ceea24..89b02798 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -59,7 +59,7 @@ The mechanism is the half people get wrong, because four different things all re | `Forget` | past this the record itself is dropped | a fresh report | `kTargetForgetReports`, `kCallsignForgetS` | | `Samples`, `Fixes` | consecutive readings that must agree | one reading the other way | `kLevelSamples`, `kConvergedFixes` | -Hysteresis is not on the list and never becomes a constant: it is the gap between two named thresholds, like the 12.0 m/s a takeoff needs and the 1.0 m/s a landing does, and naming the gap would be a third number nobody reads. +Hysteresis is not on the list and is usually no constant: it is the gap between two named thresholds, like the 12.0 m/s a takeoff needs and the 1.0 m/s a landing does, and naming the gap would be a third number nobody reads. The exception is a gap measured once and shared by every step of a ladder, which is named for what was measured: `kRecoveryMarginMv` is the spread a parked cell reads across, and each battery level is left upwards by that much above the step it was entered under (`core/power/README.md`). The subject comes first because a mechanism on its own is ambiguous where it is used: `kForgetMs` forgets what? Two of those existed, in two namespaces, 30 s each by coincidence. A constant inside a class takes the class as its subject, so `ScreenService::kPresentFloorMs` needs no more. `Delay`, `Grace`, `Debounce` and `Steady` are spelled as one of the words above, so that a grep for every hold in the tree finds every hold. diff --git a/firmware/boards/lilygo/t_echo_plus/board.h b/firmware/boards/lilygo/t_echo_plus/board.h index f2098b14..98449577 100644 --- a/firmware/boards/lilygo/t_echo_plus/board.h +++ b/firmware/boards/lilygo/t_echo_plus/board.h @@ -208,8 +208,9 @@ class TEchoPlus { if (now_ms - last_battery_ms_ < runtime::kBatteryPeriodMs) return; last_battery_ms_ = now_ms; uint16_t millivolts = 0; - if (platform_.read_battery_mv(millivolts)) - bus_.battery.push(events::BatterySample{millivolts, platform_.external_power()}); + // INFO: fc 05oct26 refused or not, the reading and VBUS go out: core/power judges it + platform_.read_battery_mv(millivolts); + bus_.battery.push(events::BatterySample{millivolts, platform_.external_power()}); } // The producer side: everything hardware says arrives on the bus, and the diff --git a/firmware/boards/lilygo/t_echo_plus/factory/README.md b/firmware/boards/lilygo/t_echo_plus/factory/README.md index fb12f637..d6672798 100644 --- a/firmware/boards/lilygo/t_echo_plus/factory/README.md +++ b/firmware/boards/lilygo/t_echo_plus/factory/README.md @@ -15,3 +15,22 @@ What it pins down: At tag 0.6.1 the write window is `USER_FLASH_START..USER_FLASH_END` = `MBR_SIZE .. BOOTLOADER_REGION_START - DFU_APP_DATA_RESERVED` (`src/usb/uf2/uf2cfg.h:20-21`), and the nRF52840 branch of `Makefile:117` sets `DFU_APP_DATA_RESERVED = 10*4096`. With the region starting at 0xF4000 that is 0x1000..0xEA000, which is what `scripts/mkuf2.py` guards and `scripts/test_mkuf2.py` asserts. The device is two commits past that tag on LilyGO's build, and neither constant moved between them. A unit that reports a different bootloader or SoftDevice version has not been checked against any of this. Read its `INFO_UF2.TXT` first: a smaller `USER_FLASH_END` silently truncates an install, and that is the failure the guards in `mkuf2.py` exist to catch. + +## What it does with a wake + +`0.6.1-2-g1224915` is Linar Yusupov's fork of the Adafruit bootloader (`lyusupov/Adafruit_nRF52_Bootloader`), two commits past the tag: `42d10b5` adds the board, and `1224915`, "Fix against unwanted wakeup by USB VBUS power on/off signal when battery is main power source", adds four lines to `src/main.c:181-184`. Adafruit's own master does not carry them. They run before the board is initialised, on every reset: + +```c +if ((NRF_POWER->RESETREAS & POWER_RESETREAS_VBUS_Msk) && dfu_skip) { + NRF_POWER->RESETREAS |= POWER_RESETREAS_VBUS_Msk; + NRF_POWER->SYSTEMOFF = 1; +} +``` + +`dfu_skip` is GPREGRET holding `0x6d`, `DFU_MAGIC_SKIP`, the byte the application leaves there on its way to SYSTEM OFF. So while it is set, a cable plugged in wakes the SoC and the bootloader puts it straight back to sleep, before MCUboot and before the application. SoftRF, whose author wrote the fix, writes the same byte before every sleep (`src/platform/nRF52.cpp:3213-3226`), so for SoftRF giving up the cable is the point, and for this firmware too: a charger plugged into a device in a flight bag must not wake it. GPREGRET is cleared only after that test, so the byte survives and every later cable is swallowed the same way. A GPIO wake does not set the VBUS bit, and the pin configuration survives the wake reset, so an armed button still wakes the unit through it. + +The application cannot simply stop writing the byte. `BUTTON_DFU` is P1.10, the device's only button (`src/boards/lilygo_techo/board.h`), and without the skip the bootloader reads it on every boot: a press that wakes the unit is still held when it looks, and `dfu_start` comes up USB DFU with no timeout. `BUTTON_FRESET` is P0.11, the pad, and both together are BLE OTA. The skip also bypasses the double-reset window and its RAM word at `0x20007F7C`, whose contents through SYSTEM OFF are not defined, at the cost of a double-click of RESET not arming on the first boot after a power-off. + +So every switch-off writes `0x6d` (`boot_magic_for_system_off` in `core/power/shutdown.h`), and every switch-off arms the button. The byte keeps a waking press out of USB DFU, and the price is the cable: on this bootloader a charger never reaches the application, so a unit switched off on `FLAT BATTERY` or `CHARGE BATTERY` keeps that word through the whole charge, and the button, which the lockout refuses on a flat cell and lets through on the cable, is the only way back (`core/power/README.md`). This firmware once withheld the button after the cutoff and after a refusal on a flat cell, which with `0x6d` left those units to the reset pin alone, and then wrote `0x00` there so a cable could reach the refusal and repaint the glass. Both are gone: a unit the cutoff switched off wakes on its button like any other, and an armed button needs `0x6d`. + +`0x57` for recovery outranks it (`hardware/platform/zephyr/dfu.h`). diff --git a/firmware/core/comms/README.md b/firmware/core/comms/README.md index 90b33637..c58bdb43 100644 --- a/firmware/core/comms/README.md +++ b/firmware/core/comms/README.md @@ -30,7 +30,7 @@ What a claim is not is access control. `CONFIG_BT_SMP` is off on this product, d `{"cmd":"status"}` is the one frame the device also sends without being asked: `set_flight_state` pushes it when the flight state changes, and `set_battery_state` on a charging flip, a level change or a five-percent step. The flight state is what every write is gated on, so a page that holds its buttons for the ground learns it landed without polling. So it carries what a pilot's screen shows and nothing a bench reads, and it has to fit `kSmallestSupportedPayload`, the 182 bytes an iPhone commonly settles at, with every field at its widest. The buffer is that limit: `json::Writer` leaves a field out whole rather than cutting it, and `format_status` then refuses the frame and counts a `link_drops`. -What a support case reads lives where it is asked for. The address and the callsign are the config reply's. Why the device came up is `reset` in the diag sys group, and whether the board has a gauge at all is `valid` in the diag power group. One boot fact stays, `went_dark_flat`, because "the battery went flat" is the answer a pilot is owed for a unit that switched itself off (`core/power/README.md`). A key with no reading behind it is left out, never sent as a zero: `battery_percent` without a gauge sample, `die_temp_c` without a fresh die reading. +What a support case reads lives where it is asked for. The address and the callsign are the config reply's. Why the device came up is `reset` in the diag sys group, and whether the gauge is reading the cell is `valid` in the diag power group, with `mv` what the divider read when it is not: no divider, a refused reading, or the USB rail on the cable (`core/power/README.md`). One boot fact stays, `went_dark_flat`, because "the battery went flat" is the answer a pilot is owed for a unit that switched itself off (`core/power/README.md`). A key with no reading behind it is left out, never sent as a zero: `battery_percent` without a reading of the cell, which on the cable there never is, `die_temp_c` without a fresh die reading. [`schemas/status.v1.schema.json`](../../../schemas/status.v1.schema.json) is the contract, and `scripts/check_status_schema.py` holds `format_status` to its keys, their order, their types and which of them may be absent. The widest frame is pinned byte for byte in `test/products/test_comms_report.cpp`. diff --git a/firmware/core/diag/README.md b/firmware/core/diag/README.md index f2542889..8499ff9c 100644 --- a/firmware/core/diag/README.md +++ b/firmware/core/diag/README.md @@ -58,14 +58,14 @@ Every field below is read off `bus::State` or off an `events::` value as it stan | Type | Payload | The tuning question it answers | |---|---|---| -| `Boot` 1 | capabilities, firmware version, reset reason, image state | which build and which parts produced the rest of the corpus, and whether the device came up from a fault | +| `Boot` 1 | capabilities, firmware version, reset reason, image state, the capture profile | which build and which parts produced the rest of the corpus, whether the device came up from a fault, and which capture the session is | | `Config` 2 | address, address table, aircraft type, alarm volume, battery and frequency trims, whose the battery trim is, units, alarm enabled, the power the transmitter was asked for and the PA row it was asked through | what the firmware was assuming while it decided everything else | | `Gnss` 3 | nav_ms, residual, HDOP, VDOP, stage and its age, sats used and in view, fix mode, reject reason | where in its own second a solution lands (`kFixLagMaxMs`, 500 ms of §G.1.16 nav age), and what the rejects cost | | `Pps` 4 | edge interval, signed error against a nominal second, samples, holdover events, ms since the edge, lock | whether `kPpsHoldoverMs` is the right patience, and what the slot map is really anchored to | | `Burst` 5 | the whole of `radio::Entry`: verdict, band, channel, address, length, RSSI, key offset, tx_keyed_us, tx_span_us, the stage margin, airborne, callsign | every verdict `radio::Event` declares against the second it happened in: two devices on one bench read one link twice | | `Dwell` 6 | slot state, band, frequency, start and end, the phase it was read at, duty, noise floor, refusal reason, tx_allowed | whether the dwell map spends the second where §C.5 says, and what refuses a burst when one is refused | | `Flight` 7 | speed, climb, altitude, HDOP, VDOP, declared state, latched state, rolling | how close the machine came to deciding the other way. Written on every evaluation, never only on transitions: a transition-only tape cannot say what the margin was, and the margin is the whole question when choosing 12.0 against 8.0 m/s | -| `Power` 8 | cell millivolts as the gauge's median and the last reading beside it, percent, level, charge condition, die temperature, the trim the charger taught this unit, supply warnings, implausible and charge counts | what a burst does to the rail, and whether `kLowMv`, `kCriticalMv` and `kFlatMv` fire where a real pack needs them to | +| `Power` 8 | cell millivolts as the gauge's median and the last reading beside it, or what the divider read when it was not the cell, percent, level, charge condition, die temperature, supply warnings, refused readings and charge counts | what a burst does to the rail, and whether `kLowMv`, `kCriticalMv` and `kFlatMv` fire where a real pack needs them to | | `Baro` 9 | pressure, derived altitude, the rate taken from it, temperature | the vertical speed window (`kBaroVsWindowMs` against `kGnssVsWindowMs`) measured rather than assumed | | `Motion` 10 | ball position, the three g-meter axes now, the normal axis at both extremes, hub error bytes | what the airframe pulled, and whether the filter lengths suit it. Absent on a unit with no hub, which the fitted flag says | | `Contact` 11 | contact, edge instant, how long the level had held, the gesture the product decided | `Controls::kLongTouchMs` and `kLongPressMs` against the presses a hand actually makes | @@ -91,13 +91,15 @@ A burst one unit sent and the burst another heard it as are one instant read twi ### The battery ladder, and what a replay works out for itself -The `Power` record carries the steps of `core/power`'s ladder the device decided rather than the ones a decoder can re-derive. `level` is a decision: runs of three consecutive samples down to a step and three back up, cleared by a cable, and no comparison on `cell_mv` can replay it (`../power/README.md`). Its codes are not in ladder order: `low`, the knee, joined last as code 4, and `critical` and `flat` are codes 2 and 3 with the thresholds they had as `low` and `cutoff`, so an older capture decodes to the same step. Until the knee was a level it rode beside it as the `caution` flag, bit 6, which newer builds leave clear and nothing reuses. `trim_offset_mv` and `trim_learned` are the other decision: `power::FloatTrim` reads a charger's float plateau off the untrimmed samples, and the corpus only ever sees millivolts that have already been through a trim, so the number it learned cannot be recovered from any record. Whether the device would adopt it is `Config`'s `battery_trim_manual`, because a trim a person measured outranks a charger's for good. +The `Power` record carries the steps of `core/power`'s ladder the device decided rather than the ones a decoder can re-derive. `level` is a decision: runs of three consecutive samples down to a step and three back up that clear it by `kRecoveryMarginMv`, cleared by a cable, and no comparison on `cell_mv` can replay it (`../power/README.md`). Its codes are not in ladder order: `low`, the knee, joined last as code 4, and `critical` and `flat` are codes 2 and 3 with the thresholds they had as `low` and `cutoff`, so an older capture decodes to the same step. Until the knee was a level it rode beside it as the `caution` flag, bit 6, which newer builds leave clear and nothing reuses. `cell_mv` is the gauge's median of three, because that is the figure `percent` and `level` were decided on and the one `scripts/power_budget.py` measures a run between: a run that started or ended on a single reading caught in a burst would be a pack drained by the sag. The median is also what hides the sag, and a record that exists to show what a burst does to the rail cannot be blind to it. So the last reading rides in the one byte left, `sample_offset_mv`, the reading minus the median in whole millivolts. On a quiet cell it is a few millivolts and exact. A reading 128 mV or more under the median saturates at -128, which says a sag happened and that it was at least that deep; the depth itself is a meter's to measure, and a 1 Hz reading lands on a 5 ms burst about once in two hundred seconds anyway. -Both fit where the record already had room: `trim_learned` is the last bit of the flag byte, beside the retired `caution`, the offset is payload bytes 13 and 14, and byte 15 is still free. +`valid` says whether `cell_mv` is the cell. On the cable it never is, because the divider reads the USB rail there (`../power/README.md`), and neither is a reading outside `power::plausible_mv`, 1800 to 4700 mV. Either way `valid` is clear, `percent` is 0 and `cell_mv` is what the divider read, so a capture on a charger keeps the rail and a refused reading keeps the bound it crossed, with `implausible` counting every refusal. 0 is an ADC that gave nothing. Builds before 2026-10-05 refused those readings in the platform and recorded nothing, which is why their `cell_mv` is 0 whenever `valid` is clear, and why one on a charger read `valid` with the rail as `cell_mv`. `charging` has been `external_power` since: nothing on this board says when a charge ends. -Four more things the ladder decides are left out on purpose, all of them a decoder's arithmetic rather than the device's: the lamp condition, which `indication::condition_for` computes from the alarm level, the power level and the fix, every one of them already in the corpus; the panel's `battery_low`, which is `power::needs_charge(level)`; `may_write` and `may_refresh`, which are `level` and a supply warning count that is already a field; and the trim window's own workings, the session low and the plateau spread, which are the derivation and not the result. The parked low-cell frame is invisible for a different reason: the capture is closed before the glass is drawn. +Until the same change the device tried to trim its own gauge against a charger's float plateau, and recorded the result as `trim_learned`, bit 7 of the flag byte, and `trim_offset_mv`, payload bytes 13 and 14. The divider never sees that plateau, so the learner went, and the bit and the two bytes stay clear and are never reused: an older capture decodes them as it always did. + +Three more things the ladder decides are left out on purpose, all of them a decoder's arithmetic rather than the device's: the lamp condition, which `indication::condition_for` computes from the alarm level, the power level and the fix, every one of them already in the corpus; the panel's `battery_low`, which is `power::needs_charge(level)`; and `may_write` and `may_refresh`, which are `level` and a supply warning count that is already a field. The parked low-cell frame is invisible for a different reason: the capture is closed before the glass is drawn. ### Duty, and a power budget on a board with no shunt @@ -150,7 +152,7 @@ One tap per fact, in the service that owns the field on `bus::State` (`core/bus/ | `Screen` | `services/screen.cpp` `record_screen` | `ScreenService::kRecordPeriodMs`, the render cadence | | `Gap` | `recorder.cpp` `flush_gap`, `services/capture.cpp` `write_gap`, `announce_rotation` | where the ring refused a record, where the partition refused a sector, and where the sector ring recycled one of this session's own | | `End` | `services/capture.cpp` `write_end` | the last record of a session that stopped rather than died | -| `Duty` | `services/power.cpp` `record_duty` | on the `Power` pass and under its instant: `PowerService::kDutyRecordPeriodMs` in a full capture, `diag::kPowerRunRecordPeriodMs` in a power run, and once more after the last `Power` as a capture parks | +| `Duty` | `services/power.cpp` `record_duty` | on the `Power` pass and under its instant: `PowerService::kDutyRecordPeriodMs` in a full capture, `diag::kPowerRunRecordPeriodMs` in a power run or a flight run, and once more after the last `Power` as a capture parks | `Gnss::reject` is the receiver's own verdict and reaches the tap the way every other receiver fact does: `gnss::FixValidity` lives on the L76K driver, which no `runtime::Context` reaches, so the board publishes its reason and its count onto `bus::State` beside the sky view as it pushes the solution, and own-ship reads them there. The board is a writer of that group by the table in `core/bus/README.md`. @@ -177,13 +179,17 @@ uint32_t dropped() const; `record()` returns on the armed flag before it encodes anything, so a disarmed device pays one branch per tap. A tap that has to gather its fields first checks `armed()` itself. -### The two profiles +### The three profiles -A profile is what a capture is for, chosen when it is armed and held in RAM beside the armed flag, so a boot comes up with neither. `Full` lists every type: it is the corpus the tuning work replays, and at eleven records a second it fills the ring in about an hour. `PowerRun` lists `Boot`, `Config`, `Power`, `Duty`, `Gap` and `End`, and nothing else. +A profile is what a capture is for, chosen when it is armed and held in RAM beside the armed flag, so a boot comes up with neither. `Full` lists every type: it is the corpus the tuning work replays, and at eleven records a second it fills the ring in about an hour. `PowerRun` lists `Boot`, `Config`, `Power`, `Duty`, `Gap` and `End`, and nothing else. `FlightRun` lists the same six at the same cadence (`paced()`), and is the one profile that changes what the device does as well as what it records (`simulates_flight()`). PowerRun exists because a discharge run to cutoff on the 2400 mAh pack is 35 to 50 hours and a full capture keeps 68 minutes. Two records every 30 s is 240 an hour, so the 45,220 slots of the diagnostics ring hold 188 hours: a run to cutoff fits whole, with its start intact, which is the part of the curve a power budget is built from. The cadence is one constant with that arithmetic behind it rather than a divider a bench can turn, because a number is defensible where a knob is not, and `Power` and `Duty` go out on the same pass so a reader divides one pair of records by another without interpolating between two clocks. -A type the profile does not list is refused and **not counted as a drop**: a `Gap` naming records the profile never wanted would be a lie about a hole. What the profile cannot do is change what a record means, which is why `Boot`, `Config` and `End` are in both: a corpus that cannot say which build and which settings produced it, or whether it ended or died, is not evidence. +FlightRun exists because a power run measures a parked unit, and the question a pilot asks is how long one lasts in the air. While a flight run is armed the product runs the glass as in flight and nothing else: the radar's flight clock and `FLIGHT` word, and the g-meter's extremes cleared at the simulated takeoff (`../../products/skyblip_go/README.md`). The rest of what flight changes reads own-ship's flight state, which is what goes on air, so it stays the unit's real one. A parked unit keeps transmitting at the ground cadence and says it is on ground, because ADS-L G.1.16 holds an on-ground report to 0.1 Hz and an airborne flag on a parked unit would put a false aircraft in front of every receiver in range. The flight log follows the real state too, so a parked unit writes no flight: a simulated one among a pilot's flights could not be told apart from a real one, and a run to cutoff would evict the real ones from the ring. What that leaves out of the draw is a 24-byte program every 4 s and a sector erase every 11 minutes. Offload and updates stay open, because the gates that refuse them in flight read the same real state. `scripts/power_budget.py` prints the transmit the run could not send beside its model, read off the profile `Boot` names. + +The profile reaches the corpus as payload byte 14 of `Boot`, with flag bit 2 set to say the byte was written. Every `Boot` before that left both zero, and zero is `Full`, so a reader takes the byte only when the flag is set: an older power run decodes with no profile rather than as a full capture. + +A type the profile does not list is refused and **not counted as a drop**: a `Gap` naming records the profile never wanted would be a lie about a hole. What the profile cannot do is change what a record means, which is why `Boot`, `Config` and `End` are in all three: a corpus that cannot say which build and which settings produced it, or whether it ended or died, is not evidence. The writer peeks and commits rather than taking, because the two failures it can meet are different. A flash that refused the write still owes that record: it stays at the head of the ring and the next pass tries again, where a `take()` in front of the write would have dropped it somewhere no counter can see. Only a record the flash has taken leaves the ring. `flight::LogSession` drains the same way, for the same reason. diff --git a/firmware/core/diag/payload.h b/firmware/core/diag/payload.h index eb67fe40..45e8b97f 100644 --- a/firmware/core/diag/payload.h +++ b/firmware/core/diag/payload.h @@ -22,6 +22,10 @@ namespace skyblip::diag { +enum class Profile : uint8_t { Full = 0, PowerRun = 1, FlightRun = 2 }; + +constexpr uint8_t kBootFlagProfileRecorded = 1u << 2; + struct Boot { uint32_t capabilities{0}; uint32_t fw_build{0}; @@ -30,6 +34,8 @@ struct Boot { uint8_t fw_minor{0}; power::ResetReason reset{power::ResetReason::Unknown}; dfu::ImageState image_state{dfu::ImageState::Confirmed}; + Profile profile{Profile::Full}; + bool profile_recorded{false}; }; constexpr uint8_t kConfigFlagAlarmEnabled = 1u << 2; @@ -151,7 +157,7 @@ constexpr uint8_t kPowerFlagExternal = 1u << 3; constexpr uint8_t kPowerFlagValid = 1u << 4; constexpr uint8_t kPowerFlagDieValid = 1u << 5; // INFO: fc 30sep26 bit 6 was caution until the level carried Low, and is never reused -constexpr uint8_t kPowerFlagTrimLearned = 1u << 7; +// INFO: fc 05oct26 bit 7 and payload bytes 13-14 were the learned trim, and are never reused struct Power { uint16_t cell_mv{0}; @@ -159,7 +165,6 @@ struct Power { uint32_t implausible{0}; uint32_t charge_warnings{0}; int16_t die_dc{0}; - int16_t trim_offset_mv{0}; int8_t sample_offset_mv{0}; uint8_t percent{0}; power::PowerLevel level{power::PowerLevel::Unknown}; @@ -168,7 +173,6 @@ struct Power { bool external_power{false}; bool valid{false}; bool die_valid{false}; - bool trim_learned{false}; }; constexpr uint8_t kBaroFlagActive = 1u << 2; diff --git a/firmware/core/diag/payload_decided.cpp b/firmware/core/diag/payload_decided.cpp index 327f1c22..1e8ccd7d 100644 --- a/firmware/core/diag/payload_decided.cpp +++ b/firmware/core/diag/payload_decided.cpp @@ -11,6 +11,8 @@ Record record_of(const Boot& value, const Instant& at) { r.payload[11] = value.fw_minor; r.payload[12] = static_cast(value.reset); r.payload[13] = static_cast(value.image_state); + r.payload[14] = static_cast(value.profile); + set_flag(r.flags, kBootFlagProfileRecorded, value.profile_recorded); return r; } @@ -24,6 +26,8 @@ bool read(const Record& record, Boot& out) { out.fw_minor = record.payload[11]; out.reset = static_cast(record.payload[12]); out.image_state = static_cast(record.payload[13]); + out.profile = static_cast(record.payload[14]); + out.profile_recorded = record.flagged(kBootFlagProfileRecorded); return true; } diff --git a/firmware/core/diag/payload_sensed.cpp b/firmware/core/diag/payload_sensed.cpp index 0bf01199..ec63fd6f 100644 --- a/firmware/core/diag/payload_sensed.cpp +++ b/firmware/core/diag/payload_sensed.cpp @@ -190,13 +190,11 @@ Record record_of(const Power& value, const Instant& at) { r.payload[10] = value.percent; r.payload[11] = static_cast(value.level); r.payload[12] = static_cast(value.charge); - put_i16(r.payload + 13, value.trim_offset_mv); r.payload[15] = static_cast(value.sample_offset_mv); set_flag(r.flags, kPowerFlagCharging, value.charging); set_flag(r.flags, kPowerFlagExternal, value.external_power); set_flag(r.flags, kPowerFlagValid, value.valid); set_flag(r.flags, kPowerFlagDieValid, value.die_valid); - set_flag(r.flags, kPowerFlagTrimLearned, value.trim_learned); return r; } @@ -211,13 +209,11 @@ bool read(const Record& record, Power& out) { out.percent = record.payload[10]; out.level = static_cast(record.payload[11]); out.charge = static_cast(record.payload[12]); - out.trim_offset_mv = get_i16(record.payload + 13); out.sample_offset_mv = get_i8(record.payload + 15); out.charging = record.flagged(kPowerFlagCharging); out.external_power = record.flagged(kPowerFlagExternal); out.valid = record.flagged(kPowerFlagValid); out.die_valid = record.flagged(kPowerFlagDieValid); - out.trim_learned = record.flagged(kPowerFlagTrimLearned); return true; } diff --git a/firmware/core/diag/profile.h b/firmware/core/diag/profile.h index 6349bf7c..6270f15a 100644 --- a/firmware/core/diag/profile.h +++ b/firmware/core/diag/profile.h @@ -8,7 +8,9 @@ namespace skyblip::diag { -enum class Profile : uint8_t { Full = 0, PowerRun = 1 }; +constexpr bool paced(Profile profile) { return profile != Profile::Full; } + +constexpr bool simulates_flight(Profile profile) { return profile == Profile::FlightRun; } // INFO: fc 21sep26 45,220 slots at two records a pass is 188 h, against a 50 h run to cutoff constexpr uint32_t kPowerRunRecordPeriodMs = 30000; diff --git a/firmware/core/diag/recorder.h b/firmware/core/diag/recorder.h index 1d14fd5c..fe6cf42a 100644 --- a/firmware/core/diag/recorder.h +++ b/firmware/core/diag/recorder.h @@ -22,9 +22,8 @@ class Recorder { static constexpr uint32_t kMsPerHour = kSecondsPerHour * 1000; static constexpr uint32_t records_per_hour(Profile profile) { - return profile == Profile::PowerRun - ? kPowerRunRecordsPerPass * kMsPerHour / kPowerRunRecordPeriodMs - : kPeriodicRecordsPerSecond * kSecondsPerHour; + return paced(profile) ? kPowerRunRecordsPerPass * kMsPerHour / kPowerRunRecordPeriodMs + : kPeriodicRecordsPerSecond * kSecondsPerHour; } void arm(Profile profile = Profile::Full); diff --git a/firmware/core/events/sensor.h b/firmware/core/events/sensor.h index 4753c8b5..597c846a 100644 --- a/firmware/core/events/sensor.h +++ b/firmware/core/events/sensor.h @@ -18,8 +18,8 @@ struct AccelSample { uint32_t at_ms; }; -// The cell's terminal voltage, and whether something is feeding the charger. -// What that pair means is core/power's problem, not the board's. +// What the divider read, and whether something is feeding the charger. Whether +// that reading is the cell at all is core/power's problem, not the board's. struct BatterySample { uint16_t millivolts; bool external_power; diff --git a/firmware/core/indication/README.md b/firmware/core/indication/README.md index 7ea46d81..baa51820 100644 --- a/firmware/core/indication/README.md +++ b/firmware/core/indication/README.md @@ -36,4 +36,4 @@ The board already answers it in hardware. The charger IC drives its own LED, doc Until 2026-09-18 this table had `Charging` and `Charged` rows, held solid for as long as a cable was in. They cost the thing the lamp exists for: on a powered install the only indicator of a running device and a valid fix was replaced by a second opinion about the charger. `Charged` was worse than redundant, since it came from a float-voltage threshold (`core/power/battery.h`) and could contradict the LED beside it. -Their priority rule was already dead code. `power::CutoffMonitor::apply` forces `PowerLevel::Normal` whenever external power is present, because the charger holds the terminal above the cell, so "charging outranks low" arbitrated a conflict that cannot occur. A cable in is never `Low` or `Critical`, and that is decided in `core/power`, once. +Their priority rule was already dead code. `power::CutoffMonitor::apply` forces `PowerLevel::Normal` whenever external power is present, because on the cable the divider reads the USB rail and not the cell, so "charging outranks low" arbitrated a conflict that cannot occur. A cable in is never `Low` or `Critical`, and that is decided in `core/power`, once. diff --git a/firmware/core/power/README.md b/firmware/core/power/README.md index 01887495..e1e55ce9 100644 --- a/firmware/core/power/README.md +++ b/firmware/core/power/README.md @@ -1,6 +1,6 @@ # core/power -What a terminal voltage means, what the device does about it, and the order it goes dark in. `battery.*` is the gauge a pilot reads, `cutoff.*` the rule that acts, `trim.*` the one calibration the device can perform on itself, `charging.*` the temperature window, `wake.*` whether a boot becomes a device, `shutdown.*` the road out, `reset_reason.*` what the last one was, `duty.h` how long a consumer was on. +What a terminal voltage means, what the device does about it, and the order it goes dark in. `battery.*` is the gauge a pilot reads, `cutoff.*` the rule that acts, `charging.*` the temperature window, `wake.*` whether a boot becomes a device, `shutdown.*` the road out, `reset_reason.*` what the last one was, `duty.h` how long a consumer was on. The cell is a 4.2 V LiPo pouch: 2400 mAh on this board, and LilyGO fits the same footprint with an 850 mAh pack on the plain T-Echo. Nothing below changes between them. A pouch cell's voltage says what fraction of the charge is left; only its capacity says how long each fraction lasts, and capacity is the thing no reading here can see. @@ -35,11 +35,11 @@ Three of the steps are a `PowerLevel`, and the level is the one fact every reade | `Normal` | | green or blue, one wink every 3 s | the state word | `%` | the wordmark | nothing | | `Low` | `kLowMv` | red, one wink every 3 s | `BAT n%` | `% LOW` | `CHARGE BATTERY` | nothing | | `Critical` | `kCriticalMv` | red, a blink every 600 ms | `BAT n%` | `% LOW` | `CHARGE BATTERY` | every durable write but the flight record | -| `Flat` | `kFlatMv` | red, a blink every 600 ms, then dark | `BAT n%` | `% LOW` | `FLAT BATTERY` | everything: the device takes itself down and leaves the button unarmed | +| `Flat` | `kFlatMv` | red, a blink every 600 ms, then dark | `BAT n%` | `% LOW` | `FLAT BATTERY` | everything: the device takes itself down, and a press after it is refused | `needs_charge` is the one predicate the product asks, true from `Low` down, and it is what puts `BAT` in the ring, `LOW` on the status row and `CHARGE BATTERY` on the glass of a long press or a companion switch-off. A stow stays blank at any level. The lamp tells `Low` from `Critical` by cadence, because the first can stand for an hour and has to cost what the alive wink costs, and the second is the step a pilot is meant to act on. -`CutoffMonitor` moves the level on runs of `kLevelSamples` consecutive readings, in both directions. Three under a step go down to it, which is what keeps a transmit burst from acting. Three at or above it come back up, which is what keeps a cell resting on a step from flickering across it: a pack spends minutes within a few millivolts of each line, and a level that rose on one good reading was a lamp, a ring and a switch-off frame that changed their minds every few seconds. A recovery climbs every step its readings cleared at once. The cable is the exception both ways, because the charge current holds the terminal above the cell: a cable puts the level at `Normal` on its first reading and nothing counts until it leaves. `Flat` latches, so a cell relaxing once the radio is quiet cannot cancel the shutdown it started. +`CutoffMonitor` moves the level on runs of `kLevelSamples` consecutive readings, in both directions. Three under a step go down to it, which is what keeps a transmit burst from acting, and a falling cell is not slowed by anything more. Coming back up takes three that clear the step by `kRecoveryMarginMv` (35 mV), which is what keeps a cell resting on a step from flickering across it. The run alone did not: on E68BD9's run to cutoff (`docs/bench/2026-10-05-power-run-E68BD9.md`) the level crossed between `Normal` and `Low` five times in five minutes at 3591 to 3601 mV, and between `Low` and `Critical` three times in a minute, because three readings a second apart on either side of a line are routine while a parked cell takes minutes to cross it. The margin is that run's spread: the gauge's median moves 35 mV inside a half hour at the median of its half-hour windows, and three readings that all clear a line have a median that clears it too, so a median that wanders less than the margin never brings the level back up. A `static_assert` keeps `kCriticalMv` plus the margin under `kLowMv`, so a cell climbing out of `Critical` lands in `Low` rather than having to clear it too. A recovery climbs every step its readings cleared at once. The cable is the exception both ways, because the charge current holds the terminal above the cell: a cable puts the level at `Normal` on its first reading and nothing counts until it leaves. `Flat` latches, so a cell relaxing once the radio is quiet cannot cancel the shutdown it started. An `Unknown` monitor has seen no believable reading yet, which is also what an unpopulated divider reads for ever, and it acts like `Normal`. Its first reading at or above `kLowMv` makes it `Normal`; one under waits for the run like any other step. @@ -51,49 +51,55 @@ The enum's values are the diagnostics record's level byte, so they are not in la A latched supply warning outranks the level entirely. POFCON compares the SoC's own rail, so once it has fired what the divider says about the cell is no longer the question, and every write but the flight record stops. It never drives the level to `Flat`, so it never starts a shutdown: POFCON warns about VDD, well under any healthy cell, so by the time it fires there is no orderly shutdown left to run, the panel park alone is 3 s and the brownout reset is milliseconds away. The one useful thing at that moment is not to be inside a write. The voltage rule keeps the shutdown, where three consecutive samples are the evidence. -## The two curves +## The curve, and the cable -`percent_from_mv` reads one of two tables, because the same reading means two different states of charge. Off charge, the terminal is the cell's own voltage under our load. On charge, the constant-current phase lifts it by the drop across the cell's internal resistance, and the 4.15 to 4.20 V constant-voltage taper is where the last fifth of the capacity goes in at a voltage that barely moves. LilyGO says the same thing from the other end: their own wiki warns that battery readings are inaccurate while USB is connected. +`percent_from_mv` reads one table, and only off the cable. On these boards the divider cannot see the cell while USB is in: both bench units read the USB rail for the whole charge, 4670 to 4700 mV on E68BD9 from a cell switched off at 45 %, and above 4700 mV on 0B1B2C (`docs/bench/2026-10-05-power-run-E68BD9.md`, #114). A cell in constant current reads well under 4.2 V, so that reading is not the cell and no curve can make a state of charge of it. LilyGO's own wiki warns that battery readings are wrong while USB is connected. -Both tables are the textbook shape for a single pouch cell, not this pack measured on this board. They are right to a few percent in the middle and worst at the ends, which is where a pilot cares. What replaces them is a logged discharge of a fitted unit under the real dwell map, which the power budget needs anyway: the shape of the table does not change, only its points. `battery.h` carries the same warning where it explains why there is no time-remaining estimate, and that estimate stays refused until the same log exists. +So on the cable the gauge shows no cell. `valid` is false, `percent` is 0 and `millivolts` is what the divider read, which a capture keeps as evidence of the rail. `charging` is the cable itself: this board has no charger status pin, so nothing says when the charge ends, and a full cell on the cable still reads `charging`. The status reply leaves `battery_percent` out, the status page reads `-- V --% CHG`, and the first reading off the cable starts the median over on the charged cell, which is the first number worth showing. There is no charge curve: the reading it needed never reaches the device. -## The trim, and the reference the device can find +The discharge table is the textbook shape for a single pouch cell, not this pack measured on this board. It is right to a few percent in the middle and worst at the ends, which is where a pilot cares. What replaces it is a logged discharge of a fitted unit under the real dwell map, which the power budget needs anyway: the shape of the table does not change, only its points. `battery.h` carries the same warning where it explains why there is no time-remaining estimate, and that estimate stays refused until the same log exists. -`kCalibrationLimitMv` and `settings.battery_offset_mv` exist because two 1% resistors and the SAADC's own gain error can put a reading out by more than 80 mV, which is tens of percentage points in the flat middle. The bench answer is one number measured against a supply on the line. +## The window, and what a refused reading leaves behind -`trim.h` is the answer for units that never see that bench. While a charger is in its constant-voltage phase it holds the terminal at its float voltage whatever the cell underneath is doing, so a reading taken there has a known true value, and the difference is the unit's error. Three conditions make the reading trustworthy: +A reading is the cell only inside `plausible_mv`: above `kImplausibleFloorMv` (1800 mV), which an unpopulated or floating divider sits under, and at most `kImplausibleCeilingMv` (4700 mV), which no 4.2 V cell reaches and a rail does. The platform's read still answers whether a reading is believable, but it hands back what the divider read either way, 0 when the ADC gave nothing, and the board publishes it on every battery pass with VBUS beside it. A refused reading never hides the cable again, so the cutoff monitor's cable override and the charge window both see it. -The cable is in, so something is holding the rail. The reading climbed into the plateau from under `kClimbCeilingMv` during this same cable session, which is what separates a charger in constant voltage from a topped-off pack drifting slowly downwards under its own load. And the reading then sat inside `kPlateauSpreadMv` for `kPlateauHoldMs`, because a plateau is the claim being made. +The gauge counts each refusal, on the cable or off it (`Gauge::refused`, the `implausible` count in the `power` record and the diagnostics reply), and shows the refused value as its `millivolts` with `valid` false, so a capture says which bound a reading crossed. Off the cable a refused reading is no step on the ladder: the cutoff monitor never sees it, so it neither counts towards a level nor breaks a run. -What that buys is bounded and worth saying: the learned trim is only as good as the charger's float tolerance, typically 1%, so it replaces an 80 mV unknown with a 40 mV one. It does not replace the bench measurement, it replaces nothing having been measured at all. A trim a person set by hand outranks it for good (`settings.battery_offset_manual`), and the learned value goes to flash through the same window and the same power rule as any other setting. +## The trim, set on the line -The device has to be switched on across a charge for any of this to happen, which is a bench, a desk, or a long sit in a window. A unit that is only ever charged while off keeps whatever trim it was given. +`kCalibrationLimitMv` and `settings.battery_offset_mv` exist because two 1% resistors and the SAADC's own gain error can put a reading out by more than 80 mV, which is tens of percentage points in the flat middle. The answer is one number measured against a supply on the line, and it is the only trim there is. + +The device cannot trim itself. A charger in constant voltage holds the cell's terminal at its float, so a reading taken there would have a known true value, but the divider reads the USB rail while the cable is in, so no float plateau ever reaches it. The learner that waited for one never learned on either bench unit, and a rail that happened to sit within `kCalibrationLimitMv` of 4.2 V would have taught it the rail's error and moved the cutoff by as much, so it is gone. `settings.battery_offset_manual` stays in the blob and in the `Config` record: every trim since is one a person set. ## What the glass says about a flat cell, and when it says it -A device that reaches `kFlatMv` in the air says so before the rails go: `FLAT BATTERY` under the mark, pushed by the shutdown the cutoff asked for. Nobody pressed anything, so the frame is the only thing that can tell a pilot what happened, and the alternatives they would otherwise pick between are a crash and a dead unit. The same shutdown withholds the wake pin (`button_wake_after`), so the press that follows is answered by the frame already on the glass and not by a boot the cell cannot pay for. +A device that reaches `kFlatMv` in the air says so before the rails go: `FLAT BATTERY` under the mark, pushed by the shutdown the cutoff asked for. Nobody pressed anything, so the frame is the only thing that can tell a pilot what happened, and the alternatives they would otherwise pick between are a crash and a dead unit. The button stays armed, as it does after every switch-off. A press that follows is refused by the lockout (`wake.h`) and leaves the frame already on the glass, so the cell pays for one bring-up (`../../products/skyblip_go/README.md`) and not for a refresh or a flight it cannot finish. + +The other way a cell arrives empty is a winter on a shelf. That unit ran no shutdown and painted nothing, so it is the refused boot that names it: `refused_frame` pushes the same `FLAT BATTERY` for the press that gets no device, and the button stays armed after it. Both roads end at the same glass, and every later press on that cell is refused and leaves it there: anything but a new word is `Leave`, because a full refresh is seconds of panel rail off a cell with none to spare. + +The cable is the way out of both, with a press. The lockout spares external power, so a press on the cable gets a device whatever the cell reads, and its first frame takes the word off the glass. The cable alone wakes nothing: VBUS wakes the SoC, and the factory bootloader puts it straight back into SYSTEM OFF before MCUboot or this firmware runs. So `FLAT BATTERY` stays on the glass for the whole charge, until the press, and the charger's own red LED is what says the cell is filling. -The other way a cell arrives empty is a winter on a shelf. That unit ran no shutdown and painted nothing, so it is the refused boot that names it: `refused_frame` pushes the same `FLAT BATTERY` for the press that gets no device, and `button_wake_after_refusal` withholds the button after it. Both roads end at the same glass and the same dead button. +That holds by the bootloader, not by this firmware. The factory bootloader reads the device's one button as its DFU button, so every SYSTEM OFF writes `0x6d`, `DFU_MAGIC_SKIP`, to GPREGRET (`boot_magic_for_system_off` in `shutdown.h`), or the press that wakes the unit would land in the UF2 drive, and LilyGO's build of it sends a VBUS wake back to SYSTEM OFF whenever that byte is set (`../../boards/lilygo/t_echo_plus/factory/README.md`). So "a charger plugged into a device in a flight bag must not wake it" holds before `boot_path` is asked. `boot_path` refuses a charger wake again, as the defence for a bootloader that would let one through, and on such a bootloader `refused_frame` would take a word off the glass and put back the wordmark, the frame of every armed device. -The cable is the way out of both. On the cable the device is an ordinary switched-off one again, because VBUS wakes the SoC, the boot is refused, and the refusal re-arms the button - so the wordmark replaces the flat frame, and the wordmark is the whole instruction. Anything else is `Leave`: the glass already says the right thing, and a full refresh is seconds of panel rail off a cell with none to spare. +Arming the button means waiting for it, because the wake pin senses a level and a button still down would wake the unit the instant the rails dropped. The cutoff waits in `AwaitRelease` like every other shutdown, and a refused press waits in `park_refusal` for the finger it refused to come up. Nothing bounds either wait, so a bag holding the button down keeps the unit awake with its rails up until it lets go. That is the price of a button that always wakes: the cutoff and a refused press used to withhold it against exactly that bag, and with the cable swallowed as well, a unit the cutoff switched off could then be woken by nothing but the reset pin (#113). -A device switched off while the cell needs charge, `Low` or `Critical`, wears a milder word, `CHARGE BATTERY`, and the cable takes it off the same way. It asks for the cable and names nothing that happened, so a press on a cell that has drained past the lockout since does not leave it there: that refusal pushes `FLAT BATTERY` over it. +A device switched off while the cell needs charge, `Low` or `Critical`, wears a milder word, `CHARGE BATTERY`. The cable does not take it off either, and the next press gets a device. It asks for the cable and names nothing that happened, so a press on a cell that has drained past the lockout since does not leave it there: that refusal pushes `FLAT BATTERY` over it. What the panel wears has to outlive the rails for that comparison to exist, so it does: `ports::SystemPower::cell_on_glass`, one of `None`, `Low` and `Flat`. A platform with nowhere to keep it answers `None`, which costs a repeated frame and nothing else. -The glass forgets on the cable, and a support case must not. A second bit, `went_dark_flat`, is set wherever the cutoff or a refused boot parks the flat frame, whether or not the panel could draw it, and it survives the refusal that takes the word off the glass. The boot that runs reads it, prints `WAS FLAT` beside the cell's voltage on the self-test page and answers `went_dark_flat` in the status reply, then drops it, so it names the one boot that followed the flat cell and no other. +The glass forgets once a press on the cable gets a device, and a support case must not. A second bit, `went_dark_flat`, is set wherever the cutoff or a refused boot parks the flat frame, whether or not the panel could draw it. The boot that runs reads it, prints `WAS FLAT` beside the cell's voltage on the self-test page and answers `went_dark_flat` in the status reply, then drops it, so it names the one boot that followed the flat cell and no other. -The cable leaving cannot be noticed. VBUS rising wakes this SoC and VBUS falling does not, so a device unplugged still flat keeps the wordmark until the next press, which is the moment a pilot asks the question anyway - and that press is a refusal, so it is answered with the flat frame. +The cable leaving changes nothing on the glass either, because VBUS falling does not wake this SoC. A unit unplugged with `FLAT BATTERY` on it keeps the word, and the next press off the cable is answered by the cell: refused against the same frame while it is under the lockout, a device once it has charged past it. ## Charge mode, considered and refused -A charger plugged into a switched-off unit wakes it, and `boot_path` sends that boot straight back to SYSTEM OFF. SoftRF-moshe-braner does something else there (`src/platform/nRF52.cpp:2512-2560`): it beeps the battery level out of the buzzer, then sleeps. That answers the question a pilot asks, "is it charging?", and it is not done here, for three reasons. +A charger plugged into a switched-off unit wakes the SoC, and the factory bootloader sends it straight back to SYSTEM OFF, as `boot_path` would. SoftRF-moshe-braner does something else there (`src/platform/nRF52.cpp:2512-2560`): it beeps the battery level out of the buzzer, then sleeps. That answers the question a pilot asks, "is it charging?", and it is not done here, for three reasons. What the device says out loud is one table, `core/indication/lamp.h`, and charge is deliberately not a row of it: this board's charger IC drives its own LED, which already answers the question. A buzzer ladder in the wake path would be a second vocabulary beside that table. What it would beep is not yet trustworthy. The gauge medians three samples before it states a percentage (`battery.h`) and the trim is a setting, so an honest level exists most of a boot later, on every cable wiggle in a flight bag, which is the case the refusal exists for. -The refusal is complete without it. A charge mode would be a third `BootPath` value beside the other two, with the rule and the sleep path untouched. +The refusal is complete without it. A charge mode would be a third `BootPath` value beside the other two, with the rule and the sleep path untouched, and on the factory bootloader it would never run: the charger wake ends before this firmware runs. ## Time in state, because there is no current @@ -107,4 +113,4 @@ What these buy is the second half of a budget nothing else on this board can sup No current is sensed anywhere on this board, so nothing here can count coulombs, and no figure in minutes may be published (`battery.h`). The sleep current the shutdown sequence leaves has never been measured either (`shutdown.h`). Both are the same bench day, and until it happens every number in this directory is a voltage or a decision about one. -What the device can do without a current sense is say how long each consumer was on, which is what the `duty` records carry and what `POWER RUN` on the capture page arms. A run from full to cutoff, offloaded and read by `scripts/power_budget.py`, gives the average draw off the pack's own capacity and needs none of the curves above to do it. The same file says which consumer that draw went to, against a table of per-part currents that is still mostly datasheet typicals. `scripts/README.md` says how to take one, and the residual the tool prints is the honest size of what remains unmeasured. +What the device can do without a current sense is say how long each consumer was on, which is what the `duty` records carry and what `POWER RUN` on the capture page arms. A run from full to cutoff, offloaded and read by `scripts/power_budget.py`, gives the average draw off the pack's own capacity and needs no curve to do it. The same file says which consumer that draw went to, against a table of per-part currents that is still mostly datasheet typicals. `scripts/README.md` says how to take one, and the residual the tool prints is the honest size of what remains unmeasured. diff --git a/firmware/core/power/battery.cpp b/firmware/core/power/battery.cpp index b712157b..7f28f576 100644 --- a/firmware/core/power/battery.cpp +++ b/firmware/core/power/battery.cpp @@ -13,7 +13,7 @@ struct Point { uint8_t percent; }; -// INFO: fc 09mar26 both curves are textbook, not this pack on this board | 20sep26 README.md +// INFO: fc 09mar26 textbook, not this pack on this board | 20sep26 README.md constexpr Point kDischargeCurve[] = { {3200, 0}, {3300, 2}, {3500, 5}, {3600, 12}, {3700, 25}, {3750, 40}, {3800, 55}, {3850, 65}, {3900, 75}, {3950, 83}, {4000, 89}, {4100, 95}, {4200, 100}, @@ -27,11 +27,6 @@ static_assert(kDischargeCurve[0].percent == 0 && kFullMv, "a curve that does not span empty to full is read past its ends"); -constexpr Point kChargeCurve[] = { - {3400, 0}, {3600, 5}, {3700, 12}, {3800, 25}, {3900, 40}, {4000, 55}, - {4050, 65}, {4100, 75}, {4150, 82}, {4180, 90}, {4190, 95}, {4200, 100}, -}; - uint16_t median_of(uint16_t a, uint16_t b, uint16_t c) { if (a > b) { const uint16_t swap = a; @@ -72,12 +67,23 @@ events::BatterySample calibrated(const events::BatterySample& raw, int16_t offse return out; } -uint8_t percent_from_mv(uint16_t millivolts, bool charging) { - return charging ? percent_on(kChargeCurve, millivolts) - : percent_on(kDischargeCurve, millivolts); -} +uint8_t percent_from_mv(uint16_t millivolts) { return percent_on(kDischargeCurve, millivolts); } void Gauge::apply(const events::BatterySample& sample) { + state_.sample_mv = sample.millivolts; + state_.external_power = sample.external_power; + state_.charging = sample.external_power; + + const bool plausible = plausible_mv(sample.millivolts); + if (!plausible) refused_++; + if (!plausible || sample.external_power) { + seen_ = 0; + state_.millivolts = sample.millivolts; + state_.percent = 0; + state_.valid = false; + return; + } + for (int i = kWindowSamples - 1; i > 0; i--) recent_[i] = recent_[i - 1]; recent_[0] = sample.millivolts; if (seen_ < kWindowSamples) seen_++; @@ -86,26 +92,9 @@ void Gauge::apply(const events::BatterySample& sample) { // everything the gauge knows. const uint16_t millivolts = seen_ < kWindowSamples ? recent_[0] : median_of(recent_[0], recent_[1], recent_[2]); - const bool was_charging = state_.charging; - const bool charging = sample.external_power && millivolts < kChargeCompleteMv; - const uint8_t percent = percent_from_mv(millivolts, charging); - - // Unplugging swaps the curve under the reading, and the cell relaxes upwards - // once the charge current stops: both are direction changes, so the gauge - // re-seats on the new curve instead of holding the old number. - const bool reseat = !state_.valid || charging != was_charging; - const bool topped_off = sample.external_power && !charging; - if (reseat || topped_off) - state_.percent = percent; - else if (charging) - state_.percent = std::max(percent, state_.percent); - else - state_.percent = std::min(percent, state_.percent); - + const uint8_t percent = percent_from_mv(millivolts); + state_.percent = state_.valid ? std::min(percent, state_.percent) : percent; state_.millivolts = millivolts; - state_.sample_mv = sample.millivolts; - state_.external_power = sample.external_power; - state_.charging = charging; state_.valid = true; } diff --git a/firmware/core/power/battery.h b/firmware/core/power/battery.h index e61ace98..c7d4bf01 100644 --- a/firmware/core/power/battery.h +++ b/firmware/core/power/battery.h @@ -11,10 +11,14 @@ namespace skyblip::power { constexpr uint16_t kEmptyMv = kFlatMv; constexpr uint16_t kFullMv = 4200; -// With external power present and the cell above this, the charger has finished: -// it is holding the float voltage, not pushing current in. There is no charge -// status pin to ask, so the plateau is the signal. -constexpr uint16_t kChargeCompleteMv = 4190; +// INFO: fc 05oct26 no 4.2 V cell reads this high, a USB rail does: README.md +constexpr uint16_t kImplausibleCeilingMv = 4700; + +static_assert(kFullMv < kImplausibleCeilingMv, "a full cell is a reading the gauge believes"); + +constexpr bool plausible_mv(uint16_t millivolts) { + return millivolts > kImplausibleFloorMv && millivolts <= kImplausibleCeilingMv; +} // INFO: fc 03aug26 The per-unit trim, in millivolts, added to a reading before // anything reads meaning into it. The divider ratio is a devicetree fact and @@ -40,17 +44,15 @@ constexpr int16_t kCalibrationLimitMv = 250; uint16_t calibrated_mv(uint16_t raw_mv, int16_t offset_mv); events::BatterySample calibrated(const events::BatterySample& raw, int16_t offset_mv); -// One reading, one meaning. Charging is not a modifier on a percentage, it is a -// different curve: a charger holds the terminal above the cell's open-circuit -// voltage by the drop across its internal resistance, so 4.00 V on charge is a -// cell far emptier than 4.00 V on the bench. -uint8_t percent_from_mv(uint16_t millivolts, bool charging); +// INFO: fc 05oct26 off the cable only: on it the divider reads the USB rail, README.md +uint8_t percent_from_mv(uint16_t millivolts); struct BatteryState { uint16_t millivolts{0}; uint16_t sample_mv{0}; uint8_t percent{0}; bool external_power{false}; + // INFO: fc 05oct26 the cable is in: no pin says when the charger is done, README.md bool charging{false}; bool valid{false}; }; @@ -58,21 +60,22 @@ struct BatteryState { // Readings become something a pilot can read. A 14 dBm burst sags the rail for // exactly as long as it lasts, so the gauge takes the median of the last three // readings rather than an average: a transient is discarded whole, while a cell -// that is really moving is followed within two samples. And a gauge that walks -// backwards while the cable is in is a bug report, so the percentage only moves -// the way the current flows until the direction itself changes. +// that is really moving is followed within two samples. Off the cable the +// percentage only falls. On the cable, or on a reading outside plausible_mv, the +// gauge has no cell to show: valid drops, millivolts is what the divider read, +// and the next reading of the cell starts the median over. // // INFO: fc 03aug26 No time-remaining estimate here, and that is a decision, not // an omission. OGN derives a drift rate from a 32-deep delay line on the voltage // (nrf52-ogn-tracker src/proc.cpp:326-345) and a "40 minutes" reading is worth // more to a pilot than "38%", so the reason has to be better than cost. // -// It is this: minutes-remaining is percent divided by the SLOPE of the curves -// above, and those curves are the textbook shape for a generic Li-ion cell, not +// It is this: minutes-remaining is percent divided by the SLOPE of the curve +// above, and that curve is the textbook shape for a generic Li-ion cell, not // this pack measured on this board (the note on kDischargeCurve says so). An // error of a few percent in the level is a few percent; the same error in the // slope is a factor. Nothing else here is a guess about the future - the -// median-of-three, the two curves and the direction rule all describe a reading +// median-of-three, the curve and the direction rule all describe a reading // that has already happened - and a percentage is a statement of state a pilot // discounts for himself, while a duration is a promise he plans a leg on. Two // facts make the promise unbackable today: the fitted cell has no identified @@ -85,7 +88,7 @@ struct BatteryState { // What unblocks it, and it is one bench day, not a quarter: a logged discharge // of a fitted unit under the real dwell map, which the power budget needs // anyway. That -// log fixes the curves, and with real curves percent falls linearly in time +// log fixes the curve, and with a real curve percent falls linearly in time // under a constant load, so the estimator is small and needs no new sampling - // a fixed-depth ring of (percent, millisecond) pairs recorded on each whole // percent step, rate taken across the ring, published only once the ring spans @@ -97,12 +100,14 @@ class Gauge { void apply(const events::BatterySample& sample); const BatteryState& state() const { return state_; } + uint32_t refused() const { return refused_; } private: static constexpr int kWindowSamples = 3; uint16_t recent_[kWindowSamples]{}; int seen_{0}; + uint32_t refused_{0}; BatteryState state_{}; }; diff --git a/firmware/core/power/cutoff.cpp b/firmware/core/power/cutoff.cpp index db425141..9f188311 100644 --- a/firmware/core/power/cutoff.cpp +++ b/firmware/core/power/cutoff.cpp @@ -81,7 +81,7 @@ PowerLevel CutoffMonitor::apply(const events::BatterySample& sample) { } count(sample.millivolts); - level_ = settled(); + level_ = settled(sample.millivolts); return level_; } @@ -90,28 +90,29 @@ void CutoffMonitor::count(uint16_t millivolts) { "a step the monitor cannot count is a step nobody reaches"); for (int i = 0; i < kSteps; i++) { const bool below = millivolts < kLadder[i].entered_below_mv; + const bool cleared = millivolts >= kLadder[i].entered_below_mv + kRecoveryMarginMv; below_[i] = below ? lengthened(below_[i]) : 0; - at_or_above_[i] = below ? 0 : lengthened(at_or_above_[i]); + cleared_[i] = cleared ? lengthened(cleared_[i]) : 0; } } void CutoffMonitor::forget_runs() { for (int i = 0; i < kSteps; i++) { below_[i] = 0; - at_or_above_[i] = 0; + cleared_[i] = 0; } } -PowerLevel CutoffMonitor::settled() const { +PowerLevel CutoffMonitor::settled(uint16_t millivolts) const { const int current = depth(level_); for (int i = kSteps - 1; i >= current; i--) if (below_[i] >= kLevelSamples) return kLadder[i].level; if (level_ == PowerLevel::Unknown) - return at_or_above_[0] > 0 ? PowerLevel::Normal : PowerLevel::Unknown; + return millivolts >= kLowMv ? PowerLevel::Normal : PowerLevel::Unknown; int risen = current; - while (risen > 0 && at_or_above_[risen - 1] >= kLevelSamples) risen--; + while (risen > 0 && cleared_[risen - 1] >= kLevelSamples) risen--; return risen == 0 ? PowerLevel::Normal : kLadder[risen - 1].level; } diff --git a/firmware/core/power/cutoff.h b/firmware/core/power/cutoff.h index 5c61f836..9b089309 100644 --- a/firmware/core/power/cutoff.h +++ b/firmware/core/power/cutoff.h @@ -18,6 +18,12 @@ constexpr uint16_t kFlatMv = 3200; static_assert(kFlatMv < kCriticalMv && kCriticalMv < kLowMv, "the ladder is read downwards: a step out of order is a step nobody reaches"); +// INFO: fc 05oct26 a parked reading spreads 35 mV per half hour on E68BD9's run: README.md +constexpr uint16_t kRecoveryMarginMv = 35; + +static_assert(kCriticalMv + kRecoveryMarginMv < kLowMv, + "a cell climbing out of Critical must land in Low, not need to clear Low too"); + // INFO: hk 02aug26 an unpopulated or unconnected divider reads as a slow drift // near zero, not as a flat cell. SoftRF calls the same floor // BATTERY_THRESHOLD_INVALID (src/driver/Battery.h:24) and refuses to act below @@ -97,11 +103,11 @@ class CutoffMonitor { static constexpr int kSteps = 3; void count(uint16_t millivolts); void forget_runs(); - PowerLevel settled() const; + PowerLevel settled(uint16_t millivolts) const; PowerLevel level_{PowerLevel::Unknown}; uint8_t below_[kSteps]{}; - uint8_t at_or_above_[kSteps]{}; + uint8_t cleared_[kSteps]{}; uint32_t implausible_{0}; uint32_t supply_warnings_{0}; bool supply_warned_{false}; diff --git a/firmware/core/power/shutdown.cpp b/firmware/core/power/shutdown.cpp index 3b5f0a56..7e4ed105 100644 --- a/firmware/core/power/shutdown.cpp +++ b/firmware/core/power/shutdown.cpp @@ -32,18 +32,21 @@ const char* to_string(PowerDownStep step) { return "NONE"; } -ButtonWake button_wake_after(ShutdownReason reason, bool external_power) { - if (external_power) return ButtonWake::Armed; - return reason == ShutdownReason::LowBattery ? ButtonWake::Withheld : ButtonWake::Armed; +void power_down(PowerDownSink& sink) { + for (const PowerDownStep step : kPowerDownOrder) sink.perform(step); } -void power_down(PowerDownSink& sink, ButtonWake button_wake) { - for (int i = 0; i < kPowerDownStepCount; i++) { - if (kPowerDownOrder[i] == PowerDownStep::WakePinArmed && - button_wake == ButtonWake::Withheld) - continue; - sink.perform(kPowerDownOrder[i]); +bool ButtonRelease::settled(uint32_t now_ms, bool button_down) { + if (button_down) { + released_ = false; + return false; } + if (!released_) { + released_ = true; + released_at_ms_ = now_ms; + return false; + } + return now_ms - released_at_ms_ >= kReleaseSettleMs; } void ShutdownSequencer::enter(ShutdownPhase phase, uint32_t now_ms) { @@ -54,7 +57,7 @@ void ShutdownSequencer::enter(ShutdownPhase phase, uint32_t now_ms) { void ShutdownSequencer::request(ShutdownReason reason, uint32_t now_ms) { if (going_down() || reason == ShutdownReason::None) return; reason_ = reason; - released_ = false; + release_ = ButtonRelease{}; enter(ShutdownPhase::Parking, now_ms); } @@ -85,20 +88,11 @@ void ShutdownSequencer::tick(uint32_t now_ms, bool button_down, bool pad_down) { case ShutdownPhase::Parking: if (now_ms - since_ms_ < kParkMs) return; - enter(waits_for_release() ? ShutdownPhase::AwaitRelease : ShutdownPhase::Off, now_ms); + enter(ShutdownPhase::AwaitRelease, now_ms); return; case ShutdownPhase::AwaitRelease: - if (button_down) { - released_ = false; - return; - } - if (!released_) { - released_ = true; - released_at_ms_ = now_ms; - return; - } - if (now_ms - released_at_ms_ >= kReleaseSettleMs) enter(ShutdownPhase::Off, now_ms); + if (release_.settled(now_ms, button_down)) enter(ShutdownPhase::Off, now_ms); return; case ShutdownPhase::Off: return; diff --git a/firmware/core/power/shutdown.h b/firmware/core/power/shutdown.h index e723f325..b7c5a3b8 100644 --- a/firmware/core/power/shutdown.h +++ b/firmware/core/power/shutdown.h @@ -143,14 +143,17 @@ class PowerDownSink { virtual void perform(PowerDownStep step) = 0; }; -enum class ButtonWake : uint8_t { Armed, Withheld }; - -// INFO: fc 07sep26 meshcore arms voltage recovery, not the button, on a low-voltage shutdown -ButtonWake button_wake_after(ShutdownReason reason, bool external_power); - // Walks kPowerDownOrder once, in order. The caller enters SYSTEM OFF after it // returns. -void power_down(PowerDownSink& sink, ButtonWake button_wake); +void power_down(PowerDownSink& sink); + +// INFO: fc 05oct26 GPREGRET for the factory bootloader: t_echo_plus/factory/README.md +constexpr uint8_t kSkipBootloaderMagic = 0x6d; +constexpr uint8_t kUf2MassStorageMagic = 0x57; + +constexpr uint8_t boot_magic_for_system_off(bool recovery_armed) { + return recovery_armed ? kUf2MassStorageMagic : kSkipBootloaderMagic; +} // Long enough that it cannot be the page press, short enough to do with gloves // on. A short press pages; this is the only other thing the one button does. @@ -168,6 +171,15 @@ constexpr uint32_t kParkMs = 3000; // (src/platform/nRF52.cpp:3199-3203). constexpr uint32_t kReleaseSettleMs = 100; +class ButtonRelease { + public: + bool settled(uint32_t now_ms, bool button_down); + + private: + bool released_{false}; + uint32_t released_at_ms_{0}; +}; + class ShutdownSequencer { public: // One sample of the world per service step. The button level is the raw @@ -187,10 +199,6 @@ class ShutdownSequencer { // hold filling up. Zero when it is not down or the hold is not armed yet. uint32_t held_ms(uint32_t now_ms) const; - bool waits_for_release() const { - return button_wake_after(reason_, /*external_power=*/false) == ButtonWake::Armed; - } - private: void enter(ShutdownPhase phase, uint32_t now_ms); @@ -198,9 +206,8 @@ class ShutdownSequencer { ShutdownReason reason_{ShutdownReason::None}; uint32_t since_ms_{0}; uint32_t hold_since_ms_{0}; - uint32_t released_at_ms_{0}; + ButtonRelease release_{}; bool holding_{false}; - bool released_{false}; bool stowing_{false}; // A press is what wakes the device from SYSTEM OFF, so the very first thing // the sequencer sees after a wake is a button that is already down. Counting diff --git a/firmware/core/power/trim.cpp b/firmware/core/power/trim.cpp deleted file mode 100644 index 910d59fa..00000000 --- a/firmware/core/power/trim.cpp +++ /dev/null @@ -1,67 +0,0 @@ -#include "core/power/trim.h" - -#include - -namespace skyblip::power { - -namespace { - -bool near_the_float(uint16_t millivolts) { - return millivolts + kCalibrationLimitMv >= kFloatReferenceMv && - millivolts <= kFloatReferenceMv + kCalibrationLimitMv; -} - -} // namespace - -void FloatTrim::open_window(uint16_t millivolts, uint32_t now_ms) { - holding_ = true; - since_ms_ = now_ms; - sum_mv_ = millivolts; - samples_ = 1; - low_mv_ = millivolts; - high_mv_ = millivolts; -} - -void FloatTrim::apply(const events::BatterySample& untrimmed, uint32_t now_ms) { - if (!untrimmed.external_power) { - cabled_ = false; - holding_ = false; - return; - } - - if (!cabled_ || untrimmed.millivolts < session_low_mv_) { - cabled_ = true; - session_low_mv_ = untrimmed.millivolts; - holding_ = false; - return; - } - - const bool climbed = untrimmed.millivolts - session_low_mv_ >= kClimbRiseMv; - if (!climbed || !near_the_float(untrimmed.millivolts)) { - holding_ = false; - return; - } - - if (!holding_) { - open_window(untrimmed.millivolts, now_ms); - return; - } - - low_mv_ = std::min(untrimmed.millivolts, low_mv_); - high_mv_ = std::max(untrimmed.millivolts, high_mv_); - if (high_mv_ - low_mv_ > kPlateauSpreadMv) { - open_window(untrimmed.millivolts, now_ms); - return; - } - - sum_mv_ += untrimmed.millivolts; - samples_++; - if (now_ms - since_ms_ < kPlateauHoldMs) return; - - const uint16_t held_mv = static_cast((sum_mv_ + samples_ / 2) / samples_); - offset_mv_ = static_cast(kFloatReferenceMv - static_cast(held_mv)); - learned_ = true; - open_window(untrimmed.millivolts, now_ms); -} - -} // namespace skyblip::power diff --git a/firmware/core/power/trim.h b/firmware/core/power/trim.h deleted file mode 100644 index 31e41034..00000000 --- a/firmware/core/power/trim.h +++ /dev/null @@ -1,44 +0,0 @@ -#ifndef SKYBLIP_CORE_POWER_TRIM_H -#define SKYBLIP_CORE_POWER_TRIM_H - -#include - -#include "core/events/sensor.h" -#include "core/power/battery.h" - -namespace skyblip::power { - -constexpr uint16_t kFloatReferenceMv = kFullMv; -// INFO: fc 20sep26 a plateau this far over the session's low is the charger's, not the cell's -constexpr uint16_t kClimbRiseMv = 200; -constexpr uint16_t kPlateauSpreadMv = 20; -constexpr uint32_t kPlateauHoldMs = 120000; - -static_assert(kClimbRiseMv > kPlateauSpreadMv, - "a climb no bigger than the plateau's own noise is not a climb"); - -class FloatTrim { - public: - void apply(const events::BatterySample& untrimmed, uint32_t now_ms); - - bool learned() const { return learned_; } - int16_t offset_mv() const { return offset_mv_; } - - private: - void open_window(uint16_t millivolts, uint32_t now_ms); - - uint32_t since_ms_{0}; - uint32_t sum_mv_{0}; - uint16_t samples_{0}; - uint16_t low_mv_{0}; - uint16_t high_mv_{0}; - uint16_t session_low_mv_{0}; - int16_t offset_mv_{0}; - bool cabled_{false}; - bool holding_{false}; - bool learned_{false}; -}; - -} // namespace skyblip::power - -#endif diff --git a/firmware/core/power/wake.cpp b/firmware/core/power/wake.cpp index 598968de..64801466 100644 --- a/firmware/core/power/wake.cpp +++ b/firmware/core/power/wake.cpp @@ -37,10 +37,6 @@ bool restarted(ResetCause causes) { } // namespace -ButtonWake button_wake_after_refusal(const BootCell& cell) { - return too_flat_to_run(cell) ? ButtonWake::Withheld : ButtonWake::Armed; -} - RefusedFrame refused_frame(const BootCell& cell, CellOnGlass on_glass) { if (too_flat_to_run(cell)) return on_glass == CellOnGlass::Flat ? RefusedFrame::Leave : RefusedFrame::FlatCell; diff --git a/firmware/core/power/wake.h b/firmware/core/power/wake.h index 63c088b6..f0f2cb28 100644 --- a/firmware/core/power/wake.h +++ b/firmware/core/power/wake.h @@ -12,7 +12,6 @@ #include "core/power/cutoff.h" #include "core/power/reset_reason.h" -#include "core/power/shutdown.h" namespace skyblip::power { @@ -53,8 +52,6 @@ static_assert(kImplausibleFloorMv < kFlatMv, // INFO: fc 05aug26 charge mode was considered and refused: README.md BootPath boot_path(ResetCause causes, bool button_down, const BootCell& cell); -ButtonWake button_wake_after_refusal(const BootCell& cell); - RefusedFrame refused_frame(const BootCell& cell, CellOnGlass on_glass); } // namespace skyblip::power diff --git a/firmware/hardware/platform/contract.h b/firmware/hardware/platform/contract.h index 06211105..5e148093 100644 --- a/firmware/hardware/platform/contract.h +++ b/firmware/hardware/platform/contract.h @@ -102,7 +102,9 @@ constexpr bool fills_the_platform_contract() { static_assert( std::is_convertible_v< decltype(std::declval().read_battery_mv(std::declval())), bool>, - "platform: read_battery_mv(uint16_t&) -> bool, which becomes events::BatterySample"); + "platform: read_battery_mv(uint16_t&) -> bool, whether power::plausible_mv believes the " + "reading. It writes what the divider read either way, 0 when the ADC gave nothing, and " + "that becomes events::BatterySample"); static_assert(std::is_convertible_v().external_power()), bool>, "platform: external_power() -> bool"); static_assert(std::is_convertible_v().button_down()), bool>, diff --git a/firmware/hardware/platform/host/platform.h b/firmware/hardware/platform/host/platform.h index 64a9d74a..1efe141b 100644 --- a/firmware/hardware/platform/host/platform.h +++ b/firmware/hardware/platform/host/platform.h @@ -1,6 +1,7 @@ #ifndef SKYBLIP_HARDWARE_PLATFORM_HOST_PLATFORM_H #define SKYBLIP_HARDWARE_PLATFORM_HOST_PLATFORM_H +#include "core/power/battery.h" #include "core/util/sha256.h" #include "core/util/span.h" #include "hardware/parts/bme280/bme280.h" @@ -124,9 +125,8 @@ class Battery { public: bool ready() const { return present; } bool read_mv(uint16_t& out_mv) const { - if (!present) return false; - out_mv = millivolts; - return true; + out_mv = present ? millivolts : 0; + return present && power::plausible_mv(out_mv); } bool present{true}; diff --git a/firmware/hardware/platform/host/system_power.h b/firmware/hardware/platform/host/system_power.h index 28bbd2ed..59d6492c 100644 --- a/firmware/hardware/platform/host/system_power.h +++ b/firmware/hardware/platform/host/system_power.h @@ -13,9 +13,9 @@ class SystemPower : public ports::SystemPower, private power::PowerDownSink { public: power::ResetCause reset_causes() const override { return causes; } - void system_off(power::ButtonWake button_wake) override { + void system_off() override { performed = 0; - power::power_down(*this, button_wake); + power::power_down(*this); offs++; } diff --git a/firmware/hardware/platform/zephyr/battery.h b/firmware/hardware/platform/zephyr/battery.h index 901140b5..e9626032 100644 --- a/firmware/hardware/platform/zephyr/battery.h +++ b/firmware/hardware/platform/zephyr/battery.h @@ -6,9 +6,10 @@ #include #include +#include #include -#include "core/power/cutoff.h" +#include "core/power/battery.h" namespace skyblip::platform::zephyr { @@ -26,25 +27,22 @@ class Battery { bool ready() const { return device_is_ready(dev_); } + // INFO: fc 05oct26 out_mv is what the divider read, refused or not: core/power counts it bool read_mv(uint16_t& out_mv) { + out_mv = 0; if (!ready()) return false; if (sensor_sample_fetch(dev_) != 0) return false; struct sensor_value volts{}; if (sensor_channel_get(dev_, SENSOR_CHAN_VOLTAGE, &volts) != 0) return false; const int32_t millivolts = volts.val1 * 1000 + volts.val2 / 1000; - if (millivolts < kPlausibleMinMv || millivolts > kPlausibleMaxMv) return false; - out_mv = static_cast(millivolts); - return true; + out_mv = static_cast(std::clamp(millivolts, 0, UINT16_MAX)); + return power::plausible_mv(out_mv); } static bool external_power() { return nrf_power_usbregstatus_vbusdet_get(NRF_POWER); } private: - // INFO: fc 07sep26 nothing here charges past the 4.2 V float, the low end is core/power's - static constexpr int32_t kPlausibleMinMv = power::kImplausibleFloorMv; - static constexpr int32_t kPlausibleMaxMv = 4700; - const struct device* dev_; }; diff --git a/firmware/hardware/platform/zephyr/dfu.h b/firmware/hardware/platform/zephyr/dfu.h index 4673db72..dfbcbd70 100644 --- a/firmware/hardware/platform/zephyr/dfu.h +++ b/firmware/hardware/platform/zephyr/dfu.h @@ -16,6 +16,7 @@ #endif #include "core/dfu/image.h" +#include "core/power/shutdown.h" #include "hardware/platform/zephyr/upload_gate.h" #include "ports/dfu.h" @@ -39,31 +40,6 @@ namespace skyblip::platform::zephyr { // for a device whose reset button is awkward or enclosed; the other route is a // double-click on RESET within 0.5 s, which is the bootloader's own and works // whatever state this firmware is in. -constexpr uint8_t kUf2MassStorageMagic = 0x57; - -// 0x6d is the opposite request: skip DFU entirely on the next boot. Written on -// the way to SYSTEM OFF (hardware/platform/zephyr/system_power.h), which is what -// SoftRF does before sleeping (src/platform/nRF52.cpp:3213-3226, magic at -// nRF52.h:128). -// -// WHAT IT BUYS. dfu_skip is tested before anything else in check_dfu_mode and -// returns immediately, so it wins over the bootloader's double-reset detector - -// and that detector reads a word of plain RAM at 0x20007F7C (DFU_DBL_RESET_MEM), -// whose contents through SYSTEM OFF are not defined. Without the magic, the first -// reset after a shutdown enters USB DFU if that word happens to hold 0x5A1AD5 and -// the reset pin was the cause. That is the "at the factory bootloader's -// discretion" this closes. -// -// WHAT IT COSTS, and §3 R1 has to keep working exactly as documented, so it is -// written down rather than discovered: taking the skip path also skips the 500 ms -// double-reset window on that ONE boot, and leaves DFU_DBL_RESET_MEM untouched -// instead of arming it. So a double-click of RESET performed on the very first -// boot after a deliberate power-off does not arm, and the pair after it does - -// the bootloader cleared the magic on that boot, so every later reset is the -// documented behaviour. R1's actual job is unaffected: it is the escape hatch for -// a device whose application is broken, and a broken application never reached a -// shutdown, so its GPREGRET is 0 and the first double-click works. -constexpr uint8_t kSkipBootloaderMagic = 0x6d; // One writer for both. False when the board declares no retention area or the // device is not ready: the caller decides what that means, and neither caller @@ -123,13 +99,13 @@ class Dfu : public ports::Dfu { recovery_armed_ = true; return ports::RecoveryPath::PowerOffToFinish; } - write_boot_magic(kUf2MassStorageMagic); + write_boot_magic(power::kUf2MassStorageMagic); sys_reboot(SYS_REBOOT_WARM); return ports::RecoveryPath::Rebooted; } static uint8_t boot_magic_for_system_off() { - return recovery_armed_ ? kUf2MassStorageMagic : kSkipBootloaderMagic; + return power::boot_magic_for_system_off(recovery_armed_); } private: diff --git a/firmware/hardware/platform/zephyr/system_power.h b/firmware/hardware/platform/zephyr/system_power.h index 0761b8ed..98326a53 100644 --- a/firmware/hardware/platform/zephyr/system_power.h +++ b/firmware/hardware/platform/zephyr/system_power.h @@ -63,16 +63,10 @@ class SystemPower : public ports::SystemPower, private power::PowerDownSink { // The order is core/power's, not this file's: everything here is one step // of it, and which step happens when is decided and tested on the host. - void system_off(power::ButtonWake button_wake) override { - // Before anything else, because it is the one step that survives the - // rails: the factory bootloader must not make its own decision about DFU - // on the next boot. See kSkipBootloaderMagic for what that buys and what - // it costs the double-click gesture on exactly one boot. A board with no - // retention area says so by returning false, and there is nothing to do - // about it here - it is the same board on which ports::Dfu::enter_recovery - // cannot work either, and that is the path that reports it. + void system_off() override { + // INFO: fc 05oct26 first, as it outlives the rails; no retention area, no recovery either (void)write_boot_magic(Dfu::boot_magic_for_system_off()); - power::power_down(*this, button_wake); + power::power_down(*this); // AFTER the walk above, not before: every step of it reconfigures pins, // and the last one arms a level-sensed wake. A DETECT that was latched // before or during that is a device that comes straight back up and looks diff --git a/firmware/ports/system_power.h b/firmware/ports/system_power.h index 3f41177f..37ddcf31 100644 --- a/firmware/ports/system_power.h +++ b/firmware/ports/system_power.h @@ -5,7 +5,6 @@ #define SKYBLIP_PORTS_SYSTEM_POWER_H #include "core/power/reset_reason.h" -#include "core/power/shutdown.h" #include "core/power/wake.h" namespace skyblip::ports { @@ -18,7 +17,7 @@ class SystemPower { // the same reason after every following reset until it is cleared. virtual power::ResetCause reset_causes() const { return power::ResetCause::None; } - virtual void system_off(power::ButtonWake button_wake) { (void)button_wake; } + virtual void system_off() {} virtual void reboot() {} diff --git a/firmware/ports/watchdog.h b/firmware/ports/watchdog.h index e6547260..3a425549 100644 --- a/firmware/ports/watchdog.h +++ b/firmware/ports/watchdog.h @@ -14,8 +14,7 @@ class Watchdog { public: virtual ~Watchdog() = default; - // Arm with the longest the loop may go unheard. Armed last, after every - // part is up, because bring-up is slower than any steady-state pass. + // Arm with the longest the loop may go unheard. // // INFO: hk 02aug26 an nRF52 watchdog cannot be disarmed once started: CRV, // RREN and CONFIG are blocked while RUNSTATUS is set (nRF52840 PS v1.8 diff --git a/firmware/products/skyblip_go/README.md b/firmware/products/skyblip_go/README.md index dfd98bd7..899d64f9 100644 --- a/firmware/products/skyblip_go/README.md +++ b/firmware/products/skyblip_go/README.md @@ -4,7 +4,7 @@ One board, one service list, one set of pages. `product.h` wires it; the shell a `pages/` is the glass half of the product and carries its own README, `input/` is what a press means here. `glass.h` is where the panel this device draws on is named: the size comes from the board (`boards/lilygo/t_echo_plus/glass.h`), and `go::Glass` is the `ui::Panel` every page and the screen service is written against. -`settings.h` is the list of values a pilot of this device can change, its defaults, what it refuses and how an older flash layout migrates forward. `kBlobVersion` moves whenever the struct's bytes move, and `from_blob` still reads every layout before it: version 1 carried `region`, `rotation` and `power_save` between `aircraft_type` and `callsign`; version 2 dropped those three and had no `battery_offset_mv`; version 3 is the same length as version 2 and a different layout, which is exactly the case a length check cannot catch and the version byte must; version 4 added `freq_trim_e1_ppm`; version 5 dropped `page_mask`, which lost its last reader when the pad's walk became three pages and the two that are doors stopped being hideable (`pages/README.md`); version 6 added `gyro_enabled`; version 7 dropped `stealth` and `gyro_enabled` with the features themselves, and is the length version 3 was, which is the collision the version byte exists for; version 8 dropped `device_addr` and `addr_table`, because the identity is the silicon's and a stored copy of it is a copy that can disagree; version 9 added `battery_offset_manual`, so a trim a person measured is told apart from one the device learned off a charger (`core/power/README.md`), and the migration reads any stored version-8 trim as hand-set, because until then there was no other way to have one. Version 10 added `range_step` and `plot`, the radar's two menu values, which until then were the screen's and came back on 4 NM after every boot; an older blob comes back on the 4 NM ring and `ALL`, which is what a new unit ships on. Both are the glass's alone: the `config` reply has nine bytes left at its worst case and neither key fits in them, so the link neither shows nor sets them. +`settings.h` is the list of values a pilot of this device can change, its defaults, what it refuses and how an older flash layout migrates forward. `kBlobVersion` moves whenever the struct's bytes move, and `from_blob` still reads every layout before it: version 1 carried `region`, `rotation` and `power_save` between `aircraft_type` and `callsign`; version 2 dropped those three and had no `battery_offset_mv`; version 3 is the same length as version 2 and a different layout, which is exactly the case a length check cannot catch and the version byte must; version 4 added `freq_trim_e1_ppm`; version 5 dropped `page_mask`, which lost its last reader when the pad's walk became three pages and the two that are doors stopped being hideable (`pages/README.md`); version 6 added `gyro_enabled`; version 7 dropped `stealth` and `gyro_enabled` with the features themselves, and is the length version 3 was, which is the collision the version byte exists for; version 8 dropped `device_addr` and `addr_table`, because the identity is the silicon's and a stored copy of it is a copy that can disagree; version 9 added `battery_offset_manual`, so a trim a person measured was told apart from one the device learned off a charger, until the divider was found to read the USB rail on the cable and the learner went (`core/power/README.md`), and the migration reads any stored version-8 trim as hand-set, because until then there was no other way to have one. Version 10 added `range_step` and `plot`, the radar's two menu values, which until then were the screen's and came back on 4 NM after every boot; an older blob comes back on the 4 NM ring and `ALL`, which is what a new unit ships on. Both are the glass's alone: the `config` reply has nine bytes left at its worst case and neither key fits in them, so the link neither shows nor sets them. That is the rule the struct states and the reason it is versioned: a setting with no reader leaves the struct, the JSON and the schema together, rather than being stored and ignored. The framing under it is `core/settings/blob.h` and the identity rules are `core/settings/address.h`, because neither is this product's to decide. `settings_store.h` is the two calls `comms::ConfigService` needs to answer a phone, and it is the only thing that couples the companion link to this struct. It is also where the address reaches that reply: the store is handed `roles.device_addr` because the struct no longer carries one, so `addr` and `addr_table` are reported and nothing can set them. The settings do not live on `bus::State`: the blackboard is core's and a product's field list cannot sit on it, so the product owns them and hands a reference to the services that read them. @@ -40,9 +40,11 @@ What that costs is a rate, and the rate follows the dwell map. Since own-ship's ### Which capture is armed -A capture is `Full` or `PowerRun` (`../../core/diag/README.md`), and the glass is the only place either is chosen: nothing arms a capture over Bluetooth. The focus a pilot moves with the pad lives in `ScreenService`, the profile that is running lives in `diag::Recorder` and is read back from there, so a boot comes up armed with nothing and remembering nothing. +A capture is `Full`, `PowerRun` or `FlightRun` (`../../core/diag/README.md`), and the glass is the only place one is chosen: nothing arms a capture over Bluetooth. The focus a pilot moves with the pad lives in `ScreenService`, the profile that is running lives in `diag::Recorder` and is read back from there, so a boot comes up armed with nothing and remembering nothing. -`CaptureService::keeps_s(profile)` is the span the page quotes before arming: the slots the claim buys over `diag::Recorder::records_per_hour(profile)`. The screen service holds the capture service by const reference for that one figure, because `bus::CaptureState` carries a single span and it belongs to the capture that is running, measured at the rate that capture is producing. Two captures at rest need two figures, and only the service that owns the partition knows how many slots a sector holds. +A flight run flies the glass and nothing else, and `OwnshipService` is the one place that does it. While the recorder is armed with it, the `bus::FlightStatus` clock (`running`, `seconds`, `time_valid`) is published from a second `flight::FlightTimer` held airborne, and the g-meter clears its extremes on that clock's takeoff as it does on a real one. Those three fields have no reader but the glass and the g-meter, which is what makes them the seam. Everything else that flight changes reads `state.own.flight_state` or the ground latch, and those stay the machine's own: the transmit cadence and the payload are on air, the flight log is the pilot's, and the offload and update gates are what let a bench fetch the run it is taking. Disarming the recorder, for any of the reasons a capture ends, puts the real timer back on the next pass. + +`CaptureService::keeps_s(profile)` is the span the page quotes before arming: the slots the claim buys over `diag::Recorder::records_per_hour(profile)`. The screen service holds the capture service by const reference for that one figure, because `bus::CaptureState` carries a single span and it belongs to the capture that is running, measured at the rate that capture is producing. Three captures at rest need three figures, and only the service that owns the partition knows how many slots a sector holds. ### How a capture ends @@ -66,9 +68,13 @@ A feature bit with no reader does not exist. The list is exactly the claims a se ## The parts of a boot -`setup()` probes the board, classifies the reset, and answers one question before anything else: is this boot a device at all. A charger plugged into a unit in a flight bag goes straight back to SYSTEM OFF with no service started (`core/power/wake.h`). +`setup()` probes the board, classifies the reset, and answers one question before anything else: is this boot a device at all. A charger plugged into a unit in a flight bag never gets that far on the factory bootloader, which sends a VBUS wake straight back to SYSTEM OFF (`../../boards/lilygo/t_echo_plus/factory/README.md`), and `core/power/wake.h` refuses it again with no service started, for a bootloader that would let it through. + +A refused boot is the second place the flat cell is named, and `park_refusal()` is the shell's second loop: it settles the one frame `core/power`'s `refused_frame` owes the glass, waits for the button to stay up for `kReleaseSettleMs`, then the rails go with the button armed. The wait is there because the press it refused may still be down, and the wake pin senses a level. `kRefusalParkCeilingMs` bounds the frame, because a panel that never reports ready must not hold a flat cell awake. Nothing bounds the finger, as nothing does on the way out of a running device (`core/power/README.md`). + +A refused press costs a whole bring-up, because `boot_path` needs the reset cause, the button, the trim out of NVS and a reading of the cell, so it is answered after `board_.begin()`. By then the board's constructor has probed every part, and `begin()` has started the GNSS UART on the gated rails the board raises at `PRE_KERNEL_1`, waited 50 ms for the radio's TCXO, mounted NVS, enabled Bluetooth and started advertising, reset and initialised the panel controller without a refresh, hashed the IMU blob on the external flash when a BHI260AP is fitted and holds one, and configured the radio. None of that has been measured. The console line that names the reset says how long it took. The one cheap reorder in sight is the IMU blob check: the refusal never reads it, and moving it from `Board::begin()` to just after the refusal in `setup()` changes nothing for a boot that runs. Taking Bluetooth or the radio out of the refused path means splitting the platform's `begin()`, which is not a reorder. -A refused boot is the second place the flat cell is named, and `park_refusal()` is the shell's second loop: it settles the one frame `core/power`'s `refused_frame` owes the glass, then the rails go. `kRefusalParkCeilingMs` bounds it, because a panel that never reports ready must not hold a flat cell awake. +The watchdog is armed before `setup()`, so a refused boot is supervised like a running one: a part that hangs bring-up on a flat cell is a bite and a reset, not a unit awake for good with nothing serving the button. SYSTEM OFF stops the watchdog, so a refusal that reaches it pays nothing more, and `park_refusal()`'s loop feeds it, because a panel inside the ceiling and a finger on the button are not hangs. The bite comes back as `WATCHDOG`, a restart, which `boot_path` holds only to the cutoff, so the boot after it runs: a hang that does not repeat leaves a device with the button served, and one that does repeats as resets a laptop on the cable can see. The cost is that bring-up has to fit the software channel, `CONFIG_TASK_WDT_MIN_TIMEOUT` less a second from the arm (`hardware/platform/zephyr/watchdog.h`), and the console line that names the reset says how far into uptime `setup()` returned. What survives a failed self test is a device that still shows why: the page stays on the glass, the button still works, and the loop refuses to fly. That is `flyable_`, and it is false when a required capability is missing. @@ -80,14 +86,14 @@ The wordmark is the ordinary one: a long press, or the companion link asking. A A recovery says more, because the frame pushed on the way in is what the glass wears while the bootloader runs: the device is a USB drive named TECHOBOOT, a `.uf2` dropped on it replaces the firmware, RST comes back to skyBlip, and nothing transmits meanwhile. `ports::RecoveryPath` decides the one line that differs. With the watchdog running, which is every boot on silicon, a reboot would carry the watchdog into the bootloader and cut its USB session, so the device powers off with the recovery magic set and the page asks for the press that finishes it. Without it the device reboots straight in and the page says it is running. Either way `enter_recovery()` is called only once the park frame is on the glass. -The fifth is the cell, `FLAT BATTERY` under the mark, and it is the only one that says something happened rather than naming a state. It is pushed by the cutoff, on the way down, because nobody asked for that power-off and the frame is the only thing left that can say why the device stopped. The same shutdown withholds the wake pin (`button_wake_after`), so the press that follows does nothing at all, and the frame it would have answered is already on the glass. +The fifth is the cell, `FLAT BATTERY` under the mark, and it is the only one that says something happened rather than naming a state. It is pushed by the cutoff, on the way down, because nobody asked for that power-off and the frame is the only thing left that can say why the device stopped. The button stays armed, so the press that follows wakes the SoC, the lockout refuses it, and the refusal leaves the glass alone, because the frame that press would have been answered with is already there. -A cell that empties itself on a shelf over a winter runs no shutdown and writes nothing, and that unit is the one a refused boot is for: the press gets no device, so the refusal pushes the same frame instead (`core/power/README.md`). Both roads end on the same glass and the same withheld button. +A cell that empties itself on a shelf over a winter runs no shutdown and writes nothing, and that unit is the one a refused boot is for: the press gets no device, so the refusal pushes the same frame instead (`core/power/README.md`). Both roads end on the same glass, with the button armed and every press refused until the cell is charged. The sixth is the warning the lamp was blinking, `CHARGE BATTERY` under the mark. A long press or the companion link switching the device off while the cell needs charge, `Low` or `Critical` (`core/power/README.md`), gets it instead of the bare wordmark, because the lamp goes dark with the rails and the pilot who picks the unit up next would otherwise find a flight's worth of charge missing on the launch. It is not the flat frame: the device was asked to stop, the button stays armed, and a press still gets a device, so it asks for the cable rather than saying something happened. A stow stays blank at any level, and on the cable the level is always `Normal`, so a unit switched off while charging wears the plain mark. -The cable is the way back, and it is one act. VBUS wakes this SoC out of SYSTEM OFF and the reset cause carries the bit, but `core/power/wake.h` refuses that boot on purpose: a charger found in a flight bag must not switch a device on. What the cable does is charge the cell and, through the refusal, leave the button armed - so that refusal also takes `FLAT BATTERY` or `CHARGE BATTERY` back off the glass and puts the wordmark there, the frame every armed, switched-off device wears. The press after it is the pilot asking for a device, and it gets one even while the cell is still filling. +The cable is the way back, with a press. VBUS wakes this SoC out of SYSTEM OFF, but the factory bootloader sends that wake straight back before this firmware runs, because every switch-off asks it to skip DFU and LilyGO's build swallows a charger wake whenever it was asked to (`core/power/README.md`). So the cable charges the cell and changes nothing on the glass: `FLAT BATTERY` or `CHARGE BATTERY` stays there through the charge, and the charger's own red LED says the cell is filling. A press on the cable is the pilot asking for a device, and it gets one even while the cell is still filling, because the lockout spares external power. Its first frame takes the word off. -That boot is also the one that says the cell had been flat, because the glass no longer does: `went_dark_flat()` is read in `setup()` off a second retained bit the cable's refusal leaves alone, drawn on the self-test page, handed to the status reply, and dropped for the boot after (`core/power/README.md`). +That boot is also the one that says the cell had been flat, because the glass is about to stop saying it: `went_dark_flat()` is read in `setup()` off a second retained bit, drawn on the self-test page, handed to the status reply, and dropped for the boot after (`core/power/README.md`). What the frame does not carry is the percentage. It would be the reading the device died at, frozen at zero, standing there unchanged through the whole charge that follows, and a figure that cannot update is a figure that lies the moment it matters. diff --git a/firmware/products/skyblip_go/main.cpp b/firmware/products/skyblip_go/main.cpp index d9926d65..7e5e2fd6 100644 --- a/firmware/products/skyblip_go/main.cpp +++ b/firmware/products/skyblip_go/main.cpp @@ -27,8 +27,13 @@ go::DiagnosticsDump g_diagnostics; } // namespace int main(void) { + // INFO: fc 05oct26 before setup, so a refused boot cannot hang awake; SYSTEM OFF stops it + if (g_platform.watchdog().arm(runtime::kHardwareWatchdogMs) != Status::Ok) + LOG_ERR("watchdog: not armed, the loop is unsupervised"); + const Status started = g_product.setup(); - LOG_INF("reset reason: %s", power::to_string(g_product.reset_reason())); + LOG_INF("reset reason: %s, setup done at %u ms", power::to_string(g_product.reset_reason()), + static_cast(k_uptime_get())); // INFO: fc 21sep26 the refusal performed: no service, at most one frame, and it never returns if (g_product.boot_path() == power::BootPath::SleepAgain) { @@ -36,9 +41,11 @@ int main(void) { LOG_INF("back to sleep at %u mV (lockout %u mV, external power %d), glass: %s", cell.millivolts, power::kBootLockoutMv, static_cast(cell.external_power), power::to_string(g_product.refused_frame())); - while (!g_product.park_refusal(static_cast(k_uptime_get()))) + while (!g_product.park_refusal(static_cast(k_uptime_get()))) { + g_platform.watchdog().feed(); k_sleep(K_MSEC(runtime::kServiceStepMs)); - g_platform.system_power().system_off(power::button_wake_after_refusal(cell)); + } + g_platform.system_power().system_off(); } if (!g_platform.pps_armed()) LOG_ERR("PPS: no edge interrupt, so no slot is ever keyed"); @@ -64,12 +71,6 @@ int main(void) { static_cast(ports::missing(g_product.capabilities(), go::kRequired))); } - // Armed last, after every part is up: bring-up is slower than any - // steady-state pass, and on the nRF52 a watchdog that has started can never - // be stopped again. - if (g_platform.watchdog().arm(runtime::kHardwareWatchdogMs) != Status::Ok) - LOG_ERR("watchdog: not armed, the loop is unsupervised"); - bool reported_stall = false; for (;;) { const int64_t began_ticks = k_uptime_ticks(); @@ -89,8 +90,7 @@ int main(void) { if (g_product.ready_to_power_off()) { const power::ShutdownReason reason = g_product.shutdown().reason(); LOG_INF("power off: %s", power::to_string(reason)); - g_platform.system_power().system_off( - power::button_wake_after(reason, g_platform.external_power())); + g_platform.system_power().system_off(); } platform::zephyr::g_loop_wake.rest_after(began_ticks); diff --git a/firmware/products/skyblip_go/pages/README.md b/firmware/products/skyblip_go/pages/README.md index 015e9d92..27ddcdc3 100644 --- a/firmware/products/skyblip_go/pages/README.md +++ b/firmware/products/skyblip_go/pages/README.md @@ -193,7 +193,7 @@ The cell is here because it is nowhere else a pilot looks. `status` has the volt What it replaced is the flight time, and nothing was lost: the radar carries the same clock bottom-left at double height, with the seconds joining it after a landing, and the radar is the page that is left on the glass. What went with the clock is the state word this dial's title used to carry. The radar says `NO FIX`, `GROUND` and `TAXI` in its ring and `FLIGHT` over its clock, so all four still have a home, and the five dials here still withhold with `---` the moment the fix goes, which is the same news in the place it is read. -`--%` is a unit with no divider fitted, or one whose gauge has not medianed its three readings yet. It withholds in the shape of the reading it stands in for, sign and all, the way the clock used to withhold as `-:--`. +`--%` is a unit with no divider fitted, one whose gauge has had no reading yet, or one on the cable, whose divider reads the USB rail and not the cell (`core/power/README.md`). It withholds in the shape of the reading it stands in for, sign and all, the way the clock used to withhold as `-:--`. It replaced a derived QNH, the one place on the device that number was shown. What that was worth is in `git log`: the derivation assumed the ISA lapse rate all the way down to the sea, so it was the setting that would make this altimeter agree with GNSS rather than the one a controller would give, drifting about 1.5 hPa per 1000 ft in cold air. The subscale went with it: the altimeter here reads the GNSS altitude, and the pressure altitude a flight level counts in is on `status`. @@ -263,7 +263,7 @@ The card reads `TRK`, not `HDG`. It is GNSS course over ground, referenced to tr Every reading on this page is a measurement except two, and those two are states a pilot has to be able to read without knowing what is inside the box. -A measurement here is the raw one, never the one the glass elsewhere is shown: no damping, no median, no rounding. The climb is `state.own`'s and not the damped rate the six-pack leans on, the ball is the hub's last sample and not the damped ball in the cage, the cell is the last divider reading and not the median the gauge computes its percentage from. Each row prints its reading once to the resolution it is held in, in the SI column: the track in hundredths of a degree, pressure altitude in centimetres, the GNSS altitude in the tenths of a metre GGA carries, the speed in hundredths of a km/h, the climb in millimetres a second and the cell in millivolts. The aeronautical column beside it is the same reading converted, in whole feet, knots and feet a minute. The percentage is the one derived figure, because a state of charge is an estimate and not something a divider measures. This is the bench's page, and a bench that sees a number move needs to know it was the sensor that moved it. +A measurement here is the raw one, never the one the glass elsewhere is shown: no damping, no median, no rounding. The climb is `state.own`'s and not the damped rate the six-pack leans on, the ball is the hub's last sample and not the damped ball in the cage, the cell is the last divider reading and not the median the gauge computes its percentage from. Each row prints its reading once to the resolution it is held in, in the SI column: the track in hundredths of a degree, pressure altitude in centimetres, the GNSS altitude in the tenths of a metre GGA carries, the speed in hundredths of a km/h, the climb in millimetres a second and the cell in millivolts. The aeronautical column beside it is the same reading converted, in whole feet, knots and feet a minute. The percentage is the one derived figure, because a state of charge is an estimate and not something a divider measures. This is the bench's page, and a bench that sees a number move needs to know it was the sensor that moved it. On the cable the cell row reads `-- V --% CHG`, because the divider reads the USB rail there, and a rail printed as the cell would be the one figure on the page that does not measure what it names. The callsign is the exception to the exceptions: it is neither a measurement nor a state but a setting, and this page is where it is read back. It is set on the radar menu or over the companion link, it goes on the air every ten seconds, and it sits at the right of the identity line, in the small font, opposite the address at double height: the address is what a device is, the callsign is what the aircraft is called, and the pair is what another aircraft sees on its own `nearby`. @@ -413,9 +413,11 @@ Nothing here is rounded, nothing is a word where the counter is a number, and no The diagnostics capture's one screen, and the only place a capture is ever armed: the recorder is never armed over Bluetooth, so this page is the switch (`../../../core/diag/README.md`). It is opened from the `DIAGNOSTICS` menu like every other bench page, and it is the only page off the walk where the button does something. -Disarmed, it offers the two captures and states the price of the one in focus. `FULL EVERY SUBJECT` and `POWER RUN POWER AND DUTY` are the two rows, the bar marks the one a double press would arm, and the pad moves the bar; off the last capture the pad hands the page back to the `DIAGNOSTICS` menu, the way it leaves a menu's rows. What each one records is `../../../core/diag/README.md`. +Disarmed, it offers the three captures and states the price of the one in focus. `FULL EVERY SUBJECT`, `POWER RUN POWER AND DUTY` and `FLIGHT RUN POWER AND DUTY` are the three rows, the bar marks the one a double press would arm, and the pad moves the bar; off the last capture the pad hands the page back to the `DIAGNOSTICS` menu, the way it leaves a menu's rows. What each one records is `../../../core/diag/README.md`. -`TAKES n OF m SECTORS` is what the capture may grow into: every free sector, plus whatever flights holds above the twelve protected hours (`../../../core/store/README.md`). `EVICTS k FLIGHTS` turns the second half of that into the thing a pilot owns, the whole stored flights that go, counted oldest first out of the flights index. Both captures pay that same price, because both buy the same slots. `KEEPS 1H08 ROLLING` is where they part, and it is why the page offers a choice at all: the ring rotates, so a capture does not stop after that long, it keeps that much of itself, and how much is those slots over `diag::Recorder::records_per_hour(profile)`. A full capture at 39,600 records an hour keeps about an hour of itself; the power run at 240 keeps 188, enough for a discharge to cutoff with its start intact. Moving the bar swaps one figure for the other, two orders of magnitude apart, which is the comparison a pilot is being asked to make. +`TAKES n OF m SECTORS` is what the capture may grow into: every free sector, plus whatever flights holds above the twelve protected hours (`../../../core/store/README.md`). `EVICTS k FLIGHTS` turns the second half of that into the thing a pilot owns, the whole stored flights that go, counted oldest first out of the flights index. Every capture pays that same price, because each buys the same slots. `KEEPS 1H08 ROLLING` is where they part, and it is why the page offers a choice at all: the ring rotates, so a capture does not stop after that long, it keeps that much of itself, and how much is those slots over `diag::Recorder::records_per_hour(profile)`. A full capture at 39,600 records an hour keeps about an hour of itself; the power run at 240 keeps 188, enough for a discharge to cutoff with its start intact. Moving the bar swaps one figure for the other, two orders of magnitude apart, which is the comparison a pilot is being asked to make. + +The flight run records what the power run records and keeps the same 188 hours, so its row reads the same on the right. What differs is the device: while it runs, the radar wears `FLIGHT` over a flight clock counting from the double press, the way it does airborne, and that clock goes back to `-:--` when the capture stops, leaving no flight time behind. On air the unit is still a parked one, at the ground cadence and flagged on ground (`../../../core/diag/README.md`), and the status page, which says what the sensors say, still reads the ground. Armed, it names the capture that is running in the state row, `STATE ARMED POWER RUN`, read back from the recorder rather than from a second copy the page keeps, and it shows the three numbers that say whether the capture is healthy: the session, the records this session wrote against the records the ring had to drop, and the sectors owned with the span they keep. That span is the rate the capture is producing, measured from the session's opening to the last recurring record the flash took (`diag::recurs`): the `Boot` and `Config` a session opens with and the `Gap` and `End` markers are events and not a rate, and counted as one they cut a power run's first figure sixfold. Until the first recurring record lands the span is the nominal one. A capture that stopped says so in place of `ARMED` - `STOPPED NO SECTORS` when the allocator refused one - because a capture that quietly stopped is a corpus with an invisible end. `FLASH FAULTS n UNREAD m` appears only when there is something to say: writes the part refused, and sectors the boot scan could not read and therefore quarantined. diff --git a/firmware/products/skyblip_go/pages/capture.cpp b/firmware/products/skyblip_go/pages/capture.cpp index d345780d..d07d5dd1 100644 --- a/firmware/products/skyblip_go/pages/capture.cpp +++ b/firmware/products/skyblip_go/pages/capture.cpp @@ -22,10 +22,10 @@ constexpr int kHintY = 182; constexpr int kStateLine = 0; constexpr int kProgressLine = 1; constexpr int kOfferLine = 1; -constexpr int kPriceLine = 3; -constexpr int kWroteLine = 6; +constexpr int kPriceLine = 4; +constexpr int kWroteLine = 7; constexpr int kArmedFaultsLine = 5; -constexpr int kOfferedFaultsLine = 7; +constexpr int kOfferedFaultsLine = 8; int line_y(int line) { return kTopY + line * kLineH; } @@ -48,7 +48,7 @@ int fmt_span(char* out, uint32_t seconds) { } const char* capture_subjects(diag::Profile profile) { - return profile == diag::Profile::PowerRun ? "POWER AND DUTY" : "EVERY SUBJECT"; + return diag::paced(profile) ? "POWER AND DUTY" : "EVERY SUBJECT"; } const char* stopped_word(bus::CaptureStop stopped) { @@ -124,9 +124,12 @@ void draw_wrote(ui::Canvas& fb, const bus::CaptureState& c) { void draw_offer(ui::Canvas& fb, const CaptureSnapshot& s) { if (s.capture.available) { - draw_offered_capture(fb, kOfferLine, diag::Profile::Full, s.focus == diag::Profile::Full); - draw_offered_capture(fb, kOfferLine + 1, diag::Profile::PowerRun, - s.focus == diag::Profile::PowerRun); + int line = kOfferLine; + diag::Profile profile = kFirstCapture; + do { + draw_offered_capture(fb, line++, profile, s.focus == profile); + profile = next_capture(profile); + } while (profile != kFirstCapture); } draw_price(fb, s); draw_wrote(fb, s.capture); @@ -173,7 +176,12 @@ void draw_hint(ui::Canvas& fb, const CaptureSnapshot& s) { } // namespace const char* capture_name(diag::Profile profile) { - return profile == diag::Profile::PowerRun ? "POWER RUN" : "FULL"; + switch (profile) { + case diag::Profile::PowerRun: return "POWER RUN"; + case diag::Profile::FlightRun: return "FLIGHT RUN"; + case diag::Profile::Full: break; + } + return "FULL"; } void draw_capture(ui::Canvas& fb, const CaptureSnapshot& s) { diff --git a/firmware/products/skyblip_go/pages/capture.h b/firmware/products/skyblip_go/pages/capture.h index 5fa1de95..b4a79e1e 100644 --- a/firmware/products/skyblip_go/pages/capture.h +++ b/firmware/products/skyblip_go/pages/capture.h @@ -12,7 +12,12 @@ namespace skyblip::go { constexpr diag::Profile kFirstCapture = diag::Profile::Full; constexpr diag::Profile next_capture(diag::Profile profile) { - return profile == diag::Profile::Full ? diag::Profile::PowerRun : kFirstCapture; + switch (profile) { + case diag::Profile::Full: return diag::Profile::PowerRun; + case diag::Profile::PowerRun: return diag::Profile::FlightRun; + case diag::Profile::FlightRun: break; + } + return kFirstCapture; } const char* capture_name(diag::Profile profile); diff --git a/firmware/products/skyblip_go/pages/status.cpp b/firmware/products/skyblip_go/pages/status.cpp index 8c08c749..fff4751d 100644 --- a/firmware/products/skyblip_go/pages/status.cpp +++ b/firmware/products/skyblip_go/pages/status.cpp @@ -155,23 +155,22 @@ void imu_field(ui::Canvas& fb, int y, const StatusSnapshot& s) { right_aligned(fb, kGlassW - kLeft, y, buf, n); } -// Volts and state of charge, and the fact that decides which of the two curves -// the percentage came from. A pilot who cannot see "CHG" cannot tell a cell that -// is filling from one that is holding 4.1 V on its way down. +// INFO: fc 05oct26 on the cable the divider reads the USB rail, so CHG stands in for the numbers void battery_row(ui::Canvas& fb, int y, const StatusSnapshot& s) { - if (!s.battery_valid) { + if (!s.battery_valid && !s.charging) { row(fb, y, "BAT", "no sensor"); return; } // Centivolts, so the two decimals a cell is judged on fit the value field. - char volts[8]; - int n = fmt_uint(volts, s.battery_mv, 1, 3); - volts[n] = 0; - - char percent[8]; - n = fmt_uint(percent, s.battery_percent, 1); - percent[n] = 0; + char volts[8] = "--"; + char percent[8] = "--"; + if (s.battery_valid) { + int n = fmt_uint(volts, s.battery_mv, 1, 3); + volts[n] = 0; + n = fmt_uint(percent, s.battery_percent, 1); + percent[n] = 0; + } // A cell on the cable is not low whatever it reads, so the charger wins the // marker. Off it, the warning is the whole reason this row is on the page. diff --git a/firmware/products/skyblip_go/product.h b/firmware/products/skyblip_go/product.h index 10c4f356..4335776f 100644 --- a/firmware/products/skyblip_go/product.h +++ b/firmware/products/skyblip_go/product.h @@ -148,7 +148,9 @@ class Product { } bool park_refusal(uint32_t now_ms) { - if (refused_frame_ == power::RefusedFrame::Leave) return true; + // INFO: fc 05oct26 the refused press may still be down, and the wake pin senses a level + const bool released = refusal_release_.settled(now_ms, platform_.button_down()); + if (refused_frame_ == power::RefusedFrame::Leave) return released; if (!refusal_asked_) { refusal_asked_ = true; refusal_since_ms_ = now_ms; @@ -159,9 +161,10 @@ class Product { } screen_.settle_park(now_ms); remember_glass(); - if (!screen_.parking()) return true; // INFO: fc 21sep26 a panel that never reports ready must not hold a flat cell awake - return now_ms - refusal_since_ms_ >= kRefusalParkCeilingMs; + const bool parked = + !screen_.parking() || now_ms - refusal_since_ms_ >= kRefusalParkCeilingMs; + return parked && released; } power::RefusedFrame refused_frame() const { return refused_frame_; } @@ -444,6 +447,7 @@ class Product { power::RefusedFrame refused_frame_{power::RefusedFrame::Leave}; std::optional recovery_taken_{}; uint32_t refusal_since_ms_{0}; + power::ButtonRelease refusal_release_{}; bool refusal_asked_{false}; power::CellOnGlass glass_remembered_{power::CellOnGlass::None}; bool went_dark_flat_{false}; diff --git a/firmware/products/skyblip_go/services/capture.cpp b/firmware/products/skyblip_go/services/capture.cpp index f429d796..73d96a81 100644 --- a/firmware/products/skyblip_go/services/capture.cpp +++ b/firmware/products/skyblip_go/services/capture.cpp @@ -58,6 +58,8 @@ void CaptureService::record_boot(uint32_t now_ms) { } value.reset = config_.reset_reason(); value.image_state = config_.image_state(); + value.profile = context_.diag.profile(); + value.profile_recorded = true; context_.diag.record(value, context_.instant(now_ms)); } diff --git a/firmware/products/skyblip_go/services/config.cpp b/firmware/products/skyblip_go/services/config.cpp index cd515efc..1e6578d8 100644 --- a/firmware/products/skyblip_go/services/config.cpp +++ b/firmware/products/skyblip_go/services/config.cpp @@ -45,7 +45,6 @@ void ConfigLinkService::tick(uint32_t now_ms) { } config_.tick(now_ms); - adopt_learned_trim(); drain_settings(now_ms); spend_gnss_cold_start(); confirm_image_once_healthy(); @@ -98,13 +97,6 @@ void ConfigLinkService::watch_link_drops(uint32_t now_ms) { // A cold start costs the next fix and the driver puts our configuration back // behind it, so it is spent where the request is read rather than reached for // through the board. -void ConfigLinkService::adopt_learned_trim() { - if (settings_.battery_offset_manual || !power_.trim_learned()) return; - if (settings_.battery_offset_mv == power_.learned_offset_mv()) return; - settings_.battery_offset_mv = power_.learned_offset_mv(); - config_.note_settings_changed(); -} - void ConfigLinkService::spend_gnss_cold_start() { if (!config_.gnss_cold_start_requested()) return; config_.clear_gnss_cold_start_request(); diff --git a/firmware/products/skyblip_go/services/config.h b/firmware/products/skyblip_go/services/config.h index f5e94556..82b12051 100644 --- a/firmware/products/skyblip_go/services/config.h +++ b/firmware/products/skyblip_go/services/config.h @@ -73,7 +73,6 @@ class ConfigLinkService : public runtime::Service { void record_update(); private: - void adopt_learned_trim(); void spend_gnss_cold_start(); void record_link(diag::LinkAction action, uint16_t session, uint16_t frame_bytes, uint32_t now_ms); diff --git a/firmware/products/skyblip_go/services/ownship.cpp b/firmware/products/skyblip_go/services/ownship.cpp index 8da5d2aa..2c5f204b 100644 --- a/firmware/products/skyblip_go/services/ownship.cpp +++ b/firmware/products/skyblip_go/services/ownship.cpp @@ -1,6 +1,7 @@ #include "products/skyblip_go/services/ownship.h" #include "core/diag/payload.h" +#include "core/diag/profile.h" #include "core/events/sensor.h" #include "core/flight/arc.h" #include "core/flight/atmosphere.h" @@ -36,10 +37,11 @@ void OwnshipService::tick(uint32_t now_ms) { record_pps(now_ms); timer_.update(flight_.state(), now_ms); + const flight::FlightTimer& timer = timer_on_glass(now_ms); context_.state.flight.confirmed_state = ground_.state(); - context_.state.flight.seconds = timer_.seconds(); - context_.state.flight.time_valid = timer_.flown(); - context_.state.flight.running = timer_.running(); + context_.state.flight.seconds = timer.seconds(); + context_.state.flight.time_valid = timer.flown(); + context_.state.flight.running = timer.running(); context_.state.flight.rolling = flight_.rolling(); acquisition_.tick(now_ms); @@ -51,6 +53,20 @@ void OwnshipService::tick(uint32_t now_ms) { context_.state.own.fix_acquired = settle_.take_acquired(); } +// INFO: fc 05oct26 a flight run flies the glass alone: air, flight log and gates read flight_ +const flight::FlightTimer& OwnshipService::timer_on_glass(uint32_t now_ms) { + if (simulating_flight()) { + simulated_.update(flight::FlightState::Airborne, now_ms); + return simulated_; + } + simulated_ = {}; + return timer_; +} + +bool OwnshipService::simulating_flight() const { + return context_.diag.armed() && diag::simulates_flight(context_.diag.profile()); +} + void OwnshipService::apply_solution(const gnss::GnssSolution& solution, uint32_t now_ms) { model::OwnState& own = context_.state.own; const model::OwnState previous = own; @@ -297,7 +313,7 @@ void OwnshipService::publish_inertial(uint32_t now_ms) { context_.state.gload.most = gmeter_.most(); context_.state.gload.least = gmeter_.least(); - const bool flying = timer_.running(); + const bool flying = context_.state.flight.running; if (flying && !flying_) gmeter_.reset(); flying_ = flying; } diff --git a/firmware/products/skyblip_go/services/ownship.h b/firmware/products/skyblip_go/services/ownship.h index 64475f01..4a220073 100644 --- a/firmware/products/skyblip_go/services/ownship.h +++ b/firmware/products/skyblip_go/services/ownship.h @@ -41,6 +41,8 @@ class OwnshipService : public runtime::Service { bool take_fix_acquired() { return settle_.take_acquired(); } private: + const flight::FlightTimer& timer_on_glass(uint32_t now_ms); + bool simulating_flight() const; void apply_solution(const gnss::GnssSolution& solution, uint32_t now_ms); void record_gnss(const gnss::GnssSolution& solution, uint32_t now_ms); void record_flight(uint32_t now_ms); @@ -73,6 +75,7 @@ class OwnshipService : public runtime::Service { bool flying_{false}; flight::FlightMonitor flight_{}; flight::FlightTimer timer_{}; + flight::FlightTimer simulated_{}; flight::GroundLatch ground_{}; gnss::FirstFix settle_{}; gnss::Acquisition acquisition_{}; diff --git a/firmware/products/skyblip_go/services/power.cpp b/firmware/products/skyblip_go/services/power.cpp index 60ef6401..3c2e1299 100644 --- a/firmware/products/skyblip_go/services/power.cpp +++ b/firmware/products/skyblip_go/services/power.cpp @@ -45,10 +45,10 @@ void PowerService::tick(uint32_t now_ms) { // cutoff that fired 40 mV early on a trimmed unit would be the // calibration causing the failure it exists to prevent. See // core/power/battery.h for what the offset is and where it comes from. - trim_.apply(raw, now_ms); const events::BatterySample sample = power::calibrated(raw, settings_.battery_offset_mv); gauge_.apply(sample); - cutoff_.apply(sample); + // INFO: fc 05oct26 a refused reading is no step on the ladder, the cable still overrides it + if (sample.external_power || power::plausible_mv(sample.millivolts)) cutoff_.apply(sample); } context_.state.power.battery = gauge_.state(); context_.state.power.level = cutoff_.level(); @@ -86,10 +86,9 @@ void PowerService::record_power(const diag::Instant& at) { value.sample_offset_mv = diag::sample_offset_mv(power.battery.sample_mv, power.battery.millivolts); value.supply_warnings = cutoff_.supply_warnings(); - value.implausible = cutoff_.implausible(); + value.implausible = gauge_.refused(); value.charge_warnings = charge_warnings_; value.die_dc = power.die_dc; - value.trim_offset_mv = trim_.offset_mv(); value.percent = power.battery.percent; value.level = power.level; value.charge = power.charge; @@ -97,7 +96,6 @@ void PowerService::record_power(const diag::Instant& at) { value.external_power = power.battery.external_power; value.valid = power.battery.valid; value.die_valid = power.die_valid; - value.trim_learned = trim_.learned(); context_.diag.record(value, at); } @@ -115,13 +113,12 @@ void PowerService::record_duty(const diag::Instant& at) { } uint32_t PowerService::power_period_ms() const { - return context_.diag.profile() == diag::Profile::PowerRun ? diag::kPowerRunRecordPeriodMs - : kRecordPeriodMs; + return diag::paced(context_.diag.profile()) ? diag::kPowerRunRecordPeriodMs : kRecordPeriodMs; } uint32_t PowerService::duty_period_ms() const { - return context_.diag.profile() == diag::Profile::PowerRun ? diag::kPowerRunRecordPeriodMs - : kDutyRecordPeriodMs; + return diag::paced(context_.diag.profile()) ? diag::kPowerRunRecordPeriodMs + : kDutyRecordPeriodMs; } } // namespace skyblip::go diff --git a/firmware/products/skyblip_go/services/power.h b/firmware/products/skyblip_go/services/power.h index fa244fff..b3df9dd0 100644 --- a/firmware/products/skyblip_go/services/power.h +++ b/firmware/products/skyblip_go/services/power.h @@ -5,7 +5,6 @@ #include "core/power/battery.h" #include "core/power/charging.h" #include "core/power/cutoff.h" -#include "core/power/trim.h" #include "ports/capabilities.h" #include "ports/die_temperature.h" #include "products/skyblip_go/settings.h" @@ -28,7 +27,7 @@ class PowerService : public runtime::Service { void record_last_pass(uint32_t now_ms); bool cutoff() const { return cutoff_.cutoff(); } - uint32_t implausible_samples() const { return cutoff_.implausible(); } + uint32_t implausible_samples() const { return gauge_.refused(); } // The power-failure comparator fired. Polled off ports::SystemPower by the // product and handed here, because this service owns the cutoff monitor and @@ -53,9 +52,6 @@ class PowerService : public runtime::Service { power::ChargeCondition charge_condition() const { return charge_; } uint32_t charge_warnings() const { return charge_warnings_; } - bool trim_learned() const { return trim_.learned(); } - int16_t learned_offset_mv() const { return trim_.offset_mv(); } - // INFO: fc 06sep26 a sensor that stopped answering must neither hold nor drive the glass static constexpr uint32_t kDieStaleMs = 30000; bool die_reading_fresh(uint32_t now_ms) const { @@ -92,7 +88,6 @@ class PowerService : public runtime::Service { power::Gauge gauge_{}; power::CutoffMonitor cutoff_{}; - power::FloatTrim trim_{}; power::ChargeCondition charge_{power::ChargeCondition::Unknown}; uint32_t charge_warnings_{0}; uint32_t recorded_ms_{0}; diff --git a/firmware/products/skyblip_go/settings.h b/firmware/products/skyblip_go/settings.h index 8a761385..7f431e26 100644 --- a/firmware/products/skyblip_go/settings.h +++ b/firmware/products/skyblip_go/settings.h @@ -73,7 +73,7 @@ struct Settings { // and its spectrum analyser - which is the instrument that had to be there // to know the number in the first place. int16_t freq_trim_e1_ppm{0}; - // INFO: fc 20sep26 a trim a person set outranks the charger the device trims itself against + // INFO: fc 05oct26 every trim is a person's since the charger learner went, kept for the blob bool battery_offset_manual{false}; uint8_t aircraft_type{kAircraftTypeLight}; bool alarm_enabled{true}; diff --git a/firmware/test/boards/test_board_battery.cpp b/firmware/test/boards/test_board_battery.cpp new file mode 100644 index 00000000..8d5b9aed --- /dev/null +++ b/firmware/test/boards/test_board_battery.cpp @@ -0,0 +1,77 @@ +// What the board publishes off the divider: every pass, read or refused, with the cable beside it. +#include + +#include "boards/lilygo/t_echo_plus/board.h" +#include "core/bus/bus.h" +#include "core/bus/state.h" +#include "core/events/sensor.h" +#include "doctest/doctest.h" +#include "hardware/platform/host/platform.h" +#include "runtime/tasks.h" + +using namespace skyblip; + +namespace { + +using Board = boards::TEchoPlus; + +struct Unit { + Unit() { REQUIRE(board.begin() == Status::Ok); } + + std::vector passes(int count) { + std::vector published; + for (int i = 0; i < count; i++) { + t += runtime::kBatteryPeriodMs; + platform.clock().set_millis(t); + board.poll(state, t); + events::BatterySample sample{}; + while (bus.battery.pop(sample)) published.push_back(sample); + } + return published; + } + + platform::host::Platform platform; + bus::Bus bus; + Board board{platform, bus}; + bus::State state; + uint32_t t{0}; +}; + +} // namespace + +// 0B1B2C's divider sat over the window for a whole charge, and hid the cable with it (#114). +TEST_CASE("board battery: a refused reading still publishes the cable and what was read") { + Unit unit; + unit.platform.battery().external_power = true; + unit.platform.battery().millivolts = 4812; + + const std::vector published = unit.passes(3); + REQUIRE(published.size() == 3); + for (const events::BatterySample& sample : published) { + CHECK(sample.external_power); + CHECK(sample.millivolts == 4812); + } +} + +TEST_CASE("board battery: an ADC that gave nothing still publishes the cable, at zero") { + Unit unit; + unit.platform.battery().external_power = true; + unit.platform.battery().present = false; + + const std::vector published = unit.passes(3); + REQUIRE(published.size() == 3); + for (const events::BatterySample& sample : published) { + CHECK(sample.external_power); + CHECK(sample.millivolts == 0); + } +} + +TEST_CASE("board battery: a believable reading off the cable goes out as it was read") { + Unit unit; + unit.platform.battery().millivolts = 3870; + + const std::vector published = unit.passes(2); + REQUIRE(published.size() == 2); + CHECK(published.back().millivolts == 3870); + CHECK_FALSE(published.back().external_power); +} diff --git a/firmware/test/core/diag/test_diag_decided.cpp b/firmware/test/core/diag/test_diag_decided.cpp index 790aec2c..f80c7579 100644 --- a/firmware/test/core/diag/test_diag_decided.cpp +++ b/firmware/test/core/diag/test_diag_decided.cpp @@ -16,6 +16,8 @@ TEST_CASE("diag record: boot carries the image and the parts the corpus was take in.fw_minor = 7; in.reset = power::ResetReason::Watchdog; in.image_state = dfu::ImageState::Probation; + in.profile = diag::Profile::FlightRun; + in.profile_recorded = true; const diag::Boot out = diag_round_trip(in); CHECK(out.capabilities == in.capabilities); @@ -25,6 +27,25 @@ TEST_CASE("diag record: boot carries the image and the parts the corpus was take CHECK(out.fw_minor == in.fw_minor); CHECK(out.reset == in.reset); CHECK(out.image_state == in.image_state); + CHECK(out.profile == diag::Profile::FlightRun); + CHECK(out.profile_recorded); +} + +// A capture from before the byte has a zero there, and zero is Full: the flag says it was never +// set. +TEST_CASE("diag record: a boot older than its profile byte reads as unrecorded, not as full") { + diag::Boot in{}; + in.profile = diag::Profile::PowerRun; + in.profile_recorded = true; + const diag::Record written = diag::record_of(in, diag::Instant{}); + CHECK(written.payload[14] == 1); + CHECK(written.flagged(diag::kBootFlagProfileRecorded)); + + const diag::Record older = diag::record_of(diag::Boot{}, diag::Instant{}); + CHECK(older.payload[14] == 0); + diag::Boot out{}; + REQUIRE(diag::read(older, out)); + CHECK_FALSE(out.profile_recorded); } TEST_CASE("diag record: config carries the settings a replay has to assume") { diff --git a/firmware/test/core/diag/test_diag_ordinals.cpp b/firmware/test/core/diag/test_diag_ordinals.cpp index 821cee46..4a7df95d 100644 --- a/firmware/test/core/diag/test_diag_ordinals.cpp +++ b/firmware/test/core/diag/test_diag_ordinals.cpp @@ -25,6 +25,15 @@ uint8_t wire(power::ResetReason value) { return kUnpinned; } +uint8_t wire(diag::Profile value) { + switch (value) { + case diag::Profile::Full: return 0; + case diag::Profile::PowerRun: return 1; + case diag::Profile::FlightRun: return 2; + } + return kUnpinned; +} + uint8_t wire(dfu::ImageState value) { switch (value) { case dfu::ImageState::Confirmed: return 0; @@ -247,6 +256,12 @@ TEST_CASE( codes_pinned(4); } +TEST_CASE( + "diag ordinals: diag::Profile's codes are the boot profile byte, and a code changed here " + "changes PROFILE in scripts/blip_records.py and the schema's profile enum with it") { + codes_pinned(3); +} + TEST_CASE( "diag ordinals: gnss::FixReject's codes are the gnss reject byte, and a code changed here " "changes REJECT in scripts/blip_records.py and the schema's reject enum with it") { diff --git a/firmware/test/core/diag/test_diag_recorder.cpp b/firmware/test/core/diag/test_diag_recorder.cpp index 542dec00..ca92cd14 100644 --- a/firmware/test/core/diag/test_diag_recorder.cpp +++ b/firmware/test/core/diag/test_diag_recorder.cpp @@ -244,6 +244,19 @@ TEST_CASE("diag recorder: a power run records the cell and the duty, and refuses CHECK(recorder.written() == 2); } +TEST_CASE("diag recorder: a flight run records what a power run records, and nothing more") { + diag::Recorder recorder; + recorder.arm(diag::Profile::FlightRun); + CHECK(recorder.profile() == diag::Profile::FlightRun); + + CHECK(recorder.record(diag::Power{}, at_ms(0))); + CHECK(recorder.record(diag::Duty{}, at_ms(0))); + CHECK_FALSE(recorder.record(diag::Flight{}, at_ms(1000))); + CHECK_FALSE(recorder.record(diag::Screen{}, at_ms(1000))); + CHECK(recorder.dropped() == 0); + CHECK(recorder.queued() == 2); +} + TEST_CASE("diag recorder: a type the profile never wanted is not counted as a hole") { diag::Recorder recorder; recorder.arm(diag::Profile::PowerRun); @@ -275,4 +288,5 @@ TEST_CASE("diag recorder: a disarmed recorder refuses whatever profile it last h TEST_CASE("diag recorder: a power run writes two records a pass and a capture fifteen a second") { CHECK(diag::Recorder::records_per_hour(diag::Profile::Full) == 15 * 3600); CHECK(diag::Recorder::records_per_hour(diag::Profile::PowerRun) == 240); + CHECK(diag::Recorder::records_per_hour(diag::Profile::FlightRun) == 240); } diff --git a/firmware/test/core/diag/test_diag_sensed.cpp b/firmware/test/core/diag/test_diag_sensed.cpp index 61d889d9..d3948b3d 100644 --- a/firmware/test/core/diag/test_diag_sensed.cpp +++ b/firmware/test/core/diag/test_diag_sensed.cpp @@ -200,7 +200,6 @@ TEST_CASE("diag record: power keeps the cell, the verdict on it and the warnings in.implausible = 12; in.charge_warnings = 1; in.die_dc = -206; - in.trim_offset_mv = -40; in.sample_offset_mv = -128; in.percent = 64; in.level = power::PowerLevel::Critical; @@ -209,7 +208,6 @@ TEST_CASE("diag record: power keeps the cell, the verdict on it and the warnings in.external_power = true; in.valid = true; in.die_valid = true; - in.trim_learned = true; const diag::Power out = diag_round_trip(in); CHECK(out.cell_mv == in.cell_mv); @@ -217,7 +215,6 @@ TEST_CASE("diag record: power keeps the cell, the verdict on it and the warnings CHECK(out.implausible == in.implausible); CHECK(out.charge_warnings == in.charge_warnings); CHECK(out.die_dc == in.die_dc); - CHECK(out.trim_offset_mv == in.trim_offset_mv); CHECK(out.sample_offset_mv == in.sample_offset_mv); CHECK(out.percent == in.percent); CHECK(out.level == in.level); @@ -226,7 +223,6 @@ TEST_CASE("diag record: power keeps the cell, the verdict on it and the warnings CHECK(out.external_power); CHECK(out.valid); CHECK(out.die_valid); - CHECK(out.trim_learned); } TEST_CASE("diag record: the knee rides inside the level, and the bit it used to ride stays clear") { @@ -238,6 +234,19 @@ TEST_CASE("diag record: the knee rides inside the level, and the bit it used to CHECK(diag_round_trip(in).level == power::PowerLevel::Low); } +TEST_CASE("diag record: the retired trim bit and bytes are written clear and never reused") { + diag::Power in{}; + in.cell_mv = 4812; + in.sample_offset_mv = -5; + in.external_power = true; + in.charging = true; + + const diag::Record r = diag::record_of(in, diag_test_instant()); + CHECK((r.flags & (1u << 7)) == 0); + CHECK(r.payload[13] == 0); + CHECK(r.payload[14] == 0); +} + TEST_CASE("diag record: a contact keeps the instant the level moved, not the poll that saw it") { diag::Contact in{}; in.at_ms = 4294967295u; diff --git a/firmware/test/core/test_power.cpp b/firmware/test/core/test_power.cpp index f3f1fe9b..87d72b7e 100644 --- a/firmware/test/core/test_power.cpp +++ b/firmware/test/core/test_power.cpp @@ -1,9 +1,9 @@ -// One terminal voltage, two meanings: on the cable the charger holds the cell -// above its own resting voltage, so the same reading is a far emptier cell. These -// pin down that the gauge says which curve it read, never walks the wrong way, and -// ignores the sag of a 14 dBm burst. A percentage that jumps when the radio keys -// is a gauge a pilot stops believing. +// One curve, read off the cable only: on it the divider reads the USB rail and +// not the cell. These pin down that the gauge never walks the wrong way, ignores +// the sag of a 14 dBm burst, and shows no cell it cannot see. A percentage that +// jumps when the radio keys is a gauge a pilot stops believing. #include +#include #include "core/events/sensor.h" #include "core/power/battery.h" @@ -25,37 +25,36 @@ static void settle(Gauge& gauge, uint16_t millivolts, bool external_power = fals } TEST_CASE("battery: the curve is monotonic and clamps at both ends") { - CHECK(percent_from_mv(3000, false) == 0); - CHECK(percent_from_mv(kEmptyMv, false) == 0); - CHECK(percent_from_mv(kFullMv, false) == 100); - CHECK(percent_from_mv(4500, false) == 100); + CHECK(percent_from_mv(3000) == 0); + CHECK(percent_from_mv(kEmptyMv) == 0); + CHECK(percent_from_mv(kFullMv) == 100); + CHECK(percent_from_mv(4500) == 100); uint8_t previous = 0; for (uint16_t mv = kEmptyMv; mv <= kFullMv; mv += 10) { - const uint8_t percent = percent_from_mv(mv, false); + const uint8_t percent = percent_from_mv(mv); CHECK(percent >= previous); previous = percent; } } -// The point of the whole module: the same reading means two different states of -// charge, because on charge the terminal sits above the cell's own voltage. -TEST_CASE("battery: charging reads lower than resting at the same voltage") { - for (uint16_t mv = 3600; mv <= 4150; mv += 50) - CHECK(percent_from_mv(mv, true) < percent_from_mv(mv, false)); - - // 4.00 V is nearly full on the bench and barely half full with a charger on it. - CHECK(percent_from_mv(4000, false) == 89); - CHECK(percent_from_mv(4000, true) == 55); -} - TEST_CASE("battery: the flat middle of the cell is not a straight line") { // 3.70 -> 3.80 V is a quarter of the capacity; 4.10 -> 4.20 V is a twentieth. - const int mid = percent_from_mv(3800, false) - percent_from_mv(3700, false); - const int top = percent_from_mv(4200, false) - percent_from_mv(4100, false); + const int mid = percent_from_mv(3800) - percent_from_mv(3700); + const int top = percent_from_mv(4200) - percent_from_mv(4100); CHECK(mid > top); } +// The window the gauge believes: a floating divider under it, a rail over it. +TEST_CASE("battery: a reading is the cell only between the floor and the ceiling") { + CHECK_FALSE(plausible_mv(0)); + CHECK_FALSE(plausible_mv(kImplausibleFloorMv)); + CHECK(plausible_mv(kImplausibleFloorMv + 1)); + CHECK(plausible_mv(kFullMv)); + CHECK(plausible_mv(kImplausibleCeilingMv)); + CHECK_FALSE(plausible_mv(kImplausibleCeilingMv + 1)); +} + TEST_CASE("gauge: nothing is reported before the first reading") { Gauge gauge; CHECK_FALSE(gauge.state().valid); @@ -65,7 +64,7 @@ TEST_CASE("gauge: nothing is reported before the first reading") { CHECK(gauge.state().valid); // The first reading stands on its own rather than being mixed with zeroes. CHECK(gauge.state().millivolts == 3900); - CHECK(gauge.state().percent == percent_from_mv(3900, false)); + CHECK(gauge.state().percent == percent_from_mv(3900)); CHECK_FALSE(gauge.state().charging); } @@ -94,7 +93,7 @@ TEST_CASE("gauge: the reading the median threw out is still the reading the benc CHECK(gauge.state().sample_mv == 3700); } -TEST_CASE("gauge: the percentage only moves the way the current flows") { +TEST_CASE("gauge: off the cable the percentage only falls") { Gauge gauge; settle(gauge, 3800); const uint8_t discharging = gauge.state().percent; @@ -111,47 +110,55 @@ TEST_CASE("gauge: the percentage only moves the way the current flows") { CHECK(gauge.state().percent < discharging); } -TEST_CASE("gauge: plugging in re-seats on the charge curve, unplugging on the other") { +// E68BD9 read 4670 to 4700 mV for 3.5 h of charging from 45 %, and called it 100 % (#114). +TEST_CASE("gauge: on the cable the divider reads the rail, so there is no cell to show") { Gauge gauge; - settle(gauge, 4000); - const uint8_t resting = gauge.state().percent; - CHECK(resting == percent_from_mv(4000, false)); + settle(gauge, 3750); + REQUIRE(gauge.state().valid); - // Same cell, cable in: the charger is pushing, so the honest number drops. - settle(gauge, 4000, /*external_power=*/true); + settle(gauge, 4690, /*external_power=*/true); + CHECK_FALSE(gauge.state().valid); CHECK(gauge.state().charging); CHECK(gauge.state().external_power); - CHECK(gauge.state().percent == percent_from_mv(4000, true)); - CHECK(gauge.state().percent < resting); + CHECK(gauge.state().percent == 0); + CHECK(gauge.state().millivolts == 4690); + CHECK(gauge.refused() == 0); +} + +// Charged on the cable, the cell is where the first reading off it says, not under the old median. +TEST_CASE("gauge: unplugging starts the median over on the charged cell") { + Gauge gauge; + settle(gauge, 3750); + settle(gauge, 4690, /*external_power=*/true); - // Cable out: the charge current stops, the cell relaxes, and the gauge is - // allowed to jump back up rather than being held down by the old curve. - settle(gauge, 4000, /*external_power=*/false); + gauge.apply(sample(4150)); + CHECK(gauge.state().valid); CHECK_FALSE(gauge.state().charging); - CHECK(gauge.state().percent == resting); + CHECK(gauge.state().millivolts == 4150); + CHECK(gauge.state().percent == percent_from_mv(4150)); } -TEST_CASE("gauge: charging never walks backwards") { +// 0B1B2C's rail sat above the ceiling for 7 h, and nothing said which bound it crossed (#114). +TEST_CASE("gauge: a refused reading is counted, and its value is what the gauge shows") { Gauge gauge; - settle(gauge, 3900, /*external_power=*/true); - const uint8_t climbing = gauge.state().percent; + settle(gauge, 3900); - gauge.apply(sample(3870, /*external_power=*/true)); - CHECK(gauge.state().percent == climbing); + gauge.apply(sample(4812, /*external_power=*/true)); + CHECK(gauge.refused() == 1); + CHECK_FALSE(gauge.state().valid); + CHECK(gauge.state().millivolts == 4812); + CHECK(gauge.state().sample_mv == 4812); - settle(gauge, 4100, /*external_power=*/true); - CHECK(gauge.state().percent > climbing); -} + gauge.apply(sample(312)); + CHECK(gauge.refused() == 2); + CHECK_FALSE(gauge.state().valid); + CHECK(gauge.state().millivolts == 312); + CHECK_FALSE(gauge.state().external_power); -TEST_CASE("gauge: a full cell on the cable is charged, not charging") { - Gauge gauge; - settle(gauge, kFullMv, /*external_power=*/true); - CHECK(gauge.state().external_power); - // No charge current can be measured, so the float plateau is the signal that - // the charger has finished. - CHECK(gauge.state().millivolts >= kChargeCompleteMv); - CHECK_FALSE(gauge.state().charging); - CHECK(gauge.state().percent == 100); + gauge.apply(sample(3880)); + CHECK(gauge.state().valid); + CHECK(gauge.state().millivolts == 3880); + CHECK(gauge.refused() == 2); } // The acting half. The gauge above says what the cell holds; this says when that @@ -233,22 +240,36 @@ TEST_CASE("cutoff: only low, critical and flat ask for the charger") { } // The cell sits on a step for the minutes it takes to cross it, and noise straddles the line. -TEST_CASE("cutoff: a level comes back up on three samples above its step, not on one") { +TEST_CASE("cutoff: a level comes back up on three samples clear of its step by the margin") { CutoffMonitor monitor; for (int i = 0; i < 3; i++) monitor.apply(sample(kCriticalMv - 10)); REQUIRE(monitor.level() == PowerLevel::Critical); - monitor.apply(sample(kCriticalMv + 5)); - monitor.apply(sample(kCriticalMv + 5)); + for (int i = 0; i < 10; i++) monitor.apply(sample(kCriticalMv + kRecoveryMarginMv - 1)); + CHECK(monitor.level() == PowerLevel::Critical); + + monitor.apply(sample(kCriticalMv + kRecoveryMarginMv)); + monitor.apply(sample(kCriticalMv + kRecoveryMarginMv)); CHECK(monitor.level() == PowerLevel::Critical); monitor.apply(sample(kCriticalMv - 10)); - monitor.apply(sample(kCriticalMv + 5)); + monitor.apply(sample(kCriticalMv + kRecoveryMarginMv)); CHECK(monitor.level() == PowerLevel::Critical); - for (int i = 0; i < 2; i++) monitor.apply(sample(kCriticalMv + 5)); + for (int i = 0; i < 2; i++) monitor.apply(sample(kCriticalMv + kRecoveryMarginMv)); CHECK(monitor.level() == PowerLevel::Low); } +// A falling cell is not slowed: the margin is only on the way up. +TEST_CASE("cutoff: a level goes down on three samples under its step, with no margin") { + CutoffMonitor monitor; + for (int i = 0; i < 3; i++) monitor.apply(sample(kCriticalMv - 10)); + for (int i = 0; i < 3; i++) monitor.apply(sample(kCriticalMv + kRecoveryMarginMv)); + REQUIRE(monitor.level() == PowerLevel::Low); + + for (int i = 0; i < 3; i++) monitor.apply(sample(kCriticalMv - 1)); + CHECK(monitor.level() == PowerLevel::Critical); +} + TEST_CASE("cutoff: a cell that recovers far climbs every step it cleared at once") { CutoffMonitor monitor; for (int i = 0; i < 3; i++) monitor.apply(sample(3400)); @@ -267,6 +288,24 @@ TEST_CASE("cutoff: a cell resting on a step for many minutes stays on it") { } } +// E68BD9's run to cutoff, its cell_mv every 30 s on 5 Oct, 02:39-02:45 and 03:39-03:43 UTC. +TEST_CASE("cutoff: a parked cell drifting across a step changes level once") { + constexpr uint16_t kRunMv[] = {3607, 3598, 3591, 3600, 3600, 3600, 3600, 3594, + 3600, 3591, 3601, 3596, 3598, 3513, 3492, 3517, + 3501, 3496, 3492, 3505, 3487, 3498, 3492}; + CutoffMonitor monitor; + PowerLevel previous = monitor.apply(sample(kRunMv[0])); + REQUIRE(previous == PowerLevel::Normal); + + std::vector changes; + for (const uint16_t millivolts : kRunMv) { + for (int i = 0; i < kLevelSamples; i++) monitor.apply(sample(millivolts)); + if (monitor.level() != previous) changes.push_back(monitor.level()); + previous = monitor.level(); + } + CHECK(changes == std::vector{PowerLevel::Low, PowerLevel::Critical}); +} + TEST_CASE("cutoff: a first reading at or above low is a sound cell, one under it waits for three") { CutoffMonitor sound; CHECK(sound.apply(sample(kLowMv)) == PowerLevel::Normal); @@ -294,9 +333,9 @@ TEST_CASE("cutoff: the ladder is low, critical, boot lockout, flat") { // And the gauge reads zero where the device stops, not before it. CHECK(kEmptyMv == kFlatMv); - CHECK(percent_from_mv(kFlatMv, false) == 0); - CHECK(percent_from_mv(kCriticalMv, false) > 0); - CHECK(percent_from_mv(kBootLockoutMv, false) > 0); + CHECK(percent_from_mv(kFlatMv) == 0); + CHECK(percent_from_mv(kCriticalMv) > 0); + CHECK(percent_from_mv(kBootLockoutMv) > 0); } TEST_CASE("cutoff: a floating ADC cannot power the device off") { diff --git a/firmware/test/core/test_power_trim.cpp b/firmware/test/core/test_power_trim.cpp deleted file mode 100644 index cd88fd48..00000000 --- a/firmware/test/core/test_power_trim.cpp +++ /dev/null @@ -1,107 +0,0 @@ -// A charger in constant voltage holds a known value, so the difference is this unit's error. -#include "core/events/sensor.h" -#include "core/power/battery.h" -#include "core/power/trim.h" -#include "doctest/doctest.h" - -using namespace skyblip; -using namespace skyblip::power; - -namespace { - -constexpr uint32_t kSecondMs = 1000; - -events::BatterySample sample(uint16_t millivolts, bool external_power) { - return events::BatterySample{millivolts, external_power}; -} - -uint32_t feed(FloatTrim& trim, uint16_t millivolts, bool external_power, uint32_t from_ms, - uint32_t span_ms) { - uint32_t now = from_ms; - for (; now <= from_ms + span_ms; now += kSecondMs) - trim.apply(sample(millivolts, external_power), now); - return now; -} - -// A whole charge: constant current well under the float, then the float itself. -uint32_t charge(FloatTrim& trim, uint16_t float_mv, uint32_t from_ms) { - const uint32_t climbed = - feed(trim, static_cast(float_mv - kClimbRiseMv), true, from_ms, 10 * kSecondMs); - return feed(trim, float_mv, true, climbed, kPlateauHoldMs); -} - -} // namespace - -TEST_CASE("trim: a unit reading low learns the millivolts it is out by") { - FloatTrim trim; - CHECK_FALSE(trim.learned()); - - charge(trim, 4160, 0); - CHECK(trim.learned()); - CHECK(int(trim.offset_mv()) == 40); - - FloatTrim high; - charge(high, 4250, 0); - CHECK(int(high.offset_mv()) == -50); -} - -TEST_CASE("trim: the trim it learns is the one the settings bound accepts") { - FloatTrim trim; - charge(trim, kFloatReferenceMv - kCalibrationLimitMv, 0); - CHECK(trim.learned()); - CHECK(int(trim.offset_mv()) == int(kCalibrationLimitMv)); - - // Past the bound a reading is wired wrong rather than out of trim. - FloatTrim broken; - charge(broken, kFloatReferenceMv - kCalibrationLimitMv - 100, 0); - CHECK_FALSE(broken.learned()); -} - -// A topped-off pack drifting down under its own load is flat too, and is no reference. -TEST_CASE("trim: a plateau it did not climb into teaches it nothing") { - FloatTrim trim; - feed(trim, 4180, true, 0, 4 * kPlateauHoldMs); - CHECK_FALSE(trim.learned()); -} - -TEST_CASE("trim: no cable, no reference") { - FloatTrim trim; - const uint32_t climbed = feed(trim, 3990, true, 0, 10 * kSecondMs); - feed(trim, 4190, false, climbed, 4 * kPlateauHoldMs); - CHECK_FALSE(trim.learned()); -} - -TEST_CASE("trim: unplugging forgets the climb") { - FloatTrim trim; - const uint32_t climbed = feed(trim, 3970, true, 0, 10 * kSecondMs); - const uint32_t unplugged = feed(trim, 3970, false, climbed, 2 * kSecondMs); - feed(trim, 4170, true, unplugged, 2 * kPlateauHoldMs); - CHECK_FALSE(trim.learned()); -} - -TEST_CASE("trim: a reading that is still moving is not a plateau") { - FloatTrim trim; - uint32_t now = feed(trim, 3900, true, 0, 10 * kSecondMs); - for (uint16_t mv = 4100; mv <= 4200; mv++) { - trim.apply(sample(mv, true), now); - now += kSecondMs; - } - CHECK_FALSE(trim.learned()); - - feed(trim, 4200, true, now, kPlateauHoldMs); - CHECK(trim.learned()); - // A window opened on the way up is worth half the spread it is allowed. - CHECK(int(trim.offset_mv()) <= int(kPlateauSpreadMv) / 2); - CHECK(int(trim.offset_mv()) >= -int(kPlateauSpreadMv) / 2); -} - -TEST_CASE("trim: two minutes of plateau, and not a second less") { - FloatTrim trim; - const uint32_t climbed = feed(trim, 3950, true, 0, 10 * kSecondMs); - feed(trim, 4150, true, climbed, kPlateauHoldMs - 10 * kSecondMs); - CHECK_FALSE(trim.learned()); - - feed(trim, 4150, true, climbed, kPlateauHoldMs); - CHECK(trim.learned()); - CHECK(int(trim.offset_mv()) == 50); -} diff --git a/firmware/test/core/test_shutdown.cpp b/firmware/test/core/test_shutdown.cpp index 45492f61..616d2db3 100644 --- a/firmware/test/core/test_shutdown.cpp +++ b/firmware/test/core/test_shutdown.cpp @@ -81,10 +81,8 @@ TEST_CASE("shutdown: the pad on its own switches nothing off") { TEST_CASE("shutdown: a stow waits for the button like any other press") { ShutdownSequencer seq; seq.request(ShutdownReason::Stow, 0); - CHECK(seq.waits_for_release()); for (uint32_t t = 0; t <= kParkMs; t += 10) seq.tick(t, true, /*pad_down=*/true); CHECK(seq.phase() == ShutdownPhase::AwaitRelease); - CHECK(button_wake_after(ShutdownReason::Stow, /*external_power=*/false) == ButtonWake::Armed); } TEST_CASE("shutdown: the wake pin waits for the button to come up") { @@ -112,23 +110,18 @@ TEST_CASE("shutdown: the wake pin waits for the button to come up") { CHECK(seq.ready_to_power_off()); } -// A bag on the button must not hold the rails up on the cell that asked to go. -TEST_CASE("shutdown: a low-battery shutdown does not wait for a button it will not arm") { +// The cutoff arms the button like every switch-off, and a held one would wake it at once. +TEST_CASE("shutdown: a low-battery shutdown waits for the button it arms") { ShutdownSequencer seq; seq.request(ShutdownReason::LowBattery, 0); uint32_t t = 0; - for (; t < kParkMs; t += 10) seq.tick(t, /*button_down=*/true); - CHECK(seq.phase() == ShutdownPhase::Parking); + for (; t <= kParkMs + 1000; t += 10) seq.tick(t, /*button_down=*/true); + CHECK(seq.phase() == ShutdownPhase::AwaitRelease); + CHECK_FALSE(seq.ready_to_power_off()); - seq.tick(kParkMs, /*button_down=*/true); - CHECK(seq.phase() == ShutdownPhase::Off); + const uint32_t released = t; + for (; t <= released + kReleaseSettleMs + 10; t += 10) seq.tick(t, false); CHECK(seq.ready_to_power_off()); - - // A long press still waits: that one does come back on a press. - ShutdownSequencer pressed; - pressed.request(ShutdownReason::LongPress, 0); - for (t = 0; t <= kParkMs; t += 10) pressed.tick(t, true); - CHECK(pressed.phase() == ShutdownPhase::AwaitRelease); } // The swap takes the same road out as a power-off: the panel gets its park frame @@ -150,8 +143,6 @@ TEST_CASE("shutdown: a recovery leaves the button armed, since a press may be wh ShutdownSequencer seq; seq.request(ShutdownReason::Recovery, 0); CHECK(std::string(to_string(ShutdownReason::Recovery)) == "RECOVERY"); - CHECK(button_wake_after(ShutdownReason::Recovery, /*external_power=*/false) == - ButtonWake::Armed); uint32_t t = 0; for (; t < kParkMs + kReleaseSettleMs + 50; t += 10) seq.tick(t, false); CHECK(seq.ready_to_power_off()); @@ -276,7 +267,7 @@ class RecordingSink : public power::PowerDownSink { TEST_CASE("power down: every step runs once, in the order the table declares") { RecordingSink sink; - power_down(sink, ButtonWake::Armed); + power_down(sink); REQUIRE(sink.count == kPowerDownStepCount); for (int i = 0; i < kPowerDownStepCount; i++) { CHECK(sink.steps[i] == kPowerDownOrder[i]); @@ -291,7 +282,7 @@ TEST_CASE("power down: every step runs once, in the order the table declares") { // looking switched off. TEST_CASE("power down: the external flash is told to sleep while it still has a rail") { RecordingSink sink; - power_down(sink, ButtonWake::Armed); + power_down(sink); CHECK(sink.at(PowerDownStep::ExternalFlashDeepPowerDown) < sink.at(PowerDownStep::PeripheralRailOff)); // And over the lines the command travels on, which is why they are released @@ -303,21 +294,21 @@ TEST_CASE("power down: the external flash is told to sleep while it still has a // A line left high on a panel with no rail back-feeds it, and the parked image drifts dark. TEST_CASE("power down: the panel's lines are released before its rail, not after") { RecordingSink sink; - power_down(sink, ButtonWake::Armed); + power_down(sink); CHECK(sink.at(PowerDownStep::PanelLinesReleased) < sink.at(PowerDownStep::PeripheralRailOff)); CHECK(sink.at(PowerDownStep::PanelLinesReleased) < sink.at(PowerDownStep::DrivenPinsReleased)); } TEST_CASE("power down: the radio is asleep before anything touches its reset line") { RecordingSink sink; - power_down(sink, ButtonWake::Armed); + power_down(sink); CHECK(sink.at(PowerDownStep::RadioSleep) < sink.at(PowerDownStep::RadioResetAsserted)); CHECK(sink.at(PowerDownStep::RadioSleep) < sink.at(PowerDownStep::DrivenPinsReleased)); } TEST_CASE("power down: the GNSS is switched off, and before its supply goes") { RecordingSink sink; - power_down(sink, ButtonWake::Armed); + power_down(sink); // The receiver with a fix is tens of milliamps: the one part that decides // whether an 850 mAh pack survives a night. REQUIRE(sink.at(PowerDownStep::GnssBackupOff) >= 0); @@ -327,7 +318,7 @@ TEST_CASE("power down: the GNSS is switched off, and before its supply goes") { TEST_CASE("power down: the rails go last, and the pins are released after them") { RecordingSink sink; - power_down(sink, ButtonWake::Armed); + power_down(sink); const int rail = sink.at(PowerDownStep::PeripheralRailOff); const int aux = sink.at(PowerDownStep::AuxRailOff); for (const PowerDownStep step : @@ -347,20 +338,9 @@ TEST_CASE("power down: the rails go last, and the pins are released after them") CHECK(sink.at(PowerDownStep::WakePinArmed) == kPowerDownStepCount - 1); } -// A press on a cell this flat is the ratchet, so only a cable brings it back. -TEST_CASE("power down: a low-battery shutdown drops the rails without arming the button") { - CHECK(button_wake_after(ShutdownReason::LowBattery, false) == ButtonWake::Withheld); - CHECK(button_wake_after(ShutdownReason::LongPress, false) == ButtonWake::Armed); - CHECK(button_wake_after(ShutdownReason::LinkRequest, false) == ButtonWake::Armed); - CHECK(button_wake_after(ShutdownReason::None, false) == ButtonWake::Armed); - // A cable that arrived during the park: VBUS is already high, so there is no - // rising edge left to wake on and the button is the only way back. - CHECK(button_wake_after(ShutdownReason::LowBattery, /*external_power=*/true) == - ButtonWake::Armed); - - RecordingSink sink; - power_down(sink, ButtonWake::Withheld); - CHECK(sink.count == kPowerDownStepCount - 1); - CHECK(sink.at(PowerDownStep::WakePinArmed) == -1); - for (int i = 0; i < kPowerDownStepCount - 1; i++) CHECK(sink.steps[i] == kPowerDownOrder[i]); +// The bootloader's DFU button is ours: a press waking on any other byte lands in USB DFU. +TEST_CASE("power down: every switch-off asks the factory bootloader to skip DFU") { + CHECK(boot_magic_for_system_off(/*recovery_armed=*/false) == 0x6d); + // Recovery is the one switch-off that asks for the UF2 drive instead. + CHECK(boot_magic_for_system_off(/*recovery_armed=*/true) == 0x57); } diff --git a/firmware/test/core/test_wake.cpp b/firmware/test/core/test_wake.cpp index b6276f01..98d0ccba 100644 --- a/firmware/test/core/test_wake.cpp +++ b/firmware/test/core/test_wake.cpp @@ -118,16 +118,6 @@ TEST_CASE("wake: a unit that reset itself runs on any cell the cutoff would have CHECK(boot_path(ResetCause::Pin, false, low) == BootPath::SleepAgain); } -// SENSE is a level detect: a button held in a bag re-wakes what it just refused. -TEST_CASE("wake: a boot refused for a flat cell leaves the button unarmed") { - CHECK(button_wake_after_refusal(healthy(power::kFlatMv)) == ButtonWake::Withheld); - BootCell on_charge = healthy(3000); - on_charge.external_power = true; - CHECK(button_wake_after_refusal(on_charge) == ButtonWake::Armed); - CHECK(button_wake_after_refusal(healthy()) == ButtonWake::Armed); - CHECK(button_wake_after_refusal(BootCell{}) == ButtonWake::Armed); -} - // A refusal with no way out is a brick, and this way out also fills the cell. TEST_CASE("wake: a flat cell on the cable gets a device when a person asks for one") { BootCell on_charge = healthy(3000); @@ -161,8 +151,8 @@ TEST_CASE("wake: a flat cell refused under the low word names itself instead") { CHECK(refused_frame(healthy(power::kFlatMv), CellOnGlass::Low) == RefusedFrame::FlatCell); } -// The cable arms the button again (button_wake_after_refusal), so the mark is the instruction. -TEST_CASE("wake: the charger that wakes a flat device takes the word back off the glass") { +// A bootloader that let a charger wake through would get the frame of every armed device. +TEST_CASE("wake: a charger wake that reaches the refusal takes the word back off the glass") { BootCell on_charge = healthy(3000); on_charge.external_power = true; CHECK(refused_frame(on_charge, CellOnGlass::Flat) == RefusedFrame::Wordmark); diff --git a/firmware/test/products/pages/test_capture.cpp b/firmware/test/products/pages/test_capture.cpp index 08006edb..4d39d473 100644 --- a/firmware/test/products/pages/test_capture.cpp +++ b/firmware/test/products/pages/test_capture.cpp @@ -9,7 +9,7 @@ using skyblip::diag::Profile; namespace { -// 266 sectors of 170 slots is 45,220: 4110 s at Full's 39,600 an hour, 678,300 s at PowerRun's 240. +// 266 sectors of 170 slots is 45,220: 4110 s at Full's 39,600 an hour, 678,300 s at a run's 240. constexpr uint32_t kFullKeepsS = 4110; constexpr uint32_t kPowerRunKeepsS = 678300; @@ -27,7 +27,7 @@ CaptureSnapshot disarmed_device() { CaptureSnapshot offering(Profile focus) { CaptureSnapshot snap = disarmed_device(); snap.focus = focus; - snap.focus_keeps_s = focus == Profile::PowerRun ? kPowerRunKeepsS : kFullKeepsS; + snap.focus_keeps_s = focus == Profile::Full ? kFullKeepsS : kPowerRunKeepsS; return snap; } @@ -44,7 +44,7 @@ CaptureSnapshot armed_device() { } bool focused(const Glass& fb, const char* name) { - return reads_in(fb, name, 0, 28, 200, 60, 1, /*ink=*/false); + return reads_in(fb, name, 0, 28, 200, 74, 1, /*ink=*/false); } } // namespace @@ -57,22 +57,35 @@ TEST_CASE("capture page: a disarmed device states the price before it is paid") CHECK(reads_in(fb, "T+812", 100, 0, 200, 14)); CHECK(reads_in(fb, "STATE OFF", 0, 14, 200, 40)); CHECK(reads_in(fb, "POWER RUN", 0, 42, 200, 70)); - CHECK(reads_in(fb, "TAKES 266 OF 330 SECTORS", 0, 56, 200, 84)); - CHECK(reads_in(fb, "EVICTS 3 FLIGHTS", 0, 70, 200, 98)); - CHECK(reads_in(fb, "KEEPS 1H08 ROLLING", 0, 84, 200, 112)); + CHECK(reads_in(fb, "FLIGHT RUN", 0, 56, 200, 84)); + CHECK(reads_in(fb, "TAKES 266 OF 330 SECTORS", 0, 70, 200, 98)); + CHECK(reads_in(fb, "EVICTS 3 FLIGHTS", 0, 84, 200, 112)); + CHECK(reads_in(fb, "KEEPS 1H08 ROLLING", 0, 98, 200, 126)); CHECK(reads_in(fb, "PAD PICKS", 0, 170, 200, 200)); CHECK(reads_in(fb, "PRESS TWICE TO ARM", 0, 170, 200, 200)); } -TEST_CASE("capture page: the two captures quote spans two orders of magnitude apart") { +TEST_CASE("capture page: the full capture and the two runs quote spans two orders apart") { Glass full; draw_capture(full, offering(Profile::Full)); Glass power_run; draw_capture(power_run, offering(Profile::PowerRun)); + Glass flight_run; + draw_capture(flight_run, offering(Profile::FlightRun)); - CHECK(reads_in(full, "KEEPS 1H08 ROLLING", 0, 84, 200, 112)); - CHECK(reads_in(power_run, "KEEPS 188H25 ROLLING", 0, 84, 200, 112)); + CHECK(reads_in(full, "KEEPS 1H08 ROLLING", 0, 98, 200, 126)); + CHECK(reads_in(power_run, "KEEPS 188H25 ROLLING", 0, 98, 200, 126)); CHECK_FALSE(reads_in(power_run, "KEEPS 1H08 ROLLING", 0, 0, 200, 200)); + CHECK(reads_in(flight_run, "KEEPS 188H25 ROLLING", 0, 98, 200, 126)); +} + +TEST_CASE("capture page: the flight run records power and duty, as the power run does") { + Glass fb; + draw_capture(fb, offering(Profile::FlightRun)); + CHECK(focused(fb, "FLIGHT RUN")); + CHECK(reads_in(fb, "POWER AND DUTY", 100, 56, 200, 84, 1, /*ink=*/false)); + CHECK(reads_in(fb, "POWER AND DUTY", 100, 42, 200, 70)); + CHECK(reads_in(fb, "EVERY SUBJECT", 100, 28, 200, 56)); } TEST_CASE("capture page: the capture in focus is the one under the bar") { @@ -87,6 +100,7 @@ TEST_CASE("capture page: the capture in focus is the one under the bar") { CHECK(focused(power_run, "POWER RUN")); CHECK(reads_in(power_run, "FULL", 0, 28, 200, 56)); CHECK_FALSE(focused(power_run, "FULL")); + CHECK_FALSE(focused(power_run, "FLIGHT RUN")); } TEST_CASE("capture page: an armed device shows what it wrote, what it lost and what it holds") { @@ -109,6 +123,11 @@ TEST_CASE("capture page: an armed device names the capture that is running") { CHECK(reads_in(fb, "STATE ARMED POWER RUN", 0, 14, 200, 40)); CHECK_FALSE(reads_in(fb, "STATE ARMED FULL", 0, 0, 200, 200)); CHECK_FALSE(reads_in(fb, "PAD PICKS", 0, 0, 200, 200)); + + snap.running = Profile::FlightRun; + Glass flight_run; + draw_capture(flight_run, snap); + CHECK(reads_in(flight_run, "STATE ARMED FLIGHT RUN", 0, 14, 200, 40)); } // A capture that stopped on its own says so: the pilot did not ask for it. @@ -120,7 +139,7 @@ TEST_CASE("capture page: a capture the allocator refused says it stopped and why draw_capture(fb, snap); CHECK(reads_in(fb, "STATE STOPPED NO SECTORS", 0, 14, 200, 40)); - CHECK(reads_in(fb, "WROTE 45220 DROP 0", 0, 98, 200, 126)); + CHECK(reads_in(fb, "WROTE 45220 DROP 0", 0, 112, 200, 140)); } // A storage fault the pilot cannot see is a corpus with holes nobody accounted for. @@ -143,4 +162,5 @@ TEST_CASE("capture page: a device with no partition offers no gesture at all") { CHECK(reads_in(fb, "NO FLASH FITTED", 0, 170, 200, 200)); CHECK_FALSE(reads_in(fb, "PRESS TWICE TO ARM", 0, 0, 200, 200)); CHECK_FALSE(reads_in(fb, "POWER RUN", 0, 0, 200, 200)); + CHECK_FALSE(reads_in(fb, "FLIGHT RUN", 0, 0, 200, 200)); } diff --git a/firmware/test/products/pages/test_status.cpp b/firmware/test/products/pages/test_status.cpp index 9572bdee..df31147e 100644 --- a/firmware/test/products/pages/test_status.cpp +++ b/firmware/test/products/pages/test_status.cpp @@ -187,10 +187,7 @@ TEST_CASE("status: the widest IMU failure still leaves the track's datum readabl CHECK(reads_in(fb, "TRUE", 0, 55, 200, 70)); } -TEST_CASE("status: the battery row states the voltage, the charge and which curve") { - // 4.00 V is nearly full off charge and about half full on it, so the two rows - // must not read the same - and the charging one carries the CHG marker, which - // is more ink either way. +TEST_CASE("status: the battery row states the voltage and the charge, and on the cable only CHG") { StatusSnapshot s; s.battery_valid = true; s.battery_mv = 4000; @@ -198,22 +195,25 @@ TEST_CASE("status: the battery row states the voltage, the charge and which curv Glass resting; draw_status(resting, s); + CHECK(reads_in(resting, "89", 0, 160, 200, 194)); - StatusSnapshot c = s; + // On the cable the divider reads the USB rail, so the row shows no number at all. + StatusSnapshot c; c.charging = true; - c.battery_percent = 55; Glass charging; draw_status(charging, c); - CHECK(charging.count_black() != resting.count_black()); + CHECK(reads_in(charging, "CHG", 0, 160, 200, 194)); + CHECK(reads_in(charging, "--", 0, 160, 200, 194)); + CHECK_FALSE(reads_in(charging, "no sensor", 0, 160, 200, 194)); // A board with no divider fitted says so rather than reading empty. StatusSnapshot absent; Glass no_sensor; draw_status(no_sensor, absent); - CHECK(no_sensor.count_black() != resting.count_black()); + CHECK(reads_in(no_sensor, "no sensor", 0, 160, 200, 194)); // The cutoff monitor's warning, on the page: a cell at 3.45 V is low and - // says so, and the same cell on the cable is charging, not low. + // says so, and the cable takes the word off. StatusSnapshot l = s; l.battery_mv = 3450; l.battery_percent = 12; @@ -225,12 +225,13 @@ TEST_CASE("status: the battery row states the voltage, the charge and which curv Glass quiet; draw_status(quiet, q); CHECK(low.count_black() > quiet.count_black()); + CHECK(reads_in(low, "LOW", 0, 160, 200, 194)); - StatusSnapshot on_cable = l; - on_cable.charging = true; + StatusSnapshot on_cable = c; + on_cable.battery_low = true; Glass cable; draw_status(cable, on_cable); - CHECK(cable.count_black() != low.count_black()); + CHECK_FALSE(reads_in(cable, "LOW", 0, 160, 200, 194)); // The row is the last one on the panel: it has to fit inside it. for (int y = 194; y < Glass::kH; y++) diff --git a/firmware/test/products/test_diag_capture.cpp b/firmware/test/products/test_diag_capture.cpp index 88c7f876..61fa1244 100644 --- a/firmware/test/products/test_diag_capture.cpp +++ b/firmware/test/products/test_diag_capture.cpp @@ -121,7 +121,7 @@ TEST_CASE("capture: the diagnostics page states the price, and one press does no CHECK(reads_in(rig.product.screen().framebuffer(), "PRESS TWICE TO STOP", 0, 0, 200, 200)); } -TEST_CASE("capture: the page prices both captures, and the pad picks between them") { +TEST_CASE("capture: the page prices every capture, and the pad picks between them") { Rig rig; REQUIRE(rig.setup() == Status::Ok); uint32_t t = 100; @@ -131,6 +131,7 @@ TEST_CASE("capture: the page prices both captures, and the pad picks between the const go::CaptureService& capture = rig.product.capture(); CHECK(capture.keeps_s(diag::Profile::Full) > 0); CHECK(capture.keeps_s(diag::Profile::PowerRun) > 100 * capture.keeps_s(diag::Profile::Full)); + CHECK(capture.keeps_s(diag::Profile::FlightRun) == capture.keeps_s(diag::Profile::PowerRun)); const go::Glass& glass = rig.product.screen().framebuffer(); CHECK(reads_in(glass, "FULL", 0, 0, 200, 200, 1, /*ink=*/false)); @@ -142,6 +143,12 @@ TEST_CASE("capture: the page prices both captures, and the pad picks between the CHECK(rig.product.screen().page() == go::Page::Capture); CHECK(reads_in(glass, "POWER RUN", 0, 0, 200, 200, 1, /*ink=*/false)); CHECK(reads_in(glass, "FULL", 0, 0, 200, 200)); + + rig.tap(t); + rig.run(t, t + 1200); + t += 1200; + CHECK(rig.product.screen().page() == go::Page::Capture); + CHECK(reads_in(glass, "FLIGHT RUN", 0, 0, 200, 200, 1, /*ink=*/false)); } TEST_CASE("capture: the double press arms the capture in focus, and no other") { @@ -162,13 +169,14 @@ TEST_CASE("capture: the double press arms the capture in focus, and no other") { } // The pad walks off the last capture rather than trapping the thumb on the page. -TEST_CASE("capture: a second tap hands the page back to the menu it was opened from") { +TEST_CASE("capture: a tap off the last capture hands the page back to the menu it came from") { Rig rig; REQUIRE(rig.setup() == Status::Ok); uint32_t t = 100; taxi(rig, t, 5); open_capture_page(rig, t); + rig.tap(t); rig.tap(t); CHECK(rig.product.screen().page() == go::Page::Capture); rig.tap(t); diff --git a/firmware/test/products/test_diag_taps_power.cpp b/firmware/test/products/test_diag_taps_power.cpp index df619896..99d3a01c 100644 --- a/firmware/test/products/test_diag_taps_power.cpp +++ b/firmware/test/products/test_diag_taps_power.cpp @@ -1,4 +1,4 @@ -// The power and duty records: the cell on its cadence, the knee, the trim, and the duty pair. +// The power and duty records: the cell on its cadence, the knee, the cable, and the duty pair. #include #include "core/bus/state.h" @@ -120,27 +120,32 @@ TEST_CASE("diag power: the reading the median threw out rides beside it, to the CHECK(sag_seen); } -// Two minutes on a charger holding its float voltage, which is the only bench this unit gets. -TEST_CASE("diag power: a trim the charger taught the unit reaches the corpus, not only settings") { +// 0B1B2C charged for 7 h with every record reading no cable and no cell (#114). +TEST_CASE("diag power: on the cable the record carries the cable and what the divider read") { Rig rig; uint32_t t = 100; REQUIRE(rig.setup() == Status::Ok); rig.platform.battery().external_power = true; - rig.platform.battery().millivolts = 3960; + rig.platform.battery().millivolts = 4812; taxi(rig, t, 3); arm(rig, t); - rig.run(t, t + 10000); - t += 10000; - rig.platform.battery().millivolts = 4160; - rig.run(t, t + power::kPlateauHoldMs + 5000); - t += power::kPlateauHoldMs + 5000; + taxi(rig, t, 4); + rig.platform.battery().present = false; + taxi(rig, t, 3); stop(rig, t); const std::vector cell = every_power_record(captured(rig)); - REQUIRE_FALSE(cell.empty()); - CHECK(cell.back().trim_learned); - CHECK(cell.back().trim_offset_mv == 40); - CHECK(cell.back().trim_offset_mv == rig.settings().battery_offset_mv); + REQUIRE(cell.size() >= 6); + for (const diag::Power& power : cell) { + CHECK(power.external_power); + CHECK(power.charging); + CHECK_FALSE(power.valid); + CHECK(power.level == power::PowerLevel::Normal); + } + CHECK(cell.front().cell_mv == 4812); + CHECK(cell.front().implausible >= 4); + CHECK(cell.back().cell_mv == 0); + CHECK(cell.back().implausible > cell.front().implausible); } // A reader divides a duty delta by a power delta: interpolating between two instants is not that. diff --git a/firmware/test/products/test_flight_run.cpp b/firmware/test/products/test_flight_run.cpp new file mode 100644 index 00000000..ae13daf4 --- /dev/null +++ b/firmware/test/products/test_flight_run.cpp @@ -0,0 +1,211 @@ +// A flight run: the glass flies while the unit sits on the bench, and nothing false goes on air. +#include +#include +#include + +#include "core/diag/payload.h" +#include "core/diag/profile.h" +#include "core/protocol/adsl.h" +#include "core/protocol/air.h" +#include "core/timing/slot.h" +#include "doctest/doctest.h" +#include "products/skyblip_go/settings.h" +#include "simulator/simulator.h" +#include "test/support/diag_taps.h" +#include "test/support/glass_text.h" +#include "test/support/product_rig.h" +#include "test/support/rig_moves.h" +#include "test/support/simulator_run.h" + +using namespace skyblip; + +namespace { + +constexpr uint32_t kAirWindowMs = 30000; + +struct Said { + int positions{0}; + int names{0}; +}; + +Said said_on_air(const simulator::Air& air) { + Said said{}; + for (int i = 0; i < air.record_count(); i++) { + const simulator::AirRecord& r = air.record(i); + if (r.event != simulator::AirEvent::Tx) continue; + if (timing::Scheduler::in_direct_slot(r.phase_ms)) + said.positions++; + else + said.names++; + } + return said; +} + +void park_on_the_bench(simulator::Simulator& h) { + std::strncpy(h.product().settings().callsign, "D-KXYZ", go::kCallsignCap - 1); + h.world().set_fix(true); + h.world().set_speed_kt(0); + h.world().set_altitude_m(300); +} + +uint32_t arm_a_flight_run(simulator::Simulator& h) { + const uint32_t armed_ms = past_settling(h); + h.product().diag().arm(diag::Profile::FlightRun); + run_on(h, armed_ms, 1000); + REQUIRE(h.product().capture().capturing()); + h.world().air().clear(); + return armed_ms + 1000; +} + +void arm_flight_run(Rig& rig, uint32_t& t) { arm(rig, t, diag::Profile::FlightRun); } + +const go::Glass& glass(Rig& rig) { return rig.product.screen().framebuffer(); } + +} // namespace + +// G.1.16: on ground the rate shall be 0.1 Hz, so the glass flies and the transmitter does not. +TEST_CASE("flight run: a parked unit keeps the ground cadence on air while the glass flies") { + simulator::Simulator h; + REQUIRE(h.setup() == Status::Ok); + park_on_the_bench(h); + const uint32_t from_ms = arm_a_flight_run(h); + run_on(h, from_ms, kAirWindowMs); + + CHECK(h.product().state().flight.running); + const Said said = said_on_air(h.world().air()); + // a position and a name every ten seconds, at most one more for the window's two ends + CHECK(said.positions >= 3); + CHECK(said.positions <= 4); + CHECK(said.names >= 3); + CHECK(said.names <= 4); +} + +TEST_CASE("flight run: every burst on air says on ground, at the real position, standing still") { + simulator::Simulator h; + REQUIRE(h.setup() == Status::Ok); + park_on_the_bench(h); + const uint32_t from_ms = arm_a_flight_run(h); + run_on(h, from_ms, kAirWindowMs); + REQUIRE(h.product().state().flight.running); + + const model::OwnState& own = h.product().state().own; + const simulator::Air& air = h.world().air(); + int checked = 0; + for (int i = 0; i < air.record_count(); i++) { + const simulator::AirRecord& r = air.record(i); + if (r.event != simulator::AirEvent::Tx || !timing::Scheduler::in_direct_slot(r.phase_ms)) + continue; + protocol::Frame frame{}; + REQUIRE(simulator::Air::framed(r, frame)); + protocol::AdslPacket p{}; + p.init(); + std::memcpy(&p.Version, frame.data, protocol::kAdslFrameBytes); + REQUIRE(p.check_crc() == 0); + p.descramble(); + CHECK(p.FlightState == static_cast(flight::FlightState::Ground)); + // the payload carries latitude in steps of 107 and longitude of 215, in 1e-7 degrees + CHECK(std::abs(p.lat_1e7() - own.lat_1e7) < 200); + CHECK(std::abs(p.lon_1e7() - own.lon_1e7) < 400); + CHECK(p.speed_q() == 0); + checked++; + } + CHECK(checked >= 3); +} + +TEST_CASE("flight run: records the pair every 30 s, and its boot names the profile") { + Rig rig; + REQUIRE(rig.setup() == Status::Ok); + uint32_t t = 100; + taxi(rig, t, 3); + arm_flight_run(rig, t); + rig.run(t, t + 65000); + t += 65000; + stop(rig, t); + const std::vector records = captured(rig); + + CHECK(count_of(records, diag::Type::Power) == 2); + CHECK(count_of(records, diag::Type::Duty) == 2); + CHECK(count_of(records, diag::Type::Flight) == 0); + diag::Boot boot{}; + REQUIRE(first_of(records, boot)); + CHECK(boot.profile_recorded); + CHECK(boot.profile == diag::Profile::FlightRun); + CHECK(rig.product.capture().keeps_s(diag::Profile::FlightRun) == + rig.product.capture().keeps_s(diag::Profile::PowerRun)); +} + +// A simulated flight in the pilot's log could not be told from a real one, so it writes none. +TEST_CASE("flight run: the radar flies, the flight log writes nothing, the gates stay grounded") { + Rig rig; + REQUIRE(rig.setup() == Status::Ok); + uint32_t t = 100; + taxi(rig, t, 3); + REQUIRE(rig.product.screen().page() == go::Page::Radar); + arm_flight_run(rig, t); + taxi(rig, t, 65); + + CHECK(rig.state().flight.running); + CHECK(rig.state().flight.seconds >= 60); + CHECK(reads_in(glass(rig), "FLIGHT", 0, 168, 50, 182)); + CHECK(reads_in(glass(rig), "0:01", 0, 176, 60, 198, 2)); + CHECK_FALSE(reads_in(glass(rig), "GROUND", 0, 0, 200, 199, 2)); + + CHECK_FALSE(rig.product.flight_log().recording()); + CHECK(rig.product.flight_log().sessions_on_flash() == 0); + CHECK(rig.state().own.flight_state == static_cast(flight::FlightState::Ground)); + CHECK(rig.state().flight.confirmed_state == flight::FlightState::Ground); +} + +TEST_CASE("flight run: the glass refreshes as a flown one does, and a parked one does not") { + Rig parked; + REQUIRE(parked.setup() == Status::Ok); + uint32_t pt = 100; + taxi(parked, pt, 3); + arm(parked, pt, diag::Profile::PowerRun); + const uint32_t parked_from = parked.state().duty.panel_partial_refreshes; + taxi(parked, pt, 130); + + Rig flying; + REQUIRE(flying.setup() == Status::Ok); + uint32_t ft = 100; + taxi(flying, ft, 3); + arm_flight_run(flying, ft); + const uint32_t flying_from = flying.state().duty.panel_partial_refreshes; + taxi(flying, ft, 130); + + // the clock turns 0:00, 0:01 and 0:02, and FLIGHT replaces GROUND once + CHECK(flying.state().duty.panel_partial_refreshes - flying_from >= 2); + CHECK(flying.state().duty.panel_partial_refreshes - flying_from > + parked.state().duty.panel_partial_refreshes - parked_from); +} + +TEST_CASE("flight run: a stop lands the glass, and leaves no flight time behind") { + Rig rig; + REQUIRE(rig.setup() == Status::Ok); + uint32_t t = 100; + taxi(rig, t, 3); + arm_flight_run(rig, t); + taxi(rig, t, 65); + REQUIRE(rig.state().flight.running); + + stop(rig, t); + taxi(rig, t, 2); + CHECK_FALSE(rig.state().flight.running); + CHECK_FALSE(rig.state().flight.time_valid); + CHECK(rig.state().flight.seconds == 0); + CHECK(reads_in(glass(rig), "GROUND", 40, 120, 160, 160, 2)); + CHECK(reads_in(glass(rig), "-:--", 0, 176, 60, 198, 2)); +} + +TEST_CASE("flight run: a power run and a full capture leave the glass on the ground") { + for (const diag::Profile profile : {diag::Profile::Full, diag::Profile::PowerRun}) { + Rig rig; + REQUIRE(rig.setup() == Status::Ok); + uint32_t t = 100; + taxi(rig, t, 3); + arm(rig, t, profile); + taxi(rig, t, 5); + CHECK_FALSE(rig.state().flight.running); + CHECK(reads_in(glass(rig), "GROUND", 40, 120, 160, 160, 2)); + } +} diff --git a/firmware/test/products/test_panel_cell.cpp b/firmware/test/products/test_panel_cell.cpp index 998a9a4f..46275358 100644 --- a/firmware/test/products/test_panel_cell.cpp +++ b/firmware/test/products/test_panel_cell.cpp @@ -45,10 +45,10 @@ TEST_CASE("product: a cell that takes the device down names itself on the way ou CHECK_FALSE(reads_in(parked, "%", 0, 0, 200, 199, 2)); CHECK(rig.platform.system_power().cell_on_glass() == power::CellOnGlass::Flat); - // The press after it gets nothing: the frame is the answer and the wake pin is never armed. - rig.platform.system_power().system_off( - power::button_wake_after(rig.product.shutdown().reason(), rig.platform.external_power())); - CHECK(rig.platform.system_power().order_of(power::PowerDownStep::WakePinArmed) == -1); + // The button stays armed, so a press after it reaches the lockout and not a dead unit. + rig.platform.system_power().system_off(); + CHECK(rig.platform.system_power().order_of(power::PowerDownStep::WakePinArmed) == + power::kPowerDownStepCount - 1); } // The other road to the same empty cell: a winter on a shelf, which runs no shutdown at all. @@ -82,8 +82,89 @@ TEST_CASE("product: a refused boot pushes no frame the glass is already wearing" CHECK(rig.platform.chips().epd.present_count == 0); } -// The cable re-arms the button, so the mark is the whole instruction again. -TEST_CASE("product: the charger that wakes a flat device takes the word back off the glass") { +// The cutoff left FLAT BATTERY on the glass, and the press that follows needs no other answer. +TEST_CASE("product: a press after a cutoff is refused and leaves the flat frame where it is") { + Rig dying; + REQUIRE(dying.setup() == Status::Ok); + dying.run(0, 2000); + dying.platform.battery().millivolts = 3100; + dying.run(2000, 20000); + REQUIRE(dying.product.ready_to_power_off()); + REQUIRE(dying.platform.system_power().cell_on_glass() == power::CellOnGlass::Flat); + + Rig pressed; + pressed.platform.system_power().glass_cell = dying.platform.system_power().cell_on_glass(); + pressed.platform.system_power().dark_flat = dying.platform.system_power().went_dark_flat(); + pressed.platform.battery().millivolts = power::kBootLockoutMv - 1; + pressed.platform.system_power().causes = power::ResetCause::LowPowerWake; + pressed.platform.board_gpio().button_down = true; + REQUIRE(pressed.setup() == Status::Ok); + REQUIRE(pressed.product.boot_path() == power::BootPath::SleepAgain); + CHECK(pressed.product.refused_frame() == power::RefusedFrame::Leave); + pressed.platform.board_gpio().button_down = false; + pressed.sleep_again(); + CHECK(pressed.platform.chips().epd.present_count == 0); + CHECK(pressed.platform.system_power().cell_on_glass() == power::CellOnGlass::Flat); + CHECK(pressed.platform.system_power().went_dark_flat()); + + pressed.platform.system_power().system_off(); + CHECK(pressed.platform.system_power().order_of(power::PowerDownStep::WakePinArmed) == + power::kPowerDownStepCount - 1); +} + +// SENSE is a level detect: armed under the finger it refused, the wake pin fires at once. +TEST_CASE("product: a refused press goes dark only once the button has come up") { + Rig rig; + rig.platform.battery().millivolts = power::kBootLockoutMv - 1; + rig.platform.system_power().causes = power::ResetCause::LowPowerWake; + rig.platform.board_gpio().button_down = true; + REQUIRE(rig.setup() == Status::Ok); + REQUIRE(rig.product.refused_frame() == power::RefusedFrame::FlatCell); + + bool dark = false; + uint32_t t = 0; + for (; t <= go::kRefusalParkCeilingMs + 2000; t += 50) { + rig.platform.clock().set_millis(t); + dark = dark || rig.product.park_refusal(t); + } + CHECK_FALSE(dark); + CHECK(rig.platform.chips().epd.present_count == 1); + + rig.platform.board_gpio().button_down = false; + CHECK_FALSE(rig.product.park_refusal(t)); + CHECK_FALSE(rig.product.park_refusal(t + power::kReleaseSettleMs - 1)); + CHECK(rig.product.park_refusal(t + power::kReleaseSettleMs)); +} + +// The lockout spares external power, so the cable and a press are the way out of a flat cell. +TEST_CASE("product: a press on the cable after a cutoff starts the device and clears the word") { + Rig dying; + REQUIRE(dying.setup() == Status::Ok); + dying.run(0, 2000); + dying.platform.battery().millivolts = 3100; + dying.run(2000, 20000); + REQUIRE(dying.product.ready_to_power_off()); + + Rig plugged; + plugged.platform.system_power().glass_cell = dying.platform.system_power().cell_on_glass(); + plugged.platform.battery().millivolts = power::kBootLockoutMv - 1; + plugged.platform.battery().external_power = true; + plugged.platform.system_power().causes = power::ResetCause::LowPowerWake; + plugged.platform.board_gpio().button_down = true; + REQUIRE(plugged.setup() == Status::Ok); + CHECK(plugged.product.boot_path() == power::BootPath::Run); + CHECK(plugged.state().started); + + plugged.platform.board_gpio().button_down = false; + plugged.run(0, 5000); + CHECK_FALSE(plugged.product.shutdown().going_down()); + CHECK_FALSE( + reads_in(plugged.platform.chips().epd.framebuffer(), "FLAT BATTERY", 0, 0, 200, 199, 2)); + CHECK(plugged.platform.system_power().cell_on_glass() == power::CellOnGlass::None); +} + +// The factory bootloader swallows a charger wake: one that got through would get the armed frame. +TEST_CASE("product: a charger wake past the bootloader takes the word back off the glass") { Rig rig; rig.platform.battery().millivolts = power::kBootLockoutMv - 1; rig.platform.battery().external_power = true; @@ -111,32 +192,6 @@ TEST_CASE("product: the charger that wakes a flat device takes the word back off CHECK(again.platform.chips().epd.present_count == 0); } -// The whole road, on the one bit that survives the rails: the cutoff writes it, the cable reads it. -TEST_CASE("product: the cable after a cutoff puts the mark back and arms the button") { - Rig dying; - REQUIRE(dying.setup() == Status::Ok); - dying.run(0, 2000); - dying.platform.battery().millivolts = 3100; - dying.run(2000, 20000); - REQUIRE(dying.product.ready_to_power_off()); - - Rig plugged; - plugged.platform.system_power().glass_cell = dying.platform.system_power().cell_on_glass(); - plugged.platform.battery().millivolts = power::kBootLockoutMv - 1; - plugged.platform.battery().external_power = true; - plugged.platform.system_power().causes = - power::ResetCause::LowPowerWake | power::ResetCause::UsbVbus; - REQUIRE(plugged.setup() == Status::Ok); - REQUIRE(plugged.product.refused_frame() == power::RefusedFrame::Wordmark); - plugged.sleep_again(); - - const go::Glass& parked = plugged.platform.chips().epd.framebuffer(); - CHECK_FALSE(reads_in(parked, "FLAT BATTERY", 0, 0, 200, 199, 2)); - CHECK(plugged.platform.system_power().cell_on_glass() == power::CellOnGlass::None); - CHECK(power::button_wake_after_refusal(plugged.product.boot_cell()) == - power::ButtonWake::Armed); -} - // The lamp's warning dies with the rails, so the glass carries it to whoever picks the device up. TEST_CASE("product: a switch-off on a critical cell asks for the charger under the mark") { Rig rig; @@ -155,8 +210,6 @@ TEST_CASE("product: a switch-off on a critical cell asks for the charger under t CHECK_FALSE(reads_in(parked, "FLAT BATTERY", 0, 0, 200, 199, 2)); CHECK(rig.platform.system_power().cell_on_glass() == power::CellOnGlass::Low); CHECK_FALSE(rig.platform.system_power().went_dark_flat()); - CHECK(power::button_wake_after(rig.product.shutdown().reason(), false) == - power::ButtonWake::Armed); } // The first step already winks the lamp red, so the glass has to carry that word too. @@ -197,34 +250,6 @@ TEST_CASE( CHECK(reads_in(rig.platform.chips().epd.framebuffer(), "CHARGE BATTERY", 0, 130, 200, 199, 2)); } -// The same road out as the flat word: the cable is the instruction the word gave. -TEST_CASE("product: the cable after a low switch-off puts the plain mark back") { - Rig low; - REQUIRE(low.setup() == Status::Ok); - low.run(0, 2000); - low.platform.battery().millivolts = power::kCriticalMv - 100; - low.run(2000, 10000); - low.product.shutdown().request(power::ShutdownReason::LongPress, 10000); - low.run(10000, 20000); - REQUIRE(low.product.ready_to_power_off()); - - Rig plugged; - plugged.platform.system_power().glass_cell = low.platform.system_power().cell_on_glass(); - plugged.platform.battery().millivolts = power::kCriticalMv - 100; - plugged.platform.battery().external_power = true; - plugged.platform.system_power().causes = - power::ResetCause::LowPowerWake | power::ResetCause::UsbVbus; - REQUIRE(plugged.setup() == Status::Ok); - REQUIRE(plugged.product.refused_frame() == power::RefusedFrame::Wordmark); - plugged.sleep_again(); - - go::Glass expected; - expected.clear(true); - ui::draw_wordmark(expected, go::kGlassW / 2, go::kGlassH / 2); - CHECK(plugged.platform.chips().epd.framebuffer().count_black() == expected.count_black()); - CHECK(plugged.platform.system_power().cell_on_glass() == power::CellOnGlass::None); -} - // A cable in says the charge is already coming, so the mark alone is the frame. TEST_CASE("product: a switch-off on the cable wears the plain mark, however low the cell") { Rig rig; @@ -241,28 +266,18 @@ TEST_CASE("product: a switch-off on the cable wears the plain mark, however low CHECK(rig.platform.system_power().cell_on_glass() == power::CellOnGlass::None); } -// The support half of a flat cell: the glass forgets it on the cable, the boot that runs does not. -TEST_CASE("product: the boot after a flat cell names it, though the cable took the word away") { +// The support half of a flat cell: the boot the cable lets run says so, once. +TEST_CASE("product: the press on the cable after a flat cell names it on the self-test page") { Rig refused; refused.platform.battery().millivolts = power::kBootLockoutMv - 1; REQUIRE(refused.setup() == Status::Ok); refused.sleep_again(); CHECK(refused.platform.system_power().went_dark_flat()); - Rig plugged; - plugged.platform.system_power().glass_cell = refused.platform.system_power().cell_on_glass(); - plugged.platform.system_power().dark_flat = refused.platform.system_power().went_dark_flat(); - plugged.platform.battery().millivolts = power::kBootLockoutMv - 1; - plugged.platform.battery().external_power = true; - plugged.platform.system_power().causes = - power::ResetCause::LowPowerWake | power::ResetCause::UsbVbus; - REQUIRE(plugged.setup() == Status::Ok); - plugged.sleep_again(); - REQUIRE(plugged.platform.system_power().cell_on_glass() == power::CellOnGlass::None); - CHECK(plugged.platform.system_power().went_dark_flat()); - Rig pressed; - pressed.platform.system_power().dark_flat = plugged.platform.system_power().went_dark_flat(); + pressed.platform.system_power().glass_cell = refused.platform.system_power().cell_on_glass(); + pressed.platform.system_power().dark_flat = refused.platform.system_power().went_dark_flat(); + pressed.platform.battery().millivolts = power::kBootLockoutMv - 1; pressed.platform.battery().external_power = true; pressed.platform.system_power().causes = power::ResetCause::LowPowerWake; pressed.platform.board_gpio().button_down = true; diff --git a/firmware/test/products/test_power_calibration.cpp b/firmware/test/products/test_power_calibration.cpp index 2cd9c7cb..97f22287 100644 --- a/firmware/test/products/test_power_calibration.cpp +++ b/firmware/test/products/test_power_calibration.cpp @@ -9,7 +9,6 @@ #include "core/events/sensor.h" #include "core/power/battery.h" #include "core/power/cutoff.h" -#include "core/power/trim.h" #include "doctest/doctest.h" #include "products/skyblip_go/settings.h" #include "test/support/product_rig.h" @@ -22,9 +21,9 @@ using namespace skyblip::power; // it is twelve percentage points of state of charge - the difference between a // pilot flying another hour and a pilot landing. TEST_CASE("battery: tens of millivolts are tens of percent in the flat middle") { - CHECK(int(percent_from_mv(3800, false)) == 55); - CHECK(int(percent_from_mv(3760, false)) == 43); - CHECK(int(percent_from_mv(3840, false)) == 63); + CHECK(int(percent_from_mv(3800)) == 55); + CHECK(int(percent_from_mv(3760)) == 43); + CHECK(int(percent_from_mv(3840)) == 63); } TEST_CASE("battery: the trim is a signed millivolt offset, and zero is the identity") { @@ -55,9 +54,9 @@ TEST_CASE("battery: a trim can never push a reading out of the range a reading h // were trimmed, a calibrated unit would cut off at a voltage its own gauge never // showed - the calibration causing the failure it exists to prevent. TEST_CASE("battery: the trimmed sample is the same sample, charger state and all") { - // Nothing but the millivolts moves: the charger flag decides which curve the - // gauge reads and whether the cutoff monitor may act at all, and a trim has - // no opinion about either. + // Nothing but the millivolts moves: the charger flag decides whether the gauge + // and the cutoff monitor read the cell at all, and a trim has no opinion + // about it. events::BatterySample on_the_cable{}; on_the_cable.millivolts = 4000; on_the_cable.external_power = true; @@ -112,37 +111,17 @@ TEST_CASE("battery: a unit that reads high is corrected by one number from the l } CHECK(uncalibrated.state().millivolts == kThisUnitReadsMv); CHECK(trimmed.state().millivolts == kBenchMv); - CHECK(trimmed.state().percent == percent_from_mv(kBenchMv, false)); + CHECK(trimmed.state().percent == percent_from_mv(kBenchMv)); // Ten percentage points of gauge error, from a divider inside tolerance. CHECK(int(uncalibrated.state().percent) == 65); CHECK(int(trimmed.state().percent) == 55); } -// The bench step, done by a charger: a unit that never saw a supply still gets a trim. -TEST_CASE("product: a charge held at the float voltage trims the unit that watched it") { - Rig rig; - REQUIRE(rig.setup() == Status::Ok); - REQUIRE(int(rig.settings().battery_offset_mv) == 0); - REQUIRE_FALSE(rig.settings().battery_offset_manual); - - uint32_t t = 0; - rig.platform.battery().external_power = true; - rig.platform.battery().millivolts = 3960; - rig.run(t, t + 10000); - t += 10000; - - // This unit reads 40 mV under the 4200 mV its charger is holding. - rig.platform.battery().millivolts = 4160; - rig.run(t, t + kPlateauHoldMs + 5000); - CHECK(int(rig.settings().battery_offset_mv) == 40); - CHECK_FALSE(rig.settings().battery_offset_manual); -} - -TEST_CASE("product: a trim somebody measured outranks the one the charger offers") { +// On the cable the divider reads the USB rail: a plateau there says nothing of this unit (#114). +TEST_CASE("product: a charge however long leaves the trim where the line set it") { Rig rig; REQUIRE(rig.setup() == Status::Ok); rig.settings().battery_offset_mv = -15; - rig.settings().battery_offset_manual = true; uint32_t t = 0; rig.platform.battery().external_power = true; @@ -150,6 +129,6 @@ TEST_CASE("product: a trim somebody measured outranks the one the charger offers rig.run(t, t + 10000); t += 10000; rig.platform.battery().millivolts = 4160; - rig.run(t, t + kPlateauHoldMs + 5000); + rig.run(t, t + 300000); CHECK(int(rig.settings().battery_offset_mv) == -15); } diff --git a/firmware/test/products/test_product.cpp b/firmware/test/products/test_product.cpp index 8d6271ea..37bf5c0f 100644 --- a/firmware/test/products/test_product.cpp +++ b/firmware/test/products/test_product.cpp @@ -109,23 +109,26 @@ TEST_CASE("product: the board reads the cell and the gauge publishes it") { rig.run(0, 12000); CHECK(rig.state().power.battery.valid); CHECK(rig.state().power.battery.millivolts == 3800); - CHECK(rig.state().power.battery.percent == power::percent_from_mv(3800, false)); + CHECK(rig.state().power.battery.percent == power::percent_from_mv(3800)); CHECK_FALSE(rig.state().power.battery.charging); CHECK_FALSE(rig.state().power.battery.external_power); } -TEST_CASE("product: the same cell on USB power reports a lower state of charge") { +// E68BD9 told its phone 100 % for 3.5 h of charging from 45 %, off the USB rail (#114). +TEST_CASE("product: on USB power the gauge shows the cable and no state of charge") { Rig rig; REQUIRE(rig.setup() == Status::Ok); - rig.platform.battery().millivolts = 4000; + rig.platform.battery().millivolts = 3750; rig.run(0, 12000); - const uint8_t resting = rig.state().power.battery.percent; + REQUIRE(rig.state().power.battery.valid); rig.platform.battery().external_power = true; + rig.platform.battery().millivolts = 4690; rig.run(12000, 24000); CHECK(rig.state().power.battery.charging); - CHECK(rig.state().power.battery.millivolts == 4000); - CHECK(rig.state().power.battery.percent < resting); + CHECK(rig.state().power.battery.external_power); + CHECK_FALSE(rig.state().power.battery.valid); + CHECK(rig.state().power.battery.percent == 0); } TEST_CASE("product: a board with no battery sense says so instead of reporting empty") { @@ -227,8 +230,7 @@ TEST_CASE("product: a charger plugged into a sleeping device is not a boot") { // What the shell does with that: the rails go down the same way a long press // takes them down, in the order core/power owns. - rig.platform.system_power().system_off( - power::button_wake_after_refusal(rig.product.boot_cell())); + rig.platform.system_power().system_off(); CHECK(rig.platform.system_power().offs == 1); CHECK(rig.platform.system_power().order_of(power::PowerDownStep::WakePinArmed) == power::kPowerDownStepCount - 1); @@ -282,7 +284,7 @@ TEST_CASE("product: the battery trim reaches the gauge and the cutoff rule toget trimmed.platform.battery().millivolts = 3540; trimmed.run(0, 12000); CHECK(trimmed.state().power.battery.millivolts == 3480); - CHECK(trimmed.state().power.battery.percent == power::percent_from_mv(3480, false)); + CHECK(trimmed.state().power.battery.percent == power::percent_from_mv(3480)); // The reader that matters: the same trimmed millivolts reached the rule that // decides when the device warns and when it goes down. CHECK(trimmed.state().power.level == power::PowerLevel::Critical); diff --git a/schemas/diagnostics.v1.schema.json b/schemas/diagnostics.v1.schema.json index af022452..43882454 100644 --- a/schemas/diagnostics.v1.schema.json +++ b/schemas/diagnostics.v1.schema.json @@ -63,7 +63,7 @@ "fixes": {"type": "integer", "minimum": 0, "description": "gnss. Solutions the own-ship service has accepted since boot. Zero on a device whose receiver is wired but silent"}, "valid": {"type": "boolean", - "description": "gnss: whether own-ship currently holds a valid fix. power: whether the battery reading is believable at all (an unpopulated divider is not). The key is per group, so the two never appear in the same frame"}, + "description": "gnss: whether own-ship currently holds a valid fix. power: whether mv is the cell. False with no divider, on a reading outside 1800 to 4700 mV, and on the cable, where the divider reads the USB rail. The key is per group, so the two never appear in the same frame"}, "baud": {"type": "integer", "minimum": 0, "description": "gnss. The rate we are ACTUALLY talking to the receiver at, which is the devicetree's rate until autobaud had to move. A receiver silently at another rate is a GNSS-less device that looks fitted"}, "identified": {"type": "boolean", @@ -78,16 +78,16 @@ "description": "gnss. The extrapolation residual in metres: the model the transmitter applies, run over the interval that just elapsed and measured against the fix that closed it. ABSENT rather than zero when there is no pair of consecutive fixes to measure, because 0 m is what a perfect prediction reports"}, "mv": {"type": "integer", "minimum": 0, "maximum": 65535, - "description": "power. The battery reading in millivolts, after the per-unit trim (settings.battery_offset_mv)"}, + "description": "power. The battery reading in millivolts, after the per-unit trim (settings.battery_offset_mv): the cell when power.valid, otherwise what the divider last read, 0 when the ADC gave nothing"}, "percent": {"type": "integer", "minimum": 0, "maximum": 100, - "description": "power. State of charge from the same reading, on the cell's curve. Meaningless unless power.valid"}, - "charging": {"type": "boolean", "description": "power. The charger is holding the rail"}, + "description": "power. State of charge from the same reading, on the discharge curve. 0 unless power.valid"}, + "charging": {"type": "boolean", "description": "power. The cable is in. No pin says when the charge ends, so a full cell on the cable still reads true"}, "level": {"type": "string", "enum": ["OK", "LOW", "CRITICAL", "FLAT", "--"], "description": "power. What core/power's cutoff rule made of the same samples: LOW about 12%, CRITICAL about 5%, FLAT the device taking itself down. '--' is Unknown: no believable reading yet"}, "supply_warnings": {"type": "integer", "minimum": 0, "description": "power. Times the nRF52840's power-failure comparator (POFCON) has fired. Any at all on a healthy cell is a supply problem or a threshold set too high, and both are bench work"}, "implausible": {"type": "integer", "minimum": 0, - "description": "power. Gauge readings the sanity floor threw away (below 1800 mV, which is an unpopulated or unconnected divider rather than a flat cell). A unit that never rises above zero here is a unit whose gauge nobody should trust"}, + "description": "power. Readings the gauge refused: at or under 1800 mV, an unpopulated or unconnected divider rather than a flat cell, or over 4700 mV, which no 4.2 V cell reaches and a rail does. On the cable or off it. mv holds the last one while power.valid is false"}, "die_temp_c": {"type": "integer", "minimum": -50, "maximum": 125, "description": "power. Die temperature in whole degrees Celsius, rounded away from zero. ABSENT rather than zero when no reading exists, because 0.0 C is a plausible hangar morning. Same key and same value as the status reply's"} }, diff --git a/schemas/diagnostics_log.v1.schema.json b/schemas/diagnostics_log.v1.schema.json index ab604924..8b374368 100644 --- a/schemas/diagnostics_log.v1.schema.json +++ b/schemas/diagnostics_log.v1.schema.json @@ -32,6 +32,8 @@ "description": "boot. Why the device came up, as core/power/reset_reason.cpp spells it. A corpus that opens on WATCHDOG is a corpus whose first seconds are evidence. Codes: 0 UNKNOWN, 1 POWER ON, 2 RESET PIN, 3 BROWNOUT, 4 SOFT RESET, 5 WATCHDOG, 6 CPU LOCKUP, 7 CHARGER WAKE, 8 BUTTON WAKE, 9 DEBUGGER"}, "image_state": {"type": "string", "enum": ["confirmed", "probation", "reverted", "refused"], "description": "boot. Whether the running image had been confirmed, was still on probation after an OTA, had just been reverted by the bootloader after it ran, or was kept because the bootloader refused the new one. Codes: 0 confirmed, 1 probation, 2 reverted, 3 refused"}, + "profile": {"type": "string", "enum": ["full", "power_run", "flight_run"], + "description": "boot. Which capture the session is, as diag::Profile declares it: full lists every type, power_run and flight_run list boot, config, power, duty, gap and end every 30 s, and flight_run also ran the glass as in flight while the transmitter, the flight log and the payload kept the unit's real state. Codes: 0 full, 1 power_run, 2 flight_run. Absent on a capture older than the field, whose flag bit 2 is clear: its byte 14 is a zero that means nothing, not full"}, "addr": {"type": "integer", "minimum": 0, "maximum": 16777215, "description": "config: own 24-bit address. burst: the sender's, where the frame named one (addr_valid). traffic: the target's"}, @@ -44,7 +46,7 @@ "battery_offset_mv": {"type": "integer", "minimum": -250, "maximum": 250, "description": "config. The per-unit gauge trim every millivolt figure in this corpus has already been through"}, "battery_trim_manual": {"type": "boolean", - "description": "config. That trim was measured by a person rather than learned off a charger's float plateau. A manual one outranks the learned one for good, so this is what says whether the trim_offset_mv on the power records would ever have been adopted"}, + "description": "config. That trim was measured by a person rather than learned off a charger's float plateau. A manual one outranked the learned one for good, so on older captures this is what says whether the trim_offset_mv on the power records would ever have been adopted. Builds since the learner went (2026-10-05) set no other kind"}, "freq_trim_e1_ppm": {"type": "integer", "minimum": -100, "maximum": 100, "description": "config. The radio's centre-frequency trim, tenths of a ppm, applied to every dwell in this corpus"}, "settings_version": {"type": "integer", "minimum": 0, "maximum": 255, @@ -148,29 +150,29 @@ "description": "flight. The same answer with the landing held back (core/flight/ground.h), which is what a permission gate reads. Codes: 0 unknown, 1 on_ground, 2 airborne"}, "rolling": {"type": "boolean", "description": "flight. Moving on the ground rather than stopped on it"}, - "cell_mv": {"type": "integer", "minimum": 0, "maximum": 65535, "description": "power. The cell's terminal voltage in millivolts, after the per-unit trim: the median of the last three readings, the figure percent and level were decided on"}, + "cell_mv": {"type": "integer", "minimum": 0, "maximum": 65535, "description": "power. When valid, the cell's terminal voltage in millivolts, after the per-unit trim: the median of the last three readings, the figure percent and level were decided on. When not valid, what the divider last read: the USB rail on the cable, a reading outside 1800 to 4700 mV that the gauge refused, or 0 when the ADC gave nothing. Builds before 2026-10-05 recorded nothing they refused, and read 0 whenever not valid"}, "sample_offset_mv": {"type": "integer", "minimum": -128, "maximum": 127, "description": "power. The last reading minus cell_mv, in millivolts, so cell_mv + sample_offset_mv is that reading, whether or not the median kept it. -128 and 127 are saturated and mean at least that far. Captures from builds before this field read 0, which the boot record's build tells apart"}, - "percent": {"type": "integer", "minimum": 0, "maximum": 100, "description": "power. State of charge on the cell's curve. Meaningless unless valid"}, + "percent": {"type": "integer", "minimum": 0, "maximum": 100, "description": "power. State of charge on the discharge curve. 0 unless valid"}, "level": {"type": "string", "enum": ["unknown", "normal", "critical", "flat", "low"], "description": "power. What core/power's cutoff rule made of the same samples, three consecutive readings to step down and three to step back up: low under kLowMv (3600), critical under kCriticalMv (3500), flat under kFlatMv (3200). Codes: 0 unknown, 1 normal, 2 critical, 3 flat, 4 low. Codes 2 and 3 kept their thresholds when they were named low and cutoff, so an older capture decodes to the same step"}, "caution": {"type": "boolean", "description": "power. Captures from builds before level code 4: the cell was past the knee, three consecutive samples under 3600 mV, cleared by a cable or by a sample back above it. Later builds carry the knee as level low, leave this bit clear and never reuse it"}, "trim_offset_mv": {"type": "integer", "minimum": -250, "maximum": 250, - "description": "power. The gauge trim this unit learned for itself off a charger holding its float voltage (core/power/trim.h). Meaningless unless trim_learned. Recorded rather than derived: cell_mv has already been through the trim in force, so the plateau the learner measured is nowhere in the corpus"}, + "description": "power. Captures from builds before 2026-10-05: the gauge trim the unit learned for itself off a charger holding its float voltage, meaningless unless trim_learned. The divider reads the USB rail on the cable and never saw that plateau, so later builds have no learner, write 0 and never reuse the bytes"}, "trim_learned": {"type": "boolean", - "description": "power. A float plateau had been held long enough for the device to trim itself against it (kPlateauHoldMs, 120 s)"}, + "description": "power. Captures from builds before 2026-10-05: a float plateau had been held long enough for the device to trim itself against it. Later builds leave this bit clear and never reuse it"}, "charge": {"type": "string", "enum": ["unknown", "ok", "too_cold", "too_hot"], "description": "power. Whether the die temperature allowed charging at all (LilyGO's 0-60 C window). Codes: 0 unknown, 1 ok, 2 too_cold, 3 too_hot"}, - "charging": {"type": "boolean", "description": "power. The charger was holding the rail"}, - "external_power": {"type": "boolean", "description": "power. Something was feeding the charger"}, - "valid": {"type": "boolean", "description": "power. The gauge reading was believable at all: an unpopulated divider is not"}, + "charging": {"type": "boolean", "description": "power. Since 2026-10-05 the same as external_power: no pin says when a charge ends. Before, a cabled reading under 4190 mV"}, + "external_power": {"type": "boolean", "description": "power. Something was feeding the charger, VBUS detect read on every battery pass. Before 2026-10-05 a refused reading hid it as false"}, + "valid": {"type": "boolean", "description": "power. cell_mv is the cell. Clear before the first reading, on the cable, where the divider reads the USB rail, and on a reading outside 1800 to 4700 mV, such as an unpopulated divider's. Builds before 2026-10-05 set it on the cable whenever the rail read inside that window"}, "die_dc": {"type": "integer", "minimum": -500, "maximum": 1250, "description": "power. SoC die temperature in tenths of a degree Celsius, which reads above ambient. Meaningless unless die_valid"}, "die_valid": {"type": "boolean", "description": "power. A die reading existed. 0.0 C is a plausible hangar morning, so absence is a flag and not a zero"}, "supply_warnings": {"type": "integer", "minimum": 0, "maximum": 65535, "description": "power. Times the nRF52840's power-failure comparator has fired, saturating. Any at all on a healthy cell is a supply problem"}, "implausible": {"type": "integer", "minimum": 0, "maximum": 65535, - "description": "power. Gauge readings the 1800 mV sanity floor threw away, saturating"}, + "description": "power. Readings the gauge refused, under 1800 mV or over 4700 mV, on the cable or off it, saturating. Silicon builds before 2026-10-05 dropped refused readings before this count, so only one at exactly 1800 mV reached it"}, "charge_warnings": {"type": "integer", "minimum": 0, "maximum": 65535, "description": "power. Times charging was refused on temperature, saturating"}, "pressure_mpa": {"type": "integer", "minimum": 0, diff --git a/schemas/status.v1.schema.json b/schemas/status.v1.schema.json index 417b24ac..e6016753 100644 --- a/schemas/status.v1.schema.json +++ b/schemas/status.v1.schema.json @@ -12,9 +12,9 @@ "upload": {"type": "boolean", "description": "A firmware upload window is open: granted by the confirmation gesture on the device, true only on the ground, closed for good by a takeoff"}, "battery_percent": {"type": "integer", "minimum": 0, "maximum": 100, - "description": "State of charge, 0 where the device takes itself down rather than where a datasheet calls the cell empty, read on the charging curve while the charger holds the rail. ABSENT until the gauge has a sample, and for ever on a board with no divider"}, + "description": "State of charge, 0 where the device takes itself down rather than where a datasheet calls the cell empty, read on the discharge curve. ABSENT until the gauge has a reading of the cell, for ever on a board with no divider, and while the cable is in, because the divider reads the USB rail there and not the cell"}, "charging": {"type": "boolean", - "description": "The charger is holding the rail"}, + "description": "The cable is in. No pin says when the charge ends, so a full cell on the cable still reads true"}, "power_level": {"type": "string", "enum": ["OK", "LOW", "CRITICAL", "FLAT", "--"], "description": "What core/power's cutoff rule made of the same samples, three consecutive readings to step down and three to step back up. LOW is the cell past the knee, about 12%: the device asks for a charger and refuses nothing. CRITICAL, about 5%, refuses every durable write but the flight record. FLAT is the device taking itself down. '--' is Unknown: no believable reading yet. Builds before CRITICAL existed sent LOW for it and CUTOFF for FLAT"}, "went_dark_flat": {"type": "boolean", diff --git a/scripts/README.md b/scripts/README.md index 5ef60750..efd335ee 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -88,6 +88,8 @@ A board that senses no current cannot report milliamps, and this one senses none Charge the unit to full and unplug it. Arm `POWER RUN` on the CAPTURE page, which is the row that records `boot`, `config`, `power` and `duty` and nothing else, every 30 s: the page prints `KEEPS 188H25 ROLLING` against the full capture's `1H08`, and 188 hours is what makes an unattended run to cutoff survive in the ring. Then leave it alone in the posture you are measuring until it takes itself down. +`FLIGHT RUN`, the row under it, records the same and keeps the same 188 hours, and runs the glass as in flight for as long as it is armed: the radar's flight clock and `FLIGHT` word, the g-meter's extremes cleared at the double press. Leave it on the page you are measuring: the panel refreshes only when the frame changes, so the page decides the refreshes the `duty` records count. On an empty sky the flying radar changes once a minute, with its clock. It cannot fly the rest, because what else flight changes is what goes on air or into the pilot's log: the unit keeps the ground cadence and the on-ground flag (ADS-L G.1.16), and writes no flight (`firmware/core/diag/README.md`). + ``` ./scripts/blip.py fetch --log diagnostics --all --out run.ndjson python3 scripts/power_budget.py run.ndjson --pack-mah 2400 @@ -99,9 +101,9 @@ The capacity comes from the command line because the device cannot know it: Lily Caveats first, the way `blip.py decode --summary` does and out of the same helpers, because a corpus with holes in it teaches the wrong number with total confidence. A session the device did not close and the tail record it dropped, a run whose opening sector the ring recycled, every missing index and unreadable slot, every `gap` record in full, why the run is not a whole one when it is not, every interval dropped and why, and how many of the table's currents cite a datasheet. None of them was metered on this board. -Then the time the Duty intervals cover, the stretch the cell was measured over, the posture split, and one row per consumer that drew anything, with its milliamp-hours, its average milliamps and its share. A row marked `(est)` has no datasheet figure behind it, only an estimate. +Then the time the Duty intervals cover, the stretch the cell was measured over, the posture split, and one row per consumer that drew anything, with its milliamp-hours, its average milliamps and its share. A row marked `(est)` has no datasheet figure behind it, only an estimate. A row for a part the unit may not have is charged only when the `boot` record's `capabilities` name it: the IMU hub needs the inclinometer bit, so a plain T-Echo is not billed for a T-Echo Plus part. A run whose `boot` record is gone is charged every row, and the caveats say so. -Two lines close it. `measured` is what the cell spent over the last stretch it ran on battery alone and on one clock. A capture outlives the first fix, so its first records count seconds since boot and the rest count UTC, and two instants on different clocks cannot be subtracted: the stretch is timed on the clock the run ended on. When that stretch starts at the gauge's 99% or more and ends on the `level=cutoff` record the parking capture writes, the run is whole and the pack is what it spent, which needs no curve at all. Otherwise it is a fraction of the pack read off the two textbook curves in `core/power/battery.cpp`, which the script says out loud because that is the assumption the run was supposed to replace. A gauge that did not fall gives no measured line at all, never a zero or a negative draw. `residual` is modelled minus measured, and the percentage beside it is how much of the discharge the table explains. +Two lines close it. `measured` is what the cell spent over the last stretch it ran on battery alone and on one clock. A capture outlives the first fix, so its first records count seconds since boot and the rest count UTC, and two instants on different clocks cannot be subtracted: the stretch is timed on the clock the run ended on. When that stretch starts at the gauge's 99% or more and ends on the `level=cutoff` record the parking capture writes, the run is whole and the pack is what it spent, which needs no curve at all. Otherwise it is a fraction of the pack read off the textbook discharge curve in `core/power/battery.cpp`, which the script says out loud because that is the assumption the run was supposed to replace. A gauge that did not fall gives no measured line at all, never a zero or a negative draw. `residual` is modelled minus measured, and the percentage beside it is how much of the discharge the table explains. ### The table, and what retires an estimate @@ -115,6 +117,8 @@ They wrap at 65536 rather than saturating, because a reader subtracts two record Posture is read off the air the transmitter spent, because a power run records no flight record to ask. Parked is three position bursts and three callsign bursts in every 30 s, about 58 ms of air a minute, because the callsign burst is not gated on flight state. Airborne is thirty and three, about 318. The threshold sits at 160, and a device with no callsign set, which drops the callsign bursts from both figures (about 29 and 289), still lands on the right side of it. +A flight run keys as a parked unit, so air time alone would call it parked. The `boot` record says which capture a session is (`profile`, absent from builds before the field, which read as they always did), and an interval of a flight run that keys under the threshold reads `simulated flight` instead, on a line of its own under the posture split. One that keys over it was flown, and reads airborne. A run with simulated flight in it prints one more line under `modelled`, `transmit not sent`: the 27 positions every 30 s a flier adds, 54 bursts of 5 ms a minute at the transmit row's 90 mA, about 0.4 mA over the simulated stretch. It is not in the modelled total, because the residual compares the table against what the cell spent and the cell never spent it. Add it to read the draw of a flying unit. The flight log the run did not write is not modelled: a 24-byte program every 4 s and a sector erase every 11 minutes, which the table has no row for. + ## link_budget.py The e.r.p. assertion in `firmware/hardware/parts/sx1262/sx1262.h` is a chain of four figures and none of them is measured: `kConductedDbm` is the band limit written to `SetTxParams` (SoftRF writes the same 14 dBm through the same PA row on this board), the antenna gain is a vendor peak, and `kFeedLossCentiDb` is an allowance nobody measured. There is no RF meter on this bench yet. What there is, is two units that each record what they sent and what they heard, and a tape measure. This script is the field run that turns those into a number. diff --git a/scripts/blip_records.py b/scripts/blip_records.py index 24b01661..ec634a90 100644 --- a/scripts/blip_records.py +++ b/scripts/blip_records.py @@ -53,6 +53,18 @@ def read(raw, flags): return read +def vouched_enum8(at, bit, names): + """An ordinal read only when a flag bit says the firmware wrote it, and absent otherwise.""" + + def read(raw, flags): + if not flags & (1 << bit): + return None + return names[raw[at]] if raw[at] < len(names) else raw[at] + + read.names = names + return read + + def flag(bit): return lambda raw, flags: bool(flags & (1 << bit)) @@ -91,6 +103,7 @@ def read(raw, flags): RESET = ("UNKNOWN", "POWER ON", "RESET PIN", "BROWNOUT", "SOFT RESET", "WATCHDOG", "CPU LOCKUP", "CHARGER WAKE", "BUTTON WAKE", "DEBUGGER") IMAGE_STATE = ("confirmed", "probation", "reverted", "refused") +PROFILE = ("full", "power_run", "flight_run") REJECT = ("NONE", "NO SOLUTION", "NO RMC", "NO GGA", "STALE", "NO DATE", "JUMP") STAGE = ("silent", "blind", "solving", "fixed") VERDICT = ("transmitted", "lost", "held", "unarmed", "received", "named", "bad_crc", "unframed", @@ -115,7 +128,7 @@ def read(raw, flags): 1: ("boot", ( ("capabilities", u32(0)), ("fw_build", u32(4)), ("fw_revision", u16(8)), ("fw_major", u8(10)), ("fw_minor", u8(11)), ("reset", enum8(12, RESET)), - ("image_state", enum8(13, IMAGE_STATE)))), + ("image_state", enum8(13, IMAGE_STATE)), ("profile", vouched_enum8(14, 2, PROFILE)))), 2: ("config", ( ("addr", u32(0)), ("battery_offset_mv", i16(4)), ("freq_trim_e1_ppm", i16(6)), ("aircraft_type", u8(8)), ("addr_table", u8(9)), ("alarm_volume", u8(10)), @@ -246,7 +259,9 @@ def decode_diag_record(raw): "utc_dated": bool(flags & 0x02), } for field, read in fields: - decoded[field] = read(payload, flags) + value = read(payload, flags) + if value is not None: + decoded[field] = value return decoded diff --git a/scripts/power_budget.py b/scripts/power_budget.py index 624feae9..1d454e1c 100644 --- a/scripts/power_budget.py +++ b/scripts/power_budget.py @@ -28,7 +28,10 @@ ELAPSED = ("elapsed", None, 0) -Consumer = collections.namedtuple("Consumer", "name milliamps counter cited source") +Consumer = collections.namedtuple("Consumer", "name milliamps counter cited source fitted", + defaults=(None,)) + +INCLINOMETER = 1 << 14 def on_ms(field): @@ -45,7 +48,7 @@ def events(field, each_ms): Consumer("nRF52840, flash, rails", 3.5, ELAPSED, False, "10 ms service pass, UARTE at 115200, no CONFIG_PM"), Consumer("IMU hub", 0.6, ELAPSED, False, - "BHI260AP accelerometer only, the gyroscope is never read"), + "BHI260AP accelerometer only, the gyroscope is never read", INCLINOMETER), Consumer("barometer, lamp, divider", 0.4, ELAPSED, False, "BME280 forced 4/s, IIR 4, our driver, non-blocking"), Consumer("868 MHz receive", 4.8, on_ms("rx_armed_ms"), True, @@ -68,6 +71,12 @@ def events(field, each_ms): # INFO: fc 21sep26 the callsign burst is not gated on airborne, so parked keys 58 ms a minute AIRBORNE_KEYED_MS_PER_MINUTE = 160 +# INFO: fc 05oct26 G.1.16 holds the ground to 0.1 Hz, 27 positions in 30 s a flight run never sends +FLIGHT_RUN_UNSENT_BURSTS_PER_MINUTE = 54 +BURST_KEYED_MS = 5 + +PARKED, AIRBORNE, SIMULATED = "parked", "airborne", "simulated flight" + FULL_PERCENT = 99 @@ -79,10 +88,24 @@ def __init__(self, session): self.notes = [] self.records = [] + @property + def profile(self): + """The capture the boot record names, or None from a build older than the field.""" + for record in self.records: + if record["type"] == "boot" and "profile" in record: + return record["profile"] + return None + @property def duty(self): return [r for r in self.records if r["type"] == "duty"] + @property + def capabilities(self): + """What the unit found fitted at boot, or None when no Boot record survived.""" + boots = [r for r in self.records if r["type"] == "boot"] + return boots[0]["capabilities"] if boots else None + @property def gaps(self): return [r for r in self.records if r["type"] == "gap"] @@ -166,10 +189,18 @@ def duty_pairs(run): yield run.records[start], run.records[end], run.records[start + 1:end] -def interval_charge(before, after, seconds): +def fitted(consumer, capabilities): + """A part the unit did not find at boot draws nothing; an unknown unit is charged everything.""" + return consumer.fitted is None or capabilities is None or bool(capabilities & consumer.fitted) + + +def interval_charge(before, after, seconds, capabilities=None): """Milliamp-seconds per consumer over one interval.""" charge = {} for consumer in CONSUMERS: + if not fitted(consumer, capabilities): + charge[consumer.name] = 0.0 + continue kind, field, each_ms = consumer.counter if kind == "elapsed": on_seconds = seconds @@ -181,16 +212,26 @@ def interval_charge(before, after, seconds): return charge -def posture(before, after, seconds): - """Airborne or parked, read off the air the transmitter spent.""" +def posture(before, after, seconds, simulating=False): + """Airborne or parked, read off the air the transmitter spent, a flight run's ground apart.""" keyed_per_minute = delta(before, after, "tx_keyed_ms") * 60.0 / seconds - return "airborne" if keyed_per_minute >= AIRBORNE_KEYED_MS_PER_MINUTE else "parked" + if keyed_per_minute >= AIRBORNE_KEYED_MS_PER_MINUTE: + return AIRBORNE + return SIMULATED if simulating else PARKED + + +def unsent_transmit_milliamps(simulated_s, seconds): + """What a flier keys over a flight run's grounded intervals, averaged over the whole span.""" + transmit = next(c for c in CONSUMERS if c.counter == on_ms("tx_keyed_ms")) + keyed_share = FLIGHT_RUN_UNSENT_BURSTS_PER_MINUTE * BURST_KEYED_MS / 60000.0 + return transmit.milliamps * keyed_share * simulated_s / seconds def model(run): """The modelled draw over every interval the corpus can vouch for.""" charge = {consumer.name: 0.0 for consumer in CONSUMERS} - held = {"airborne": 0.0, "parked": 0.0} + held = {AIRBORNE: 0.0, PARKED: 0.0, SIMULATED: 0.0} + simulating = run.profile == "flight_run" seconds = 0.0 skipped = [] for before, after, between in duty_pairs(run): @@ -199,9 +240,9 @@ def model(run): skipped.append(reason) continue span = stamp(after) - stamp(before) - for name, spent in interval_charge(before, after, span).items(): + for name, spent in interval_charge(before, after, span, run.capabilities).items(): charge[name] += spent - held[posture(before, after, span)] += span + held[posture(before, after, span, simulating)] += span seconds += span return charge, seconds, held, skipped @@ -278,6 +319,8 @@ def caveats(run, skipped, out): for gap in run.gaps: out(" a gap record: %d records the ring had to refuse, over %.1f s" % (gap["dropped"], gap["span_ms"] / 1000.0)) + if run.capabilities is None: + out(" no boot record: every row is charged as if the unit had every part") shortfall = short_of_whole(discharge(run)) if shortfall: out(" not a whole run: %s" % shortfall) @@ -306,7 +349,10 @@ def report(run, pack_mah, out): out("cell %s %d -> %d mV %s -> %s" % (clock(cell["hours"] * 3600), cell["from_mv"], cell["to_mv"], cell["from_level"], cell["to_level"])) - out("posture airborne %s, parked %s" % (clock(held["airborne"]), clock(held["parked"]))) + out("posture airborne %s, parked %s" % (clock(held[AIRBORNE]), clock(held[PARKED]))) + if held[SIMULATED]: + out(" simulated flight %s: the glass flew, the air and the flight log stayed on " + "the ground" % clock(held[SIMULATED])) out("") modelled_mas = sum(charge.values()) @@ -320,6 +366,10 @@ def report(run, pack_mah, out): "" if consumer.cited else "(est) ", consumer.source)) out("%-26s %8.1f %7.2f %5.0f%%" % ("modelled", modelled_mas / 3600.0, modelled_mas / seconds, 100.0)) + if held[SIMULATED]: + out("%-26s %8s %+7.2f %d bursts a minute a flier keys and a grounded unit may not" + % ("transmit not sent", "", unsent_transmit_milliamps(held[SIMULATED], seconds), + FLIGHT_RUN_UNSENT_BURSTS_PER_MINUTE)) if not cell: out("") @@ -335,7 +385,7 @@ def report(run, pack_mah, out): 100.0 * modelled_ma / cell["milliamps"])) if not cell["whole"]: out("") - out("that measured figure leans on the two curves in core/power/battery.cpp, a") + out("that measured figure leans on the discharge curve in core/power/battery.cpp, a") out("textbook cell and not this pack: only a run from full to cutoff measures the") out("capacity instead of assuming it") diff --git a/scripts/test_blip.py b/scripts/test_blip.py index 29457d30..b3df650c 100644 --- a/scripts/test_blip.py +++ b/scripts/test_blip.py @@ -89,6 +89,12 @@ def test_boot_reads_capabilities_version_reset_and_image_state(self): "boot", 0, capabilities=0x1FFF, fw_build=4242, fw_revision=7, fw_major=1, fw_minor=2, reset="WATCHDOG", image_state="probation")) + def test_boot_names_its_profile_only_where_the_flag_says_it_was_written(self): + payload = struct.pack("<2IH5B", 0x1FFF, 4242, 7, 1, 2, 5, 1, 2) + self.assertEqual(decoded(1, payload, 0b100)["profile"], "flight_run") + older = struct.pack("<2IH5B", 0x1FFF, 4242, 7, 1, 2, 5, 1, 0) + self.assertNotIn("profile", decoded(1, older)) + def test_config_reads_signed_trims_and_its_three_flag_bits(self): payload = struct.pack("