From 225dfb6678a742713bec0acb0ca60fdcac49b859 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:04:30 -0500 Subject: [PATCH] yo yo yo zik --- .../assets/components/sentry_notifications.js | 14 ++--- .../assets/components/tools/sentry.js | 27 ++++---- starpilot/system/the_galaxy/the_galaxy.py | 62 ++++++++++++++++--- system/hardware/hardwared.py | 47 +++++++++++++- system/hardware/power_monitoring.py | 30 ++++++--- 5 files changed, 142 insertions(+), 38 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/sentry_notifications.js b/starpilot/system/the_galaxy/assets/components/sentry_notifications.js index 5227e18b8..cb378cecb 100644 --- a/starpilot/system/the_galaxy/assets/components/sentry_notifications.js +++ b/starpilot/system/the_galaxy/assets/components/sentry_notifications.js @@ -87,15 +87,15 @@ async function readJsonResponse(response) { export async function enableSentryPush() { if (typeof Notification === "undefined" || !("serviceWorker" in navigator) || !("PushManager" in window)) { - return { ok: false, message: "This browser does not support Chrome Web Push." } + return { ok: false, message: "This browser does not support Web Push notifications." } } if (!window.isSecureContext) { - return { ok: false, message: "Chrome notifications require Galaxy over HTTPS." } + return { ok: false, message: "Browser notifications require Galaxy over HTTPS." } } const permission = await requestSentryNotificationPermission() if (permission !== "granted") { - return { ok: false, message: "Chrome notification permission was not granted." } + return { ok: false, message: "Browser notification permission was not granted." } } const configResponse = await fetch(galaxyPath("/api/sentry/push/config"), { cache: "no-store" }) @@ -123,13 +123,13 @@ export async function enableSentryPush() { }) const payload = await readJsonResponse(response) if (!response.ok) return { ok: false, message: payload.error || "Galaxy could not save this browser." } - return { ok: true, message: "Chrome notifications enabled for this browser." } + return { ok: true, message: "Browser notifications enabled for this device." } } -export async function sendSentryTestPush() { - const response = await fetch(galaxyPath("/api/sentry/push/test"), { method: "POST" }) +export async function sendSentryTestNotification() { + const response = await fetch(galaxyPath("/api/sentry/test-notification"), { method: "POST" }) const payload = await readJsonResponse(response) - if (!response.ok) throw new Error(payload.error || "Galaxy could not send the test push.") + if (!response.ok) throw new Error(payload.error || "Galaxy could not send the test notification.") return payload } diff --git a/starpilot/system/the_galaxy/assets/components/tools/sentry.js b/starpilot/system/the_galaxy/assets/components/tools/sentry.js index 4c311cea1..fb4262b6e 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/sentry.js +++ b/starpilot/system/the_galaxy/assets/components/tools/sentry.js @@ -2,7 +2,7 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" import { galaxyPath, isGalaxyTunnel } from "/assets/js/utils.js" import { enableSentryPush, - sendSentryTestPush, + sendSentryTestNotification, } from "/assets/components/sentry_notifications.js" const state = reactive({ @@ -127,20 +127,23 @@ async function enablePush() { const result = await enableSentryPush() showSnackbar(result.message) } catch (error) { - showSnackbar(error.message || "Could not enable Chrome notifications.") + showSnackbar(error.message || "Could not enable browser notifications.") } finally { state.pushBusy = false } } -async function sendTestPush() { +async function sendTestNotification() { if (state.pushBusy) return state.pushBusy = true try { - await sendSentryTestPush() - showSnackbar("Test push sent. Check your Chrome notifications.") + const payload = await sendSentryTestNotification() + const channels = Object.entries(payload.channels || {}) + .filter(([, configured]) => configured) + .map(([channel]) => channel === "webPush" ? "browser" : channel) + showSnackbar(`Test notification sent through ${channels.join(", ")}.`) } catch (error) { - showSnackbar(error.message || "Could not send the test push.") + showSnackbar(error.message || "Could not send the test notification.") } finally { state.pushBusy = false } @@ -188,7 +191,9 @@ function renderEvent() { `)} - ` : html`
No camera images were available for this event.
`} + ` : event.kind === "power_off" + ? html`Power-off alerts do not include camera captures because the device is shutting down.
` + : html`No camera images were available for this event.
`} ` } @@ -266,13 +271,13 @@ export function SentryMode() {Enable notifications once, then use the test push to verify Galaxy can reach this browser even when the page is not active.
+The test notification uses every configured channel: browser Web Push, ntfy, and webhook.
iPhone users: add Galaxy to your Home Screen as a web app before enabling notifications. iOS web push requires the Home Screen web app.
`} diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index a278b9cc0..a5b2ef3a2 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -553,16 +553,20 @@ def _normalize_sentry_event(payload) -> dict | None: event_id = str(payload.get("eventId") or "").strip() kind = str(payload.get("kind") or "").strip().lower() - if not event_id or kind not in {"warning", "alarm"}: + if not event_id or kind not in {"warning", "alarm", "power_off"}: return None - return { + event = { "eventId": event_id[:96], "kind": kind, "detectedAt": str(payload.get("detectedAt") or ""), "message": str(payload.get("message") or "Movement detected while parked.")[:500], "imagePaths": _safe_sentry_image_paths(payload.get("imagePaths")), } + reason = str(payload.get("reason") or "").strip() + if reason: + event["reason"] = reason[:96] + return event def _sentry_image_path(event_id: str, filename: str) -> Path | None: @@ -743,6 +747,24 @@ def _sentry_push_subscription_count() -> int: return len(_load_sentry_push_subscriptions()) +def _sentry_notification_channels() -> dict[str, bool]: + return { + "webPush": _sentry_push_subscription_count() > 0, + "webhook": bool((params.get("SentryModeWebhook", encoding="utf-8") or "").strip()), + "ntfy": bool((params.get("SentryModeNtfyUrl", encoding="utf-8") or "").strip()), + } + + +def _sentry_test_notification_event() -> dict: + return { + "eventId": f"notification-test-{int(time.time())}-{secrets.token_hex(4)}", + "kind": "warning", + "detectedAt": datetime.now(timezone.utc).isoformat(), + "message": "This is a test StarPilot Sentry notification.", + "imagePaths": [], + } + + def _dispatch_sentry_push(event: dict) -> None: try: from openpilot.starpilot.system.the_galaxy.web_push import webpush @@ -7001,17 +7023,34 @@ def setup(app): @app.route("/api/sentry/push/test", methods=["POST"]) def sentry_push_test(): if _sentry_push_subscription_count() == 0: - return jsonify({"error": "Enable Chrome notifications first."}), 409 + return jsonify({"error": "Enable browser notifications first."}), 409 - event = { - "eventId": f"push-test-{int(time.time())}-{secrets.token_hex(4)}", - "kind": "warning", - "detectedAt": datetime.now(timezone.utc).isoformat(), - "message": "This is a test StarPilot Sentry push notification.", - } + event = _sentry_test_notification_event() threading.Thread(target=_dispatch_sentry_push, args=(event,), name="galaxy-sentry-push-test", daemon=True).start() return jsonify({"accepted": True, "eventId": event["eventId"]}), 202 + @app.route("/api/sentry/test-notification", methods=["POST"]) + def sentry_test_notification(): + channels = _sentry_notification_channels() + if not any(channels.values()): + return jsonify({ + "error": "Configure browser notifications, ntfy, or a webhook before sending a test notification.", + "channels": channels, + }), 409 + + event = _sentry_test_notification_event() + threading.Thread( + target=_dispatch_sentry_event, + args=(event,), + name="galaxy-sentry-notification-test", + daemon=True, + ).start() + return jsonify({ + "accepted": True, + "eventId": event["eventId"], + "channels": channels, + }), 202 + @app.route("/api/sentry/status", methods=["GET"]) def sentry_status(): raw_event = params.get("SentryModeLastEvent", encoding="utf-8") or "{}" @@ -7119,7 +7158,10 @@ def setup(app): return jsonify({"error": "Invalid sentry event."}), 400 params.put("SentryModeLastEvent", json.dumps(event, separators=(",", ":"))) - threading.Thread(target=_dispatch_sentry_event, args=(event,), name="galaxy-sentry-notify", daemon=True).start() + if request.args.get("blocking") == "1": + _dispatch_sentry_event(event) + else: + threading.Thread(target=_dispatch_sentry_event, args=(event,), name="galaxy-sentry-notify", daemon=True).start() return jsonify({"accepted": True, "eventId": event["eventId"]}), 202 # ── Galaxy pairing (mirrors settings.cc L262-282) ────────────────── diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index a07a47e03..1e3d9f39f 100644 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -8,8 +8,10 @@ import sys import threading import time from collections import OrderedDict, namedtuple +from datetime import datetime, timezone import psutil +import requests import cereal.messaging as messaging from cereal import log @@ -48,6 +50,38 @@ DISCONNECT_TIMEOUT = 5. # wait 5 seconds before going offroad after disconnect PANDA_STATES_TIMEOUT = round(1000 / SERVICE_LIST['pandaStates'].frequency * 1.5) # 1.5x the expected pandaState frequency ONROAD_CYCLE_TIME = 1 # seconds to wait offroad after requesting an onroad cycle +SENTRY_POWER_OFF_MESSAGES = { + "offroad_timeout": "Sentry Mode is stopping because the off-road power timeout was reached.", + "low_voltage": "Sentry Mode is stopping because vehicle voltage is too low.", + "battery_capacity_exhausted": "Sentry Mode is stopping because the estimated battery capacity is exhausted.", + "forced_power_down": "Sentry Mode is stopping because a power-down was requested.", +} + + +def notify_sentry_power_off(reason: str, power_monitor: PowerMonitoring) -> bool: + port = os.environ.get("SP_GALAXY_PORT", "8083" if PC else "8082") + event = { + "eventId": f"power-off-{time.time_ns()}", + "kind": "power_off", + "detectedAt": datetime.now(timezone.utc).isoformat(), + "reason": reason, + "message": SENTRY_POWER_OFF_MESSAGES.get(reason, "Sentry Mode is stopping because device power is being removed."), + "voltage": round(power_monitor.car_voltage_mV / 1000, 3), + "instantVoltage": round(power_monitor.car_voltage_instant_mV / 1000, 3), + "batteryCapacityUwh": power_monitor.get_car_battery_capacity(), + } + try: + response = requests.post( + f"http://127.0.0.1:{port}/api/sentry/events?blocking=1", + json=event, + timeout=4, + ) + response.raise_for_status() + return True + except requests.RequestException as error: + cloudlog.warning(f"Sentry power-off notification unavailable: {error}") + return False + class Chestnut: """Keep the ASM2464PD dock on the firmware expected by the GPU runtime.""" @@ -262,6 +296,7 @@ def hardware_thread(end_event, hw_queue) -> None: engaged_prev = False pwrsave = False offroad_cycle_count = 0 + sentry_power_off_notified = False params = Params() power_monitor = PowerMonitoring() @@ -477,9 +512,17 @@ def hardware_thread(end_event, hw_queue) -> None: msg.deviceState.somPowerDrawW = som_power_draw # Check if we need to shut down - if power_monitor.should_shutdown(onroad_conditions["ignition"], in_car, off_ts, started_seen, starpilot_toggles): - cloudlog.warning(f"shutting device down, offroad since {off_ts}") + shutdown_reason = power_monitor.shutdown_reason( + onroad_conditions["ignition"], in_car, off_ts, started_seen, starpilot_toggles, + ) + if shutdown_reason is not None: + cloudlog.warning(f"shutting device down, reason={shutdown_reason}, offroad since {off_ts}") + if params.get_bool("SentryModeEnabled") and not sentry_power_off_notified: + sentry_power_off_notified = True + notify_sentry_power_off(shutdown_reason, power_monitor) params.put_bool("DoShutdown", True) + else: + sentry_power_off_notified = False msg.deviceState.started = started_ts is not None msg.deviceState.startedMonoTime = int(1e9*(started_ts or 0)) diff --git a/system/hardware/power_monitoring.py b/system/hardware/power_monitoring.py index 76f29d599..baf22fbd0 100644 --- a/system/hardware/power_monitoring.py +++ b/system/hardware/power_monitoring.py @@ -107,22 +107,36 @@ class PowerMonitoring: return int(self.car_battery_capacity_uWh) # See if we need to shutdown - def should_shutdown(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, started_seen: bool, starpilot_toggles: SimpleNamespace): + def shutdown_reason(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, + started_seen: bool, starpilot_toggles: SimpleNamespace) -> str | None: if offroad_timestamp is None: - return False + return None now = time.monotonic() - should_shutdown = False offroad_time = (now - offroad_timestamp) low_voltage_shutdown = (self.car_voltage_mV < (starpilot_toggles.low_voltage_shutdown * 1e3) and offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S) - should_shutdown |= offroad_time > starpilot_toggles.device_shutdown_time - should_shutdown |= low_voltage_shutdown - should_shutdown |= (self.car_battery_capacity_uWh <= 0) + reason = None + if offroad_time > starpilot_toggles.device_shutdown_time: + reason = "offroad_timeout" + elif low_voltage_shutdown: + reason = "low_voltage" + elif self.car_battery_capacity_uWh <= 0: + reason = "battery_capacity_exhausted" + + should_shutdown = reason is not None should_shutdown &= not ignition should_shutdown &= (not self.params.get_bool("DisablePowerDown")) should_shutdown &= in_car should_shutdown &= offroad_time > DELAY_SHUTDOWN_TIME_S - should_shutdown |= self.params.get_bool("ForcePowerDown") + + forced = self.params.get_bool("ForcePowerDown") + should_shutdown |= forced should_shutdown &= started_seen or (now > MIN_ON_TIME_S) - return should_shutdown + if not should_shutdown: + return None + return "forced_power_down" if forced else reason + + def should_shutdown(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, + started_seen: bool, starpilot_toggles: SimpleNamespace): + return self.shutdown_reason(ignition, in_car, offroad_timestamp, started_seen, starpilot_toggles) is not None