with shutting down plex and raspberry if runtime falls below 300 and 200 secs
This commit is contained in:
@@ -829,12 +829,81 @@ The collector also sends an ntfy notification when:
|
||||
- Battery charge crosses below 50%
|
||||
- Battery charge crosses below 5%
|
||||
- Estimated battery runtime crosses below 5 minutes
|
||||
- Local Raspberry Pi shutdown is requested below 200 seconds of runtime
|
||||
|
||||
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.
|
||||
|
||||
### Automatic shutdown of the Plex server
|
||||
|
||||
When the UPS status is `OB` or `LB` and estimated runtime is below five
|
||||
minutes, the collector requests a shutdown of `plex.local` over SSH as user
|
||||
`ups`. The request is sent once per outage. A failed SSH request is retried by
|
||||
the next cron run, while a return to `OL` resets the shutdown state.
|
||||
|
||||
Generate a dedicated key on the UPS monitor:
|
||||
|
||||
```bash
|
||||
sudo install -d -m 700 /opt/ups/.ssh
|
||||
sudo ssh-keygen -t ed25519 -f /opt/ups/.ssh/plex_shutdown -N ""
|
||||
sudo ssh-keyscan -H plex.local | sudo tee /opt/ups/.ssh/known_hosts
|
||||
sudo chmod 600 /opt/ups/.ssh/plex_shutdown /opt/ups/.ssh/known_hosts
|
||||
```
|
||||
|
||||
Verify the scanned SSH host-key fingerprint against `plex.local` through a
|
||||
trusted channel before enabling automatic shutdown.
|
||||
|
||||
On `plex.local`, create the restricted account and authorize shutdown:
|
||||
|
||||
```bash
|
||||
sudo useradd --create-home --shell /bin/bash ups
|
||||
sudo visudo -f /etc/sudoers.d/ups-shutdown
|
||||
```
|
||||
|
||||
Add this exact sudoers rule:
|
||||
|
||||
```sudoers
|
||||
ups ALL=(root) NOPASSWD: /usr/sbin/shutdown -h now
|
||||
```
|
||||
|
||||
Add the public key from `/opt/ups/.ssh/plex_shutdown.pub` to
|
||||
`/home/ups/.ssh/authorized_keys` on `plex.local`, prefixed with a forced command
|
||||
and SSH restrictions:
|
||||
|
||||
```text
|
||||
restrict,command="sudo /usr/sbin/shutdown -h now" ssh-ed25519 AAAA... ups-shutdown
|
||||
```
|
||||
|
||||
Verify SSH before relying on the automatic action:
|
||||
|
||||
```bash
|
||||
sudo ssh -i /opt/ups/.ssh/plex_shutdown \
|
||||
-o UserKnownHostsFile=/opt/ups/.ssh/known_hosts ups@plex.local
|
||||
```
|
||||
|
||||
This test will shut down `plex.local`. The hostname, user, key, and known-hosts
|
||||
file can be overridden for the cron job with:
|
||||
|
||||
```text
|
||||
UPS_SHUTDOWN_HOST
|
||||
UPS_SHUTDOWN_USER
|
||||
UPS_SHUTDOWN_KEY
|
||||
UPS_SHUTDOWN_KNOWN_HOSTS
|
||||
```
|
||||
|
||||
### Automatic shutdown of the Raspberry Pi
|
||||
|
||||
When UPS status is `OB` or `LB` and estimated runtime drops below 200 seconds,
|
||||
the collector sends an ntfy warning and requests shutdown of the Raspberry Pi
|
||||
running the script. It calls systemd-logind through D-Bus using the `dbus-next`
|
||||
Python package; it does not invoke a shell command.
|
||||
|
||||
Because the collector runs from root's crontab, systemd-logind permits the
|
||||
power-off request. A failed request is retried by the next cron run, and the
|
||||
shutdown state resets after the UPS returns to `OL`.
|
||||
|
||||
---
|
||||
|
||||
# 19. Testing the Python program
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
PyNUTClient>=2.8.5
|
||||
dbus-next
|
||||
paramiko
|
||||
rrdtool
|
||||
|
||||
# The NUT server and USB driver are still required to communicate with the UPS.
|
||||
|
||||
+129
-1
@@ -33,12 +33,16 @@ NUT UPS name:
|
||||
BX950MI
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import paramiko
|
||||
import rrdtool
|
||||
from dbus_next.aio import MessageBus
|
||||
from dbus_next.constants import BusType
|
||||
from PyNUTClient import PyNUT
|
||||
from ntfy_notify import send_notification
|
||||
|
||||
@@ -53,6 +57,20 @@ RRD_DIR = "/var/lib/rrd"
|
||||
RRD_FILE = os.path.join(RRD_DIR, "bx950mi.rrd")
|
||||
ALERT_STATE_FILE = os.path.join(RRD_DIR, "bx950mi-alert-state.json")
|
||||
|
||||
SHUTDOWN_HOST = os.environ.get("UPS_SHUTDOWN_HOST", "plex.local")
|
||||
SHUTDOWN_USER = os.environ.get("UPS_SHUTDOWN_USER", "ups")
|
||||
SHUTDOWN_KEY_FILE = os.environ.get(
|
||||
"UPS_SHUTDOWN_KEY",
|
||||
"/opt/ups/.ssh/plex_shutdown",
|
||||
)
|
||||
SHUTDOWN_KNOWN_HOSTS = os.environ.get(
|
||||
"UPS_SHUTDOWN_KNOWN_HOSTS",
|
||||
"/opt/ups/.ssh/known_hosts",
|
||||
)
|
||||
SHUTDOWN_COMMAND = "sudo /usr/sbin/shutdown -h now"
|
||||
REMOTE_SHUTDOWN_RUNTIME_SECONDS = 5 * 60
|
||||
LOCAL_SHUTDOWN_RUNTIME_SECONDS = 200
|
||||
|
||||
GRAPH_DIR = "/var/www/html/ups"
|
||||
HTML_FILE = os.path.join(GRAPH_DIR, "index.html")
|
||||
WEEKLY_HTML_FILE = os.path.join(GRAPH_DIR, "week.html")
|
||||
@@ -462,6 +480,62 @@ def crossed_below(previous, current, threshold):
|
||||
)
|
||||
|
||||
|
||||
def shutdown_remote_server(
|
||||
host=SHUTDOWN_HOST,
|
||||
user=SHUTDOWN_USER,
|
||||
key_file=SHUTDOWN_KEY_FILE,
|
||||
known_hosts=SHUTDOWN_KNOWN_HOSTS,
|
||||
timeout=10,
|
||||
):
|
||||
"""Shut down a remote Linux server over restricted, verified SSH."""
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
client.load_host_keys(known_hosts)
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
|
||||
try:
|
||||
client.connect(
|
||||
hostname=host,
|
||||
username=user,
|
||||
key_filename=key_file,
|
||||
timeout=timeout,
|
||||
banner_timeout=timeout,
|
||||
auth_timeout=timeout,
|
||||
look_for_keys=False,
|
||||
allow_agent=False,
|
||||
)
|
||||
client.exec_command(SHUTDOWN_COMMAND, timeout=timeout)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
async def request_local_poweroff():
|
||||
"""Request a local power-off through systemd-logind over D-Bus."""
|
||||
|
||||
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
|
||||
|
||||
try:
|
||||
introspection = await bus.introspect(
|
||||
"org.freedesktop.login1",
|
||||
"/org/freedesktop/login1",
|
||||
)
|
||||
login = bus.get_proxy_object(
|
||||
"org.freedesktop.login1",
|
||||
"/org/freedesktop/login1",
|
||||
introspection,
|
||||
)
|
||||
manager = login.get_interface("org.freedesktop.login1.Manager")
|
||||
await manager.call_power_off(False)
|
||||
finally:
|
||||
bus.disconnect()
|
||||
|
||||
|
||||
def shutdown_local_server():
|
||||
"""Shut down the Linux server running this collector."""
|
||||
|
||||
asyncio.run(request_local_poweroff())
|
||||
|
||||
|
||||
def process_alerts(current):
|
||||
"""Notify on status changes and configured threshold crossings."""
|
||||
|
||||
@@ -472,6 +546,8 @@ def process_alerts(current):
|
||||
current_charge = current.get("battery_charge")
|
||||
previous_runtime = previous.get("battery_runtime")
|
||||
current_runtime = current.get("battery_runtime")
|
||||
shutdown_sent = previous.get("remote_shutdown_sent", False)
|
||||
local_shutdown_sent = previous.get("local_shutdown_sent", False)
|
||||
|
||||
# An empty state is the initial baseline, not an alert condition.
|
||||
if previous:
|
||||
@@ -501,6 +577,56 @@ def process_alerts(current):
|
||||
f"{current_runtime / 60:.1f} minutes"
|
||||
)
|
||||
|
||||
shutdown_required = (
|
||||
current_status in {"OB", "LB"}
|
||||
and current_runtime is not None
|
||||
and current_runtime < REMOTE_SHUTDOWN_RUNTIME_SECONDS
|
||||
)
|
||||
|
||||
if shutdown_required and not shutdown_sent:
|
||||
try:
|
||||
shutdown_remote_server()
|
||||
shutdown_sent = True
|
||||
message = (
|
||||
f"Remote shutdown requested for {SHUTDOWN_HOST}: "
|
||||
f"UPS runtime is {current_runtime / 60:.1f} minutes"
|
||||
)
|
||||
print(message)
|
||||
notify(message)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Remote shutdown of {SHUTDOWN_HOST} failed: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
local_shutdown_required = (
|
||||
current_status in {"OB", "LB"}
|
||||
and current_runtime is not None
|
||||
and current_runtime < LOCAL_SHUTDOWN_RUNTIME_SECONDS
|
||||
)
|
||||
|
||||
if local_shutdown_required and not local_shutdown_sent:
|
||||
message = (
|
||||
"Raspberry Pi shutdown requested: UPS runtime is "
|
||||
f"{current_runtime:.0f} seconds"
|
||||
)
|
||||
notify(message)
|
||||
|
||||
try:
|
||||
shutdown_local_server()
|
||||
local_shutdown_sent = True
|
||||
print(message)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Local shutdown failed: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# A return to utility power starts a new shutdown-alert cycle.
|
||||
if current_status == "OL":
|
||||
shutdown_sent = False
|
||||
local_shutdown_sent = False
|
||||
|
||||
# Keep the last valid reading when NUT temporarily omits a value.
|
||||
state = {
|
||||
"status": current_status or previous_status,
|
||||
@@ -510,6 +636,8 @@ def process_alerts(current):
|
||||
"battery_runtime": (
|
||||
current_runtime if current_runtime is not None else previous_runtime
|
||||
),
|
||||
"remote_shutdown_sent": shutdown_sent,
|
||||
"local_shutdown_sent": local_shutdown_sent,
|
||||
}
|
||||
save_alert_state(state)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user