diff --git a/common/libcommon.a b/common/libcommon.a
index e78f4a9ee..d16fcda9f 100644
Binary files a/common/libcommon.a and b/common/libcommon.a differ
diff --git a/common/params_keys.h b/common/params_keys.h
index 6004f3578..7f8c40170 100644
--- a/common/params_keys.h
+++ b/common/params_keys.h
@@ -369,6 +369,7 @@ inline static std::unordered_map keys = {
{"StarPilotCarParamsPersistent", {PERSISTENT, BYTES, "", ""}},
{"StarPilotDongleId", {PERSISTENT | DONT_LOG, STRING, "", "", 0}},
{"StarPilotFavoriteSlots", {PERSISTENT, JSON, "[]", "[]", 1}},
+ {"ControllerActionSlots", {PERSISTENT, JSON, "[]", "[]", 1}},
{"WheelControlLearnSlot", {CLEAR_ON_MANAGER_START | DONT_LOG, INT}},
{"WheelControlMappings", {PERSISTENT, JSON, "[]", "[]", 1}},
{"WheelControlStatus", {CLEAR_ON_MANAGER_START | DONT_LOG, JSON, "{}", "{}"}},
diff --git a/common/params_pyx.so b/common/params_pyx.so
index 47765a834..64a3b09c9 100755
Binary files a/common/params_pyx.so and b/common/params_pyx.so differ
diff --git a/starpilot/common/favorite_slots.py b/starpilot/common/favorite_slots.py
index ff9dd56d0..eb646fefb 100644
--- a/starpilot/common/favorite_slots.py
+++ b/starpilot/common/favorite_slots.py
@@ -480,20 +480,12 @@ def trigger_favorite_action(key: str | None, params_memory: Params | None = None
return True
-def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_memory: Params | None = None, *,
+def execute_favorite_key(key: str | None, params: Params | None = None, params_memory: Params | None = None, *,
eligible_keys: Iterable[str] | None = None) -> bool:
- """Universal polymorphic favorite execution dispatcher."""
- if slot_index < 0 or slot_index >= FAVORITE_SLOT_COUNT:
- return False
-
params = params or Params(return_defaults=True)
eligible_keys = set(eligible_keys) if eligible_keys is not None else None
- slots = load_favorite_slots(params, eligible_keys=eligible_keys)
- slot = slots[slot_index]
- key = slot.get("key")
- if not slot.get("enabled") or not key:
+ if not favorite_key_is_valid(params, key, eligible_keys=eligible_keys):
return False
-
if not is_param_action_safe_onroad(key, params):
return False
@@ -516,6 +508,22 @@ def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_m
return False
+def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_memory: Params | None = None, *,
+ eligible_keys: Iterable[str] | None = None) -> bool:
+ """Universal polymorphic favorite execution dispatcher."""
+ if slot_index < 0 or slot_index >= FAVORITE_SLOT_COUNT:
+ return False
+
+ params = params or Params(return_defaults=True)
+ eligible_keys = set(eligible_keys) if eligible_keys is not None else None
+ slots = load_favorite_slots(params, eligible_keys=eligible_keys)
+ slot = slots[slot_index]
+ key = slot.get("key")
+ if not slot.get("enabled") or not key:
+ return False
+ return execute_favorite_key(key, params, params_memory, eligible_keys=eligible_keys)
+
+
def unassign_favorite_slot(slot_index: int, params: Params | None = None, params_memory: Params | None = None, *,
eligible_keys: Iterable[str] | None = None) -> list[dict[str, Any]] | None:
"""Reset a slot to the disabled/unassigned state and notify listeners."""
diff --git a/starpilot/common/tests/test_favorite_slots.py b/starpilot/common/tests/test_favorite_slots.py
index d7b48e658..66f143116 100644
--- a/starpilot/common/tests/test_favorite_slots.py
+++ b/starpilot/common/tests/test_favorite_slots.py
@@ -12,6 +12,7 @@ from openpilot.starpilot.common.favorite_slots import (
SETTINGS_CATALOG_PATH,
build_favorite_slot_options,
default_favorite_slots,
+ execute_favorite_key,
filter_favorite_slot_options,
load_settings_catalog,
load_favorite_slots,
@@ -156,6 +157,17 @@ def test_toggle_favorite_slot_flips_bool_and_requests_refresh():
assert memory.get_bool("StarPilotTogglesUpdated") is True
+def test_execute_favorite_key_uses_same_dispatch_without_a_visible_slot():
+ params = FakeParams()
+ memory = FakeParams()
+ params.put("RedneckCruise", False)
+
+ assert execute_favorite_key("RedneckCruise", params, memory, eligible_keys={"RedneckCruise"}) is True
+ assert params.get_bool("RedneckCruise") is True
+ assert memory.get_bool("StarPilotTogglesUpdated") is True
+ assert execute_favorite_key("NotBool", params, memory, eligible_keys={"RedneckCruise"}) is False
+
+
def test_toggle_favorite_slot_blocks_alpha_longitudinal_onroad():
params = FakeParams()
params.put("IsOnroad", True)
diff --git a/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.css b/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.css
index 5e1b999a5..2464f3888 100644
--- a/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.css
+++ b/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.css
@@ -18,6 +18,8 @@
.wheelHeader h2,
.wheelHeader p,
+.wheelSectionHeading h3,
+.wheelSectionHeading p,
.wheelCard h3,
.wheelCard p {
margin: 0;
@@ -43,11 +45,26 @@
}
.wheelSlotGrid,
+.wheelControllerGrid,
.wheelMappings {
display: grid;
gap: 12px;
}
+.wheelSection {
+ display: grid;
+ gap: 12px;
+}
+
+.wheelSectionHeading p {
+ margin-top: 5px;
+ opacity: 0.68;
+}
+
+.wheelControllerGrid {
+ grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
+}
+
.wheelSlotLabel,
.wheelMapping span,
.wheelMuted,
@@ -68,6 +85,31 @@
color: #cbb8ff;
}
+.wheelActionPicker {
+ display: grid;
+ gap: 6px;
+ margin-top: 14px;
+}
+
+.wheelActionPicker span {
+ opacity: 0.68;
+}
+
+.wheelActionPicker select,
+.wheelActionPicker input {
+ width: 100%;
+ min-height: 42px;
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-md);
+ padding: 8px 10px;
+ color: inherit;
+ background: var(--main-bg);
+}
+
+.wheelSpeedPicker {
+ max-width: 220px;
+}
+
.wheelMapping {
margin-top: 12px;
padding-top: 12px;
diff --git a/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js b/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js
index e8711e811..1cb24c1e0 100644
--- a/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js
+++ b/starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js
@@ -9,6 +9,11 @@ const state = reactive({
joystickDevice: "",
mappings: [],
slots: [],
+ controllerSlots: [],
+ controllerOptions: [],
+ speedUnit: "mph",
+ speedMinimum: 5,
+ speedMaximum: 90,
learning: false,
learningSlot: null,
remainingSeconds: 0,
@@ -30,6 +35,11 @@ async function refresh() {
state.joystickDevice = typeof payload.joystick_device === "string" ? payload.joystick_device : ""
state.mappings = Array.isArray(payload.mappings) ? payload.mappings : []
state.slots = Array.isArray(payload.slots) ? payload.slots : []
+ state.controllerSlots = Array.isArray(payload.controller_slots) ? payload.controller_slots : []
+ state.controllerOptions = Array.isArray(payload.controller_options) ? payload.controller_options : []
+ state.speedUnit = typeof payload.speed_unit === "string" ? payload.speed_unit : "mph"
+ state.speedMinimum = Number(payload.speed_minimum || 5)
+ state.speedMaximum = Number(payload.speed_maximum || 90)
state.learning = !!payload.learning
state.learningSlot = Number.isInteger(payload.learning_slot) ? payload.learning_slot : null
state.remainingSeconds = Number(payload.remaining_seconds || 0)
@@ -117,6 +127,71 @@ function slotCard(slot, index) {
`
}
+function controllerSlotCard(slot, index) {
+ const targetIndex = 3 + index
+ const selectedKey = slot?.key || ""
+ const selectedOption = state.controllerOptions.find(option => option.key === selectedKey)
+ const mappings = () => state.mappings.filter(mapping => mapping.slot === targetIndex)
+ const learning = () => state.learning && state.learningSlot === targetIndex
+ const selectAction = event => {
+ const key = event.currentTarget.value
+ const option = state.controllerOptions.find(candidate => candidate.key === key)
+ const value = option?.value_type === "speed" ? Number(slot?.value || option.default_value || 30) : null
+ request("action", { slot: index, key, value })
+ }
+ return html`
+
+
+
+ Action
+
+ Not configured
+ ${state.controllerOptions.map(option => html`
+ ${option.label}
+ `)}
+
+
+ ${selectedOption?.value_type === "speed" ? html`
+
+ Set speed (${() => state.speedUnit})
+ request("action", { slot: index, key: selectedKey, value: Number(event.currentTarget.value) })}" />
+
+ ` : ""}
+ ${selectedOption?.value_type === "speed"
+ ? "Uses the current mph/km/h setting and applies while openpilot is engaged on software-controlled cruise."
+ : selectedKey === "__starpilot_controller_action__:selfie"
+ ? "Captures in the background and saves the driver-camera image in Galaxy → Sentry Mode."
+ : "Controller-only action. It does not create an on-screen Favorite button."}
+ ${() => learning() ? html`
+ Press one button on your controller, macropad, or keyboard.
+ ` : ""}
+
+ ${() => mappings().length ? mappings().map(mappingRow) : html`No buttons mapped. `}
+
+
+ `
+}
+
+function mappingTargetName(slot) {
+ const index = Number(slot)
+ return index < 3 ? `Favorite #${index + 1}` : `Controller Action #${index - 2}`
+}
+
function testResultClass() {
if (!state.lastTested) return "waiting"
return state.lastTested.mapped ? "success" : "failure"
@@ -137,7 +212,7 @@ function testPanel() {
const device = state.lastTested.device_name || "External input"
const button = state.lastTested.event_name || `Button ${state.lastTested.event_code}`
return state.lastTested.mapped
- ? `${button} on ${device} is mapped to Favorite #${Number(state.lastTested.slot) + 1}.`
+ ? `${button} on ${device} is mapped to ${mappingTargetName(state.lastTested.slot)}.`
: `${button} on ${device} does not have a mapping.`
}}
@@ -170,7 +245,7 @@ export function WheelControls() {