diff --git a/frogpilot/system/the_pond/assets/components/router.js b/frogpilot/system/the_pond/assets/components/router.js index fe8b96d3f..21827d144 100644 --- a/frogpilot/system/the_pond/assets/components/router.js +++ b/frogpilot/system/the_pond/assets/components/router.js @@ -6,6 +6,7 @@ import { ErrorLogs } from "/assets/components/tools/error_logs.js" import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js" import { GalaxyPairing } from "/assets/components/tools/galaxy.js" import { Home } from "/assets/components/home/home.js" +import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js" import { NavDestination } from "/assets/components/navigation/navigation_destination.js" import { NavKeys } from "/assets/components/navigation/navigation_keys.js" import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js" @@ -45,6 +46,7 @@ function Root() { createRoute("settings", "/settings/:section/:subsection?", SettingsView), createRoute("speed_limits", "/download_speed_limits", SpeedLimits), createRoute("model_manager", "/manage_models", ModelManager), + createRoute("longitudinal_maneuvers", "/longitudinal_maneuvers", LongitudinalManeuvers), createRoute("plots", "/plots", LivePlots), createRoute("thememaker", "/theme_maker", ThemeMaker), createRoute("testing_ground", "/testing_ground", TestingGround), diff --git a/frogpilot/system/the_pond/assets/components/sidebar.js b/frogpilot/system/the_pond/assets/components/sidebar.js index 1a497ba0c..f083ac548 100644 --- a/frogpilot/system/the_pond/assets/components/sidebar.js +++ b/frogpilot/system/the_pond/assets/components/sidebar.js @@ -19,6 +19,7 @@ const MenuItems = { { name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" }, { name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" }, { name: "Galaxy", link: "/galaxy", icon: "bi-globe2" }, + { name: "Long Maneuvers", link: "/longitudinal_maneuvers", icon: "bi-signpost-split" }, { name: "Model Manager", link: "/manage_models", icon: "bi-cpu" }, { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" }, { name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" }, diff --git a/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.css b/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.css new file mode 100644 index 000000000..e3f45ed24 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.css @@ -0,0 +1,117 @@ +.longManeuverPage { + max-width: var(--width-xxl); +} + +.longManeuverCard { + background-color: var(--secondary-bg); + border-radius: var(--border-radius-md); + color: var(--text-color); + max-width: 90%; + padding: var(--border-radius-xl); +} + +.longManeuverIntro { + margin-top: 0; +} + +.longManeuverActions { + display: flex; + gap: var(--gap-sm); + margin-top: var(--margin-base); +} + +.longManeuverButton { + background: var(--main-fg); + border: none; + border-radius: var(--border-radius-sm); + color: var(--text-color); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-demi-bold); + padding: var(--padding-sm) var(--padding-base); +} + +.longManeuverButton:hover { + transform: var(--hover-scale-sm); + transition: transform var(--transition-fast), background-color var(--transition-fast); +} + +.longManeuverButton:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; +} + +.longManeuverButton.danger { + background: var(--danger-bg); +} + +.longManeuverButton.danger:hover { + background: var(--danger-hover-bg); +} + +.longManeuverError { + color: var(--danger-fg); + margin-top: var(--margin-base); +} + +.longManeuverMuted { + color: var(--text-muted); +} + +.longManeuverStatusGrid { + display: grid; + gap: 0.5em 1em; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: var(--margin-base); +} + +.longManeuverStatusGrid p { + font-size: var(--font-size-sm); + margin: 0; +} + +.longManeuverCurrent { + margin-top: var(--margin-base); +} + +.longManeuverCurrent p { + font-size: var(--font-size-sm); + margin: 0 0 var(--margin-xs); +} + +.longManeuverInstructions, +.longManeuverHistory { + margin-top: var(--margin-base); +} + +.longManeuverInstructions h3, +.longManeuverHistory h3 { + margin-bottom: var(--margin-xs); +} + +.longManeuverInstructions ol, +.longManeuverHistory ol { + margin: var(--margin-xs) 0 0; + padding-left: 1.4rem; +} + +.longManeuverInstructions li, +.longManeuverHistory li { + font-size: var(--font-size-sm); + margin-bottom: 0.25rem; +} + +@media only screen and (max-width: 768px) and (orientation: portrait) { + .longManeuverCard { + max-width: 100%; + } + + .longManeuverActions { + flex-direction: column; + } + + .longManeuverStatusGrid { + grid-template-columns: 1fr; + } +} + diff --git a/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js b/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js new file mode 100644 index 000000000..77d895407 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js @@ -0,0 +1,170 @@ +import { html, reactive } from "https://esm.sh/@arrow-js/core" + +const state = reactive({ + loading: true, + busy: false, + error: "", + data: null, +}) + +let initialized = false +let pollHandle = null +const POLL_INTERVAL_MS = 1000 + +function safeNumber(value, fallback = 0) { + const n = Number(value) + return Number.isFinite(n) ? n : fallback +} + +function formatAgeSeconds(value) { + const sec = safeNumber(value, -1) + if (sec < 0) return "unknown" + if (sec < 1) return "just now" + if (sec < 60) return `${Math.round(sec)}s ago` + const min = sec / 60 + if (min < 60) return `${Math.round(min)}m ago` + const hr = min / 60 + return `${Math.round(hr)}h ago` +} + +async function fetchStatus() { + try { + const response = await fetch("/api/longitudinal_maneuvers/status") + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error || response.statusText || "Failed to load maneuver status") + } + + state.data = payload + state.error = "" + } catch (error) { + state.error = error?.message || "Failed to load maneuver status" + } finally { + state.loading = false + } +} + +function stopPolling() { + if (!pollHandle) return + clearTimeout(pollHandle) + pollHandle = null +} + +function ensurePolling() { + if (pollHandle) return + + const poll = async () => { + await fetchStatus() + pollHandle = setTimeout(poll, POLL_INTERVAL_MS) + } + + pollHandle = setTimeout(poll, POLL_INTERVAL_MS) +} + +async function runAction(action) { + if (state.busy) return + state.busy = true + + try { + const response = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" }) + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error || response.statusText || `Failed to ${action} maneuvers`) + } + + state.data = payload + state.error = "" + showSnackbar(payload.message || "Action complete.") + } catch (error) { + const message = error?.message || `Failed to ${action} maneuvers` + state.error = message + showSnackbar(message, "error") + } finally { + state.busy = false + } +} + +function initialize() { + if (initialized) return + initialized = true + fetchStatus() + ensurePolling() +} + +function statusLine(label, value) { + return html`

${label}: ${value}

` +} + +export function LongitudinalManeuvers() { + initialize() + + return html` +
+

Long Maneuvers

+ +
+

+ Run the longitudinal maneuver suite from your phone and monitor progress live. +

+ +
+ + +
+ + ${() => state.loading ? html`

Loading status...

` : ""} + ${() => state.error ? html`

${state.error}

` : ""} + + ${() => state.data ? html` +
+ ${statusLine("Mode Enabled", state.data.modeEnabled ? "Yes" : "No")} + ${statusLine("State", state.data.state || "idle")} + ${statusLine("Onroad", state.data.isOnroad ? "Yes" : "No")} + ${statusLine("Engaged", state.data.isEngaged ? "Yes" : "No")} + ${statusLine("Phase", state.data.phase || "n/a")} + ${statusLine("Paddle Mode", state.data.paddleMode || "auto")} + ${statusLine("Step", `${safeNumber(state.data.stepIndex, 0)}/${safeNumber(state.data.stepTotal, 0)}`)} + ${statusLine("Run", `${safeNumber(state.data.runIndex, 0)}/${safeNumber(state.data.runTotal, 0)}`)} + ${statusLine("Updated", formatAgeSeconds(state.data.updatedAgeSec))} +
+ +
+

Current Maneuver: ${state.data.maneuver || "n/a"}

+

Popup Text: ${state.data.uiText1 || ""} ${state.data.uiText2 ? `| ${state.data.uiText2}` : ""}

+
+ +
+

Quick Guide

+
    +
  1. Find a large, empty, straight road or lot with no traffic.
  2. +
  3. Press Start / Arm here, then engage openpilot with SET.
  4. +
  5. Keep full supervision and be ready to disengage at all times.
  6. +
  7. For GM pedal-long cars, the suite runs both pedal-only and pedal+paddle phases automatically.
  8. +
  9. When the status says complete, collect logs and generate your HTML report.
  10. +
+
+ +
+

Progress Chain

+ ${() => (state.data.history || []).length ? html` +
    + ${(state.data.history || []).slice().reverse().map((line) => html`
  1. ${line}
  2. `)} +
+ ` : html`

No steps logged yet.

`} +
+ ` : ""} +
+
+ ` +} + diff --git a/frogpilot/system/the_pond/templates/index.html b/frogpilot/system/the_pond/templates/index.html index 073b3b7bc..26a15f2a1 100644 --- a/frogpilot/system/the_pond/templates/index.html +++ b/frogpilot/system/the_pond/templates/index.html @@ -37,6 +37,7 @@ + diff --git a/frogpilot/system/the_pond/the_pond.py b/frogpilot/system/the_pond/the_pond.py index 25bc10c74..03888e1f9 100644 --- a/frogpilot/system/the_pond/the_pond.py +++ b/frogpilot/system/the_pond/the_pond.py @@ -1314,6 +1314,116 @@ def _set_testing_ground_selection(slot_id, variant): _write_testing_grounds_state_unlocked(state) return state +def _default_longitudinal_maneuver_status(): + return { + "state": "idle", + "phase": "", + "paddleMode": "auto", + "maneuver": "", + "runIndex": 0, + "runTotal": 0, + "stepIndex": 0, + "stepTotal": 0, + "phaseStepIndex": 0, + "phaseStepTotal": 0, + "uiShow": False, + "uiSize": "small", + "uiText1": "Long Maneuvers", + "uiText2": "", + "updatedAtSec": 0.0, + "history": [], + } + +def _load_longitudinal_maneuver_status(): + status = _default_longitudinal_maneuver_status() + raw = params.get("LongitudinalManeuverStatus", encoding="utf-8") or "" + if raw: + try: + payload = json.loads(raw) + if isinstance(payload, dict): + status.update(payload) + except Exception: + pass + + history = status.get("history") + if not isinstance(history, list): + history = [] + status["history"] = [str(line) for line in history if str(line).strip()][-120:] + + try: + status["updatedAtSec"] = float(status.get("updatedAtSec") or 0.0) + except Exception: + status["updatedAtSec"] = 0.0 + + return status + +def _save_longitudinal_maneuver_status(status): + status_copy = dict(status) + history = status_copy.get("history") + if not isinstance(history, list): + history = [] + status_copy["history"] = [str(line) for line in history if str(line).strip()][-120:] + status_copy["updatedAtSec"] = float(status_copy.get("updatedAtSec") or time.time()) + params.put("LongitudinalManeuverStatus", json.dumps(status_copy, separators=(",", ":"))) + return status_copy + +def _append_longitudinal_maneuver_history(status, line): + if not line: + return status + history = list(status.get("history", [])) + history.append(str(line)) + status["history"] = history[-120:] + return status + +def _serialize_longitudinal_maneuver_status(status): + updated_at = _safe_float(status.get("updatedAtSec"), 0.0) + age_seconds = max(0.0, time.time() - updated_at) if updated_at > 0 else None + mode_enabled = params.get_bool("LongitudinalManeuverMode") + paddle_mode = params.get("LongitudinalManeuverPaddleMode", encoding="utf-8") or str(status.get("paddleMode") or "auto") + return { + **status, + "modeEnabled": mode_enabled, + "paddleMode": paddle_mode, + "isOnroad": params.get_bool("IsOnroad"), + "isEngaged": params.get_bool("IsEngaged"), + "updatedAgeSec": age_seconds, + } + +def _set_longitudinal_maneuver_mode(enabled): + status = _load_longitudinal_maneuver_status() + if enabled: + params.put_bool("LongitudinalManeuverMode", True) + params.put("LongitudinalManeuverPaddleMode", "auto") + status.update({ + "state": "armed", + "phase": "", + "maneuver": "", + "runIndex": 0, + "runTotal": 0, + "stepIndex": 0, + "phaseStepIndex": 0, + "uiShow": True, + "uiSize": "small", + "uiText1": "Long Maneuvers Armed", + "uiText2": "Engage with SET to start the test suite.", + "updatedAtSec": time.time(), + }) + _append_longitudinal_maneuver_history(status, "Armed from The Pond. Engage with SET to start.") + else: + params.put_bool("LongitudinalManeuverMode", False) + params.put("LongitudinalManeuverPaddleMode", "auto") + status.update({ + "state": "stopped", + "uiShow": True, + "uiSize": "small", + "uiText1": "Long Maneuvers Stopped", + "uiText2": "Test mode disabled.", + "updatedAtSec": time.time(), + }) + _append_longitudinal_maneuver_history(status, "Stopped from The Pond.") + + return _save_longitudinal_maneuver_status(status) + def setup(app): model_status_debug = { "last_signature": None, @@ -2474,6 +2584,27 @@ def setup(app): "isOnroad": params.get_bool("IsOnroad"), }), 200 + @app.route("/api/longitudinal_maneuvers/status", methods=["GET"]) + def get_longitudinal_maneuvers_status(): + status = _load_longitudinal_maneuver_status() + return jsonify(_serialize_longitudinal_maneuver_status(status)), 200 + + @app.route("/api/longitudinal_maneuvers/start", methods=["POST"]) + def start_longitudinal_maneuvers(): + status = _set_longitudinal_maneuver_mode(True) + return jsonify({ + "message": "Longitudinal maneuver mode armed. Engage with SET to start.", + **_serialize_longitudinal_maneuver_status(status), + }), 200 + + @app.route("/api/longitudinal_maneuvers/stop", methods=["POST"]) + def stop_longitudinal_maneuvers(): + status = _set_longitudinal_maneuver_mode(False) + return jsonify({ + "message": "Longitudinal maneuver mode disabled.", + **_serialize_longitudinal_maneuver_status(status), + }), 200 + @app.route("/api/update/fast/status", methods=["GET"]) def get_fast_update_status(): state_data = _get_fast_update_state()