This commit is contained in:
firestar5683
2026-08-29 22:38:52 -05:00
parent b5286ec678
commit 388662ceb0
15 changed files with 578 additions and 135 deletions
@@ -588,14 +588,14 @@ KIA_CARNIVAL_UNWIND_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.18
KIA_CARNIVAL_UNWIND_FRICTION_JERK_DEADZONE_JERK = 0.65
KIA_CARNIVAL_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH = 0.25
KIA_CARNIVAL_UNWIND_FF_REDUCTION_MAX = 0.45
KIA_CARNIVAL_UNWIND_FF_SPEED = 15.0
KIA_CARNIVAL_UNWIND_FF_SPEED = 9.0
KIA_CARNIVAL_UNWIND_FF_SPEED_WIDTH = 2.0
KIA_CARNIVAL_UNWIND_FF_SPEED_CUTOFF = 23.0
KIA_CARNIVAL_UNWIND_FF_SPEED_CUTOFF_WIDTH = 2.0
KIA_CARNIVAL_UNWIND_FF_OVERSHOOT = 0.20
KIA_CARNIVAL_UNWIND_FF_OVERSHOOT_WIDTH = 0.12
KIA_CARNIVAL_UNWIND_FF_JERK = 0.65
KIA_CARNIVAL_UNWIND_FF_JERK_WIDTH = 0.25
KIA_CARNIVAL_UNWIND_FF_OVERSHOOT = 0.08
KIA_CARNIVAL_UNWIND_FF_OVERSHOOT_WIDTH = 0.06
KIA_CARNIVAL_UNWIND_FF_JERK = 0.45
KIA_CARNIVAL_UNWIND_FF_JERK_WIDTH = 0.20
TUCSON_4TH_GEN_CENTER_TAPER_MAX = 0.44
TUCSON_4TH_GEN_CENTER_TAPER_LAT = 0.28
@@ -26,6 +26,7 @@ from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import (
get_honda_accord_stop_go_accel_cap,
get_honda_accord_stop_go_accel_rise_rate,
get_toyota_rav4_tss2_lead_departure_tune,
get_toyota_rav4_tss2_lead_creep_tune,
get_force_stop_distance_bias,
get_force_stop_handoff_distance,
get_stop_sign_low_speed_hold,
@@ -1323,11 +1324,17 @@ class LongitudinalPlanner:
lead_gap = float(getattr(lead, "dRel", 0.0))
lead_speed = max(float(getattr(lead, "vLead", 0.0)), 0.0)
lead_accel = float(getattr(lead, "aLeadK", 0.0))
creep_tune = get_toyota_rav4_tss2_lead_creep_tune(self.CP)
min_lead_speed, min_lead_accel = (
(STANDSTILL_LEAD_CREEP_RELEASE_MIN_LEAD_SPEED,
STANDSTILL_LEAD_CREEP_RELEASE_MIN_LEAD_ACCEL)
if creep_tune is None else creep_tune
)
return bool(
float(v_ego) <= STANDSTILL_LEAD_DEPART_MAX_EGO_SPEED and
lead_gap >= standstill_nudge_gap + STANDSTILL_LEAD_CREEP_RELEASE_MIN_GAP_MARGIN and
lead_speed >= STANDSTILL_LEAD_CREEP_RELEASE_MIN_LEAD_SPEED and
lead_accel >= STANDSTILL_LEAD_CREEP_RELEASE_MIN_LEAD_ACCEL
lead_speed >= min_lead_speed and
lead_accel >= min_lead_accel
)
def get_safe_depart_release_hold_lead(self, v_ego):
@@ -22,7 +22,7 @@ HONDA_ACCORD_STOP_GO_ACCEL_RISE_RATE = 4.0
HYUNDAI_ELANTRA_LEAD_FOLLOW_JERK_SCALE = 1.25
GENESIS_GV70_ELECTRIFIED_LEAD_FOLLOW_JERK_SCALE = 1.35
FORD_LIGHTNING_LEAD_FOLLOW_JERK_SCALE = 1.35
HONDA_CRV_5G_LEAD_FOLLOW_JERK_SCALE = 1.20
HONDA_CRV_5G_LEAD_FOLLOW_JERK_SCALE = 1.35
GM_SILVERADO_EARLY_FOLLOW_MIN_EGO_SPEED = 18.0
GM_SILVERADO_EARLY_FOLLOW_MAX_DISTANCE = 130.0
GM_SILVERADO_EARLY_FOLLOW_MIN_MODEL_PROB = 0.85
@@ -73,6 +73,8 @@ TOYOTA_RAV4_TSS2_FAR_FOLLOW_BRAKE_SLEW_RATE = 2.5
TOYOTA_RAV4_TSS2_FAR_FOLLOW_RELEASE_SLEW_RATE = 1.75
TOYOTA_RAV4_TSS2_LEAD_DEPART_ACCEL_HOLD_MAX_ACCEL = 0.70
TOYOTA_RAV4_TSS2_LEAD_DEPART_ACCEL_ASSIST = 0.20
TOYOTA_RAV4_TSS2_LEAD_CREEP_MIN_LEAD_SPEED = 0.15
TOYOTA_RAV4_TSS2_LEAD_CREEP_MIN_LEAD_ACCEL = -0.05
TOYOTA_PRIUS_STOPPED_LEAD_OBSTACLE_BIAS_M = 1.5
TOYOTA_PRIUS_STOPPED_LEAD_MAX_EGO_SPEED = 22.0
TOYOTA_PRIUS_STOPPED_LEAD_MAX_SPEED = 1.0
@@ -356,6 +358,16 @@ def get_toyota_rav4_tss2_lead_departure_tune(CP):
return None
def get_toyota_rav4_tss2_lead_creep_tune(CP):
"""Allow a credible RAV4 vision lead to release the standstill hold early."""
if is_toyota_rav4_tss2_post_departure_tune(CP):
return (
TOYOTA_RAV4_TSS2_LEAD_CREEP_MIN_LEAD_SPEED,
TOYOTA_RAV4_TSS2_LEAD_CREEP_MIN_LEAD_ACCEL,
)
return None
def get_toyota_rav4_tss2_early_lead_cap(CP, lead, v_ego, accel_min):
"""Start a mild RAV4 coast/brake response before a hard lead approach."""
if (
@@ -772,6 +772,9 @@ class TestLatControl:
assert overshooting_unwind < 0.70
assert highway_overshoot > overshooting_unwind
low_speed_exit = get_kia_carnival_unwind_ff_scale(0.31, 0.43, -0.88, 11.0)
assert low_speed_exit < 0.90
def test_genesis_g90_ff_scale_curve(self):
assert get_genesis_g90_ff_scale(0.0, 0.0, 20.0) == 1.0
assert get_genesis_g90_ff_scale(0.5, 0.0, 20.0) > get_genesis_g90_ff_scale(-0.5, 0.0, 20.0)
@@ -48,6 +48,7 @@ from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import (
get_tracked_lead_catchup_speed_range,
get_toyota_prius_stopped_lead_obstacle_bias,
get_toyota_rav4_tss2_lead_departure_tune,
get_toyota_rav4_tss2_lead_creep_tune,
get_toyota_rav4_tss2_early_lead_cap,
get_toyota_sienna_post_departure_restop_cap,
is_toyota_rav4_tss2_radar_follow_lead,
@@ -3335,6 +3336,26 @@ def test_rav4_tss2_variants_use_the_car_specific_post_departure_tune():
assert not is_toyota_rav4_tss2_post_departure_tune(other_cp)
def test_rav4_tss2_lead_creep_tune_is_vehicle_specific():
rav4 = ToyotaCarInterface.get_non_essential_params(TOYOTA_CAR.TOYOTA_RAV4_TSS2_2023)
other = ToyotaCarInterface.get_non_essential_params(TOYOTA_CAR.TOYOTA_RAV4_TSS2_2022)
assert get_toyota_rav4_tss2_lead_creep_tune(rav4) == pytest.approx((0.15, -0.05))
assert get_toyota_rav4_tss2_lead_creep_tune(other) is None
def test_rav4_tss2_standstill_lead_creep_does_not_wait_for_lead_acceleration():
rav4 = ToyotaCarInterface.get_non_essential_params(TOYOTA_CAR.TOYOTA_RAV4_TSS2_2023)
civic = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
rav4_planner = LongitudinalPlanner(rav4, init_v=0.0)
civic_planner = LongitudinalPlanner(civic, init_v=0.0)
lead = make_lead(status=True, d_rel=6.4, v_lead=0.2, a_lead=0.0, model_prob=1.0)
gap = longitudinal_planner_module.STOP_DISTANCE - 0.5
assert rav4_planner.is_slow_creep_lead_depart(lead, 0.0, gap)
assert not civic_planner.is_slow_creep_lead_depart(lead, 0.0, gap)
def test_rav4_tss2_early_lead_cap_starts_a_mild_response():
CP = ToyotaCarInterface.get_non_essential_params(TOYOTA_CAR.TOYOTA_RAV4_TSS2_2023)
lead = make_lead(status=True, d_rel=100.0, v_lead=13.0, a_lead=-1.1, model_prob=0.9)
@@ -44,7 +44,7 @@ def test_lead_follow_jerk_scale_is_platform_specific():
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021")) == 1.25
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="hyundai", carFingerprint="GENESIS_GV70_ELECTRIFIED_1ST_GEN")) == 1.35
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="ford", carFingerprint="FORD_F_150_LIGHTNING_MK1")) == 1.35
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="honda", carFingerprint="HONDA_CRV_5G")) == 1.20
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="honda", carFingerprint="HONDA_CRV_5G")) == 1.35
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="other", carFingerprint="OTHER_CAR")) == 1.0
+72 -24
View File
@@ -21,26 +21,54 @@ class BluetoothDeviceButton(BigButton):
self._offroad = offroad
self._selected_audio = selected_audio
self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32)
self._forget_btn = ForgetButton(lambda: self._manager.forget(self.device.address))
self._forget_btn = ForgetButton(self._forget_device)
self.update_device(device, selected_audio, offroad)
def _forget_device(self):
self._manager.forget(self.device.address)
def _get_label_font_size(self):
return 48
@property
def _show_forget_btn(self):
return self.device.paired and self._offroad
return self.device.paired and self._offroad and self._manager.operation_for(self.device.address) != "forgetting"
def update_device(self, device, selected_audio: str, offroad: bool):
self.device = device
self._selected_audio = selected_audio
self._offroad = offroad
states = ["connected" if device.connected else "paired" if device.paired else "pair"]
if device.audio:
states.append("audio selected" if selected_audio.upper() == device.address.upper() else "audio")
if device.controller:
states.append("controller")
self.set_value(" / ".join(states))
def _update_state(self):
super()._update_state()
operation = self._manager.operation_for(self.device.address)
pairing = self._manager.status.pairing_address.upper() == self.device.address.upper()
audio_selected = self._selected_audio.upper() == self.device.address.upper()
if operation or pairing:
self.set_value(operation or "pairing")
self.set_enabled(False)
elif self.device.connected:
capabilities = []
if audio_selected:
capabilities.append("audio output")
elif self.device.audio:
capabilities.append("audio")
if self.device.controller:
capabilities.append("controller")
self.set_value("connected" + (f" / {' / '.join(capabilities)}" if capabilities else ""))
self.set_enabled(True)
elif self.device.paired:
self.set_value("connect")
self.set_enabled(True)
else:
capabilities = []
if self.device.audio:
capabilities.append("audio")
if self.device.controller:
capabilities.append("controller")
self.set_value("pair" + (f" / {' / '.join(capabilities)}" if capabilities else ""))
self.set_enabled(self._offroad)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._show_forget_btn and rl.check_collision_point_rec(mouse_pos, self._forget_btn.rect):
@@ -117,20 +145,28 @@ class BluetoothLayoutMici(NavScroller):
self._scan_btn = BigButton("scan for devices", "scan", self._dialog_icon, scroll=True)
self._scan_btn.set_click_callback(lambda: self._manager.set_scanning(True))
self._scanning_btn = BluetoothScanningButton()
self._device_buttons = {}
self._scan_on_ready = False
self._scroller.add_widgets([self._power_btn, self._scan_btn, self._scanning_btn])
self._rebuild()
def show_event(self):
super().show_event()
self._manager.set_active(True)
self._scan_on_ready = True
gui_app.add_nav_stack_tick(self._tick)
def hide_event(self):
if self._manager.status.discovering and self._manager.status.offroad:
self._manager.set_scanning(False)
self._manager.set_active(False)
gui_app.remove_nav_stack_tick(self._tick)
super().hide_event()
def _toggle_power(self):
self._manager.set_power(not self._manager.status.enabled)
enabled = not self._manager.status.enabled
self._scan_on_ready = enabled
self._manager.set_power(enabled)
def _rebuild(self):
status = self._manager.status
@@ -139,35 +175,43 @@ class BluetoothLayoutMici(NavScroller):
self._scan_btn.set_enabled(status.enabled and status.offroad)
items = [self._power_btn]
for device in status.devices:
button = BluetoothDeviceButton(device, self._manager, self._bluetooth_icon, status.selected_audio, status.offroad)
button.set_enabled(status.offroad or device.paired)
button.set_click_callback(lambda selected=device: self._device_actions(selected))
button = self._device_buttons.get(device.address)
if button is None:
button = BluetoothDeviceButton(device, self._manager, self._bluetooth_icon, status.selected_audio, status.offroad)
button.set_click_callback(lambda address=device.address: self._device_selected(address))
self._device_buttons[device.address] = button
self._scroller.add_widget(button)
else:
button.update_device(device, status.selected_audio, status.offroad)
items.append(button)
if status.enabled:
items.append(self._scanning_btn if status.discovering else self._scan_btn)
self._scroller.items.clear()
self._scroller.add_widgets(items)
self._device_buttons = {device.address: self._device_buttons[device.address] for device in status.devices}
self._scroller.items[:] = items
def _device_actions(self, device):
def _device_selected(self, address: str):
device = next((device for device in self._manager.status.devices if device.address == address), None)
if device is None:
return
if not device.paired:
self._manager.pair(device.address)
return
elif not device.connected:
self._manager.connect(device.address)
else:
self._device_actions(device)
options = ["disconnect" if device.connected else "connect"]
def _device_actions(self, device):
options = ["disconnect"]
if device.audio:
selected = self._manager.status.selected_audio.upper() == device.address.upper()
options.append("stop using for audio" if selected else "use for audio")
if device.connected and self._manager.status.offroad:
options.append("test audio")
if self._manager.status.offroad:
options.append("forget")
dialog_holder = {}
def apply():
action = dialog_holder["dialog"].get_selected_option()
if action == "connect":
self._manager.connect(device.address)
elif action == "disconnect":
if action == "disconnect":
self._manager.disconnect(device.address)
elif action == "use for audio":
self._manager.select_audio(device.address)
@@ -176,8 +220,6 @@ class BluetoothLayoutMici(NavScroller):
elif action == "test audio":
self._manager.test_audio(device.address)
gui_app.push_widget(BluetoothAudioTestDialog(self._manager, self._dialog_icon))
elif action == "forget":
self._manager.forget(device.address)
dialog = BigMultiOptionDialog(options=options, default=options[0], right_btn_callback=apply)
dialog_holder["dialog"] = dialog
@@ -217,12 +259,18 @@ class BluetoothLayoutMici(NavScroller):
status.discovering,
status.offroad,
status.selected_audio,
status.pairing_address,
tuple((device.address, device.name, device.paired, device.connected, device.audio, device.controller) for device in status.devices),
)
if signature != self._last_signature:
self._last_signature = signature
self._rebuild()
if self._scan_on_ready and status.available and status.enabled:
self._scan_on_ready = False
if status.offroad and not status.discovering:
self._manager.set_scanning(True)
error = self._manager.consume_error()
if error:
self._scan_on_ready = False
gui_app.push_widget(BigDialog("Bluetooth", error))
self._handle_prompt()
+2
View File
@@ -63,6 +63,7 @@ class BluetoothStatus:
discovering: bool = False
offroad: bool = False
selected_audio: str = ""
pairing_address: str = ""
devices: tuple[BluetoothDevice, ...] = ()
prompt: dict[str, Any] | None = None
error: str = ""
@@ -76,6 +77,7 @@ class BluetoothStatus:
discovering=bool(value.get("discovering", False)),
offroad=bool(value.get("offroad", False)),
selected_audio=str(value.get("selected_audio", "")),
pairing_address=str(value.get("pairing_address", "")),
devices=tuple(BluetoothDevice.from_dict(device) for device in value.get("devices", ())),
prompt=value.get("prompt"),
error=str(value.get("error", "")),
@@ -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-3"
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-4"
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"
@@ -1,55 +1,67 @@
.bluetoothPage {
display: flex;
flex-direction: column;
gap: 16px;
gap: 18px;
margin: 0 auto;
max-width: var(--width-xxxl);
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
}
.bluetoothHeader,
.bluetoothTitle,
.bluetoothDeviceHeader,
.bluetoothToolbar,
.bluetoothActions,
.bluetoothBadges {
display: flex;
.bluetoothDeviceName,
.bluetoothSectionHeader,
.bluetoothSectionHeader > div {
align-items: center;
gap: 10px;
flex-wrap: wrap;
display: flex;
}
.bluetoothHeader,
.bluetoothDeviceHeader {
.bluetoothHeader {
justify-content: space-between;
gap: 18px;
}
.bluetoothTitle {
gap: 14px;
}
.bluetoothTitle > i {
color: #a98ce5;
font-size: 2.5rem;
align-items: center;
background: rgba(139, 108, 197, 0.16);
border: 1px solid rgba(169, 140, 229, 0.34);
border-radius: 16px;
color: #b99bec;
display: flex;
font-size: 2rem;
height: 58px;
justify-content: center;
width: 58px;
}
.bluetoothHeader h2,
.bluetoothHeader p,
.bluetoothCard h3 {
.bluetoothDeviceRow h3,
.bluetoothDeviceRow p,
.bluetoothEmptyPage h3,
.bluetoothEmptyPage p,
.bluetoothSectionHeader h3 {
margin: 0;
}
.bluetoothHeader p {
margin-top: 6px;
opacity: 0.8;
margin-top: 4px;
opacity: 0.72;
}
.bluetoothCard,
.bluetoothNotice,
.bluetoothError,
.bluetoothPrompt {
background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
padding: 16px;
}
.bluetoothDeviceList {
display: grid;
gap: 12px;
padding: 14px 16px;
}
.bluetoothNotice,
@@ -57,83 +69,329 @@
color: #ffe2a3;
}
.bluetoothAudioCountdown {
display: flex;
align-items: center;
gap: 18px;
padding: 16px 20px;
border: 1px solid rgba(64, 201, 124, 0.45);
border-radius: var(--border-radius-lg);
background: rgba(64, 201, 124, 0.12);
}
.bluetoothAudioCountdown strong {
min-width: 72px;
color: #b8ffd4;
font-size: 2rem;
text-align: center;
}
.bluetoothError {
color: #ff9ab3;
}
.bluetoothAddress {
display: inline-block;
margin-top: 5px;
font-family: monospace;
opacity: 0.65;
.bluetoothAudioCountdown {
align-items: center;
background: rgba(64, 201, 124, 0.12);
border: 1px solid rgba(64, 201, 124, 0.45);
border-radius: var(--border-radius-lg);
display: flex;
gap: 18px;
padding: 16px 20px;
}
.bluetoothBadge {
border-radius: 999px;
padding: 4px 9px;
background: rgba(255, 255, 255, 0.08);
font-size: 0.78rem;
font-weight: 700;
}
.bluetoothBadgePaired {
color: #d9c9ff;
background: rgba(139, 108, 197, 0.22);
}
.bluetoothBadgeConnected {
.bluetoothAudioCountdown strong {
color: #b8ffd4;
background: rgba(64, 201, 124, 0.2);
font-size: 2rem;
min-width: 72px;
text-align: center;
}
.bluetoothSwitch,
.bluetoothToolbar button,
.bluetoothActions button,
.bluetoothSwitch {
.bluetoothActions button {
align-items: center;
background: linear-gradient(135deg, #765bb6, #9474ce);
border: 0;
border-radius: var(--border-radius-md);
padding: 10px 14px;
background: linear-gradient(135deg, #7a62b8, #8b6cc5);
color: #fff;
font-weight: 700;
cursor: pointer;
display: inline-flex;
font-weight: 700;
gap: 8px;
justify-content: center;
padding: 10px 14px;
transition: filter 140ms ease, opacity 140ms ease, transform 140ms ease;
}
.bluetoothSwitch:hover,
.bluetoothToolbar button:hover:not(:disabled),
.bluetoothActions button:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
.bluetoothSwitch input {
accent-color: #c4a7ff;
margin: 0;
}
.bluetoothToolbar {
flex-wrap: wrap;
gap: 10px;
}
.bluetoothToolbar button.scanning i {
animation: bluetoothSpin 1s linear infinite;
}
.bluetoothToolbar .bluetoothSecondaryButton {
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
color: var(--text-color);
}
.bluetoothScanHint {
color: var(--text-muted);
font-size: 0.86rem;
margin-left: auto;
opacity: 0.78;
}
.bluetoothDeviceList {
display: grid;
gap: 16px;
}
.bluetoothSection {
background: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.bluetoothSectionHeader {
background: var(--input-bg);
justify-content: space-between;
padding: 12px 16px;
}
.bluetoothSectionHeader > div {
gap: 10px;
}
.bluetoothSectionHeader i {
color: #b99bec;
font-size: 1.15rem;
}
.bluetoothSectionHeader h3 {
font-size: 1rem;
}
.bluetoothSectionHeader > span {
background: rgba(169, 140, 229, 0.16);
border-radius: 999px;
color: #d9c9ff;
font-size: 0.78rem;
font-weight: 700;
min-width: 26px;
padding: 4px 8px;
text-align: center;
}
.bluetoothSectionBody > .bluetoothDeviceRow + .bluetoothDeviceRow {
border-top: 1px solid var(--sidebar-border-color);
}
.bluetoothDeviceRow {
align-items: center;
display: grid;
gap: 14px;
grid-template-columns: 48px minmax(180px, 1fr) auto;
padding: 15px 16px;
transition: background-color 140ms ease;
}
.bluetoothDeviceRow:hover {
background: rgba(255, 255, 255, 0.025);
}
.bluetoothDeviceRow.connected {
box-shadow: inset 3px 0 0 #40c97c;
}
.bluetoothDeviceIcon {
align-items: center;
background: rgba(169, 140, 229, 0.12);
border: 1px solid rgba(169, 140, 229, 0.24);
border-radius: 50%;
color: #cbb2fa;
display: flex;
font-size: 1.25rem;
height: 44px;
justify-content: center;
width: 44px;
}
.bluetoothDeviceDetails {
min-width: 0;
}
.bluetoothDeviceName {
gap: 9px;
}
.bluetoothDeviceName h3 {
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bluetoothConnectedDot {
background: #40c97c;
border-radius: 50%;
box-shadow: 0 0 0 3px rgba(64, 201, 124, 0.13);
flex: 0 0 auto;
height: 8px;
width: 8px;
}
.bluetoothDeviceRow p {
color: var(--text-muted);
font-size: 0.8rem;
margin-top: 3px;
opacity: 0.75;
}
.bluetoothDeviceStatus {
color: #cbb2fa;
display: inline-block;
font-size: 0.78rem;
font-weight: 700;
margin-top: 4px;
}
.bluetoothActions {
margin-top: 14px;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.bluetoothActions button.selected {
background: linear-gradient(135deg, #258b58, #40c97c);
}
.bluetoothActions button.danger {
background: linear-gradient(135deg, #b14a6b, #d95a7b);
.bluetoothActions .bluetoothIconButton {
min-width: 42px;
padding-left: 12px;
padding-right: 12px;
}
.bluetoothActions .bluetoothForgetButton {
background: rgba(217, 90, 123, 0.13);
border: 1px solid rgba(217, 90, 123, 0.34);
color: #ff9ab3;
}
.bluetoothToolbar button:disabled,
.bluetoothActions button:disabled,
.bluetoothSwitch:has(input:disabled) {
cursor: not-allowed;
opacity: 0.5;
opacity: 0.45;
transform: none;
}
.bluetoothSwitch input {
margin: 0;
.bluetoothEmptyState,
.bluetoothEmptyPage,
.bluetoothLoading {
color: var(--text-muted);
text-align: center;
}
.bluetoothEmptyState {
padding: 28px 18px;
}
.bluetoothEmptyPage {
background: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
padding: 46px 20px;
}
.bluetoothEmptyPage > i {
color: #b99bec;
display: block;
font-size: 2.5rem;
margin-bottom: 10px;
}
.bluetoothEmptyPage p {
margin-top: 6px;
}
.bluetoothLoading {
padding: 36px;
}
.bluetoothLoading span {
animation: bluetoothBounce 1.1s infinite ease-in-out;
background: #b99bec;
border-radius: 50%;
display: inline-block;
height: 8px;
margin: 0 4px;
width: 8px;
}
.bluetoothLoading span:nth-child(2) {
animation-delay: 0.14s;
}
.bluetoothLoading span:nth-child(3) {
animation-delay: 0.28s;
}
@keyframes bluetoothSpin {
to { transform: rotate(360deg); }
}
@keyframes bluetoothBounce {
0%, 60%, 100% { opacity: 0.35; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-7px); }
}
@media (max-width: 760px) {
.bluetoothPage {
padding-left: var(--padding-base);
padding-right: var(--padding-base);
}
.bluetoothHeader,
.bluetoothToolbar {
align-items: stretch;
}
.bluetoothHeader {
flex-direction: column;
}
.bluetoothSwitch {
align-self: flex-start;
}
.bluetoothScanHint {
flex-basis: 100%;
margin-left: 0;
}
.bluetoothDeviceRow {
grid-template-columns: 44px minmax(0, 1fr);
}
.bluetoothActions {
grid-column: 1 / -1;
justify-content: flex-start;
padding-left: 58px;
}
}
@media (max-width: 480px) {
.bluetoothToolbar button {
flex: 1;
}
.bluetoothActions {
padding-left: 0;
}
.bluetoothActions button:not(.bluetoothIconButton) {
flex: 1 1 auto;
}
}
@@ -9,6 +9,7 @@ const state = reactive({
discovering: false,
offroad: false,
selectedAudio: "",
pairingAddress: "",
devices: [],
prompt: null,
audioTestAddress: "",
@@ -96,6 +97,7 @@ async function refresh() {
state.discovering = !!payload.discovering
state.offroad = !!payload.offroad
state.selectedAudio = String(payload.selected_audio || "")
state.pairingAddress = String(payload.pairing_address || "")
state.devices = Array.isArray(payload.devices) ? payload.devices : []
state.prompt = payload.prompt || null
state.error = payload.error || (response.ok ? "" : "Bluetooth service unavailable")
@@ -117,21 +119,54 @@ function initialize() {
}, 2000)
}
function capabilityBadges(device) {
const badges = []
if (device.audio) badges.push(html`<span class="bluetoothBadge">Audio</span>`)
if (device.controller) badges.push(html`<span class="bluetoothBadge">Controller</span>`)
if (device.paired) badges.push(html`<span class="bluetoothBadge bluetoothBadgePaired">Paired</span>`)
if (device.connected) badges.push(html`<span class="bluetoothBadge bluetoothBadgeConnected">Connected</span>`)
return badges
function normalizedAddress(device) {
return String(device.address || "").toUpperCase()
}
function isPairing(device) {
return !!state.pairingAddress && state.pairingAddress.toUpperCase() === normalizedAddress(device)
}
function deviceIcon(device) {
if (device.audio && device.controller) return "bi-headset"
if (device.audio) return "bi-headphones"
if (device.controller) return "bi-controller"
return "bi-bluetooth"
}
function deviceCapabilities(device) {
const capabilities = []
if (device.audio) capabilities.push("Audio")
if (device.controller) capabilities.push("Controller")
return capabilities.join(" · ") || "Bluetooth device"
}
function deviceStatus(device) {
if (isPairing(device)) return "Pairing…"
if (device.connected) {
const audioSelected = state.selectedAudio.toUpperCase() === normalizedAddress(device)
return audioSelected ? "Connected · Audio output" : "Connected"
}
return device.paired ? "Saved" : "Ready to pair"
}
function knownDevices() {
return state.devices.filter((device) => device.paired || device.trusted || device.connected)
}
function availableDevices() {
return state.devices.filter((device) => !device.paired && !device.trusted && !device.connected)
}
function deviceActions(device) {
const audioSelected = () => state.selectedAudio.toUpperCase() === device.address.toUpperCase()
const pairing = () => isPairing(device)
return html`
<div class="bluetoothActions">
${!device.paired ? html`
<button disabled="${() => !state.offroad || !!state.busy}" @click="${() => request("pair", { address: device.address })}">Pair</button>
<button disabled="${() => !state.offroad || !!state.busy || pairing()}" @click="${() => request("pair", { address: device.address })}">
${() => pairing() ? "Pairing…" : "Pair"}
</button>
` : html`
<button disabled="${() => !!state.busy}" @click="${() => request(device.connected ? "disconnect" : "connect", { address: device.address })}">
${device.connected ? "Disconnect" : "Connect"}
@@ -147,14 +182,48 @@ function deviceActions(device) {
</button>
` : ""}
` : ""}
<button class="danger" disabled="${() => !state.offroad || !!state.busy}" @click="${() => {
<button class="bluetoothIconButton bluetoothForgetButton" title="Forget device" aria-label="Forget ${device.name}"
disabled="${() => !state.offroad || !!state.busy}" @click="${() => {
if (window.confirm(`Forget ${device.name}?`)) request("forget", { address: device.address })
}}">Forget</button>
}}"><i class="bi bi-trash3" aria-hidden="true"></i></button>
`}
</div>
`
}
function deviceRow(device) {
return html`
<div class="${() => `bluetoothDeviceRow ${device.connected ? "connected" : ""}`}">
<div class="bluetoothDeviceIcon"><i class="bi ${deviceIcon(device)}" aria-hidden="true"></i></div>
<div class="bluetoothDeviceDetails">
<div class="bluetoothDeviceName">
<h3>${device.name}</h3>
${device.connected ? html`<span class="bluetoothConnectedDot" title="Connected"></span>` : ""}
</div>
<p>${deviceCapabilities(device)}</p>
<span class="bluetoothDeviceStatus">${() => deviceStatus(device)}</span>
</div>
${deviceActions(device)}
</div>
`
}
function deviceSection(title, icon, devices, emptyText = "") {
return html`
<section class="bluetoothSection">
<div class="bluetoothSectionHeader">
<div><i class="bi ${icon}" aria-hidden="true"></i><h3>${title}</h3></div>
<span>${devices.length}</span>
</div>
<div class="bluetoothSectionBody">
${devices.length ? devices.map(deviceRow) : html`
<div class="bluetoothEmptyState">${emptyText}</div>
`}
</div>
</section>
`
}
export function Bluetooth() {
initialize()
return html`
@@ -164,7 +233,7 @@ export function Bluetooth() {
<i class="bi bi-bluetooth" aria-hidden="true"></i>
<div>
<h2>Bluetooth</h2>
<p>Connect audio devices and controllers.</p>
<p>Connect speakers, headphones, media controls, and controllers.</p>
</div>
</div>
<label class="bluetoothSwitch">
@@ -188,28 +257,30 @@ export function Bluetooth() {
<div class="bluetoothToolbar">
<button disabled="${() => !state.offroad || !state.enabled || !!state.busy}"
class="${() => state.discovering ? "scanning" : ""}"
@click="${() => request(state.discovering ? "stop_scan" : "scan")}">
${() => state.discovering ? "Stop Scanning" : "Scan for Devices"}
<i class="${() => `bi ${state.discovering ? "bi-arrow-repeat" : "bi-search"}`}" aria-hidden="true"></i>
${() => state.discovering ? "Searching…" : "Search for Devices"}
</button>
<button disabled="${() => !!state.busy}" @click="${refresh}">Refresh</button>
<button class="bluetoothSecondaryButton" disabled="${() => !!state.busy}" @click="${refresh}">
<i class="bi bi-arrow-clockwise" aria-hidden="true"></i> Refresh
</button>
<span class="bluetoothScanHint">Put a device in pairing mode before searching.</span>
</div>
<div class="bluetoothDeviceList">
${() => state.loading ? html`<div class="bluetoothCard">Loading...</div>` : ""}
${() => !state.loading && state.devices.length === 0 ? html`
<div class="bluetoothCard">${state.enabled ? "No Bluetooth devices found." : "Enable Bluetooth to find devices."}</div>
` : ""}
${() => state.devices.map((device) => html`
<div class="bluetoothCard">
<div class="bluetoothDeviceHeader">
<div>
<h3>${device.name}</h3>
</div>
<div class="bluetoothBadges">${capabilityBadges(device)}</div>
</div>
${deviceActions(device)}
${() => state.loading ? html`<div class="bluetoothLoading"><span></span><span></span><span></span></div>` : ""}
${() => !state.loading && !state.enabled ? html`
<div class="bluetoothEmptyPage">
<i class="bi bi-bluetooth" aria-hidden="true"></i>
<h3>Bluetooth is off</h3>
<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.")}
` : ""}
</div>
</div>
`
@@ -40,7 +40,7 @@
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
<link rel="stylesheet" href="/assets/components/tools/update_manager.css">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=favorite-actions-1">
<link rel="stylesheet" href="/assets/components/tools/bluetooth.css?v=bluetooth-3">
<link rel="stylesheet" href="/assets/components/tools/bluetooth.css?v=bluetooth-4">
<link rel="stylesheet" href="/assets/components/tools/wheel_controls.css?v=controllers-2">
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
<link rel="stylesheet" href="/assets/components/tools/sentry.css">
@@ -37,6 +37,11 @@ def test_bluetooth_actions_use_reactive_disabled_bindings():
assert 'request("test_audio", { address: device.address })' in source
assert "startAudioTestCountdown" in source
assert "The test sound is sent at NOW" in source
assert 'deviceSection("My Devices"' in source
assert 'deviceSection("Available Devices"' in source
assert "bluetoothForgetButton" in source
assert "bi-trash3" in source
assert "state.pairingAddress" in source
def test_controller_test_mode_has_explicit_start_and_stop():
@@ -149,6 +149,7 @@ def test_bluetooth_status_api(monkeypatch):
"enabled": True,
"error": "",
"offroad": True,
"pairing_address": "",
"powered": True,
"prompt": None,
"selected_audio": "",
+20 -5
View File
@@ -13,6 +13,7 @@ class BluetoothManager:
self._active = False
self._exit = False
self._operation_error = ""
self._operations = {}
self._audio_test_deadline = 0.0
self._thread = threading.Thread(target=self._poll, daemon=True)
self._thread.start()
@@ -34,6 +35,10 @@ class BluetoothManager:
self._operation_error = ""
return error
def operation_for(self, address: str) -> str:
with self._lock:
return self._operations.get(address.upper(), "")
def audio_test_phase(self) -> str:
with self._lock:
deadline = self._audio_test_deadline
@@ -58,13 +63,23 @@ class BluetoothManager:
self._status = BluetoothStatus(error=str(error))
time.sleep(1.0 if self._active else 2.0)
def _run(self, fn, *args) -> None:
def _run(self, fn, *args, operation: str = "", address: str = "") -> None:
normalized_address = address.upper()
if normalized_address:
with self._lock:
self._operations[normalized_address] = operation
def worker():
try:
fn(*args)
except Exception as error:
with self._lock:
self._operation_error = str(error)
finally:
if normalized_address:
with self._lock:
if self._operations.get(normalized_address) == operation:
self._operations.pop(normalized_address, None)
threading.Thread(target=worker, daemon=True).start()
def set_power(self, enabled: bool) -> None:
@@ -74,16 +89,16 @@ class BluetoothManager:
self._run(self._client.start_scan if scanning else self._client.stop_scan)
def pair(self, address: str) -> None:
self._run(self._client.pair, address)
self._run(self._client.pair, address, operation="pairing", address=address)
def connect(self, address: str) -> None:
self._run(self._client.connect, address)
self._run(self._client.connect, address, operation="connecting", address=address)
def disconnect(self, address: str) -> None:
self._run(self._client.disconnect, address)
self._run(self._client.disconnect, address, operation="disconnecting", address=address)
def forget(self, address: str) -> None:
self._run(self._client.forget, address)
self._run(self._client.forget, address, operation="forgetting", address=address)
def select_audio(self, address: str) -> None:
self._run(self._client.select_audio, address)