Skip to content

Battery State and Calibration

The board has no charge-detect pin, so beebo infers the battery state from how the voltage moves, and the ADC can be calibrated against a voltmeter.

Voltage alone can't tell "charging" from "fully charged and plugged in" — both sit above the same rest-voltage-adjacent threshold. The Heltec V4.3 has no charge-detect GPIO (unlike some other supported boards), so beebo derives a charge trend purely from how the battery-sense voltage moves over time, shared identically between companion_radio and simple_repeater firmware (src/helpers/BattTrend.h).

Every resample tick (battery.sample_period seconds, default 300s/5min, 0 = default), the current reading is compared to the previous one:

State (BATT_STATE_*) Meaning
init No confirmed trend yet — boot, or fewer than BATT_STABLE_CONFIRM_CYCLES (2) consecutive in-band samples since the last edge. Also what a real battery reports once settled with no charge edge since boot.
charging Voltage rose more than the hysteresis band (25mV) since the last sample.
discharging Voltage fell more than the hysteresis band since the last sample.
charged 2 consecutive in-band samples, and a charge edge has fired at some point since boot — a real battery that has topped out.
plugged battery.present is explicitly set to no — external power only, no cell attached.

There's no absolute-voltage "is this plugged in?" threshold: once adc.multiplier is properly calibrated, a real LiPo's own max voltage (~4.2V) is what an uncalibrated-board threshold would have used, so a full battery and "no battery, running on the charger IC's no-load rail" become indistinguishable by voltage alone. plugged is instead driven directly by the persisted battery.present setting, and every other state assumes a battery is actually attached.

Two consecutive in-band samples are required before calling a reading init/charged rather than trusting the first one — a sample taken right after a charge/discharge edge can still be settling (IR drop between the cell and the board's sense point relaxing), so one flat-looking reading isn't enough on its own; while current is actually flowing that's caught by the delta check instead, so only the settle-time case needs the debounce.

seen_activity (and therefore charged vs init) latches for the life of the boot and is only reset by a reboot — once a real edge has fired, the node won't drop back to reporting init just because it goes quiet again.

beebo battery state and the node console's battery state print the state; init shows as UNKNOWN. battery state <state> forces it for testing (RAM-only, refused while battery.present is no). On the wire it is CMD_GET_BATT_STATE (companion binary protocol) and the battery.state plain-text command (repeater/room-server/sensor CLI).

The sample period is a persisted preference, beebo settings board.battery.sample_period [seconds] (battery.sample_period on the plain-text CLI, backed by NodePrefs.batt_sample_period_secs in src/helpers/CommonCLI.h). A separate board.battery.sample_window (batt_sample_window_secs) controls how long before the period elapses the firmware starts trying for an idle-gated sample. How long the radio must have been quiet before a sample is taken at all is IDLE_MARGIN_DEFAULT_MS (BattTrend.h, 100ms) — a compiled constant, not a settings-tree leaf. The plain-text commands are also reachable remotely over the mesh admin channel — the MeshCore Phone/Desktop app's admin cmd <target> "<cmd>" and beebo admin both forward raw text straight into the same CommonCLI/handleCommand parser, so get battery.sample_period, set battery.sample_period <secs>, battery.state, etc. work identically whether sent over a direct link or relayed through the mesh (see Admin Transport Control).

beebo status node's Battery row renders the trend state rather than a bare voltage-derived percentage:

State Rendered as
charging ⚡ N% — voltage-curve percent, capped at 99% (never claims full while current is still flowing).
charged 🔋 100% — the only state allowed to show 100% outright.
plugged 🔌 — no percent shown; battery.present says there's no cell to read a percent from.
discharging 🔋/🪫 N% — voltage-curve percent, low-battery icon under 20%.
init Plain voltage-curve percent, or ⚡ alone if the voltage is already above the plugged threshold before a trend has been established.

Calibrating the ADC

Since the ADC reading is only ever as good as adc_multiplier's calibration, a plugged/no-battery reading is also a convenient calibration reference: with no LiPo attached, \(V_{BAT}\) is just the charger IC's (CN3165) no-load CV setpoint (~4.20V, its standard single-cell regulation voltage — see the board schematic), not a real cell voltage. Comparing that known target against the raw reading gives a direct multiplier correction: new_multiplier = old_multiplier × (4.20 / measured_volts), set via beebo settings board.adc.multiplier <value>.

beebo battery calibrate does this for you: it reads the reported voltage and the current multiplier, asks for a voltmeter reading between V_{BAT} and GND (defaulting to 4.2 V), and solves for the new multiplier. Give the reading as an argument, battery calibrate 4.18, and it is applied at once. The node console takes the same command, so a phone can calibrate with a meter at hand. See Commands.

The 4.2 V default is the charger's regulation voltage, which a fully charged battery sits at. To calibrate against it without a meter, charge the battery until the charger stops, and set board.battery.present to yes first, so the node treats the reading as a real cell. With a meter, enter its reading instead.

ADC resolution

The battery ADC sample resolution is itself configurable, beebo settings board.adc.resolution <10|12>, defaulting to 12 bits. At 10-bit resolution and the calibrated multiplier above, each ADC LSB is ≈ 17 mV at the battery (3.3V / 1024 × multiplier), which is coarse enough to show up as a visible jump between two readings taken seconds apart even while the underlying voltage is essentially flat. 12-bit resolution (the default) quarters the step size to ≈ 4 mV/step. The ESP32-S3 ADC is natively 12-bit; 10-bit mode just discards the bottom 2 bits in software, so switching resolution doesn't change the amount of analog noise on VBAT — only how visibly it shows up in the reading. At 10-bit, small fluctuations get absorbed inside a single coarse 17 mV step and the reading looks artificially stable; at 12-bit, the same fluctuations move the reading across several 4 mV steps and it looks noisier, even though nothing about the underlying signal changed. Neither mode filters or averages beyond the existing 8-sample readout, so this is purely a resolution/visibility tradeoff, not a change in accuracy or conversion speed.