diff --git a/server/app/quiet_hours.py b/server/app/quiet_hours.py index d09005f..b2c51ab 100644 --- a/server/app/quiet_hours.py +++ b/server/app/quiet_hours.py @@ -114,6 +114,18 @@ def in_quiet_hours(cfg) -> bool: return in_quiet +def quiet_span_s(cfg) -> int: + """Seconds per day quiet hours keeps the device asleep -- 0 when + disabled. Used by common.py's battery-remaining estimate to turn a + per-wake battery cost into a wall-clock duration: quiet hours cuts + how many wakes happen per day without changing what any one wake + costs, so it belongs in the wakes-per-day math, not the per-wake + rate itself.""" + if not cfg.quiet_hours_enabled: + return 0 + return _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end) + + def max_expected_gap_s(cfg) -> int: """Longest gap between wakes the device might legitimately have -- normally just refresh_interval_s, but quiet hours can make the real diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index aeb3610..fc05237 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -232,7 +232,7 @@ def api_queue( "firmware_available": cfg.firmware_available_version, "battery_percent": cfg.battery_percent, "battery_as_of": cfg.battery_as_of, - "battery_estimate_s": battery_estimate_s(cfg), + "battery_estimate_s": battery_estimate_s(cfg, db), "controller_id": cfg.controlled_by_user_id, "controller": ( (cfg.controlled_by.display_name or cfg.controlled_by.username) diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 8f95fb0..3d2dab3 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -19,7 +19,7 @@ from .. import calendar_feed, quiet_hours from ..db import frame_locked from ..image_pipeline import render_frame from ..immich_client import ImmichClient -from ..models import Frame, User, UserFrame +from ..models import BatteryLog, Frame, User, UserFrame logger = logging.getLogger(__name__) @@ -38,8 +38,8 @@ RECHARGE_JUMP_PCT = 5 # a report this much above the recent baseline = battery # 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 +BATTERY_ESTIMATE_SAMPLE_COUNT = 100 # most recent battery_log rows considered +MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average # "Overdue" threshold multiplier: the device should check in roughly every # refresh_interval_s; give it half again as long before flagging it. @@ -115,22 +115,78 @@ def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict dither_strength=frame.dither_strength, manage=manage) -def battery_estimate_s(frame: Frame) -> int | None: - """Linear remaining-time estimate from the current discharge cycle's - observed rate, or None when there's not enough signal to be honest - about (too little time observed, or too little drop -- a flat line - extrapolates to garbage).""" - hist = frame.battery_history - if len(hist) < 2: +def _avg_wake_interval_s(frame: Frame) -> float: + """Average wall-clock seconds between wakes: refresh_interval_s + scaled up for however much of each day quiet hours removes from the + wake schedule entirely -- fewer wakes/day, not a cheaper wake. This + is what lets battery_estimate_s convert a per-wake drop rate into a + remaining-time estimate that reacts to both settings immediately, + rather than only after enough new history accumulates under them.""" + active_day_s = max(1, 86400 - quiet_hours.quiet_span_s(frame)) + interval_s = max(1, frame.refresh_interval_s) + wakes_per_day = max(1, active_day_s // interval_s) + return 86400 / wakes_per_day + + +def battery_estimate_s(frame: Frame, db: Session) -> int | None: + """Remaining-time estimate from a recency-weighted average of the + *per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT + rows of the permanent battery_log table -- not just the current + discharge cycle's battery_history, which resets to empty on every + recharge and so often doesn't hold enough signal on its own even + though the frame has plenty of history overall. + + Consecutive reports are assumed to be consecutive wakes (firmware + reports battery on every wake while on battery), so each step's + (prev_percent - next_percent) is that wake's cost. A step where + percent went *up* is a recharge, not negative drain, and is skipped + entirely rather than folded in as a weird outlier; a flat step + (0% change) still counts as a real, cheap wake -- excluding those + would systematically overstate the per-wake cost by only counting + the wakes that happened to tick the percentage down. Steps are + weighted linearly by recency (step i of n gets weight i, 1-indexed) + so a recent change in usage pattern shows up quickly instead of + being washed out by a long flat history. + + The resulting %/wake rate is then converted to wall-clock time using + the frame's *current* refresh_interval_s and quiet-hours settings + (see _avg_wake_interval_s), not whatever cadence produced the + historical data -- so halving refresh_interval_s roughly halves the + estimate immediately (not exactly halves: quiet hours removes a + fixed wake-free window from every day regardless of interval, which + is the "other things going on" that keeps the scaling sublinear).""" + if frame.battery_percent < 0: return None - first_ts, first_pct = hist[0] - last_ts, last_pct = hist[-1] - span = last_ts - first_ts - drop = first_pct - last_pct - if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT: + rows = db.execute( + select(BatteryLog.percent) + .where(BatteryLog.frame_id == frame.id) + .order_by(BatteryLog.ts.desc()) + .limit(BATTERY_ESTIMATE_SAMPLE_COUNT) + ).scalars().all() + if len(rows) < MIN_ESTIMATE_SAMPLES + 1: return None - rate = drop / span # percent per second - return int(last_pct / rate) + percents = list(reversed(rows)) # chronological order + + weighted_drop_total = 0.0 + weight_total = 0.0 + valid_steps = 0 + for i in range(1, len(percents)): + prev_pct, next_pct = percents[i - 1], percents[i] + if next_pct > prev_pct: + continue # recharge (or a swap) -- not a discharge sample + weight = i # later steps (larger i) count more + weighted_drop_total += weight * (prev_pct - next_pct) + weight_total += weight + valid_steps += 1 + + if valid_steps < MIN_ESTIMATE_SAMPLES or weight_total <= 0: + return None + avg_drop_per_wake = weighted_drop_total / weight_total + if avg_drop_per_wake <= 0: + return None # flat -- no honest rate to extrapolate + + remaining_wakes = frame.battery_percent / avg_drop_per_wake + return int(remaining_wakes * _avg_wake_interval_s(frame)) def shell_context(request, db: Session, user, active_frame: Frame | None = None, diff --git a/server/app/static/device_status_bar.js b/server/app/static/device_status_bar.js index 3c53b79..2ad828b 100644 --- a/server/app/static/device_status_bar.js +++ b/server/app/static/device_status_bar.js @@ -27,9 +27,9 @@ function renderDeviceStatusBar(device) { if (device.battery) { rows.push(['Battery', `${device.battery.percent}%`, false]); // Shown as soon as there's any battery reading at all, even before - // battery_estimate_s can compute a rate (needs 2h+ span and a 2%+ - // drop within the current discharge cycle -- see common.py) -- so - // it's clear the number is coming, not that the feature is broken. + // battery_estimate_s has enough discharge samples in battery_log to + // average (see common.py) -- so it's clear the number is coming, not + // that the feature is broken. const hasEstimate = device.battery_estimate_s !== null && device.battery_estimate_s !== undefined; rows.push([ 'Est. battery life left',