yo yo yo zik

This commit is contained in:
firestar5683
2026-08-16 19:04:30 -05:00
parent 0593e53b04
commit 225dfb6678
5 changed files with 142 additions and 38 deletions
@@ -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
}
@@ -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() {
</a>
`)}
</div>
` : html`<p class="sentry-empty">No camera images were available for this event.</p>`}
` : event.kind === "power_off"
? html`<p class="sentry-empty">Power-off alerts do not include camera captures because the device is shutting down.</p>`
: html`<p class="sentry-empty">No camera images were available for this event.</p>`}
`
}
@@ -266,13 +271,13 @@ export function SentryMode() {
<div class="sentry-action-row">
<button class="sentry-button" @click="${enablePush}" disabled="${() => state.pushBusy}">
${() => state.pushBusy ? "Enabling…" : "Enable Chrome notifications"}
${() => state.pushBusy ? "Enabling…" : "Enable browser notifications"}
</button>
<button class="sentry-button sentry-button-secondary" @click="${sendTestPush}" disabled="${() => state.pushBusy}">
${() => state.pushBusy ? "Sending…" : "Send test push"}
<button class="sentry-button sentry-button-secondary" @click="${sendTestNotification}" disabled="${() => state.pushBusy}">
${() => state.pushBusy ? "Sending…" : "Send test notification"}
</button>
</div>
<p class="sentry-muted">Enable notifications once, then use the test push to verify Galaxy can reach this browser even when the page is not active.</p>
<p class="sentry-muted">The test notification uses every configured channel: browser Web Push, ntfy, and webhook.</p>
<p class="sentry-muted">iPhone users: add Galaxy to your Home Screen as a web app before enabling notifications. iOS web push requires the Home Screen web app.</p>
`}
</section>
+52 -10
View File
@@ -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) ──────────────────
+45 -2
View File
@@ -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))
+22 -8
View File
@@ -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