#!/bin/bash
#
# Script de test lunar automat baterie UPS
# Rulează pe 1 ale fiecărei luni la 00:00
# Trimite raport prin notificările Proxmox (PVE::Notify)
#
# IMPORTANT: Timing-ul de citire este CRITIC!
# - Battery.charge scade DOAR între 10-40 secunde după pornirea testului
# - UPS actualizează valorile cu delay de 5-10 secunde
#
# Creat: 2025-10-06
# Autor: Claude Code
LOGFILE="/var/log/ups-monthly-test.log"
TRENDFILE="/var/log/ups-battery-trend.csv"
UPS_NAME="nutdev1"
UPS_USER="admin"
UPS_PASS="parola99"
TEMPLATE_DIR="/etc/pve/notification-templates/default"
START_TIME=$(date +%s)
HOSTNAME=$(hostname)
FQDN=$(hostname -f)
# Funcție logging
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a $LOGFILE
}
# Câte rulări formează linia de bază și câte intră în fereastra recentă.
# Baza = primele rulări înregistrate, adică bateria în starea ei cea mai bună
# pe care o avem măsurată. Fereastra recentă e mediană pe 3, ca o lună
# atipică (frig, sarcină mare) să nu declanșeze singură o alarmă.
BASELINE_RUNS=6
TREND_WINDOW=3
# Adaugă rularea curentă în fișierul de tendință
append_trend() {
if [ ! -f "$TRENDFILE" ]; then
echo "date,voltage_drop,min_voltage,charge_drop,before_load,after_load,duration" > "$TRENDFILE"
fi
echo "$(date '+%Y-%m-%d'),$1,$2,$3,$4,$5,$6" >> "$TRENDFILE"
}
# Numărul de rulări înregistrate (fără antet)
trend_rows() {
tail -n +2 "$TRENDFILE" 2>/dev/null | grep -c ',' || echo 0
}
# Mediana coloanei voltage_drop. $1 = head|tail (cele mai vechi / cele mai noi),
# $2 = câte rânduri. head/tail se aplică pe ordinea cronologică, sortarea abia
# după — altfel s-ar lua cele mai mici valori, nu cele mai vechi.
trend_median() {
local mode="$1" n="$2"
tail -n +2 "$TRENDFILE" 2>/dev/null | cut -d, -f2 | grep -E '^[0-9]+\.?[0-9]*$' \
| { if [ "$mode" = "head" ]; then head -n "$n"; else tail -n "$n"; fi; } \
| sort -g \
| awk '{v[NR]=$1} END {
if (NR == 0) { print "" ; exit }
if (NR % 2) printf "%.2f", v[(NR+1)/2]
else printf "%.2f", (v[NR/2] + v[NR/2+1]) / 2
}'
}
# Funcție pentru crearea template-urilor de notificare
create_templates() {
mkdir -p $TEMPLATE_DIR
# Template: Subject
cat > "$TEMPLATE_DIR/ups-battery-test-subject.txt.hbs" << 'EOFTEMPLATE'
[{{ hostname }}] UPS Battery Test - {{ health_status }}
EOFTEMPLATE
# Template: Body Text
cat > "$TEMPLATE_DIR/ups-battery-test-body.txt.hbs" << 'EOFTEMPLATE'
========================================
UPS MONTHLY BATTERY TEST REPORT
========================================
Hostname: {{ hostname }}
Date: {{ test_date }}
UPS: {{ ups_name }}
BATTERY HEALTH: {{ health_status }}
{{ health_emoji }} {{ health_description }}
TEST RESULTS:
-------------
Battery Charge Drop: {{ charge_drop }}%
Battery Voltage Drop: {{ voltage_drop }}V
Baseline Voltage Drop: {{ baseline_drop }}V ({{ baseline_runs }} rulari de referinta)
Recent Median (3 run): {{ recent_drop }}V
Trend vs Baseline: {{ trend_ratio }}x
Minimum Charge Reached: {{ min_charge }}%
Minimum Voltage: {{ min_voltage }}V
Recovery Time: {{ recovery_time }}s
BEFORE TEST:
- Battery Charge: {{ before_charge }}%
- Battery Voltage: {{ before_voltage }}V
- UPS Load: {{ before_load }}%
AFTER TEST ({{ test_duration }}s):
- Battery Charge: {{ after_charge }}%
- Battery Voltage: {{ after_voltage }}V
- UPS Load: {{ after_load }}%
RECOMMENDATIONS:
{{ recommendations }}
========================================
Script: /opt/scripts/ups-monthly-test.sh
Log: /var/log/ups-monthly-test.log
========================================
EOFTEMPLATE
# Template: Body HTML
cat > "$TEMPLATE_DIR/ups-battery-test-body.html.hbs" << 'EOFTEMPLATE'
[BATTERY] UPS Battery Test Report
Hostname: {{ hostname }}
Date: {{ test_date }}
UPS: {{ ups_name }}
{{ health_emoji }} Battery Health: {{ health_status }}
{{ health_description }}
Test Metrics
Charge Drop
{{ charge_drop }}%
Voltage Drop
{{ voltage_drop }}V
Min Charge
{{ min_charge }}%
Recovery Time
{{ recovery_time }}s
Trend vs Baseline
{{ trend_ratio }}x
Baseline Drop
{{ baseline_drop }}V
Detailed Measurements
| Parameter |
Before Test |
After Test |
| Battery Charge |
{{ before_charge }}% |
{{ after_charge }}% |
| Battery Voltage |
{{ before_voltage }}V |
{{ after_voltage }}V |
| UPS Load |
{{ before_load }}% |
{{ after_load }}% |
📋 Recommendations
{{{ recommendations }}}
EOFTEMPLATE
log "Templates created in $TEMPLATE_DIR/"
}
# Verifică și creează template-urile dacă nu există
# Template-urile se rescriu și dacă sunt dintr-o versiune veche, fără câmpurile
# de tendință — altfel raportul ar ieși cu metrici lipsă.
if [ ! -f "$TEMPLATE_DIR/ups-battery-test-subject.txt.hbs" ] || \
! grep -q 'trend_ratio' "$TEMPLATE_DIR/ups-battery-test-body.txt.hbs" 2>/dev/null; then
log "Creating/updating notification templates..."
create_templates
fi
log "========================================"
log "UPS MONTHLY BATTERY TEST - START"
log "========================================"
# 1. Verificare status UPS înainte de test
log "Step 1: Verificare status UPS înainte de test..."
BEFORE_STATUS=$(upsc $UPS_NAME ups.status 2>/dev/null)
BEFORE_CHARGE=$(upsc $UPS_NAME battery.charge 2>/dev/null)
BEFORE_VOLTAGE=$(upsc $UPS_NAME battery.voltage 2>/dev/null)
BEFORE_LOAD=$(upsc $UPS_NAME ups.load 2>/dev/null)
log " Status: $BEFORE_STATUS"
log " Battery Charge: $BEFORE_CHARGE%"
log " Battery Voltage: $BEFORE_VOLTAGE V"
log " Load: $BEFORE_LOAD%"
# Verifică dacă UPS este online
if [[ $BEFORE_STATUS != *"OL"* ]]; then
log "ERROR: UPS nu este online! Status: $BEFORE_STATUS"
log "Test ANULAT"
exit 1
fi
# Verifică încărcare baterie
if [ "$BEFORE_CHARGE" -lt 95 ]; then
log "WARNING: Baterie nu este complet încărcată ($BEFORE_CHARGE%)"
fi
# 2. Pornire test baterie
log ""
log "Step 2: Pornire test baterie..."
TEST_START_TIME=$(date +%s)
upscmd -u $UPS_USER -p $UPS_PASS $UPS_NAME test.battery.start.quick 2>&1 | tee -a $LOGFILE
if [ ${PIPESTATUS[0]} -eq 0 ]; then
log "Test baterie pornit cu succes!"
else
log "ERROR: Nu am putut porni testul de baterie!"
exit 1
fi
# 3. TIMING CRITIC: Așteptare 10-15 secunde pentru ca charge să scadă
log ""
log "Step 3: Monitorizare test baterie (timing critic pentru charge drop)..."
MIN_CHARGE=$BEFORE_CHARGE
MIN_VOLTAGE=$BEFORE_VOLTAGE
CHARGE_AT_15S=$BEFORE_CHARGE
VOLTAGE_AT_15S=$BEFORE_VOLTAGE
# Primele 5 secunde - inițializare test
sleep 5
# 10-40 secunde - fereastra critică când charge scade
for i in {1..7}; do
CURRENT_CHARGE=$(upsc $UPS_NAME battery.charge 2>/dev/null)
CURRENT_VOLTAGE=$(upsc $UPS_NAME battery.voltage 2>/dev/null)
# Capturează minimul
if [ ! -z "$CURRENT_CHARGE" ] && [ "$CURRENT_CHARGE" -lt "$MIN_CHARGE" ]; then
MIN_CHARGE=$CURRENT_CHARGE
fi
if [ ! -z "$CURRENT_VOLTAGE" ]; then
MIN_VOLTAGE=$(echo "$CURRENT_VOLTAGE $MIN_VOLTAGE" | awk '{if ($1 < $2) print $1; else print $2}')
fi
# Citire la 15 secunde (punct optim)
if [ $i -eq 2 ]; then
CHARGE_AT_15S=$CURRENT_CHARGE
VOLTAGE_AT_15S=$CURRENT_VOLTAGE
log " [15s CRITICAL] Charge: $CURRENT_CHARGE% | Voltage: $CURRENT_VOLTAGE V"
else
log " [$((5 + i*5))s] Charge: $CURRENT_CHARGE% | Voltage: $CURRENT_VOLTAGE V"
fi
sleep 5
done
TEST_END_TIME=$(date +%s)
TEST_DURATION=$((TEST_END_TIME - TEST_START_TIME))
log " Minimum Charge: $MIN_CHARGE%"
log " Minimum Voltage: $MIN_VOLTAGE V"
# 4. Așteptare recuperare și citire finală
log ""
log "Step 4: Așteptare recuperare baterie (15 secunde)..."
sleep 15
AFTER_STATUS=$(upsc $UPS_NAME ups.status 2>/dev/null)
AFTER_CHARGE=$(upsc $UPS_NAME battery.charge 2>/dev/null)
AFTER_VOLTAGE=$(upsc $UPS_NAME battery.voltage 2>/dev/null)
AFTER_LOAD=$(upsc $UPS_NAME ups.load 2>/dev/null)
log " Status: $AFTER_STATUS"
log " Battery Charge: $AFTER_CHARGE%"
log " Battery Voltage: $AFTER_VOLTAGE V"
log " Load: $AFTER_LOAD%"
# 5. Calcul metrici
CHARGE_DROP=$((BEFORE_CHARGE - MIN_CHARGE))
VOLTAGE_DROP=$(echo "$BEFORE_VOLTAGE - $MIN_VOLTAGE" | bc 2>/dev/null || echo "0")
# Rotunjire voltage drop la 2 zecimale
VOLTAGE_DROP=$(printf "%.2f" $VOLTAGE_DROP 2>/dev/null || echo $VOLTAGE_DROP)
log ""
log "Step 5: Analiza rezultate test..."
log " Durată test: $TEST_DURATION secunde"
log " Scădere încărcare: $CHARGE_DROP% (de la $BEFORE_CHARGE% la $MIN_CHARGE%)"
log " Scădere tensiune: $VOLTAGE_DROP V (de la $BEFORE_VOLTAGE V la $MIN_VOLTAGE V)"
# 6. Evaluare sănătate baterie — pe tendință, nu pe prag absolut
#
# De ce nu pe prag absolut: driverul nu măsoară încărcarea, o deduce liniar din
# tensiune între battery.voltage.low (20.8V) și .high (26.0V). Un CHARGE_DROP de
# 15% înseamnă deci o cădere sub ~25.2V, iar 55% înseamnă sub ~23.1V — într-un
# test de 40 de secunde. O baterie ajunsă acolo e deja moartă. Pragurile vechi
# nu se puteau atinge, așa că verdictul a ieșit EXCELLENT 11 luni la rând.
#
# Ce se măsoară în schimb: căderea de tensiune sub sarcină, comparată cu linia de
# bază a acestei baterii. Rezistența internă crește pe măsură ce bateria
# îmbătrânește, deci raportul crește lent și dă avertisment din timp.
append_trend "$VOLTAGE_DROP" "$MIN_VOLTAGE" "$CHARGE_DROP" "$BEFORE_LOAD" "$AFTER_LOAD" "$TEST_DURATION"
TREND_ROWS=$(trend_rows)
BASELINE_DROP=$(trend_median head $BASELINE_RUNS)
RECENT_DROP=$(trend_median tail $TREND_WINDOW)
BASELINE_RUNS_USED=$BASELINE_RUNS
if [ "$TREND_ROWS" -lt "$BASELINE_RUNS" ]; then
BASELINE_RUNS_USED=$TREND_ROWS
fi
TREND_RATIO="n/a"
if [ -n "$BASELINE_DROP" ] && [ -n "$RECENT_DROP" ]; then
TREND_RATIO=$(awk -v r="$RECENT_DROP" -v b="$BASELINE_DROP" \
'BEGIN { if (b > 0) printf "%.2f", r / b; else print "n/a" }')
fi
log ""
log "Step 5b: Tendință față de linia de bază..."
log " Rulări înregistrate: $TREND_ROWS (fișier: $TRENDFILE)"
log " Linie de bază (mediana primelor $BASELINE_RUNS_USED): ${BASELINE_DROP:-n/a} V"
log " Recent (mediana ultimelor $TREND_WINDOW): ${RECENT_DROP:-n/a} V"
log " Raport: ${TREND_RATIO}x"
BATTERY_HEALTH="UNKNOWN"
HEALTH_CLASS="fair"
HEALTH_EMOJI="[INFO]"
HEALTH_DESCRIPTION=""
RECOMMENDATIONS=""
# Praguri de tensiune absolută, ca plasă de siguranță. Nu înlocuiesc tendința —
# prind cazul în care bateria cedează brusc, înainte să apuce media să urce.
FLOOR=$(awk -v v="$MIN_VOLTAGE" 'BEGIN {
if (v == "" ) { print "none" }
else if (v < 24.0) { print "critical" }
else if (v < 25.0) { print "warn" }
else { print "none" }
}')
if [ "$TREND_ROWS" -lt $((BASELINE_RUNS + TREND_WINDOW)) ] && [ "$FLOOR" = "none" ]; then
BATTERY_HEALTH="BASELINE"
HEALTH_CLASS="good"
HEALTH_EMOJI="[INFO]"
HEALTH_DESCRIPTION="Se colectează linia de bază. Verdictul pe tendință apare după $((BASELINE_RUNS + TREND_WINDOW)) rulări (acum: $TREND_ROWS)."
RECOMMENDATIONS="- Istoric insuficient pentru o comparație pe tendință
- Rulări înregistrate: $TREND_ROWS din $((BASELINE_RUNS + TREND_WINDOW)) necesare
- Nicio acțiune necesară
"
log " Sănătate baterie: BASELINE (istoric insuficient)"
elif [ "$FLOOR" = "critical" ]; then
BATTERY_HEALTH="POOR"
HEALTH_CLASS="poor"
HEALTH_EMOJI="[CRITICAL]"
HEALTH_DESCRIPTION="Tensiunea a coborât sub 24.0V într-un test de $TEST_DURATION secunde. Bateria nu mai susține sarcina."
RECOMMENDATIONS="- 🔴 URGENT: înlocuiește bateria acum
- Minim atins: ${MIN_VOLTAGE}V (cutoff UPS: 20.8V)
- Autonomia reală e de ordinul secundelor, nu al minutelor
- Shutdown-ul orchestrat s-ar putea să nu apuce să termine
"
log " Sănătate baterie: SLABĂ (minim ${MIN_VOLTAGE}V < 24.0V) - NECESITĂ ÎNLOCUIRE!"
elif [ "$TREND_RATIO" = "n/a" ]; then
BATTERY_HEALTH="UNKNOWN"
HEALTH_CLASS="fair"
HEALTH_EMOJI="[WARNING]"
HEALTH_DESCRIPTION="Nu s-a putut calcula tendința (fișier de tendință gol sau corupt)."
RECOMMENDATIONS="- Verifică $TRENDFILE
- Verifică dacă upsc întoarce battery.voltage
"
log " Sănătate baterie: NECUNOSCUTĂ (tendință incalculabilă)"
else
RATIO_CLASS=$(awk -v r="$TREND_RATIO" -v f="$FLOOR" 'BEGIN {
c = (r < 1.25) ? 1 : (r < 1.50) ? 2 : (r < 2.00) ? 3 : 4
if (f == "warn" && c < 3) c = 3
print c
}')
case "$RATIO_CLASS" in
1)
BATTERY_HEALTH="EXCELLENT"
HEALTH_CLASS="excellent"
HEALTH_EMOJI="[OK]"
HEALTH_DESCRIPTION="Căderea de tensiune sub sarcină e la nivelul liniei de bază (${TREND_RATIO}x). Bateria nu dă semne de îmbătrânire."
RECOMMENDATIONS="- ✅ Bateria e sănătoasă
- Recent ${RECENT_DROP}V față de baza ${BASELINE_DROP}V
- Nicio acțiune necesară
"
log " Sănătate baterie: EXCELENTĂ (${TREND_RATIO}x față de bază)"
;;
2)
BATTERY_HEALTH="GOOD"
HEALTH_CLASS="good"
HEALTH_EMOJI="[OK]"
HEALTH_DESCRIPTION="Căderea de tensiune a crescut cu 25-50% față de linia de bază (${TREND_RATIO}x). Uzură normală."
RECOMMENDATIONS="- Îmbătrânire vizibilă, dar în limite normale
- Recent ${RECENT_DROP}V față de baza ${BASELINE_DROP}V
- Urmărește lunar; la 1.5x se planifică înlocuirea
"
log " Sănătate baterie: BUNĂ (${TREND_RATIO}x față de bază)"
;;
3)
BATTERY_HEALTH="FAIR"
HEALTH_CLASS="fair"
HEALTH_EMOJI="[WARNING]"
HEALTH_DESCRIPTION="Căderea de tensiune s-a mărit de ${TREND_RATIO}x față de linia de bază. Rezistența internă a crescut semnificativ."
RECOMMENDATIONS="- ⚠️ Planifică înlocuirea în 3-6 luni
- Recent ${RECENT_DROP}V față de baza ${BASELINE_DROP}V
- Minim atins în test: ${MIN_VOLTAGE}V
- Comandă bateriile de schimb (2x 12V 7Ah)
"
log " Sănătate baterie: ACCEPTABILĂ (${TREND_RATIO}x față de bază)"
;;
*)
BATTERY_HEALTH="POOR"
HEALTH_CLASS="poor"
HEALTH_EMOJI="[CRITICAL]"
HEALTH_DESCRIPTION="Căderea de tensiune s-a dublat față de linia de bază (${TREND_RATIO}x). Bateria și-a pierdut capacitatea."
RECOMMENDATIONS="- 🔴 URGENT: înlocuiește bateria
- Recent ${RECENT_DROP}V față de baza ${BASELINE_DROP}V
- Autonomia nu mai acoperă procedura de shutdown orchestrat
- Vezi proxmox/cluster/ups/docs/UPS-BATTERY-REPLACEMENT.md
"
log " Sănătate baterie: SLABĂ (${TREND_RATIO}x față de bază) - NECESITĂ ÎNLOCUIRE!"
;;
esac
fi
# 7. Monitorizare recuperare (30 secunde)
log ""
log "Step 6: Monitorizare recuperare baterie..."
RECOVERY_START=$(date +%s)
sleep 30
RECOVERY_CHARGE=$(upsc $UPS_NAME battery.charge 2>/dev/null)
RECOVERY_TIME=$(($(date +%s) - RECOVERY_START))
log " Charge după $RECOVERY_TIME secunde: $RECOVERY_CHARGE%"
# 8. Calculează timpul total
END_TIME=$(date +%s)
RUNTIME=$((END_TIME - START_TIME))
# 9. Determină severity pentru notificare
if [ "$BATTERY_HEALTH" = "EXCELLENT" ] || [ "$BATTERY_HEALTH" = "GOOD" ] || [ "$BATTERY_HEALTH" = "BASELINE" ]; then
SEVERITY="info"
elif [ "$BATTERY_HEALTH" = "FAIR" ] || [ "$BATTERY_HEALTH" = "UNKNOWN" ]; then
SEVERITY="warning"
else
SEVERITY="error"
fi
# 10. Trimite notificarea prin PVE::Notify
log ""
log "Step 7: Trimitere notificare prin PVE::Notify..."
# Escape pentru Perl heredoc
RECOMMENDATIONS_ESCAPED=$(echo "$RECOMMENDATIONS" | sed "s/'/\\'/g")
perl -I/usr/share/perl5 << EOFPERL
use strict;
use warnings;
use PVE::Notify;
my \$template_data = {
'hostname' => '$FQDN',
'test_date' => '$(date '+%Y-%m-%d %H:%M:%S')',
'ups_name' => '$UPS_NAME',
'health_status' => '$BATTERY_HEALTH',
'health_class' => '$HEALTH_CLASS',
'health_emoji' => '$HEALTH_EMOJI',
'health_description' => '$HEALTH_DESCRIPTION',
'charge_drop' => '$CHARGE_DROP',
'voltage_drop' => '$VOLTAGE_DROP',
'baseline_drop' => '${BASELINE_DROP:-n/a}',
'recent_drop' => '${RECENT_DROP:-n/a}',
'trend_ratio' => '$TREND_RATIO',
'baseline_runs' => '$BASELINE_RUNS_USED',
'min_charge' => '$MIN_CHARGE',
'min_voltage' => '$MIN_VOLTAGE',
'before_charge' => '$BEFORE_CHARGE',
'before_voltage' => '$BEFORE_VOLTAGE',
'before_load' => '$BEFORE_LOAD',
'after_charge' => '$AFTER_CHARGE',
'after_voltage' => '$AFTER_VOLTAGE',
'after_load' => '$AFTER_LOAD',
'test_duration' => '$TEST_DURATION',
'recovery_time' => '$RECOVERY_TIME',
'recommendations' => '$RECOMMENDATIONS_ESCAPED'
};
my \$fields = {
'hostname' => '$HOSTNAME',
'type' => 'ups-battery-test',
'health' => '$BATTERY_HEALTH'
};
eval {
PVE::Notify::notify('$SEVERITY', 'ups-battery-test', \$template_data, \$fields);
print "Notification sent successfully\\n";
};
if (\$@) {
print STDERR "Failed to send notification: \$@\\n";
exit 1;
}
EOFPERL
PERL_EXIT_CODE=$?
if [ $PERL_EXIT_CODE -eq 0 ]; then
log "Notificare trimisă cu succes prin PVE::Notify"
else
log "ERROR: Notificarea a eșuat (exit code: $PERL_EXIT_CODE)"
fi
# 11. Finalizare
log ""
log "========================================"
log "UPS MONTHLY BATTERY TEST - COMPLETE"
log "Sănătate baterie: $BATTERY_HEALTH"
log "Scădere încărcare: $CHARGE_DROP%"
log "Scădere tensiune: $VOLTAGE_DROP V"
log "Linie de bază: ${BASELINE_DROP:-n/a} V | recent: ${RECENT_DROP:-n/a} V | raport: ${TREND_RATIO}x"
log "Timp total: $RUNTIME secunde"
log "========================================"
exit 0