Sentry and Tunes

This commit is contained in:
firestar5683
2026-08-14 12:05:45 -05:00
parent 8e1caf8fe4
commit cdfd5d1d66
19 changed files with 621 additions and 9 deletions
@@ -26,6 +26,7 @@ import { ToggleControl } from "/assets/components/tools/toggles.js"
import { VASMAnnotations } from "/assets/components/tools/v_asm.js"
import { PipSideCamera } from "/assets/components/tools/pip_sidecam.js"
import { UpdateManager } from "/assets/components/tools/update_manager.js"
import { startSentryNotifications } from "/assets/components/sentry_notifications.js"
let router, routerState
@@ -187,3 +188,5 @@ if (document.readyState === "loading") {
} else {
mountRouterWhenReady()
}
startSentryNotifications()
@@ -0,0 +1,57 @@
const STORAGE_KEY = "starpilot.sentry.last-event"
const POLL_INTERVAL_MS = 5000
let started = false
let initialized = false
function lastSeenEventId() {
try {
return window.localStorage.getItem(STORAGE_KEY) || ""
} catch {
return ""
}
}
function rememberEvent(eventId) {
try {
window.localStorage.setItem(STORAGE_KEY, eventId)
} catch {
}
}
async function pollSentryEvent() {
try {
const response = await fetch("/api/sentry/status", { cache: "no-store" })
if (!response.ok) return
const payload = await response.json()
const event = payload?.lastEvent
const eventId = String(event?.eventId || "")
if (!eventId) return
const previous = lastSeenEventId()
rememberEvent(eventId)
if (!initialized || eventId === previous) return
if (typeof Notification === "undefined" || Notification.permission !== "granted") return
new Notification("StarPilot Sentry Mode", {
body: String(event.message || "Movement detected while parked."),
tag: `starpilot-sentry-${eventId}`,
})
} catch (error) {
console.debug("Sentry notification poll failed:", error)
} finally {
initialized = true
}
}
export async function requestSentryNotificationPermission() {
if (typeof Notification === "undefined") return "unsupported"
return Notification.requestPermission()
}
export function startSentryNotifications() {
if (started) return
started = true
pollSentryEvent()
window.setInterval(pollSentryEvent, POLL_INTERVAL_MS)
}
@@ -272,6 +272,11 @@ function syncInputs() {
el.value = resolveColorInputValue(param)
}
for (const el of document.querySelectorAll("input.ds-text-input[id^='ds-']")) {
if (document.activeElement === el) continue
el.value = toSelectValue(state.values[el.id.slice(3)])
}
// Sync selects — hydrate options + set value
for (const el of document.querySelectorAll("select.ds-select[id^='ds-']")) {
const key = el.id.slice(3)
@@ -1475,6 +1480,7 @@ function renderSettingRow(p) {
const isNumeric = p.ui_type === "numeric"
const isSlider = isNumeric && p.control === "slider"
const isText = p.ui_type === "text"
const isColor = p.ui_type === "color"
const isAction = p.ui_type === "action"
const isGroup = isGroupParam(p)
@@ -1600,6 +1606,17 @@ function renderSettingRow(p) {
<option value="">Loading...</option>
</select>
`
} else if (isText) {
rowControl = html`
<input
type="${p.input_type || "text"}"
class="ds-manual-input ds-text-input"
id="ds-${p.key}"
value="${() => toSelectValue(state.values[p.key])}"
placeholder="${p.placeholder || ""}"
disabled="${() => isLocked()}"
@change="${() => updateParam(p.key, "text")}" />
`
} else if (p.ui_type === "color") {
rowControl = html`
<div style="display:flex; align-items:center; gap:0.75rem;">
@@ -13,6 +13,40 @@
}
]
},
{
"name": "Sentry Mode",
"icon": "bi-shield-exclamation",
"params": [
{
"key": "SentryModeEnabled",
"label": "Sentry Mode",
"description": "Detect movement while parked and send alerts through Galaxy.",
"data_type": "bool",
"ui_type": "toggle",
"settings_tier": "simple"
},
{
"key": "SentryModeWebhook",
"label": "Sentry Webhook",
"description": "Optional webhook that Galaxy will POST to when sentry mode detects movement. Discord webhooks are supported.",
"data_type": "string",
"ui_type": "text",
"input_type": "url",
"placeholder": "https://…",
"settings_tier": "simple"
},
{
"key": "SentryModeNtfyUrl",
"label": "Sentry ntfy URL",
"description": "Optional ntfy topic URL, for example https://ntfy.sh/my-starpilot-topic.",
"data_type": "string",
"ui_type": "text",
"input_type": "url",
"placeholder": "https://ntfy.sh/…",
"settings_tier": "simple"
}
]
},
{
"name": "Lateral (Steering)",
"icon": "bi-arrows-move",
@@ -1,6 +1,7 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js"
import { requestSentryNotificationPermission } from "/assets/components/sentry_notifications.js"
const state = reactive({
paired: false,
@@ -114,6 +115,15 @@ export function GalaxyPairing() {
<a class="galaxy-url" href="${state.url}" target="_blank" rel="noopener">
${state.url}
</a>
<button
class="galaxy-button"
@click="${async () => {
const permission = await requestSentryNotificationPermission()
showSnackbar(permission === "granted" ? "Browser sentry notifications enabled." : "Browser notifications were not enabled.")
}}"
>
Enable browser alerts
</button>
<button
class="galaxy-button galaxy-button-danger"
@click="${() => { state.showUnpairModal = true }}"
+114
View File
@@ -39,6 +39,7 @@ from opendbc.car.toyota.values import ToyotaStarPilotFlags
from openpilot.common.constants import CV
from openpilot.common.params import ParamKeyFlag, ParamKeyType, Params
from openpilot.common.realtime import DT_HW
from openpilot.common.swaglog import cloudlog
from openpilot.common.time_helpers import system_time_valid
from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.hardware.hw import Paths
@@ -501,6 +502,85 @@ def _build_default_params():
starpilot_default_params = _build_default_params()
def _sentry_event_roots() -> tuple[Path, ...]:
roots = [Path("/data/media/0/sentryd")]
if PC:
roots.insert(0, Path(Paths.comma_home()) / "starpilot" / "data" / "sentryd")
return tuple(root.resolve() for root in roots)
def _safe_sentry_image_paths(raw_paths) -> list[str]:
if not isinstance(raw_paths, list):
return []
roots = _sentry_event_roots()
safe_paths = []
for raw_path in raw_paths:
try:
path = Path(str(raw_path)).resolve()
if path.is_file() and any(path.is_relative_to(root) for root in roots):
safe_paths.append(str(path))
except (OSError, TypeError, ValueError):
continue
return safe_paths
def _normalize_sentry_event(payload) -> dict | None:
if not isinstance(payload, dict):
return 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"}:
return None
return {
"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")),
}
def _dispatch_sentry_event(event: dict) -> None:
message = f"🚨 StarPilot Sentry Mode: {event['message']}"
webhook = (params.get("SentryModeWebhook", encoding="utf-8") or "").strip()
if webhook:
files = []
handles = []
try:
for image_path in event.get("imagePaths", []):
handle = open(image_path, "rb")
handles.append(handle)
files.append(("file", (Path(image_path).name, handle, "image/jpeg")))
body = {"content": message, "event": json.dumps(event, separators=(",", ":"))}
response = requests.post(webhook, data=body, files=files or None, timeout=10)
response.raise_for_status()
except Exception:
cloudlog.exception("Galaxy: sentry webhook notification failed")
finally:
for handle in handles:
try:
handle.close()
except OSError:
pass
ntfy_url = (params.get("SentryModeNtfyUrl", encoding="utf-8") or "").strip()
if ntfy_url:
try:
response = requests.post(
ntfy_url,
data=message.encode("utf-8"),
headers={"Title": "StarPilot Sentry Mode", "Priority": "urgent", "Tags": "warning,car"},
timeout=10,
)
response.raise_for_status()
except Exception:
cloudlog.exception("Galaxy: ntfy notification failed")
TOGGLE_BACKUP_FORMAT = "starpilot-toggle-backup"
TOGGLE_BACKUP_VERSION = 1
TOGGLE_BACKUP_MAX_ENCODED_BYTES = 2_000_000
@@ -4059,11 +4139,13 @@ def setup(app):
def disable_device_settings_asset_cache(response):
if request.path in {
"/assets/components/router.js",
"/assets/components/sentry_notifications.js",
"/assets/components/home/home.js",
"/assets/components/home/home.css",
"/assets/components/tools/device_settings.js",
"/assets/components/tools/device_settings.css",
"/assets/components/tools/device_settings_layout.json",
"/assets/components/tools/galaxy.js",
"/assets/components/tools/v_asm.js",
"/assets/components/tools/v_asm.css",
"/assets/components/tools/pip_sidecam.js",
@@ -6594,6 +6676,38 @@ def setup(app):
"warning": "This wipes local params, backups, themes, models, maps, and route data.",
}), 202
@app.route("/api/sentry/status", methods=["GET"])
def sentry_status():
raw_event = params.get("SentryModeLastEvent", encoding="utf-8") or "{}"
raw_status = params.get("SentryModeStatus", encoding="utf-8") or "{}"
try:
last_event = json.loads(raw_event)
except (TypeError, ValueError, json.JSONDecodeError):
last_event = {}
try:
status = json.loads(raw_status)
except (TypeError, ValueError, json.JSONDecodeError):
status = {}
return jsonify({
"enabled": params.get_bool("SentryModeEnabled"),
"status": status if isinstance(status, dict) else {},
"lastEvent": last_event if isinstance(last_event, dict) else {},
})
@app.route("/api/sentry/events", methods=["POST"])
def sentry_event():
if request.remote_addr not in {None, "127.0.0.1", "::1"}:
return jsonify({"error": "Sentry events must originate on the device."}), 403
event = _normalize_sentry_event(request.get_json(silent=True))
if event is None:
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()
return jsonify({"accepted": True, "eventId": event["eventId"]}), 202
# ── Galaxy pairing (mirrors settings.cc L262-282) ──────────────────
GALAXY_DIR = _get_galaxy_dir()
GALAXY_AUTH_FILE = GALAXY_DIR / "glxyauth"