X2_dashboard/x2_dashboard/backend/plugins/example_battery_guard.py
2026-08-13 16:23:18 +04:00

113 lines
4.0 KiB
Python

"""
Battery guard - a worked example of a background monitor.
Watches the PMU battery level, warns once per threshold crossing, and can drop
the robot into a safe mode before it browns out mid-stride. Demonstrates
on_tick, ctx.push readouts and charted series.
"""
from backend.plugin_api import Plugin
plugin = Plugin(
id="battery_guard",
name="Battery guard",
description="Warns on low battery and can auto-safe the robot before it browns out.",
icon="\U0001F50B",
order=20,
)
@plugin.slider("warn_at", label="Warn below", min=5, max=60, step=1, default=25, unit="%")
async def warn_at(ctx, value):
ctx.storage["warn_at"] = float(value)
ctx.storage["warned"] = False
return f"Warning threshold set to {value:.0f}%"
@plugin.slider("safe_at", label="Auto-safe below", min=1, max=30, step=1, default=10, unit="%",
help="Switches to Damping so the robot settles instead of collapsing.")
async def safe_at(ctx, value):
ctx.storage["safe_at"] = float(value)
ctx.storage["safed"] = False
return f"Auto-safe threshold set to {value:.0f}%"
@plugin.toggle("auto_safe", label="Auto-safe enabled", default=False,
help="Off by default - turn on only when you want the robot to act unattended.")
async def auto_safe(ctx, value):
ctx.storage["auto_safe"] = bool(value)
return "Auto-safe armed" if value else "Auto-safe disarmed"
@plugin.action("reset_alarms", label="Reset alarms", style="default")
async def reset_alarms(ctx):
ctx.storage["warned"] = False
ctx.storage["safed"] = False
await ctx.push("status", "armed")
return "Alarms reset"
plugin.readout("level", "Battery", unit="%", chart=True, precision=1)
plugin.readout("draw", "Draw", unit="A", precision=2)
plugin.readout("status", "Guard status", format="text")
plugin.readout("est_runtime", "Estimated runtime", unit="min", precision=0)
@plugin.on_start
async def start(ctx):
ctx.storage.setdefault("warn_at", 25.0)
ctx.storage.setdefault("safe_at", 10.0)
ctx.storage.setdefault("auto_safe", False)
ctx.storage.setdefault("warned", False)
ctx.storage.setdefault("safed", False)
await ctx.push("status", "armed")
@plugin.on_tick(interval=3.0)
async def tick(ctx):
level = ctx.state.battery_pct
if level is None:
await ctx.push("status", "no PMU data")
return
current = ctx.state.battery_current
await ctx.push("level", round(level, 1))
ctx.record("level", level)
if current is not None:
await ctx.push("draw", round(abs(current), 2))
# A crude but useful projection: remaining percent over the observed drain
# rate. Only meaningful while discharging.
history = ctx.hub.series("battery_pct", limit=120)
if len(history) >= 20:
(t0, v0), (t1, v1) = history[0], history[-1]
elapsed = t1 - t0
drop = v0 - v1
if elapsed > 5 and drop > 0.01:
minutes = (level / (drop / elapsed)) / 60.0
await ctx.push("est_runtime", round(min(minutes, 9999), 0))
safe_at = ctx.storage["safe_at"]
warn_at = ctx.storage["warn_at"]
if level <= safe_at:
await ctx.push("status", "critical")
if ctx.storage.get("auto_safe") and not ctx.storage.get("safed"):
ctx.storage["safed"] = True
await ctx.bridge.stop_motion()
outcome = await ctx.bridge.set_mode("DAMPING_DEFAULT")
await ctx.log(
f"Battery {level:.1f}% - auto-safe engaged: {outcome.message}", level="error"
)
await ctx.bridge.speak("Battery critical. Entering safe mode.", priority=10)
elif level <= warn_at:
await ctx.push("status", "low")
if not ctx.storage.get("warned"):
ctx.storage["warned"] = True
await ctx.log(f"Battery low: {level:.1f}%", level="warn")
await ctx.bridge.set_led(mode=2, r=250, g=178, b=25)
else:
await ctx.push("status", "normal")
ctx.storage["warned"] = False
ctx.storage["safed"] = False