From e1bca5a81ab2d108749b64b375d089bf325980b2 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Wed, 22 Jul 2026 16:51:37 -0400 Subject: [PATCH] Fix false recharge-cycle detection from a single noisy battery reading A report was flagged as "the battery got recharged" (resetting battery_history and stats_recharge_cycles, and re-arming the low-battery alert) whenever it came in >= RECHARGE_JUMP_PCT above the single immediately-previous report. That's exactly what a real recharge looks like, but it's also exactly what a normal reading looks like right after one noisy low report: e.g. 60, 59, 58, then a stray 53, then back to a perfectly normal 58 -- 58 >= 53+5 falsely read as a recharge. Now compared against the max of the last RECHARGE_LOOKBACK (3) reports instead of just the one before it, so a lone stray reading doesn't get to set the bar a normal reading then trips. A real recharge still needs to clear all of them, so genuine recharges are still caught immediately (verified: 18% -> 90% still triggers, history still resets). Paired with the firmware-side battery.c change (trimmed-mean ADC sampling) that reduces how often a stray reading like the 53 above happens in the first place. --- server/app/routers/common.py | 11 ++++++++++- server/app/routers/device.py | 8 +++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 1fe5de4..8672dc1 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -21,7 +21,16 @@ logger = logging.getLogger(__name__) # Battery-history / estimate tuning (see /frame/battery and battery_estimate_s). BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame -RECHARGE_JUMP_PCT = 5 # a report this much above the previous one = battery was recharged +RECHARGE_JUMP_PCT = 5 # a report this much above the recent baseline = battery was recharged +# How many of the most recent reports make up that baseline. A lone noisy +# reading (ADC/regulator glitch -- see firmware/main/battery.c) can still +# dip or spike a single report; comparing against just the one immediately +# previous report meant that a normal reading right after a noisy dip +# looked like a 5%+ jump and falsely registered as a recharge. Comparing +# against the max of the last few reports instead means an actual recharge +# still needs to clear all of them, while a single stray low one doesn't +# get to set the bar. +RECHARGE_LOOKBACK = 3 MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time... MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 2b57d8b..7a53b82 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -28,6 +28,7 @@ from .common import ( BATTERY_HISTORY_MAX, BATTERY_LOG_MAX, RECHARGE_JUMP_PCT, + RECHARGE_LOOKBACK, immich_client_for, immich_creds, list_assets, @@ -202,7 +203,12 @@ def frame_battery( alert_frame_name = "" with frame_locked(db, frame.id) as locked: locked.stats_battery_reports += 1 - if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT: + # See RECHARGE_LOOKBACK: compared against the max of the last few + # reports, not just the single previous one, so a lone noisy dip + # can't make the next normal reading look like a recharge. + recent = locked.battery_history[-RECHARGE_LOOKBACK:] + recent_max = max((pct for _, pct in recent), default=None) + if recent_max is not None and body.percent >= recent_max + RECHARGE_JUMP_PCT: # Percent jumped up meaningfully -- the battery was recharged # (or swapped). Start a fresh discharge cycle so runtime and # discharge-rate estimates never span a charge -- and let a