mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-20 07:43:48 +08:00
yo yo yo zik
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) ──────────────────
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user