with alerts
This commit is contained in:
@@ -821,6 +821,20 @@ weekly dashboard at `/ups/week.html` shows one-week history for every collected
|
|||||||
value. The pages link to each other, refresh every 60 seconds, and open a
|
value. The pages link to each other, refresh every 60 seconds, and open a
|
||||||
full-size PNG when a graph is clicked.
|
full-size PNG when a graph is clicked.
|
||||||
|
|
||||||
|
## UPS notifications
|
||||||
|
|
||||||
|
The collector also sends an ntfy notification when:
|
||||||
|
|
||||||
|
- The UPS status changes
|
||||||
|
- Battery charge crosses below 50%
|
||||||
|
- Battery charge crosses below 5%
|
||||||
|
- Estimated battery runtime crosses below 5 minutes
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 19. Testing the Python program
|
# 19. Testing the Python program
|
||||||
|
|||||||
+116
-1
@@ -34,11 +34,13 @@ NUT UPS name:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import rrdtool
|
import rrdtool
|
||||||
from PyNUTClient import PyNUT
|
from PyNUTClient import PyNUT
|
||||||
|
from ntfy_notify import send_notification
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -49,6 +51,7 @@ UPS_NAME = "BX950MI"
|
|||||||
|
|
||||||
RRD_DIR = "/var/lib/rrd"
|
RRD_DIR = "/var/lib/rrd"
|
||||||
RRD_FILE = os.path.join(RRD_DIR, "bx950mi.rrd")
|
RRD_FILE = os.path.join(RRD_DIR, "bx950mi.rrd")
|
||||||
|
ALERT_STATE_FILE = os.path.join(RRD_DIR, "bx950mi-alert-state.json")
|
||||||
|
|
||||||
GRAPH_DIR = "/var/www/html/ups"
|
GRAPH_DIR = "/var/www/html/ups"
|
||||||
HTML_FILE = os.path.join(GRAPH_DIR, "index.html")
|
HTML_FILE = os.path.join(GRAPH_DIR, "index.html")
|
||||||
@@ -400,6 +403,116 @@ def update_rrd():
|
|||||||
f"nominal={realpower_nominal}",
|
f"nominal={realpower_nominal}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"battery_charge": battery_charge,
|
||||||
|
"battery_runtime": battery_runtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Notifications
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_alert_state():
|
||||||
|
"""Load values saved by the previous collector run."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(ALERT_STATE_FILE, encoding="utf-8") as state_file:
|
||||||
|
return json.load(state_file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"Could not read alert state: {exc}", file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_alert_state(state):
|
||||||
|
"""Atomically save values for the next collector run."""
|
||||||
|
|
||||||
|
temporary_file = f"{ALERT_STATE_FILE}.tmp"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(temporary_file, "w", encoding="utf-8") as state_file:
|
||||||
|
json.dump(state, state_file, indent=2, sort_keys=True)
|
||||||
|
state_file.write("\n")
|
||||||
|
os.replace(temporary_file, ALERT_STATE_FILE)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Could not save alert state: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def notify(message):
|
||||||
|
"""Send an alert without stopping UPS data collection on failure."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_notification(message)
|
||||||
|
print(f"Notification sent: {message}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Notification failed: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def crossed_below(previous, current, threshold):
|
||||||
|
"""Return whether a numeric value crossed below a threshold."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
previous is not None
|
||||||
|
and current is not None
|
||||||
|
and previous >= threshold
|
||||||
|
and current < threshold
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_alerts(current):
|
||||||
|
"""Notify on status changes and configured threshold crossings."""
|
||||||
|
|
||||||
|
previous = load_alert_state()
|
||||||
|
previous_status = previous.get("status")
|
||||||
|
current_status = current.get("status")
|
||||||
|
previous_charge = previous.get("battery_charge")
|
||||||
|
current_charge = current.get("battery_charge")
|
||||||
|
previous_runtime = previous.get("battery_runtime")
|
||||||
|
current_runtime = current.get("battery_runtime")
|
||||||
|
|
||||||
|
# An empty state is the initial baseline, not an alert condition.
|
||||||
|
if previous:
|
||||||
|
if (
|
||||||
|
previous_status
|
||||||
|
and current_status
|
||||||
|
and current_status != previous_status
|
||||||
|
):
|
||||||
|
notify(
|
||||||
|
f"UPS status changed: {previous_status} -> {current_status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if crossed_below(previous_charge, current_charge, 50):
|
||||||
|
notify(
|
||||||
|
f"UPS battery charge is below 50%: {current_charge:.1f}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
if crossed_below(previous_charge, current_charge, 5):
|
||||||
|
notify(
|
||||||
|
"UPS battery charge is critically low "
|
||||||
|
f"(below 5%): {current_charge:.1f}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
if crossed_below(previous_runtime, current_runtime, 5 * 60):
|
||||||
|
notify(
|
||||||
|
"UPS battery runtime is below 5 minutes: "
|
||||||
|
f"{current_runtime / 60:.1f} minutes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep the last valid reading when NUT temporarily omits a value.
|
||||||
|
state = {
|
||||||
|
"status": current_status or previous_status,
|
||||||
|
"battery_charge": (
|
||||||
|
current_charge if current_charge is not None else previous_charge
|
||||||
|
),
|
||||||
|
"battery_runtime": (
|
||||||
|
current_runtime if current_runtime is not None else previous_runtime
|
||||||
|
),
|
||||||
|
}
|
||||||
|
save_alert_state(state)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Graph: UPS status
|
# Graph: UPS status
|
||||||
@@ -1109,7 +1222,9 @@ def create_dashboards():
|
|||||||
def main():
|
def main():
|
||||||
create_rrd()
|
create_rrd()
|
||||||
|
|
||||||
update_rrd()
|
current_values = update_rrd()
|
||||||
|
|
||||||
|
process_alerts(current_values)
|
||||||
|
|
||||||
create_graphs()
|
create_graphs()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user