diff --git a/frogpilot/system/the_pond/assets/components/tools/device_settings.css b/frogpilot/system/the_pond/assets/components/tools/device_settings.css index 29070dcda..be8226326 100644 --- a/frogpilot/system/the_pond/assets/components/tools/device_settings.css +++ b/frogpilot/system/the_pond/assets/components/tools/device_settings.css @@ -302,72 +302,93 @@ padding: 0.2rem 0.6rem; } -.ds-slider-container { +.ds-stepper-container { width: 100%; } @media (min-width: 768px) { - .ds-slider-container { + .ds-stepper-container { max-width: 350px; } } -.ds-slider { - appearance: none; +.ds-stepper { + align-items: center; + background: var(--input-bg); + border: var(--border-style-main); + border-radius: var(--border-radius-base); + display: grid; + gap: var(--gap-sm); + grid-template-columns: 56px 1fr 56px; + padding: 0.45rem; +} + +.ds-stepper-meta { + align-items: center; + color: var(--text-muted); + display: flex; + flex-direction: column; + font-size: var(--font-size-xs); + gap: 0.1rem; + justify-content: center; + min-height: 2rem; + text-align: center; +} + +.ds-default-value { + color: var(--text-color); + font-weight: var(--font-weight-semibold); +} + +.ds-reset-btn { background: transparent; - padding-bottom: var(--padding-sm); - width: 100%; -} - -.ds-slider:focus { - outline: none; -} - -.ds-slider::-webkit-slider-runnable-track { - background: var(--track-color); - border-radius: var(--border-radius-base); - height: 0.4rem; - width: 100%; -} - -.ds-slider::-webkit-slider-thumb { - appearance: none; - background: var(--main-fg); - border-radius: 50%; - box-shadow: var(--shadow-sm); + border: var(--border-style-main); + border-radius: var(--border-radius-sm); + color: var(--text-color); cursor: pointer; - height: 1.2rem; - margin-top: -0.4rem; - transition: transform var(--transition-fast), box-shadow var(--transition-fast); - width: 1.2rem; + font-size: var(--font-size-xs); + margin-top: 0.15rem; + padding: 0.2rem 0.45rem; + transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); } -.ds-slider::-webkit-slider-thumb:hover { - box-shadow: var(--shadow-md); - transform: scale(1.15); -} - -.ds-slider::-moz-range-track { - background: var(--track-color); - border-radius: var(--border-radius-base); - height: 0.4rem; - width: 100%; -} - -.ds-slider::-moz-range-thumb { +.ds-reset-btn:hover:not(:disabled) { background: var(--main-fg); - border: none; - border-radius: 50%; - box-shadow: var(--shadow-sm); - cursor: pointer; - height: 1.2rem; - transition: transform var(--transition-fast), box-shadow var(--transition-fast); - width: 1.2rem; + border-color: var(--main-fg); + color: var(--color-black); } -.ds-slider::-moz-range-thumb:hover { - box-shadow: var(--shadow-md); - transform: scale(1.15); +.ds-reset-btn:disabled { + cursor: not-allowed; + opacity: var(--disabled-opacity); +} + +.ds-stepper-btn { + align-items: center; + background: var(--sidebar-bg); + border: var(--border-style-main); + border-radius: var(--border-radius-base); + color: var(--text-color); + cursor: pointer; + display: inline-flex; + font-size: 1.25rem; + font-weight: var(--font-weight-bold); + height: 2.1rem; + justify-content: center; + line-height: 1; + transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); +} + +.ds-stepper-btn:hover:not(:disabled) { + background: var(--main-fg); + border-color: var(--main-fg); + color: var(--color-black); + transform: translateY(-1px); +} + +.ds-stepper-btn:disabled { + cursor: not-allowed; + opacity: var(--disabled-opacity); } /* ――― Mobile ――― */ diff --git a/frogpilot/system/the_pond/assets/components/tools/device_settings.js b/frogpilot/system/the_pond/assets/components/tools/device_settings.js index 86838205e..caa9d1dfe 100644 --- a/frogpilot/system/the_pond/assets/components/tools/device_settings.js +++ b/frogpilot/system/the_pond/assets/components/tools/device_settings.js @@ -14,12 +14,14 @@ const state = reactive({ allKeys: [], paramMetaByKey: {}, values: {}, + defaultValues: {}, loadingLayout: true, loadingValues: true, filter: "", expanded: {}, fetched: false, activeSectionSlug: "", + numericUpdating: {}, }) function slugifySectionName(name) { @@ -128,6 +130,18 @@ function syncInputs() { } } +async function fetchDefaultValues() { + try { + const defaultsRes = await fetch("/api/params/defaults") + if (!defaultsRes.ok) return false + const defaultsData = await defaultsRes.json() + state.defaultValues = defaultsData || {} + return true + } catch (e) { + return false + } +} + async function fetchLayoutAndParams() { state.loadingLayout = true state.loadingValues = true @@ -163,11 +177,17 @@ async function fetchLayoutAndParams() { // Pull params once at page load; local state handles subsequent edits. try { - const res = await fetch("/api/params/all") - const data = await res.json() + const valuesRes = await fetch("/api/params/all") + + const data = await valuesRes.json() state.values = data + + if (!(await fetchDefaultValues())) { + state.defaultValues = {} + } } catch (e) { console.error("Failed to fetch param values:", e) + state.defaultValues = {} } state.loadingValues = false @@ -248,6 +268,172 @@ function coerceValueByType(rawValue, dataType) { return rawValue } +function stepPrecision(step, explicitPrecision) { + if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") { + const parsed = Number.parseInt(explicitPrecision, 10) + if (Number.isFinite(parsed) && parsed >= 0) return parsed + } + + const stepStr = String(step ?? "") + if (!stepStr.includes(".")) return 0 + return stepStr.split(".")[1].length +} + +function clampNumeric(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function snapNumericToBoundsAndStep(rawValue, bounds, precision) { + const min = Number(bounds.min) + const max = Number(bounds.max) + const step = Number(bounds.step) + const value = Number(rawValue) + if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(value)) return null + + const clamped = clampNumeric(value, min, max) + if (!Number.isFinite(step) || step <= 0) { + return clampNumeric(Number(clamped.toFixed(precision)), min, max) + } + + const snapped = min + Math.round((clamped - min) / step) * step + return clampNumeric(Number(snapped.toFixed(precision)), min, max) +} + +function resolveCurrentNumericValue(param, bounds) { + const raw = state.values[param.key] + const precision = stepPrecision(bounds.step, param.precision) + const snapped = snapNumericToBoundsAndStep(raw, bounds, precision) + if (snapped !== null) return snapped + + const fallback = Number(bounds.min) + return Number.isFinite(fallback) ? fallback : 0 +} + +function resolveDefaultNumericValue(param, bounds) { + const precision = stepPrecision(bounds.step, param.precision) + const stockKey = `${param.key}Stock` + + // Prefer live vehicle stock values when available. + const liveStock = snapNumericToBoundsAndStep(state.values?.[stockKey], bounds, precision) + if (liveStock !== null) return liveStock + + // Fallback to default table stock value if present. + const defaultStock = snapNumericToBoundsAndStep(state.defaultValues?.[stockKey], bounds, precision) + if (defaultStock !== null) return defaultStock + + // Final fallback: generic param default. + return snapNumericToBoundsAndStep(state.defaultValues?.[param.key], bounds, precision) +} + +function isNumericUpdating(key) { + return !!state.numericUpdating[key] +} + +function showParamSnackbar(message, level, timeout = 2200) { + showSnackbar(message, level, timeout, { + key: "device-settings-param-update", + replace: true, + }) +} + +function syncNumericDisplay(param, rawValue) { + const displayEl = document.getElementById(`ds-display-${param.key}`) + if (!displayEl) return + + const bounds = numericBounds(param) + displayEl.textContent = formatSliderValue( + rawValue, + String(bounds.step), + param.precision, + param.key, + ) +} + +async function updateNumericParam(param, numericValue, options = {}) { + const key = param.key + const current = state.values[key] + const successMessage = options.successMessage + state.numericUpdating = { ...state.numericUpdating, [key]: true } + state.values = { ...state.values, [key]: numericValue } + syncNumericDisplay(param, numericValue) + try { + const res = await fetch("/api/params", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, value: coerceValueByType(numericValue, param.data_type) }), + }) + const data = await res.json() + + if (res.ok) { + const updated = (data.updated && typeof data.updated === "object") ? data.updated : {} + const resolvedValue = Object.prototype.hasOwnProperty.call(updated, key) ? updated[key] : numericValue + state.values = { ...state.values, [key]: resolvedValue, ...updated } + state.numericUpdating = { ...state.numericUpdating, [key]: false } + syncNumericDisplay(param, resolvedValue) + showParamSnackbar(successMessage || data.message || `Parameter '${key}' updated.`) + scheduleSyncInputs() + } else { + state.values = { ...state.values, [key]: current } + state.numericUpdating = { ...state.numericUpdating, [key]: false } + syncNumericDisplay(param, current) + showParamSnackbar(data.error || "Failed to update parameter", "error") + } + } catch (e) { + state.values = { ...state.values, [key]: current } + state.numericUpdating = { ...state.numericUpdating, [key]: false } + syncNumericDisplay(param, current) + showParamSnackbar("Network error — is the device reachable?", "error") + } +} + +function stepNumericParam(param, direction) { + const bounds = numericBounds(param) + const min = Number(bounds.min) + const max = Number(bounds.max) + const step = Number(bounds.step) + + if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(step) || step <= 0) return + if (isNumericUpdating(param.key)) return + + const current = resolveCurrentNumericValue(param, bounds) + const precision = stepPrecision(step, param.precision) + const epsilon = Math.pow(10, -(precision + 2)) + + const next = snapNumericToBoundsAndStep(current + (direction * step), bounds, precision) + if (next === null) return + if (Math.abs(next - current) <= epsilon) return + + updateNumericParam(param, next) +} + +async function resetNumericParam(param) { + const bounds = numericBounds(param) + let defaultValue = resolveDefaultNumericValue(param, bounds) + if (defaultValue === null) { + const loaded = await fetchDefaultValues() + if (!loaded) { + showParamSnackbar("Couldn't load defaults. Try refreshing the page.", "error") + return + } + defaultValue = resolveDefaultNumericValue(param, bounds) + } + + if (defaultValue === null) { + showParamSnackbar("No default value available for this setting.", "error") + return + } + if (isNumericUpdating(param.key)) return + + const current = resolveCurrentNumericValue(param, bounds) + const precision = stepPrecision(bounds.step, param.precision) + const epsilon = Math.pow(10, -(precision + 2)) + if (Math.abs(defaultValue - current) <= epsilon) return + + updateNumericParam(param, defaultValue, { + successMessage: `Parameter '${param.key}' reset to default.`, + }) +} + async function updateParam(key, elType) { const current = state.values[key] const el = document.getElementById(`ds-${key}`) @@ -275,15 +461,15 @@ async function updateParam(key, elType) { if (res.ok) { const updated = (data.updated && typeof data.updated === "object") ? data.updated : {} state.values = { ...state.values, [key]: formattedVal, ...updated } - showSnackbar(data.message || `${key} updated`) + showParamSnackbar(data.message || `Parameter '${key}' updated.`) scheduleSyncInputs() } else { revertInput(key, current, elType) - showSnackbar(data.error || "Failed to update parameter") + showParamSnackbar(data.error || "Failed to update parameter", "error") } } catch (e) { revertInput(key, current, elType) - showSnackbar("Network error — is the device reachable?") + showParamSnackbar("Network error — is the device reachable?", "error") } } @@ -302,22 +488,6 @@ function revertInput(key, current, elType) { } el.value = current - const displayEl = document.getElementById(`ds-display-${key}`) - if (displayEl) { - const precision = el.getAttribute("data-precision") - const pInt = precision ? parseInt(precision, 10) : null - displayEl.textContent = formatSliderValue(current, el.getAttribute("step"), pInt, key) - } -} - -function handleSliderInput(e, key) { - const displayEl = document.getElementById(`ds-display-${key}`) - if (!displayEl) return - - const el = e.target - const precision = el.getAttribute("data-precision") - const pInt = precision ? parseInt(precision, 10) : null - displayEl.textContent = formatSliderValue(el.value, el.getAttribute("step"), pInt, key) } function toggleManage(key) { @@ -354,25 +524,47 @@ function renderSettingRow(p) { ` : ""} - ${isNumeric ? html`${state.values[p.key] !== undefined ? formatSliderValue(state.values[p.key], p.step !== undefined ? String(p.step) : undefined, p.precision, p.key) : ".."}` : ""} + ${isNumeric ? html`${() => { + const currentValue = state.values[p.key] + const bounds = numericBounds(p) + return currentValue !== undefined ? formatSliderValue(currentValue, String(bounds.step), p.precision, p.key) : ".." + }}` : ""} ${isNumeric ? html` -
+
${(() => { const bounds = numericBounds(p) + const currentNumeric = resolveCurrentNumericValue(p, bounds) + const precision = stepPrecision(bounds.step, p.precision) + const epsilon = Math.pow(10, -(precision + 2)) + const updating = isNumericUpdating(p.key) + const canDecrease = !updating && currentNumeric > (Number(bounds.min) + epsilon) + const canIncrease = !updating && currentNumeric < (Number(bounds.max) - epsilon) + const defaultNumeric = resolveDefaultNumericValue(p, bounds) + const defaultLabel = defaultNumeric !== null + ? formatSliderValue(defaultNumeric, String(bounds.step), p.precision, p.key) + : "N/A" + const canReset = !updating && defaultNumeric !== null && Math.abs(defaultNumeric - currentNumeric) > epsilon return html` - +
+ +
+ ${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)} + Default: ${defaultLabel} + +
+ +
` })()}
diff --git a/frogpilot/system/the_pond/assets/js/snackbar.js b/frogpilot/system/the_pond/assets/js/snackbar.js index 5a87689ff..4225378b1 100644 --- a/frogpilot/system/the_pond/assets/js/snackbar.js +++ b/frogpilot/system/the_pond/assets/js/snackbar.js @@ -1,26 +1,50 @@ -function showSnackbar(msg, level, timeout = 3500) { +function showSnackbar(msg, level, timeout = 3500, options = {}) { const wrapper = document.getElementById("snackbar_wrapper") + if (!wrapper) return + + const key = String(options?.key || "").trim() + const replace = options?.replace !== false + + const dismissSnackbar = (snackbar) => { + if (!snackbar) return + if (snackbar.__hideTimer) clearTimeout(snackbar.__hideTimer) + if (snackbar.__removeTimer) clearTimeout(snackbar.__removeTimer) + snackbar.style.opacity = 0 + snackbar.__removeTimer = setTimeout(() => snackbar.remove(), 1000) + } + + const setSnackbarContent = (snackbar) => { + snackbar.innerHTML = msg + snackbar.className = "snackbar show" + if (level === "error") { + snackbar.style.backgroundColor = "#f44336" + } else { + snackbar.style.backgroundColor = "" + } + } + + if (key && replace) { + const existing = Array.from(wrapper.children).find((el) => el.dataset?.snackbarKey === key) + if (existing) { + setSnackbarContent(existing) + existing.style.opacity = 1 + if (existing.__hideTimer) clearTimeout(existing.__hideTimer) + if (existing.__removeTimer) clearTimeout(existing.__removeTimer) + existing.__hideTimer = setTimeout(() => dismissSnackbar(existing), timeout) + return + } + } // Ensure max 2 snackbars are visible if (wrapper.children.length >= 2) { - const first = wrapper.children[0] - first.style.opacity = 0 - setTimeout(() => first.remove(), 1000) + dismissSnackbar(wrapper.children[0]) } const snackbar = document.createElement("div") - snackbar.innerHTML = msg - snackbar.className = "snackbar show" snackbar.id = `snackbar_${Math.random().toString(36).slice(5)}` - - if (level === "error") { - snackbar.style.backgroundColor = "#f44336" - } + if (key) snackbar.dataset.snackbarKey = key + setSnackbarContent(snackbar) wrapper.appendChild(snackbar) - - setTimeout(() => { - snackbar.style.opacity = 0 - setTimeout(() => snackbar.remove(), 1000) - }, timeout) + snackbar.__hideTimer = setTimeout(() => dismissSnackbar(snackbar), timeout) } diff --git a/frogpilot/system/the_pond/the_pond.py b/frogpilot/system/the_pond/the_pond.py index d75c86295..ead2f95e3 100644 --- a/frogpilot/system/the_pond/the_pond.py +++ b/frogpilot/system/the_pond/the_pond.py @@ -1733,6 +1733,41 @@ def setup(app): return jsonify(result), 200 + @app.route("/api/params/defaults", methods=["GET"]) + def get_default_params(): + allowed_keys, types = _get_param_type_info() + defaults_lookup = { + key: default_val + for key, default_val, _, _ in frogpilot_default_params + if key in allowed_keys + } + + result = {} + for key in allowed_keys: + t = types.get(key, str) + default_val = defaults_lookup.get(key) + + try: + if t == bool: + if isinstance(default_val, bytes): + default_str = default_val.decode("utf-8", errors="replace") + else: + default_str = str(default_val or "") + result[key] = default_str in ("1", "true", "True") + elif t == float: + result[key] = float(default_val) + elif t == int: + result[key] = int(float(default_val)) + else: + if isinstance(default_val, bytes): + result[key] = default_val.decode("utf-8", errors="replace") + else: + result[key] = str(default_val or "") + except Exception: + result[key] = None + + return jsonify(result), 200 + @app.route("/api/models/installed", methods=["GET"]) def get_installed_models(): catalog = get_model_catalog()