Battery: monitor, calibration and failsafes¶
The battery is measured, estimated and calibrated by the BattTask, a task of its own. The flight core only receives a signal when the warning level changes, and decides what the drone does. This page covers the hardware, the estimator, the calibration, the checks, the failsafes and how to test them.
| What | Where |
|---|---|
| Estimator (pure C, host-tested) | App/control/battery.c |
| BattTask: ADC sampling, signals, last-flight charge | App/app/batt_task.c |
| ADC binding (injected group, VREFINT) | App/bsp/batt_adc.c |
| Failsafes and events | App/control/fc_core.c, battery() |
| POWER frame, CHECKS item 1, BATTERY calibration glue | App/app/app_task.c, App/control/checks.c, App/control/calib_mgr.c |
| Settings | APP_BATT_* in App/app/app_config.h; parameters 20–24 |
Hardware¶
| Signal | ACwing FC rev A | Bench (NUCLEO-WB55) |
|---|---|---|
| VBAT | PA5 = ADC1_IN10, divider 100k/10k (×1/11, 34 V full scale) | A0 = PC0 = ADC1_IN1, optional |
| Current | PA4 = ADC1_IN9, CUR pin of ESC link A |
A1 = PC1 = ADC1_IN2, optional |
| Current sensor | ACwing ESC 1S: TSC213 + 2 mΩ shunt = 0.1 V/A | – |
On the bench nothing is wired, so APP_BATT_ADC is 0: no BattTask, no POWER
frame, CHECKS item 1 is N/A and the app hides the battery, as before. To try
the chain on the bench, wire a 100k/10k divider from a bench supply to A0 (and
a voltage for the "current" to A1, 0.1 V = 1 A), then set APP_BATT_ADC 1.
The ADC runs three conversions in the injected group (VBAT, CUR,
VREFINT), 16× hardware oversampling, 247.5-cycle sampling for the 9 kΩ source
of the divider. Each batt_adc_read() returns the previous result and starts
the next conversion, so nothing waits for the ADC. The pin voltages are scaled
with VREFINT and its factory calibration, so they do not depend on the exact
3.1 V supply.
Tasks and signals¶
sequenceDiagram
participant ADC as ADC1 (injected)
participant BT as BattTask · Normal · 50 Hz
participant FC as flight core (ImuTask)
participant AT as AppTask
participant LT as LogTask
BT->>ADC: read previous result, start next
BT->>BT: batt_step(): vbat, current, mAh, cells, level, SOC, time left
BT-->>FC: fc_batt_signal(level, cell mV) only on a change (SPSC)
BT-->>FC: fc_batt_heartbeat(now) every step
FC->>FC: events 30/31/32, failsafe LAND / RTH, EMERGENCY lock
AT->>BT: batt_read() snapshot (seqlock): POWER, CHECKS, CALIB
AT-->>BT: batt_task_post_scales() after a calibration (SPSC)
BT-->>LT: LR_POWER record, 10 Hz (log queue)
- Separation. The estimator and the ADC live in the BattTask; the flight core keeps the decisions, because it owns the flight state. They share only a queue of level changes and a heartbeat time stamp: no lock, no waiting.
- Priority. The BattTask runs at
osPriorityNormal: below the ImuTask, above the AppTask and the ExtTask, so a busy app link cannot delay a battery signal. - Heartbeat. The IWDG is fed only by the ImuTask, so a stalled BattTask
would not reset the board. The flight core therefore checks the heartbeat:
silent for more than 1 s → EVENT 33 Battery monitor stopped (ERROR,
firmware extension), the battery is treated as unknown (ARM and TAKEOFF
refused with
BATTERY_LOW, CHECKS item 1 FAIL). Before the first heartbeat the battery is also unknown.
The estimator¶
All quantities come from two pin voltages, sampled every 20 ms.
Voltage and current.
with \(s_V\) = scaleV (110 for the 100k/10k divider), \(k_I\) = 0.1 V/A and \(s_I\) = scaleI (1 nominal). Both are low-pass filtered: \(\tau\) = 0.1 s for \(V\), 0.05 s for \(I\). The charge used is \(\int I\,dt\) (positive current only), in mAh since power-on.
Presence and cells. The battery is present above 2.5 V, absent below
1.5 V for 200 ms (USB only). The cell count is the parameter battCells
when set, otherwise it is detected once per connection, disarmed, 0.5 s after
the battery appears:
A deeply discharged pack (below 3.5 V per cell on 6S) can be counted one cell
short: set battCells on such models.
Warning level. It uses the sagged cell voltage, as the handover asks: \(v_c = V/n\), low-pass filtered with \(\tau\) = 0.5 s. A level (LOW < battWarn, CRITICAL < battCrit, EMERGENCY < battEmerg) is entered when \(v_c\) has been below its threshold for 2 s. It is left only when disarmed, after \(v_c\) has been 50 mV above the threshold for 2 s (a fresh battery). So a level reached in flight does not flicker off when the pilot cuts the throttle and the voltage recovers.
Internal resistance. Every 100 ms the mean \(V\) and \(I\) of the window are compared with the previous window; on a load step \(|\Delta I| > 0.5\) A,
starting from 40 mΩ per cell. The rest voltage is \(v_r = v_c + I\,R\).
State of charge. \(v_r\) goes through a LiPo open-circuit-voltage table (3.27 V = 0 %, 3.84 V = 50 %, 4.20 V = 100 %, 5 % steps). Then:
- disarmed and resting (\(|I| < 0.3\) A): SOC follows the voltage (\(\tau\) = 2 s) and is the anchor for the coulomb counter;
- otherwise, with a current sensor and
battCapacityset: coulomb counting, \(\mathrm{SOC} = \mathrm{SOC}_0 - (\mathrm{mAh} - \mathrm{mAh}_0)/C\), with a slow pull towards the voltage estimate (\(\tau\) = 600 s) that corrects a wrong capacity; - otherwise: the voltage estimate, \(\tau\) = 10 s.
POWER.pct is \(\mathrm{SOC} \times 100\).
Time left. Only armed, with a current sensor and a capacity:
with \(\bar I\) the current filtered over 10 s and 20 % kept for the landing (as
the app simulator). Otherwise POWER.remain is 0xFFFF (n/a).
Parameters¶
| id | Name | Default | Range | Notes |
|---|---|---|---|---|
| 20 | battWarn | 3.6 | 3.3–3.9 V/cell | handover |
| 21 | battCrit | 3.5 | 3.2–3.8 V/cell | handover |
| 22 | battEmerg | 3.3 | 3.0–3.6 V/cell | handover; warn > crit > emerg |
| 23 | battCapacity | 0 | 0–20000 mAh | firmware extension; 0 = unknown (no coulomb counting, no time left); listed only with CAPS CURRENT |
| 24 | battCells | 0 | 0–6 | firmware extension; 0 = detect |
The ids 23, 24 (and 70, logMode) are firmware extensions. The ACwing Pilot
app shows them in Settings › Safety › Battery (and › Drone & firmware for
logMode); the remaining app work is in
HANDOVER_APP.md.
CHECKS item 1 and ARM¶
| Situation | State | Value |
|---|---|---|
| no battery monitor on this board | N/A | 0 |
| BattTask silent | FAIL | 0 |
| no battery (USB only: the motors are unpowered) | WARN | 0 |
| cell < battCrit, or latched level ≥ CRITICAL | FAIL | cell mV |
| cell < battWarn, or latched level LOW | WARN | cell mV |
| otherwise | OK | cell mV |
A FAIL in item 1 makes ARM answer BATTERY_LOW, and the flight core refuses
ARM and TAKEOFF with BATTERY_LOW whenever the level is CRITICAL or worse or
the monitor is silent.
Failsafes¶
stateDiagram-v2
[*] --> OK
OK --> LOW: cell < battWarn for 2 s / EVENT 30 WARN
LOW --> CRITICAL: cell < battCrit for 2 s / EVENT 31 ERROR
CRITICAL --> EMERGENCY: cell < battEmerg for 2 s / EVENT 32 CRITICAL
LOW --> OK: disarmed and recovered
CRITICAL --> OK: disarmed and recovered
EMERGENCY --> OK: disarmed and recovered
note right of CRITICAL
in air: LAND (RTH with GNSS + home), FLIGHT.failsafe bit 2,
EVENT 22 with the action; the pilot can take over
end note
note right of EMERGENCY
in air: LAND, locked: SET_MODE, HOLD, RTH, TAKEOFF refused
with BATTERY_LOW; only KILL overrides
end note
- CRITICAL, in air. Once per flight: the automatic action becomes LAND (RTH needs GNSS, home and enough charge: none of them exists on this hardware yet), FLIGHT.failsafe bit 2 is set, and EVENT 22 is sent after EVENT 31 with the action as argument. A LAND or RTH already running is kept. The pilot can take over with SET_MODE, as the simulator allows; the failsafe does not trigger again.
- EMERGENCY, in air. LAND now, and the landing is locked until disarm:
SET_MODE, HOLD, RTH and TAKEOFF are refused with
BATTERY_LOW; only KILL works. - Other failsafes do not take over. While a battery failsafe lands the drone, an app-link loss is only flagged (FLIGHT.failsafe bit 0); it cannot replace LAND with HOLD.
BATTERY calibration (§10.6)¶
CAL_START(6)→ WAITING_USER, step 1. CALIB showsvbatlive, read with the scale in effect. Refused withSENSOR_MISSINGwithout CAPS CURRENT.- Measure the pack with a multimeter,
CAL_SET_REF(6, 0, volts)(2.5–30 V): \(s_V \leftarrow s_V \cdot V_{meas}/V_{read}\). A ratio outside 0.7–1.3 isOUT_OF_RANGE; no battery →SENSOR_MISSING. The new scale applies at once, step 2. - After charging the pack flown last,
CAL_SET_REF(6, 1, mAh_charged)(50–20000), orCAL_CAPTUREto skip → DONE, step 3. CAL_SAVEstores the scales in the flash image (with a backup forCAL_RESTORE).
The charge of the last flight. The BattTask counts the mAh from ARM to DISARM; the AppTask stores it, with the scaleI it was counted with, in the flash image when the drone is disarmed (only above 20 mAh). The new scale is
The handover writes scaleI = mAh / usedMah, which is the same only when
\(s_{I,flight}\) = 1. Using the scale of the flight keeps repeated calibrations
from compounding. A ratio outside 0.5–2 is refused.
One flash write per flight
Storing the last flight's charge writes the configuration page at every disarm after a real flight. The two A/B pages are rated 10 000 erase cycles each on the STM32WB55, about 20 000 flights.
POWER frame¶
| Field | Value |
|---|---|
| vbat | filtered pack voltage, mV |
| current | filtered current, cA (0 without a current sensor) |
| used | mAh since power-on |
| cells | count (0 until known) |
| cellAvg | sagged cell voltage (the one the failsafes use), mV |
| pct | state of charge |
| remain | s, 0xFFFF n/a |
| warn | latched level |
Sent at 5 Hz (STREAM_RATES 1–10) and on REQUEST(POWER) when the board
measures the battery; CAPS.cells carries the cell count.
Testing¶
- Host (
Tests/host/test_power.c, 95 checks): estimator (cells 1S–6S, 2 s level timing, no flicker while armed, coulomb counting against 450 mAh, time left, internal resistance 25 mΩ recovered from load steps, battery removed), BattTask signals, every failsafe (events and order, LAND, override, EMERGENCY lock, link loss during the landing, KILL), the heartbeat, the calibration (through calib_mgr and through the protocol), CHECKS, the POWER frame and the migration of the flash image. The POWER and CALIB frames it produces are decoded by the app's own decoders. - On the bench. With
APP_BATT_ADC 1and a bench supply on A0 through the divider: lower the supply slowly through 3.6 / 3.5 / 3.3 V per cell and watch EVENT 30 / 31 / 32 in the app; arm with the debugger flagg_fc_dbg_in_airset to see LAND and the lock.