From a404d198a49659a174f203d3344a12227f72116e Mon Sep 17 00:00:00 2001 From: Ignace Date: Sat, 15 Aug 2026 15:52:11 +0200 Subject: [PATCH] battery-charge alerts using a rolling mean of the last 10 valid measurements. --- README.md | 7 +++++-- ups2rrd.py | 50 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 29ee425..9e67323 100644 --- a/README.md +++ b/README.md @@ -826,8 +826,8 @@ full-size PNG when a graph is clicked. The collector also sends an ntfy notification when: - The UPS status changes -- Battery charge crosses below 50% -- Battery charge crosses below 5% +- Mean battery charge over the last 10 measurements crosses below 50% +- Mean battery charge over the last 10 measurements crosses below 5% - Estimated battery runtime crosses below 5 minutes - Local Raspberry Pi shutdown is requested below 200 seconds of runtime @@ -835,6 +835,9 @@ Alert state is stored in `/var/lib/rrd/bx950mi-alert-state.json`, allowing changes to be detected across cron runs. The first run establishes a baseline without sending notifications. Threshold notifications can fire again after a value recovers above the threshold and later drops below it. +Battery-charge alerts start after 10 valid measurements have been collected; +using their rolling mean prevents a single erroneous `0%` report from causing +a false alarm. Raw measurements are still stored in the RRD and graphed. ### Automatic shutdown of the Plex server diff --git a/ups2rrd.py b/ups2rrd.py index 64bd5a6..3c19de2 100644 --- a/ups2rrd.py +++ b/ups2rrd.py @@ -70,6 +70,7 @@ SHUTDOWN_KNOWN_HOSTS = os.environ.get( SHUTDOWN_COMMAND = "sudo /usr/sbin/shutdown -h now" REMOTE_SHUTDOWN_RUNTIME_SECONDS = 5 * 60 LOCAL_SHUTDOWN_RUNTIME_SECONDS = 200 +BATTERY_CHARGE_MEAN_SAMPLES = 10 GRAPH_DIR = "/var/www/html/ups" HTML_FILE = os.path.join(GRAPH_DIR, "index.html") @@ -480,6 +481,33 @@ def crossed_below(previous, current, threshold): ) +def battery_charge_history(previous, current_charge): + """Return up to the last 10 valid battery-charge measurements.""" + + history = previous.get("battery_charge_history", []) + if not isinstance(history, list): + history = [] + history = [ + value + for value in history + if isinstance(value, (int, float)) and not isinstance(value, bool) + ] + + if current_charge is not None: + history.append(current_charge) + + return history[-BATTERY_CHARGE_MEAN_SAMPLES:] + + +def complete_charge_mean(history): + """Return the mean only when a complete charge window is available.""" + + if len(history) < BATTERY_CHARGE_MEAN_SAMPLES: + return None + + return sum(history) / len(history) + + def shutdown_remote_server( host=SHUTDOWN_HOST, user=SHUTDOWN_USER, @@ -544,6 +572,10 @@ def process_alerts(current): current_status = current.get("status") previous_charge = previous.get("battery_charge") current_charge = current.get("battery_charge") + previous_charge_history = battery_charge_history(previous, None) + charge_history = battery_charge_history(previous, current_charge) + previous_charge_mean = complete_charge_mean(previous_charge_history) + current_charge_mean = complete_charge_mean(charge_history) previous_runtime = previous.get("battery_runtime") current_runtime = current.get("battery_runtime") shutdown_sent = previous.get("remote_shutdown_sent", False) @@ -560,15 +592,22 @@ def process_alerts(current): f"UPS status changed: {previous_status} -> {current_status}" ) - if crossed_below(previous_charge, current_charge, 50): + if current_charge_mean is not None and ( + previous_charge_mean is None + or crossed_below(previous_charge_mean, current_charge_mean, 50) + ) and current_charge_mean < 50: notify( - f"UPS battery charge is below 50%: {current_charge:.1f}%" + "UPS mean battery charge over the last 10 measurements " + f"is below 50%: {current_charge_mean:.1f}%" ) - if crossed_below(previous_charge, current_charge, 5): + if current_charge_mean is not None and ( + previous_charge_mean is None + or crossed_below(previous_charge_mean, current_charge_mean, 5) + ) and current_charge_mean < 5: notify( - "UPS battery charge is critically low " - f"(below 5%): {current_charge:.1f}%" + "UPS mean battery charge over the last 10 measurements " + f"is critically low (below 5%): {current_charge_mean:.1f}%" ) if crossed_below(previous_runtime, current_runtime, 5 * 60): @@ -633,6 +672,7 @@ def process_alerts(current): "battery_charge": ( current_charge if current_charge is not None else previous_charge ), + "battery_charge_history": charge_history, "battery_runtime": ( current_runtime if current_runtime is not None else previous_runtime ),