""" 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