diff --git a/starpilot/system/the_galaxy/assets/components/tools/sentry.js b/starpilot/system/the_galaxy/assets/components/tools/sentry.js index 6ac87c0df..91f73a770 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/sentry.js +++ b/starpilot/system/the_galaxy/assets/components/tools/sentry.js @@ -11,7 +11,9 @@ const state = reactive({ params: {}, status: {}, event: {}, + liveCapture: {}, testBusy: false, + liveBusy: false, pushBusy: false, }) @@ -87,6 +89,36 @@ async function sendTestEvent() { } } +async function readJsonResponse(response) { + const body = await response.text() + if (!body) return {} + + try { + return JSON.parse(body) + } catch { + throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`) + } +} + +async function viewLive() { + if (state.liveBusy) return + state.liveBusy = true + try { + const response = await fetch(galaxyPath("/api/sentry/live"), { cache: "no-store" }) + const payload = await readJsonResponse(response) + if (!response.ok) { + showSnackbar(payload.error || "Live camera capture failed.") + return + } + state.liveCapture = payload + showSnackbar("Live camera snapshot captured.") + } catch (error) { + showSnackbar(error.message || "Network error — is the device reachable?") + } finally { + state.liveBusy = false + } +} + async function enablePush() { if (state.pushBusy) return state.pushBusy = true @@ -135,6 +167,24 @@ function renderEvent() { ` } +function renderLiveCapture() { + const capture = state.liveCapture || {} + const imageUrls = Array.isArray(capture.imageUrls) ? capture.imageUrls : [] + if (imageUrls.length === 0) return html`

No live snapshot captured yet.

` + + const cacheKey = encodeURIComponent(capture.capturedAt || "") + return html` +

Captured ${capture.capturedAt || "just now"}.

+
+ ${imageUrls.map((url, index) => html` + + Live Sentry camera ${index + 1} + + `)} +
+ ` +} + export function SentryMode() { startPolling() const remote = isGalaxyTunnel() @@ -198,9 +248,23 @@ 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.

+

iPhone users: add Galaxy to your Home Screen as a web app before enabling notifications. iOS web push requires the Home Screen web app.

`} +
+
+
+

Live view

+

Capture one still from both cameras while parked.

+
+ +
+ ${() => renderLiveCapture()} +
+
diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index de0400bf4..02afc5f5d 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -623,6 +623,39 @@ def _capture_sentry_test_images(event_id: str) -> list[str]: return paths +_SENTRY_LIVE_CAPTURE_LOCK = threading.Lock() +_SENTRY_LIVE_EVENT_ID = "live" + + +def _capture_sentry_live_images() -> list[str]: + from openpilot.system.camerad.snapshot import jpeg_write, snapshot + + params.put_bool("SentryModeCapture", True) + try: + rear, front = snapshot(allow_existing=True, include_front=True) + except Exception: + cloudlog.exception("Galaxy: live Sentry snapshot failed") + return [] + finally: + params.put_bool("SentryModeCapture", False) + + if rear is None and front is None: + return [] + + directory = _sentry_event_roots()[0] / _SENTRY_LIVE_EVENT_ID + directory.mkdir(parents=True, exist_ok=True) + paths = [] + if rear is not None: + path = directory / "wide.jpg" + jpeg_write(str(path), rear) + paths.append(str(path)) + if front is not None: + path = directory / "driver.jpg" + jpeg_write(str(path), front) + paths.append(str(path)) + return paths + + _SENTRY_PUSH_LOCK = threading.Lock() _SENTRY_PUSH_PRIVATE_KEY_NAME = "sentry_vapid_private.pem" _SENTRY_PUSH_SUBSCRIPTIONS_NAME = "sentry_push_subscriptions.json" @@ -7005,6 +7038,23 @@ def setup(app): return jsonify({"error": "Sentry image not found."}), 404 return send_file(image_path, mimetype="image/jpeg", max_age=0) + @app.route("/api/sentry/live", methods=["GET"]) + def sentry_live(): + if not params.get_bool("IsOffroad"): + return jsonify({"error": "Live Sentry view is only available while parked."}), 409 + + with _SENTRY_LIVE_CAPTURE_LOCK: + image_paths = _capture_sentry_live_images() + if not image_paths: + return jsonify({"error": "Unable to capture the Sentry cameras."}), 503 + + captured_at = datetime.now(timezone.utc).isoformat() + event = _public_sentry_event({ + "eventId": _SENTRY_LIVE_EVENT_ID, + "imagePaths": image_paths, + }) + return jsonify({"capturedAt": captured_at, "imageUrls": event["imageUrls"]}) + @app.route("/api/sentry/test", methods=["POST"]) def sentry_test(): if request.remote_addr not in {None, "127.0.0.1", "::1"}: diff --git a/system/camerad/snapshot.py b/system/camerad/snapshot.py index 0ab8faa68..35e86d570 100755 --- a/system/camerad/snapshot.py +++ b/system/camerad/snapshot.py @@ -73,14 +73,14 @@ def get_snapshots(frame="roadCameraState", front_frame="driverCameraState"): return rear, front -def snapshot(allow_existing=False): +def snapshot(allow_existing=False, include_front=None): params = Params() if (not params.get_bool("IsOffroad")) or params.get_bool("IsTakingSnapshot"): print("Already taking snapshot") return None, None - front_camera_allowed = params.get_bool("RecordFront") + front_camera_allowed = params.get_bool("RecordFront") if include_front is None else bool(include_front) params.put_bool("IsTakingSnapshot", True) set_offroad_alert("Offroad_IsTakingSnapshot", True) time.sleep(2.0) # Give hardwared time to read the param, or if just started give camerad time to start