3 Commits
Author SHA1 Message Date
tfaour fc65b19cf2 Bump firmware to 1.2.4
Build and release firmware / build-and-release (push) Successful in 1m47s
Trimmed-mean battery ADC sampling to reduce noisy readings.
2026-07-22 16:57:34 -04:00
tfaour e1bca5a81a Fix false recharge-cycle detection from a single noisy battery reading
Build and push server image / build-and-push (push) Successful in 38s
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.
2026-07-22 16:51:37 -04:00
tfaour 845e4f9509 Reduce battery-reading noise with a trimmed-mean ADC sample
Sometimes a single reading came in noticeably off from the real trend
(a regulator/RF transient during sampling), and the next normal reading
would then look like a big jump relative to that bad one -- server-side,
enough to misfire the recharge-cycle heuristic (see the paired server
commit). Went from 8 raw-averaged samples to 16, sorted, with the 3
extreme samples on each end dropped before averaging the remaining 10 --
a handful of outliers can no longer skew the result the way a plain
average let them.
2026-07-22 16:51:28 -04:00
4 changed files with 48 additions and 10 deletions
+30 -7
View File
@@ -1,3 +1,5 @@
#include <stdlib.h>
#include "driver/gpio.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_adc/adc_oneshot.h"
@@ -11,7 +13,13 @@ static const char *TAG = "battery";
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
#define BATTERY_ADC_GPIO CONFIG_FRAME_BATTERY_ADC_GPIO
#define BATTERY_SAMPLES 8
#define BATTERY_SAMPLES 16
/* Trimmed mean: the extreme BATTERY_TRIM samples on each end (regulator/
* RF transients, not the true resting voltage) are dropped before
* averaging the rest -- a plain average lets even one or two of those
* skew the result enough to read as a real percent change downstream
* (see the recharge-jump handling in routers/device.py). */
#define BATTERY_TRIM 3
/* The external divider halves the battery voltage (2x200k, per the
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
* pin, inside the 12dB-attenuation ADC range. */
@@ -34,6 +42,11 @@ static const struct {
{ 3300, 5 }, { 3000, 0 },
};
static int int_cmp(const void *a, const void *b)
{
return *(const int *)a - *(const int *)b;
}
static int mv_to_percent(int mv)
{
int n = sizeof(LIPO_CURVE) / sizeof(LIPO_CURVE[0]);
@@ -139,19 +152,17 @@ int battery_read_percent(void)
ESP_LOGW(TAG, "ADC calibration unavailable, using nominal scaling");
}
int mv_sum = 0;
int mv_samples[BATTERY_SAMPLES];
int samples = 0;
for (int i = 0; i < BATTERY_SAMPLES; i++) {
int value;
if (calibrated) {
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
mv_sum += value;
samples++;
mv_samples[samples++] = value;
}
} else {
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
mv_sum += value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
samples++;
mv_samples[samples++] = value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
}
}
}
@@ -167,7 +178,19 @@ int battery_read_percent(void)
return -1;
}
int battery_mv = (mv_sum / samples) * BATTERY_DIVIDER_RATIO;
/* Only trim if there's enough left afterward to still be a
* meaningful average -- falls back to a plain average of whatever
* came in on a wake where most reads failed. */
qsort(mv_samples, samples, sizeof(int), int_cmp);
int trim = (samples > 2 * BATTERY_TRIM) ? BATTERY_TRIM : 0;
int mv_sum = 0;
int kept = 0;
for (int i = trim; i < samples - trim; i++) {
mv_sum += mv_samples[i];
kept++;
}
int battery_mv = (mv_sum / kept) * BATTERY_DIVIDER_RATIO;
if (battery_mv < BATTERY_MV_MIN || battery_mv > BATTERY_MV_MAX) {
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
return -1;
+1 -1
View File
@@ -1 +1 @@
1.2.3
1.2.4
+10 -1
View File
@@ -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
+7 -1
View File
@@ -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