This commit is contained in:
firestar5683
2026-09-01 09:26:29 -05:00
parent eef1d0513e
commit d0fc9f9f46
30 changed files with 350 additions and 88 deletions
+8 -1
View File
@@ -62,6 +62,8 @@ THRESHOLD = 1 - 1 / math.e # Requires the condition to be true fo
NON_DRIVING_GEARS = [GearShifter.neutral, GearShifter.park, GearShifter.reverse, GearShifter.unknown]
ALWAYS_ON_LATERAL_UNSUPPORTED_CAR_MAKES = frozenset({"volvo"})
# Temporary fallback until the weather-compatible API is hosted locally.
STARPILOT_API = os.getenv("STARPILOT_API", "https://frogpilot.com/api")
@@ -328,6 +330,11 @@ def default_ev_tuning_enabled(CP):
ev_vehicle |= getattr(CP, "transmissionType", None) == car.CarParams.TransmissionType.direct
return bool(ev_vehicle)
def always_on_lateral_available(CP) -> bool:
return getattr(CP, "brand", None) not in ALWAYS_ON_LATERAL_UNSUPPORTED_CAR_MAKES
def get_starpilot_toggles(sm=messaging.SubMaster(["starpilotPlan"]), *, read_persisted_force_params=False):
toggles_text = sm["starpilotPlan"].starpilotToggles
if toggles_text:
@@ -810,7 +817,7 @@ class StarPilotVariables:
toggle.warningSoft_volume = self.get_value("WarningSoftVolume", cast=float, condition=toggle.alert_volume_controller)
toggle.warningImmediate_volume = max(self.get_value("WarningImmediateVolume", cast=float, condition=toggle.alert_volume_controller, default=25), 25)
toggle.always_on_lateral = self.get_value("AlwaysOnLateral")
toggle.always_on_lateral = self.get_value("AlwaysOnLateral") and always_on_lateral_available(CP)
lkas_button_assigned_to_aol = self.get_button_function("LKASButtonControl") == BUTTON_FUNCTIONS["AOL_TOGGLE"]
toggle.ford_lkas_aol_toggle = toggle.car_make == "ford" and lkas_button_assigned_to_aol
toggle.always_on_lateral_lkas = (
@@ -25,6 +25,11 @@ def test_ford_can_map_lkas_button_to_aol():
assert spv._lkas_allowed_for_aol("ford", 0, []) is True
def test_volvo_aol_is_held_off_until_pscm_sequence_is_validated():
assert spv.always_on_lateral_available(SimpleNamespace(brand="volvo")) is False
assert spv.always_on_lateral_available(SimpleNamespace(brand="honda")) is True
def test_explicit_main_cruise_aol_mapping_is_not_disabled_by_longitudinal_gate():
aol_button = spv.BUTTON_FUNCTIONS["AOL_TOGGLE"]
+18 -4
View File
@@ -16,7 +16,12 @@ from openpilot.starpilot.common.experimental_state import (
sync_manual_ce_state,
)
from openpilot.starpilot.common.favorite_slots import FAVORITE_ACTION_TRAFFIC_MODE_COUNTER, toggle_favorite_slot
from openpilot.starpilot.common.starpilot_variables import ERROR_LOGS_PATH, GearShifter, NON_DRIVING_GEARS
from openpilot.starpilot.common.starpilot_variables import (
ERROR_LOGS_PATH,
GearShifter,
NON_DRIVING_GEARS,
always_on_lateral_available,
)
from openpilot.starpilot.common.lateral_only_experimental import experimental_mode_available
from openpilot.starpilot.system.wheel_controls import (
CONTROLLER_ACTION_COUNTERS,
@@ -36,6 +41,7 @@ class StarPilotCard:
def __init__(self, CP, FPCP):
self.CP = CP
self.always_on_lateral_supported = always_on_lateral_available(CP)
self.params = Params(return_defaults=True)
self.params_memory = Params(memory=True)
@@ -84,7 +90,10 @@ class StarPilotCard:
self._distance_poll_counter = 0
self._onroad_distance_pressed = False
self.always_on_lateral_set = bool(FPCP.alternativeExperience & ALTERNATIVE_EXPERIENCE.ALWAYS_ON_LATERAL)
self.always_on_lateral_set = (
self.always_on_lateral_supported and
bool(FPCP.alternativeExperience & ALTERNATIVE_EXPERIENCE.ALWAYS_ON_LATERAL)
)
self.long_press_threshold = CRUISE_LONG_PRESS
self.very_long_press_threshold = CRUISE_LONG_PRESS * 5
@@ -230,9 +239,11 @@ class StarPilotCard:
]
button_event_types = [self._button_type_raw(be) for be in carState.buttonEvents]
button_aol_supported = self.CP.brand == "hyundai" or starpilot_toggles.lkas_allowed_for_aol
button_aol_supported = self.always_on_lateral_supported and (
self.CP.brand == "hyundai" or starpilot_toggles.lkas_allowed_for_aol
)
if getattr(self.CP, "carFingerprint", None) == HYUNDAI_CAR.HYUNDAI_SONATA_HYBRID:
button_aol_supported = bool(starpilot_toggles.lkas_allowed_for_aol)
button_aol_supported = self.always_on_lateral_supported and bool(starpilot_toggles.lkas_allowed_for_aol)
button_managed_aol = starpilot_toggles.always_on_lateral_lkas or (button_aol_supported and starpilot_toggles.main_cruise_aol_toggle)
g70_main_cruise_aol_managed = (
getattr(self.CP, "carFingerprint", None) == HYUNDAI_CAR.GENESIS_G70_2020
@@ -319,6 +330,9 @@ class StarPilotCard:
self.prev_cruise_enabled = carState.cruiseState.enabled
self.prev_cruise_available = carState.cruiseState.available
if not self.always_on_lateral_supported:
self.always_on_lateral_allowed = False
self.always_on_lateral_enabled = self.always_on_lateral_allowed and self.always_on_lateral_set
self.always_on_lateral_enabled &= carState.gearShifter not in NON_DRIVING_GEARS
self.always_on_lateral_enabled &= not hyundai_aol_needs_engagement or self.hyundai_aol_ready
@@ -395,6 +395,31 @@ def test_hyundai_lkas_button_can_start_aol_before_normal_engagement(monkeypatch,
assert ret.pauseLateral is False
def test_volvo_aol_stays_disabled_even_with_stale_enabled_toggle(monkeypatch, tmp_path):
monkeypatch.setattr(spc, "Params", FakeParams)
monkeypatch.setattr(spc, "ERROR_LOGS_PATH", tmp_path)
card = spc.StarPilotCard(
SimpleNamespace(brand="volvo"),
SimpleNamespace(alternativeExperience=spc.ALTERNATIVE_EXPERIENCE.ALWAYS_ON_LATERAL),
)
car_state = make_car_state(available=True, enabled=True)
starpilot_car_state = SimpleNamespace(distancePressed=False)
sm = make_sm()
toggles = make_toggles(
always_on_lateral=True,
always_on_lateral_main=True,
always_on_lateral_lkas=True,
lkas_allowed_for_aol=True,
main_cruise_aol_toggle=True,
)
ret = card.update(car_state, starpilot_car_state, sm, toggles)
assert ret.alwaysOnLateralAllowed is False
assert ret.alwaysOnLateralEnabled is False
def test_sonata_hybrid_lkas_button_can_start_aol_before_normal_engagement(monkeypatch, tmp_path):
monkeypatch.setattr(spc, "Params", FakeParams)
monkeypatch.setattr(spc, "ERROR_LOGS_PATH", tmp_path)
+84 -22
View File
@@ -36,6 +36,9 @@ class PairingAgent:
self._condition = threading.Condition()
self._prompt: dict[str, Any] | None = None
self._response: tuple[bool, str] | None = None
self._generation = 0
self._auto_accept_paths: set[str] = set()
self._auto_accept_incoming = False
@property
def prompt(self) -> dict[str, Any] | None:
@@ -44,26 +47,34 @@ class PairingAgent:
def clear(self) -> None:
with self._condition:
self._generation += 1
self._prompt = None
self._response = None
self._condition.notify_all()
def display(self, kind: str, device_path: str, value: str) -> None:
with self._condition:
self._generation += 1
self._prompt = {"id": uuid.uuid4().hex, "kind": kind, "device_path": device_path, "value": value, "display_only": True}
def request(self, kind: str, device_path: str, value: str = "", timeout: float = 60.0) -> tuple[bool, str]:
if self.auto_accept(kind, device_path):
return True, ""
prompt_id = uuid.uuid4().hex
with self._condition:
self._generation += 1
generation = self._generation
self._response = None
self._prompt = {"id": prompt_id, "kind": kind, "device_path": device_path, "value": value, "display_only": False}
deadline = time.monotonic() + timeout
while self._response is None:
while self._response is None and self._generation == generation:
remaining = deadline - time.monotonic()
if remaining <= 0:
self._prompt = None
return False, ""
self._condition.wait(remaining)
if self._generation != generation:
return False, ""
response = self._response
self._response = None
self._prompt = None
@@ -77,6 +88,21 @@ class PairingAgent:
self._condition.notify_all()
return True
def set_auto_accept(self, device_path: str, enabled: bool) -> None:
with self._condition:
if enabled:
self._auto_accept_paths.add(device_path)
else:
self._auto_accept_paths.discard(device_path)
def set_auto_accept_incoming(self, enabled: bool) -> None:
with self._condition:
self._auto_accept_incoming = enabled
def auto_accept(self, kind: str, device_path: str) -> bool:
with self._condition:
return kind in {"confirmation", "authorization"} and (self._auto_accept_incoming or device_path in self._auto_accept_paths)
class BlueZClient:
def __init__(self):
@@ -118,34 +144,19 @@ class BlueZClient:
while True:
message = self._agent_queue.get()
member = message.header.fields.get(HeaderFields.member, "")
device_path = str(message.body[0]) if message.body else ""
if member in {"RequestPinCode", "RequestPasskey", "RequestConfirmation", "RequestAuthorization", "AuthorizeService"}:
threading.Thread(target=self._handle_agent_request, args=(message, member, device_path), daemon=True).start()
continue
try:
response_signature = None
response_body: tuple = ()
device_path = str(message.body[0]) if message.body else ""
if member == "Release":
self.agent.clear()
elif member == "RequestPinCode":
accepted, value = self.agent.request("pin", device_path)
if not accepted:
raise PermissionError
response_signature, response_body = "s", (value,)
elif member == "DisplayPinCode":
self.agent.display("display_pin", device_path, str(message.body[1]))
elif member == "RequestPasskey":
accepted, value = self.agent.request("passkey", device_path)
if not accepted:
raise PermissionError
response_signature, response_body = "u", (int(value),)
elif member == "DisplayPasskey":
self.agent.display("display_passkey", device_path, f"{int(message.body[1]):06d}")
elif member == "RequestConfirmation":
accepted, _ = self.agent.request("confirmation", device_path, f"{int(message.body[1]):06d}")
if not accepted:
raise PermissionError
elif member in ("RequestAuthorization", "AuthorizeService"):
accepted, _ = self.agent.request("authorization", device_path)
if not accepted:
raise PermissionError
elif member == "Cancel":
self.agent.clear()
else:
@@ -156,6 +167,34 @@ class BlueZClient:
except Exception as error:
self.router.send(new_error(message, "org.bluez.Error.Canceled", "s", (str(error),)))
def _handle_agent_request(self, message: Any, member: str, device_path: str) -> None:
try:
response_signature = None
response_body: tuple = ()
if member == "RequestPinCode":
accepted, value = self.agent.request("pin", device_path)
if not accepted:
raise PermissionError
response_signature, response_body = "s", (value,)
elif member == "RequestPasskey":
accepted, value = self.agent.request("passkey", device_path)
if not accepted:
raise PermissionError
response_signature, response_body = "u", (int(value),)
elif member == "RequestConfirmation":
accepted, _ = self.agent.request("confirmation", device_path, f"{int(message.body[1]):06d}")
if not accepted:
raise PermissionError
else:
accepted, _ = self.agent.request("authorization", device_path)
if not accepted:
raise PermissionError
self.router.send(new_method_return(message, response_signature, response_body))
except PermissionError:
self.router.send(new_error(message, "org.bluez.Error.Rejected", "s", ("Pairing rejected",)))
except Exception as error:
self.router.send(new_error(message, "org.bluez.Error.Canceled", "s", (str(error),)))
def managed_objects(self) -> dict[str, dict[str, dict[str, Any]]]:
body = self._call("/", OBJECT_MANAGER, "GetManagedObjects")
return unwrap_variant(body[0]) if body else {}
@@ -198,11 +237,17 @@ class BlueZClient:
def status(self) -> dict[str, Any]:
objects = self.managed_objects()
_, adapter = self.adapter(objects)
prompt = self.agent.prompt
if prompt is not None:
prompt = dict(prompt)
device = objects.get(prompt.get("device_path", ""), {}).get(DEVICE_IFACE, {})
prompt["address"] = str(device.get("Address", ""))
prompt["name"] = str(device.get("Alias") or device.get("Name") or prompt["address"] or "Bluetooth device")
return {
"powered": bool(adapter.get("Powered", False)),
"discovering": bool(adapter.get("Discovering", False)),
"devices": self.devices(objects, include_discovering=bool(adapter.get("Discovering", False))),
"prompt": self.agent.prompt,
"prompt": prompt,
}
def set_powered(self, powered: bool) -> None:
@@ -212,6 +257,19 @@ class BlueZClient:
if reply.header.message_type == MessageType.error:
raise RuntimeError(str(reply.body[0] if reply.body else "Unable to change Bluetooth power"))
def set_discoverable(self, discoverable: bool) -> None:
path, _ = self.adapter()
address = DBusAddress(path, bus_name=BLUEZ, interface=ADAPTER_IFACE)
reply = self.router.send_and_get_reply(Properties(address).set("Pairable", "b", True), timeout=10.0)
if reply.header.message_type == MessageType.error:
raise RuntimeError(str(reply.body[0] if reply.body else "Unable to enable Bluetooth pairing"))
reply = self.router.send_and_get_reply(Properties(address).set("DiscoverableTimeout", "u", 0), timeout=10.0)
if reply.header.message_type == MessageType.error:
raise RuntimeError(str(reply.body[0] if reply.body else "Unable to configure Bluetooth discoverability"))
reply = self.router.send_and_get_reply(Properties(address).set("Discoverable", "b", discoverable), timeout=10.0)
if reply.header.message_type == MessageType.error:
raise RuntimeError(str(reply.body[0] if reply.body else "Unable to change Bluetooth discoverability"))
def start_discovery(self) -> None:
path, _ = self.adapter()
self._call(path, ADAPTER_IFACE, "StartDiscovery")
@@ -238,7 +296,11 @@ class BlueZClient:
def pair(self, address: str, device_path: str | None = None) -> None:
self._register_agent()
device = {"path": device_path} if device_path else self.device_for_address(address)
self._call(device["path"], DEVICE_IFACE, "Pair", timeout=90.0)
self.agent.set_auto_accept(device["path"], True)
try:
self._call(device["path"], DEVICE_IFACE, "Pair", timeout=90.0)
finally:
self.agent.set_auto_accept(device["path"], False)
self.set_device_property(address, "Trusted", "b", True)
self.agent.clear()
+29
View File
@@ -17,6 +17,7 @@ OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "t
SCAN_DURATION = 20.0
AUDIO_TEST_START_DELAY = 3.0
AUDIO_TEST_HOLD_TIME = 3.0
SCAN_RESULT_TTL = 30.0
class BluetoothController:
@@ -32,6 +33,7 @@ class BluetoothController:
self._pairing_error = ""
self._last_reconnect = 0.0
self._scan_deadline = 0.0
self._recent_devices: dict[str, tuple[dict[str, Any], float]] = {}
self._audio_test_deadline = 0.0
self._sleep = sleep
self.params.remove("BluetoothAudioTestActive")
@@ -58,6 +60,11 @@ class BluetoothController:
self._radio.start()
self._bluez = self._bluez_factory()
self._bluez.set_powered(True)
self._bluez.agent.set_auto_accept_incoming(self._offroad())
try:
self._bluez.set_discoverable(True)
except Exception as error:
cloudlog.warning(f"Bluetooth discoverability setup failed: {error}")
return self._bluez
def _reset_client(self) -> None:
@@ -72,6 +79,24 @@ class BluetoothController:
def _offroad(self) -> bool:
return self.params.get_bool("IsOffroad")
def _merge_recent_devices(self, result: dict[str, Any]) -> None:
now = time.monotonic()
current = {str(device.get("address", "")).upper() for device in result["devices"]}
if result["discovering"]:
for device in result["devices"]:
address = str(device.get("address", "")).upper()
if address:
self._recent_devices[address] = (dict(device), now + SCAN_RESULT_TTL)
return
for address, (device, expires) in list(self._recent_devices.items()):
if expires <= now:
self._recent_devices.pop(address, None)
elif address not in current:
result["devices"].append(dict(device))
result["devices"].sort(key=lambda device: (not device["connected"], not device["paired"],
-(device["rssi"] or -127), device["name"].lower()))
def status(self) -> dict[str, Any]:
# Status lazily initializes the radio, so serialize it with power changes.
with self._lock:
@@ -92,6 +117,8 @@ class BluetoothController:
try:
result.update(self._client().status())
result["available"] = True
self._bluez.agent.set_auto_accept_incoming(result["offroad"])
self._merge_recent_devices(result)
prompt = result.get("prompt")
if prompt is not None and self._pairing_address:
prompt["address"] = self._pairing_address
@@ -169,6 +196,7 @@ class BluetoothController:
self._radio.stop()
self.params.put_bool("BluetoothEnabled", False)
self._scan_deadline = 0.0
self._recent_devices.clear()
elif command == "start_scan":
if not self.params.get_bool("BluetoothEnabled"):
raise RuntimeError("Enable Bluetooth before scanning")
@@ -196,6 +224,7 @@ class BluetoothController:
self._client().disconnect(address)
elif command == "forget":
self._client().remove(address)
self._recent_devices.pop(address.upper(), None)
if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper():
self.params.remove("BluetoothAudioAddress")
elif command == "select_audio":
@@ -39,6 +39,9 @@ class FakeAgent:
def __init__(self):
self.responses = []
def set_auto_accept_incoming(self, _enabled):
pass
def respond(self, prompt_id, accepted, value):
self.responses.append((prompt_id, accepted, value))
return prompt_id == "prompt"
@@ -48,6 +51,7 @@ class FakeBlueZ:
def __init__(self):
self.agent = FakeAgent()
self.powered = False
self.discoverable = False
self.discovering = False
self.closed = False
self.actions = []
@@ -67,6 +71,9 @@ class FakeBlueZ:
def set_powered(self, powered):
self.powered = powered
def set_discoverable(self, discoverable):
self.discoverable = discoverable
def status(self):
return {"powered": self.powered, "discovering": self.discovering, "devices": [dict(self.device)], "prompt": None}
@@ -2,7 +2,7 @@ import { html, reactive } from "/assets/vendor/arrow-core.js"
import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js"
import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1"
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-9"
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-13"
import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2"
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
@@ -13,7 +13,7 @@ import { MapsManager } from "/assets/components/tools/maps.js"
import { NavDestination } from "/assets/components/navigation/navigation_destination.js?v=nav-search-context-2"
import { NavKeys } from "/assets/components/navigation/navigation_keys.js?v=app-keys-session-1"
import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js"
import { SettingsView } from "/assets/components/settings.js?v=router-cycle-fix-3"
import { SettingsView } from "/assets/components/settings.js?v=router-cycle-fix-5"
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
import { Sidebar } from "/assets/components/sidebar.js?v=controllers-nav-1"
import { SentryMode } from "/assets/components/tools/sentry.js"
@@ -13,6 +13,7 @@ const state = reactive({
selectedAudio: "",
pairingAddress: "",
devices: [],
revision: 0,
prompt: null,
audioTestAddress: "",
audioTestLabel: "",
@@ -31,17 +32,11 @@ function bluetoothPageActive() {
return document.querySelector(".bluetoothPage") !== null || currentPath === bluetoothPath
}
function pollDelay() {
return state.busy || state.discovering || state.pairingAddress ? 500 : 2000
}
function schedulePoll(delay = pollDelay()) {
if (pollTimer !== null) clearTimeout(pollTimer)
pollTimer = setTimeout(async () => {
pollTimer = null
if (bluetoothPageActive() && state.busy !== "power") await refresh()
schedulePoll()
}, delay)
function schedulePoll() {
if (pollTimer !== null) return
pollTimer = setInterval(() => {
if (bluetoothPageActive() && state.busy !== "power") refresh()
}, 750)
}
function startAudioTestCountdown(address, delayMs, requestStartedAt) {
@@ -107,6 +102,7 @@ async function refreshOnce() {
state.selectedAudio = String(payload.selected_audio || "")
state.pairingAddress = String(payload.pairing_address || "")
state.devices = Array.isArray(payload.devices) ? payload.devices : []
state.revision++
state.prompt = payload.prompt || null
state.error = payload.error || (response.ok ? "" : "Bluetooth service unavailable")
}
@@ -194,7 +190,12 @@ async function refresh() {
function initialize() {
if (initialized) return
initialized = true
schedulePoll(0)
window.addEventListener("focus", refresh)
document.addEventListener("visibilitychange", () => {
if (!document.hidden && bluetoothPageActive()) refresh()
})
refresh()
schedulePoll()
}
function normalizedAddress(device) {
@@ -359,10 +360,14 @@ export function Bluetooth() {
<p>Turn it on to reconnect saved devices or find something new.</p>
</div>
` : ""}
${() => !state.loading && state.enabled ? html`
${deviceSection("My Devices", "bi-check2-circle", knownDevices(), "No saved devices yet.")}
${deviceSection("Available Devices", "bi-radar", availableDevices(), state.discovering ? "Searching for nearby devices…" : "No nearby devices found. Start a search to try again.")}
` : ""}
${() => {
if (state.loading || !state.enabled) return ""
void state.revision
return html`
${deviceSection("My Devices", "bi-check2-circle", knownDevices(), "No saved devices yet.")}
${deviceSection("Available Devices", "bi-radar", availableDevices(), state.discovering ? "Searching for nearby devices…" : "No nearby devices found. Start a search to try again.")}
`
}}
</div>
</div>
`
@@ -50,7 +50,7 @@
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script type="module">
import("/assets/components/router.js?v=router-cycle-fix-4").catch((err) => {
import("/assets/components/router.js?v=router-cycle-fix-5").catch((err) => {
console.error("[the_galaxy] bootstrap failed", err);
const target = document.getElementById("app") || document.body;
const pre = document.createElement("pre");
@@ -21,8 +21,8 @@ def test_router_and_settings_cache_bust_is_consistent():
router = ROUTER_PATH.read_text(encoding="utf-8")
index = INDEX_PATH.read_text(encoding="utf-8")
assert "/assets/components/settings.js?v=router-cycle-fix-3" in router
assert "/assets/components/router.js?v=router-cycle-fix-3" in index
assert "/assets/components/settings.js?v=router-cycle-fix-5" in router
assert "/assets/components/router.js?v=router-cycle-fix-5" in index
def test_bluetooth_actions_use_reactive_disabled_bindings():