From 388662ceb0efd17a14f4232e32caacba07eae058 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:38:52 -0500 Subject: [PATCH] bingo --- .../controls/lib/latcontrol_vehicle_tunes.py | 10 +- .../controls/lib/longitudinal_planner.py | 11 +- .../lib/longitudinal_vehicle_tunes.py | 14 +- selfdrive/controls/tests/test_latcontrol.py | 3 + .../tests/test_longitudinal_planner.py | 21 + .../controls/tests/test_starpilot_planner.py | 2 +- .../ui/mici/layouts/settings/bluetooth.py | 96 +++-- starpilot/system/bluetooth/protocol.py | 2 + .../the_galaxy/assets/components/router.js | 2 +- .../assets/components/tools/bluetooth.css | 394 +++++++++++++++--- .../assets/components/tools/bluetooth.js | 125 ++++-- .../system/the_galaxy/templates/index.html | 2 +- .../tests/test_frontend_module_graph.py | 5 + .../tests/test_navigation_params.py | 1 + system/ui/lib/bluetooth_manager.py | 25 +- 15 files changed, 578 insertions(+), 135 deletions(-) diff --git a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py index e6d660b2e..7dac4d212 100644 --- a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py +++ b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py @@ -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 diff --git a/selfdrive/controls/lib/longitudinal_planner.py b/selfdrive/controls/lib/longitudinal_planner.py index 98a202dbb..9a2cb54d6 100755 --- a/selfdrive/controls/lib/longitudinal_planner.py +++ b/selfdrive/controls/lib/longitudinal_planner.py @@ -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): diff --git a/selfdrive/controls/lib/longitudinal_vehicle_tunes.py b/selfdrive/controls/lib/longitudinal_vehicle_tunes.py index 746b8b146..1b5a8a628 100644 --- a/selfdrive/controls/lib/longitudinal_vehicle_tunes.py +++ b/selfdrive/controls/lib/longitudinal_vehicle_tunes.py @@ -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 ( diff --git a/selfdrive/controls/tests/test_latcontrol.py b/selfdrive/controls/tests/test_latcontrol.py index ae6b8a0ea..716981a2c 100644 --- a/selfdrive/controls/tests/test_latcontrol.py +++ b/selfdrive/controls/tests/test_latcontrol.py @@ -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) diff --git a/selfdrive/controls/tests/test_longitudinal_planner.py b/selfdrive/controls/tests/test_longitudinal_planner.py index b5da9e32a..9c09f0b2c 100644 --- a/selfdrive/controls/tests/test_longitudinal_planner.py +++ b/selfdrive/controls/tests/test_longitudinal_planner.py @@ -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) diff --git a/selfdrive/controls/tests/test_starpilot_planner.py b/selfdrive/controls/tests/test_starpilot_planner.py index 53d8c28ca..14ddff17e 100644 --- a/selfdrive/controls/tests/test_starpilot_planner.py +++ b/selfdrive/controls/tests/test_starpilot_planner.py @@ -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 diff --git a/selfdrive/ui/mici/layouts/settings/bluetooth.py b/selfdrive/ui/mici/layouts/settings/bluetooth.py index 049c95adc..b3a8b3d2f 100644 --- a/selfdrive/ui/mici/layouts/settings/bluetooth.py +++ b/selfdrive/ui/mici/layouts/settings/bluetooth.py @@ -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() diff --git a/starpilot/system/bluetooth/protocol.py b/starpilot/system/bluetooth/protocol.py index f28744d75..ac23bac87 100644 --- a/starpilot/system/bluetooth/protocol.py +++ b/starpilot/system/bluetooth/protocol.py @@ -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", "")), diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index 65a05a571..80982c0f1 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -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" diff --git a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css index e6fbf2ab1..37a02e6ae 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css +++ b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css @@ -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; + } } diff --git a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js index 3a311162d..9b5e74526 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js +++ b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js @@ -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`Audio`) - if (device.controller) badges.push(html`Controller`) - if (device.paired) badges.push(html`Paired`) - if (device.connected) badges.push(html`Connected`) - 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`
${deviceCapabilities(device)}
+ ${() => deviceStatus(device)} +Connect audio devices and controllers.
+Connect speakers, headphones, media controls, and controllers.