fix(telemetry): report real battery % while charging#10943
Conversation
StatusLEDModule re-derived charge/power state (discharging/charging/ charged/critical) from getHasUSB()/getIsCharging()/getBatteryChargePercent() independently of other consumers of the same signals. Move it to PowerStatus::getPowerState() so there's one source of truth; StatusLEDModule now just reads it. LED behavior unchanged. Also hoists the pre-existing power_state != Charging && power_state != Charged guard (duplicated across the LED_HEARTBEAT #if/#else branches) into a single local, since both copies needed the new qualified enum name anyway. Hardened PowerState itself: explicit numbering with Unknown = 0 as the default/unset value (was unnumbered, no Unknown), and getPowerState() now returns Unknown before the first real reading arrives instead of guessing from all-default fields. StatusLEDModule's default member initializer moves from Discharging to Unknown to match - runOnce() only branches on Charging/Charged/Critical, so anything else (including Unknown) still falls through to the same heartbeat default; no LED behavior change. This gives a future is_charging-style protobuf field a stable, ready-made source to map onto directly. Assisted-by: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Andrew Yong <me@ndoo.sg>
Client-visible behavior change: nodes that are actively charging (plugged in, battery below 100%) now report their real battery percentage in mesh telemetry (device_metrics.battery_level) instead of the fixed sentinel 101. Apps/dashboards that show a node's live battery % while it charges will start seeing it move again instead of being pinned at 101/powered for the whole charge cycle. Root cause: battery_level was forced to MAGIC_USB_BATTERY_LEVEL (101) whenever isCharging() was true, at any percentage, masking the real number for the whole 0-99% charge ramp - on any board where charge detection works, regardless of which mechanism drives isCharging() (EXT_CHRG_DETECT/ EXT_PWR_DETECT pins, a PMU/fuel-gauge's own charge-status bit, or plain USB power detection). This is a pre-existing, general bug independent of meshtastic#10906. I noticed it after meshtastic#10906 merged, on my own device (a TP4057 charger wired to STBY->EXT_PWR_DETECT and CHRG->EXT_CHRG_DETECT), which reported 101 for its entire charge cycle instead of the real percentage. telemetry.proto documents battery_level as "0-100 (>100 means powered)" - 101 means "no reading available," not "currently charging." Fix: key the sentinel off !hasBattery alone. hasBattery is false whenever there's genuinely no battery ADC/fuel-gauge/PMU on the board, or battery hardware present with no cell inserted; every other board (the common case, including fuel-gauge/PMU boards) now reports its real percentage while charging, 0 through 100 - including a real 100% while still powered, previously reported as 101. Overriding that real 100% reading with 101 would violate the >100-means-powered contract (protobufs commit b546551 replaced an earlier "0 means powered" convention specifically to get an unambiguous "no real reading exists" sentinel, not a modifier meant to override a valid reading), and conflicts with iOS's BatteryLevel.swift, which already special-cases battery_level == 100 for its "topped-up-and-charging" bolt icon. The cost - losing "powered at exactly 100%" as a mesh-visible signal - is the same cost every other percentage already pays under this fix; a real is_charging field is the right way to recover it, not an overloaded battery_level. Deliberately not touched: boards defining NO_BATTERY_LEVEL_ON_CHARGE (today: only pico2_w5500_e22), whose getBatteryPercent() returns a placeholder 0 while charging because their voltage reading is untrusted then. Flipping hasBattery false for that board's whole charge cycle would break isCharging()'s own fallback formula, the JSON HTTP status API's is_charging field, and the on-device debug overlay. Showing 0% while charging there matches its own local display and is out of scope here. Client impact: checked Android, iOS and device-ui source directly. None treat 1-99 as a charging indicator, so no client-visible signal is removed there - the 0-99% ramp goes from hidden/frozen to live. At exactly 100%, iOS's bolt icon and device-ui's charging icon (both keyed off battery_level/percentage == 100) now trigger correctly instead of being defeated by the old 101 override; Android's ">100 means powered" text narrows from "Powered" to "100%" at that one value. This repo's own on-device Base UI (UIRenderer.cpp's remote-node status line) had the identical bug: it shows "Plugged In" in place of any percentage whenever battery_level > 100, so a remote node's entry froze at "Plugged In" for its whole charge cycle too - this fix restores the live percentage there as well. Assisted-by: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Andrew Yong <me@ndoo.sg>
📝 WalkthroughWalkthroughThis PR introduces a shared ChangesCoarse Power State Unification
Estimated code review effort: 2 (Simple) | ~15 minutes Sequence Diagram(s)sequenceDiagram
participant PowerStatus
participant StatusLEDModule
participant LEDs
StatusLEDModule->>PowerStatus: getPowerState()
PowerStatus-->>StatusLEDModule: PowerState (Charging/Charged/Discharging/Critical/Unknown)
StatusLEDModule->>StatusLEDModule: compute notCharging
StatusLEDModule->>LEDs: update charge LED based on Charging/Charged
StatusLEDModule->>LEDs: update power LED based on Critical
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
⚡ Try this PR in the Web FlasherNote Building this pull request… the flash button, badges and supported-board |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/PowerStatus.h (2)
14-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winComment exceeds 1–2 line guideline.
This 3-line doc comment exceeds the repo's comment-length guideline for C++ files.
✏️ Suggested trim
-/// Coarse charge/power state, derived from PowerStatus's own fields. -/// Explicitly numbered, with Unknown=0 as the default/unset value, so this can be wire-mapped to a future -/// protobuf enum (e.g. an admin/telemetry is_charging-style field) without the numbering shifting under it. +/// Coarse charge/power state, derived from PowerStatus's own fields. +/// Explicitly numbered (Unknown=0) so future wire-mapping to a protobuf enum is stable. enum class PowerState { Unknown = 0, Discharging = 1, Charging = 2, Charged = 3, Critical = 4 };As per coding guidelines, "Keep code comments minimal: one or two lines maximum, only when the reason is not obvious, and do not restate the next line."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/PowerStatus.h` around lines 14 - 17, Shorten the doc comment above PowerState in PowerStatus.h to fit the one-or-two-line C++ comment guideline. Keep only the essential rationale for the enum’s explicit numbering and Unknown=0 default, and remove the extra explanatory sentence so the comment does not restate obvious code behavior.Source: Coding guidelines
64-75: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winSame comment-length guideline applies here.
This doc comment on
getPowerState()also spans 3 lines.Separately, worth verifying: for boards without a battery pin/PMU (
getBatteryChargePercent()returns the101sentinel when!getHasBattery()), ifgetHasUSB()/getIsCharging()are ever both false while the sentinel is101, this method falls into the final branch and returnsDischarging(since101 > 5) rather than something reflecting "powered, no battery sensor." This may be an unreachable combination in practice (such boards are typically always externally powered), but since this method is now the shared source of truth intended for future richer charging-state reporting, it's worth confirming the assumption holds for all board variants.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/PowerStatus.h` around lines 64 - 75, Shorten the doc comment on getPowerState() to comply with the comment-length guideline, and verify the no-battery sentinel path in getPowerState() is safe for all boards. In particular, confirm that getBatteryChargePercent() returning 101 when getHasBattery() is false cannot reach the final Discharging branch when getHasUSB() and getIsCharging() are both false; if that combination is possible, update getPowerState() to return a state that explicitly reflects “powered, no battery sensor” instead of treating it as Discharging.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/PowerStatus.h`:
- Around line 14-17: Shorten the doc comment above PowerState in PowerStatus.h
to fit the one-or-two-line C++ comment guideline. Keep only the essential
rationale for the enum’s explicit numbering and Unknown=0 default, and remove
the extra explanatory sentence so the comment does not restate obvious code
behavior.
- Around line 64-75: Shorten the doc comment on getPowerState() to comply with
the comment-length guideline, and verify the no-battery sentinel path in
getPowerState() is safe for all boards. In particular, confirm that
getBatteryChargePercent() returning 101 when getHasBattery() is false cannot
reach the final Discharging branch when getHasUSB() and getIsCharging() are both
false; if that combination is possible, update getPowerState() to return a state
that explicitly reflects “powered, no battery sensor” instead of treating it as
Discharging.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 57271e0d-f73b-42e8-82f7-afac0999a789
📒 Files selected for processing (4)
src/PowerStatus.hsrc/modules/StatusLEDModule.cppsrc/modules/StatusLEDModule.hsrc/modules/Telemetry/DeviceTelemetry.cpp
Problem
Related to #10906.
device_metrics.battery_levelis forced to the101sentinel (MAGIC_USB_BATTERY_LEVEL) wheneverisCharging()istrue, on any board, regardless of which mechanism makes ittrue—EXT_CHRG_DETECT/EXT_PWR_DETECTpins, a PMU/fuel-gauge's own charge-status bit, or plain USB power detection. This is a pre-existing, general bug, not specific to any one board or introduced by any one PR: it masks the real number for the whole 0-99% charge ramp on every board where charge detection works at all, and always has.I noticed it after #10906 merged: my device — which monitors the STBY→EXT_PWR_DETECT and CHRG→EXT_CHRG_DETECT pins of a TP4057 — reported
battery_levelas101for the entire charge cycle instead of the real percentage.1 #10906 didn't introduce this, though — it's independent of that PR's actual changes. Any board where charge detection already worked before #10906 hits the same masking, and any future fix to charge detection on another board (like #10941, filed alongside this PR) will surface it there too.Protobufs as the source of truth
telemetry.protodocumentsbattery_levelas0-100 (>100 means powered)2 —101means "no reading available," not "currently charging." This PR treats that contract as authoritative throughout: a real reading is real data and gets reported as such, never overridden by an inferred charge state. That's the standard the fix below, and the 100%-edge-case decision after it, are both held to.Fix
Fall back to the sentinel only when there's no trustworthy reading at all (
!hasBattery). Every real reading, including100% while still charging, is now reported as-is.Telemetry sentinel logic
The full truth table over the two inputs the sentinel actually reads:
hasBatteryisCharging()battery_levelbeforebattery_levelaftertruetrue101truefalsefalsetrue101101(unaffected)falsefalse101101(unaffected)Only the first row changes. On real hardware,
EXT_CHRG_DETECT(when present) decidesisCharging()outright, regardless ofEXT_PWR_DETECT; without it,EXT_PWR_DETECTalone decides.hasBatteryisfalsefor boards with no battery-sensing hardware at all, and for battery hardware present with no cell inserted. After this fix, it no longer matters which signal makes either inputtrue— only whether a reading exists (hasBattery) and, when it does, what the reading is. That includes real % landing on exactly100whileisCharging()istrue— see The 100%-while-charging edge case, below.The same rule as a flowchart, after this PR —
isCharging()no longer appears at all:flowchart TD HB{hasBattery?} -->|no| S["battery_level = 101"] HB -->|yes| R["battery_level = real %"]Compare against Appendix:
battery_levelsentinel logic before this PR, below, whereisCharging()forces101on its own, independent ofhasBattery.The 100%-while-charging edge case
A node at exactly
100% while still charging now reports100, not101.101was previously used there to preserve "this node is currently powered" as a mesh-visible signal, but that would violate the contract above: the commit that introduced>100 means powered3 shows the sentinel was meant to mean "no real reading exists," not to override a valid one.It also conflicts with iOS's
BatteryLevel.swift4, which already special-casesbattery_level == 100for its "topped-up-and-charging" icon — reporting101there defeats it.This affects one value: a node at exactly
100% while charging can no longer be distinguished, over the mesh, from a node at100% that's simply sitting fully charged and unplugged. Every value from0to99already loses that same distinction under this fix (see Regression scope); a realis_chargingfield is the right way to recover it for all of them (see Future work).Groundwork: centralizing
PowerStateStatusLEDModuleneeds a reliable, per-board-correct charge state for its own LED cadence. Ondevelop, it derives that state privately.5 This PR moves the derivation intoPowerStatus::getPowerState()instead, and hardens it with an explicitUnknown = 0default, so a futureis_charging-style field (see Future work) has a stable, ready-made source to map onto directly.LED behavior is unchanged — this is groundwork, not a fix on its own — and it's unaffected by the
101→100change above:getPowerState()'s solid-LEDChargedstate triggers ongetBatteryChargePercent() >= 1006, so a node landing on a real100still crosses that threshold the same as101did.PowerStatus::getPowerState(), after this PR — highlighted nodes are the only part this PR actually changes; everything else isStatusLEDModule's pre-existing derivation, relocated unmodified (compare against Appendix:PowerStatederivation before this PR, below):flowchart TD I{initialized?} -->|no| U[Unknown] I -->|yes| P{"USB present or\nisCharging()"} P -->|no| L{percent > 5} L -->|yes| DIS[Discharging] L -->|no| CRIT[Critical] P -->|yes| F{percent ≥ 100} F -->|yes| CHGD[Charged] F -->|no| CHG[Charging] style I fill:#ffe28a,stroke:#a86b00,stroke-width:3px,color:#000000 style U fill:#ffe28a,stroke:#a86b00,stroke-width:3px,color:#000000StatusLEDModulereads this directly:Chargingblinks ~1Hz,Chargedsolid on,Discharginga brief once-a-second heartbeat flash,Criticala fast 250ms blink.Regression scope & client impact
battery_levelstays101unconditionally.101— including at exactly 100%, where the mesh can no longer tell "still powered" apart from "idle" (see The 100%-while-charging edge case, above).pico2_w5500_e22(NO_BATTERY_LEVEL_ON_CHARGE7) — still shows0%while charging, matching its own local display; forcing it to101too would require breakingisCharging()'s fallback, the JSON HTTP API'sis_chargingfield, and the debug overlay.Checked Android8, iOS4, and device-ui9
main-branch source directly: none treat1-99as a charging indicator, so no existing signal is removed there. This repo's own on-device Base UI (the OLED screen's remote-node status line,UIRenderer.cpp)10 has the identical bug directly: it shows "Plugged In" in place of any percentage wheneverbattery_level > 100, so a remote node's entry froze at "Plugged In" for its whole charge cycle too — this fix restores the live percentage there as well. Across all four, the only remaining visible change is at exactly 100%, discussed above.Testing
Build-verified:
tracker-t1000-e,pico2_w5500_e22,russell,thinknode_m6.Hardware-verified:
BATsense line at ~5V while on USB, which the OCV clamp11 saturates to a real100— confirmedbatteryLevel: 100(not the old101).101via!hasBattery(unaffected by this PR).NRF_APM, same root cause as [Bug]: Seeed Tracker T1000-E tracker doesn't detect when USB power is applied #4367/fix #4367 make USB power detection work correctly on seeed trackers #4376 — filed separately as fix(nrf52): enable USB/charge detection on Seeed Wio Tracker L1 #10941). With that combined locally:Battery: usbPower=1, isCharging=1, batMv=4149, batPct=97, andbattery_levelreporting95live — not the old frozen101.hasBattery/isCharging()are derived differently board to board —EXT_CHRG_DETECT/EXT_PWR_DETECTpins, a PMU/fuel-gauge's own charge-status bit, a generic ADC voltage divider, or board-specific overrides likeELECROW_ThinkNode_M6's branch. The three boards above don't cover that whole matrix, so maintainer/community testing on other commonly-used devices (see Attestations) would help catch any board-specific surprises this session didn't exercise.Native unit tests didn't run locally (pre-existing toolchain issues, unrelated to this change). No existing test covers this logic.
Future work
DeviceMetricshas nois_chargingfield12, so nothing downstream can distinguish "charging" from "discharging at some percentage" — and after this fix,battery_levelno longer tries to carry that signal at all. The groundwork above (PowerStatus::getPowerState()) is what's missing to build it properly: aprotobufsschema change, populating it inDeviceTelemetry.cppfromgetPowerState(), and client updates (Android/iOS/device-ui) to consume it instead of inferring frombattery_level.The local JSON HTTP API already validates this need: it exposes
powerStatus->getIsCharging()13 directly to a directly-connected client, but only as a raw boolean, only for WiFi/Ethernet boards, and only over HTTP — not over the mesh. A second, complementary route would generalize that same idea properly: exposegetPowerState()through the admin protobuf instead, so any companion app connected to a node (BLE/serial/TCP, not just WiFi/Ethernet HTTP) can query its power/charging status as the richerDischarging/Charging/Charged/Criticalstate, rather than a plain boolean or an inference frombattery_level. Unlike theDeviceMetricsfield above, this wouldn't need a mesh-wide telemetry broadcast — just a direct connection to that node.AdminMessage, as defined ondevelop, has no such request/response.🤝 Attestations
tbeam-s3-core), and Seeed Wio Tracker L1 — all boot clean; build-only fortracker-t1000-e,pico2_w5500_e22,russell,thinknode_m6.Appendix:
battery_levelsentinel logic before this PRProvided for comparison against the flowchart in Telemetry sentinel logic, above. On
develop,isCharging()forces101on its own, independent ofhasBattery— the source of the bug this PR fixes:flowchart TD HB{hasBattery?} -->|no| S1["battery_level = 101"] HB -->|yes| IC{isCharging?} IC -->|yes| S2["battery_level = 101"] IC -->|no| R["battery_level = real %"] style IC fill:#ffe28a,stroke:#a86b00,stroke-width:3px,color:#000000 style S2 fill:#ffe28a,stroke:#a86b00,stroke-width:3px,color:#000000Highlighted: the
isCharging()branch this PR removes. After this PR, onlyhasBatterydecides the sentinel (see the flowchart in Telemetry sentinel logic, above).Appendix:
PowerStatederivation before this PRProvided for comparison against the flowchart in Groundwork: centralizing
PowerState, above. This isStatusLEDModule's private derivation ondevelop5, notPowerStatus::getPowerState()— that function doesn't exist before this PR.StatusLEDModule's private derivation, ondevelop, before this PR:flowchart TD P{"USB present or\nisCharging()"} -->|no| L{percent > 5} L -->|yes| DIS[discharging] L -->|no| CRIT[critical] P -->|yes| F{percent ≥ 100} F -->|yes| CHGD[charged] F -->|no| CHG[charging]No
Unknownstate exists before this PR:power_statedefaults todischargingat declaration, so the LED assumes discharging cadence until the firstSTATUS_TYPE_POWERupdate arrives, rather than an explicit unset state.Summary by CodeRabbit
New Features
Bug Fixes
Footnotes
Comment on #10906. ↩
protobufs/meshtastic/telemetry.proto#L14-L18—DeviceMetrics.battery_leveldoc comment. ↩protobufs@b546551— "Make power level less dumb," the commit that introduced>100 means powered(replacing0 means powered). ↩BatteryLevel.swift#L16-L51— special-cases== 100and> 100separately. ↩ ↩2src/modules/StatusLEDModule.cpp#L51-L61(pre-refactor) — the charge/power state derivation this PR moves intoPowerStatus::getPowerState(). ↩ ↩2src/PowerStatus.h#L72—getPowerState()'sChargedthreshold (introduced in this PR; not yet ondevelop). ↩src/Power.cpp#L361andvariants/rp2350/diy/pico2_w5500_e22/variant.h#L30— the only board definingNO_BATTERY_LEVEL_ON_CHARGE. ↩BuildNodeDescription.kt#L116(if (it in 1..MAX_BATTERY_PERCENT), whereMAX_BATTERY_PERCENT = 100at L39) andLocalStatsWidget.kt#L184(state.batteryLevel > 100) — both key offbattery_level > 100. ↩BatteryLevel.cpp#L21-L22— needspercentage >= 100andvoltage > CHARGING_VOLTAGE(4.30) for itsChargingicon. ↩src/graphics/draw/UIRenderer.cpp#L1060-L1073— the remote-node battery-status line'spct > 100→ "Plugged In" branch. ↩src/Power.cpp#L391(OCV-curve interpolation, clamped[0, 100]) andMAX17048Sensor.cpp#L76(fuel gauge, same clamp). ↩protobufs/meshtastic/telemetry.proto#L14-L39— fullDeviceMetricsmessage. ↩src/mesh/http/ContentHandler.cpp#L758. ↩