Compare commits

...

13 Commits

Author SHA1 Message Date
firestarsdog 089fd38fc2 Add minimal ELM327 RFCOMM support 2026-09-20 19:55:18 -04:00
whoisdomi f15a1974d5 Ioniq 6 turn blips 2026-09-19 21:01:03 -05:00
whoisdomi 08139a021a Car Date/Time fallback when gps/wifi not available 2026-09-19 21:01:02 -05:00
whoisdomi fbe982f47b Ioniq 6 Date/Time DBC Signal
Added date/time signal from Ioniq 6 can
2026-09-19 21:01:01 -05:00
firestarsdog 5e6e978438 Gen2 Bolt HSA Fix? Maybe?
0x315 : Mode 1 when not engaged, not Mode 9
2026-09-19 18:25:59 -04:00
firestarsdog b990a776b2 Curve radial menu corner gradient 2026-09-18 22:26:26 -04:00
firestarsdog 88cbf88756 TV Set 2026-09-18 22:17:55 -04:00
whoisdomi 373c411baa Model Stuff 2026-09-18 19:47:39 -05:00
Prabhaav Pillai 990e68804c fix recording and revert nav tab 2026-09-18 20:04:20 -04:00
firestarsdog b295a57281 mici ux 2026-09-18 18:12:53 -04:00
firestarsdog 673ca37396 Small UI UX 2026-09-18 03:42:24 -04:00
firestar5683 44beb5b778 EV6 2026-09-17 08:33:35 -05:00
firestar5683 00ac287223 EV6 2026-09-17 08:33:10 -05:00
37 changed files with 1482 additions and 196 deletions
+1 -1
View File
@@ -1197,7 +1197,7 @@ class CarController(CarControllerBase):
# cannot linger after a disengage or main-off event.
if should_send_bolt_acc_pedal_friction:
can_sends.append(gmcan.create_friction_brake_command(
self.packer_ch, friction_brake_bus, experiment_brake, idx, bolt_acc_pedal_friction_main_on,
self.packer_ch, friction_brake_bus, experiment_brake, idx, bolt_acc_pedal_friction_main_on and CC.longActive,
near_stop, at_full_stop, self.CP))
if self.CP.carFingerprint not in CC_ONLY_CAR:
friction_brake_bus = get_friction_brake_bus(self.CP)
@@ -441,12 +441,6 @@ def suppress_redundant_gv70_brake_cancel(CP, brake_pressed: bool, lat_active: bo
)
def clear_ioniq_6_torque_when_request_inactive(CP, apply_torque: int, apply_steer_req: bool) -> int:
if CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and not apply_steer_req:
return 0
return apply_torque
class CarController(CarControllerBase):
def __init__(self, dbc_names, CP):
super().__init__(dbc_names, CP)
@@ -642,8 +636,6 @@ class CarController(CarControllerBase):
if not CC.latActive:
apply_torque = 0
apply_torque = clear_ioniq_6_torque_when_request_inactive(self.CP, apply_torque, apply_steer_req)
# Hold torque with induced temporary fault when cutting the actuation bit
# FIXME: we don't use this with CAN FD?
torque_fault = CC.latActive and not apply_steer_req
@@ -1033,7 +1025,9 @@ class CarController(CarControllerBase):
left_sound_active=left_warning.sound_active, right_sound_active=right_warning.sound_active,
)
else:
adrv_messages = hyundaicanfd.create_adrv_messages(self.packer, self.CAN, self.frame)
adrv_messages = hyundaicanfd.create_adrv_messages(self.packer, self.CAN, self.frame,
car_fingerprint=self.CP.carFingerprint,
drive_gear=drive_gear)
can_sends.extend(adrv_messages)
# The front radar treats ADAS_DRV's 0x100 broadcast as its host heartbeat
# and stops publishing object tracks when it disappears.
@@ -8,6 +8,34 @@ from opendbc.car.crc import CRC16_XMODEM
from opendbc.car.hyundai.values import HyundaiFlags, CAR, CANFD_ALT_BUTTONS_RESUME_CAR
_adrv_0x51_templates: dict[CAR, bytes] = {}
def cache_adrv_0x51_template(car_fingerprint: CAR, dat: bytes | None) -> None:
if car_fingerprint != CAR.KIA_EV6:
return
if dat is None:
_adrv_0x51_templates.pop(car_fingerprint, None)
elif len(dat) == 32 and any(dat[3:]):
_adrv_0x51_templates[car_fingerprint] = bytes(dat)
def create_adrv_0x51(packer, CAN, frame: int, car_fingerprint: CAR | None = None, drive_gear: bool = False):
template = _adrv_0x51_templates.get(car_fingerprint)
if template is None:
return packer.make_can_msg("ADRV_0x51", CAN.ACAN, {})
# EV6 MRR30 tracks stop when the ADAS takeover replaces this platform payload with zeros.
dat = bytearray(template)
dat[2] = (template[2] + frame + 1) & 0xFF
dat[3] = (dat[3] & ~0x1) | int(drive_gear)
crc = hkg_can_fd_checksum(0x51, None, dat)
dat[0] = crc & 0xFF
dat[1] = (crc >> 8) & 0xFF
return CanData(0x51, bytes(dat), CAN.ACAN)
def _set_value(msg: bytearray, sig, ival: int) -> None:
i = sig.lsb // 8
bits = sig.size
@@ -788,15 +816,13 @@ def create_fca_warning_light(packer, CAN, frame):
return ret
def create_adrv_messages(packer, CAN, frame, blended_hda2=False):
def create_adrv_messages(packer, CAN, frame, blended_hda2=False, car_fingerprint=None, drive_gear=False):
# messages needed to car happy after disabling
# the ADAS Driving ECU to do longitudinal control
ret = []
values = {
}
ret.append(packer.make_can_msg("ADRV_0x51", CAN.ACAN, values))
ret.append(create_adrv_0x51(packer, CAN, frame, car_fingerprint, drive_gear))
if blended_hda2:
return ret
+16 -1
View File
@@ -2,6 +2,7 @@ import time
# Provenance: portions of HKG angle integration are adapted from sunnypilot/opendbc's
# hkg-angle-steering-2025 branch at cc4b08625. See CREDITS.md and THIRD_PARTY_NOTICES.md.
from opendbc.car import get_safety_config, structs, uds
from opendbc.car.hyundai import hyundaicanfd
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import HyundaiFlags, CAR, CarControllerParams, \
CANFD_UNSUPPORTED_LONGITUDINAL_CAR, \
@@ -391,11 +392,25 @@ class CarInterface(CarInterfaceBase):
skip_disable_ecu = True
if not skip_disable_ecu:
disable_can_recv = can_recv
if CP.carFingerprint == CAR.KIA_EV6 and can_recv is not None:
hyundaicanfd.cache_adrv_0x51_template(CP.carFingerprint, None)
base_can_recv = can_recv
adrv_bus = CanBus(CP).ACAN
def disable_can_recv(*args, **kwargs):
packets = base_can_recv(*args, **kwargs)
for packet in packets or []:
for msg in packet:
if msg.src == adrv_bus and msg.address == 0x51:
hyundaicanfd.cache_adrv_0x51_template(CP.carFingerprint, msg.dat)
return packets
# Try ECU disable. If it succeeds (IGN-ON mode), enable longitudinal.
# If it fails (READY mode returns NRC 0x22, or timeout), strip LONG safety flag
# so panda forwards stock SCC messages normally (lateral-only mode).
ecu_log(f"=== ECU DISABLE attempt: addr=0x{addr:x}, bus={bus} ===")
ecu_disabled = disable_ecu(can_recv, can_send, bus=bus, addr=addr, com_cont_req=communication_control,
ecu_disabled = disable_ecu(disable_can_recv, can_send, bus=bus, addr=addr, com_cont_req=communication_control,
reset=bool(CP.flags & HyundaiFlags.CAN_CANFD_BLENDED))
if CP.carFingerprint in (CAR.HYUNDAI_IONIQ_6, CAR.HYUNDAI_IONIQ_5_PE):
@@ -20,8 +20,7 @@ from opendbc.car.hyundai.carcontroller import CarController, CANCEL_BUTTON_DELAY
should_track_stop_accel_directly_for_car, \
preserve_stock_canfd_lfa_status, \
preserve_stock_canfd_lkas_status, \
suppress_redundant_gv70_brake_cancel, \
clear_ioniq_6_torque_when_request_inactive
suppress_redundant_gv70_brake_cancel
from opendbc.car.hyundai.carstate import CarState, decode_canfd_camera_lead, decode_ioniq_6_blindspot_radar_state, \
get_canfd_cruise_available
from opendbc.car.hyundai.interface import CarInterface, KIA_EV9_ACCEL_MAX, get_communication_control_request
@@ -149,6 +148,60 @@ class TestHyundaiFingerprint:
assert get_communication_control_request(CAR.HYUNDAI_IONIQ_6) == radar_keepalive_request
def test_ev6_adrv_0x51_replays_factory_payload(self):
CP = CarParams.new_message()
CP.carFingerprint = CAR.KIA_EV6
CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.CANFD_LKA_STEERING | HyundaiFlags.EV)
packer = CANPacker(DBC[CP.carFingerprint][Bus.pt])
can_bus = CanBus(CP)
factory = bytes.fromhex("88ed2e091700ffff5e0d0000012006ff021c2200000000000800000010000000")
hyundaicanfd.cache_adrv_0x51_template(CAR.KIA_EV6, factory)
try:
address, dat, bus = hyundaicanfd.create_adrv_0x51(packer, can_bus, 7, CAR.KIA_EV6, drive_gear=True)
_, parked_dat, _ = hyundaicanfd.create_adrv_0x51(packer, can_bus, 8, CAR.KIA_EV6, drive_gear=False)
_, other_dat, _ = hyundaicanfd.create_adrv_0x51(packer, can_bus, 7, CAR.HYUNDAI_IONIQ_6)
finally:
hyundaicanfd.cache_adrv_0x51_template(CAR.KIA_EV6, None)
assert address == 0x51
assert bus == can_bus.ACAN
assert dat[2] == (factory[2] + 8) & 0xFF
assert dat[3:] == factory[3:]
assert int.from_bytes(dat[:2], "little") == hkg_can_fd_checksum(address, None, bytearray(dat))
assert parked_dat[3] == factory[3] & ~0x1
assert parked_dat[4:] == factory[4:]
assert int.from_bytes(parked_dat[:2], "little") == hkg_can_fd_checksum(address, None, bytearray(parked_dat))
assert other_dat[3:] == bytes(29)
def test_ev6_init_captures_factory_adrv_0x51(self, monkeypatch):
fingerprint = gen_empty_fingerprint()
fingerprint[CanBus(None, fingerprint).CAM][0x50] = 16
radar_config = get_radar_track_config(CAR.KIA_EV6)
fingerprint[radar_config.bus][radar_config.start_addr] = radar_config.expected_length
car_fw = [CarParams.CarFw(ecu=Ecu.adas, fwVersion=b"", address=0x730, brand="hyundai")]
CP = CarInterface.get_params(CAR.KIA_EV6, fingerprint, car_fw, True, False, False, get_test_toggles())
factory = bytes.fromhex("6b657d090900e1ff000000000020ffff00000000000000000800000010000000")
def can_recv(*, wait_for_one=True):
msg = SimpleNamespace(address=0x51, src=CanBus(CP).ACAN, dat=factory)
return [[msg]]
def fake_disable_ecu(capturing_can_recv, *_args, **_kwargs):
capturing_can_recv(wait_for_one=True)
return True
monkeypatch.setattr("opendbc.car.hyundai.interface.disable_ecu", fake_disable_ecu)
CarInterface.init(CP, can_recv, None)
packer = CANPacker(DBC[CP.carFingerprint][Bus.pt])
try:
_, dat, _ = hyundaicanfd.create_adrv_0x51(packer, CanBus(CP), 0, CAR.KIA_EV6, drive_gear=True)
finally:
hyundaicanfd.cache_adrv_0x51_template(CAR.KIA_EV6, None)
assert dat[3:] == factory[3:]
def test_carnival_hev_low_speed_torque_rate_limits(self):
CP = CarInterface.get_params(CAR.KIA_CARNIVAL_HEV_4TH_GEN, gen_empty_fingerprint(), [],
False, False, False, None)
@@ -626,14 +679,7 @@ class TestHyundaiFingerprint:
assert not (CP.flags & HyundaiFlags.CANFD_LKA_STEERING)
assert bool(CP.flags & HyundaiFlags.CANFD_CAMERA_SCC)
def test_ioniq_6_clears_torque_with_inactive_safety_request(self):
ioniq_6_cp = SimpleNamespace(carFingerprint=CAR.HYUNDAI_IONIQ_6)
other_cp = SimpleNamespace(carFingerprint=CAR.KIA_EV6)
assert clear_ioniq_6_torque_when_request_inactive(ioniq_6_cp, -409, False) == 0
assert clear_ioniq_6_torque_when_request_inactive(ioniq_6_cp, -409, True) == -409
assert clear_ioniq_6_torque_when_request_inactive(other_cp, -409, False) == -409
def test_palisade_2023_uses_can_canfd_blended_layout(self):
palisade_2023 = CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, gen_empty_fingerprint(), [], True, False, False, None)
assert palisade_2023.flags & HyundaiFlags.CAN_CANFD_BLENDED
assert DBC[palisade_2023.carFingerprint][Bus.pt] == "hyundai_palisade_2023_generated"
@@ -727,10 +727,14 @@ BO_ 1259 LOCAL_TIME2: 8 XXX
SG_ NEW_SIGNAL_3 : 39|1@0+ (1,0) [0|1] "" XXX
BO_ 1264 LOCAL_TIME: 8 XXX
SG_ HOURS : 12|5@0+ (1,0) [0|31] "" XXX
SG_ MINUTES : 21|6@0+ (1,0) [0|63] "" XXX
SG_ SECONDS : 31|8@0+ (1,0) [0|59] "" XXX
SG_ HOURS : 8|8@1+ (1,0) [0|23] "" XXX
SG_ MINUTES : 16|8@1+ (1,0) [0|59] "" XXX
SG_ SECONDS : 24|8@1+ (1,0) [0|59] "" XXX
SG_ MONTH : 34|4@1+ (1,0) [1|12] "" XXX
SG_ YEAR : 40|8@1+ (1,2000) [2000|2255] "" XXX
SG_ DAY : 48|8@1+ (1,0) [1|31] "" XXX
CM_ BO_ 1264 "Cluster wall clock, 1Hz. Local time, not UTC. All 0xFF until the cluster initializes.";
CM_ SG_ 96 BRAKE_PRESSURE "User applied brake pedal pressure. Ramps from computer applied pressure on falling edge of cruise. Cruise cancels if !=0";
CM_ SG_ 101 BRAKE_POSITION "User applied brake pedal position, max is ~700. Signed on some vehicles";
CM_ SG_ 203 ADAS_ActvACISta "ADAS Active AngleControlInterface State";
@@ -964,10 +964,14 @@ BO_ 1259 LOCAL_TIME2: 8 XXX
SG_ NEW_SIGNAL_3 : 39|1@0+ (1,0) [0|1] "" XXX
BO_ 1264 LOCAL_TIME: 8 XXX
SG_ HOURS : 12|5@0+ (1,0) [0|31] "" XXX
SG_ MINUTES : 21|6@0+ (1,0) [0|63] "" XXX
SG_ SECONDS : 31|8@0+ (1,0) [0|59] "" XXX
SG_ HOURS : 8|8@1+ (1,0) [0|23] "" XXX
SG_ MINUTES : 16|8@1+ (1,0) [0|59] "" XXX
SG_ SECONDS : 24|8@1+ (1,0) [0|59] "" XXX
SG_ MONTH : 34|4@1+ (1,0) [1|12] "" XXX
SG_ YEAR : 40|8@1+ (1,2000) [2000|2255] "" XXX
SG_ DAY : 48|8@1+ (1,0) [1|31] "" XXX
CM_ BO_ 1264 "Cluster wall clock, 1Hz. Local time, not UTC. All 0xFF until the cluster initializes.";
CM_ SG_ 96 BRAKE_PRESSURE "User applied brake pedal pressure. Ramps from computer applied pressure on falling edge of cruise. Cruise cancels if !=0";
CM_ SG_ 101 BRAKE_POSITION "User applied brake pedal position, max is ~700. Signed on some vehicles";
CM_ SG_ 203 ADAS_ActvACISta "ADAS Active AngleControlInterface State";
+2
View File
@@ -179,6 +179,8 @@ testpaths = [
"system/tests",
"system/ubloxd",
"system/webrtc",
"starpilot/system/bluetooth/tests",
"starpilot/system/obd/tests",
"tools/lib/tests",
"tools/replay",
"tools/cabana",
+107 -22
View File
@@ -29,6 +29,7 @@ from pathlib import Path
OPENPILOT_REPO = "commaai/openpilot"
OPENPILOT_LFS_BATCH_URL = "https://huggingface.co/commaai/openpilot-lfs.git/info/lfs/objects/batch"
RESOURCES_REPO = os.environ.get("STARPILOT_RESOURCES_REPO", "firestar5683/StarPilot-Resources")
HF_BUCKET = os.environ.get("STARPILOT_HF_BUCKET", "StarPilot-Driving/StarPilot-Resources")
RESOURCE_BRANCH = "Models"
@@ -80,7 +81,8 @@ def default_workspace() -> Path:
return Path.home() / "Desktop" / "StarPilot-Model-Releases"
def run(command: list[str], *, cwd: Path | None = None, capture: bool = False) -> subprocess.CompletedProcess:
def run(command: list[str], *, cwd: Path | None = None, capture: bool = False,
env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
print("$ " + " ".join(shlex.quote(part) for part in command))
return subprocess.run(
command,
@@ -88,6 +90,7 @@ def run(command: list[str], *, cwd: Path | None = None, capture: bool = False) -
check=True,
text=capture,
capture_output=capture,
env=env,
)
@@ -160,7 +163,6 @@ def stream_response(response, destination: Path, prefix: bytes = b"") -> tuple[i
def download_lfs_object(oid: str, expected_size: int, ref: str, destination: Path) -> tuple[int, str]:
batch_url = f"https://github.com/{OPENPILOT_REPO}.git/info/lfs/objects/batch"
payload = json.dumps({
"operation": "download",
"transfers": ["basic"],
@@ -168,7 +170,7 @@ def download_lfs_object(oid: str, expected_size: int, ref: str, destination: Pat
"ref": {"name": ref},
}).encode("utf-8")
with http_request(
batch_url,
OPENPILOT_LFS_BATCH_URL,
method="POST",
payload=payload,
headers={"Accept": "application/vnd.git-lfs+json", "Content-Type": "application/vnd.git-lfs+json"},
@@ -395,6 +397,32 @@ def parse_pasted_release(text: str, model_id_override: str | None, behavior_vers
)
def release_from_source_file(source_file: Path, model_id_override: str | None, display_name_override: str | None,
behavior_version: str) -> ReleaseInfo:
if not source_file.is_file():
raise ReleaseError(f"--source-file not found: {source_file}")
if source_file.suffix.lower() != ".onnx":
raise ReleaseError(f"--source-file must be a .onnx file: {source_file}")
display_name = display_name_override or source_name_from_path(source_file.name)
model_id = model_id_override or slug_model_id(display_name)
if not MODEL_ID_RE.fullmatch(model_id):
raise ReleaseError(f"Invalid model ID {model_id!r}; use lowercase letters, digits, '-' or '_' (pass --model-id)")
iteration_match = re.search(r"\b(v\d+)\b", display_name, flags=re.IGNORECASE)
return ReleaseInfo(
model_id=model_id,
display_name=display_name,
release_date=dt.date.today().isoformat(),
branch="",
source_ref="",
source_path=str(source_file),
input_format="supercombo" if "supercombo" in source_file.name.lower() else "split",
behavior_version=behavior_version,
uses_external_gpu=source_file.name.lower().startswith("big_"),
commits=[],
model_iteration=iteration_match.group(1).lower() if iteration_match else "",
)
def resolve_branch_commit(branch: str) -> str:
url = f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{urllib.parse.quote(branch, safe='')}"
payload = get_json(url)
@@ -508,11 +536,16 @@ def remote_compile(info: ReleaseInfo, source: Path, ip: str, workspace: Path, ke
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
assert process.stdout is not None
for line in process.stdout:
print(f"[device] {line}", end="")
try:
print(f"[device] {line}", end="")
except UnicodeEncodeError:
print(f"[device] {line}".encode(sys.stdout.encoding or 'utf-8', errors='replace').decode(sys.stdout.encoding or 'utf-8'), end="")
log.write(line)
return_code = process.wait()
if return_code != 0:
@@ -684,18 +717,43 @@ def validate_manifest_update(before: object, after: object, replacing_model_id:
)
def find_hf() -> str:
candidates = [shutil.which("hf"), str(Path.home() / ".local/bin/hf")]
for candidate in candidates:
def windows_user_site_candidates(python: str) -> list[str]:
appdata = os.environ.get("APPDATA")
if not appdata:
return []
version = subprocess.run(
[python, "-c", "import sys; print(f'{sys.version_info[0]}{sys.version_info[1]}')"],
capture_output=True, text=True,
)
if version.returncode != 0 or not version.stdout.strip().isdigit():
return []
return [os.path.join(appdata, "Python", f"Python{version.stdout.strip()}", "site-packages")]
def find_hf() -> tuple[list[str], dict[str, str] | None]:
for python in (sys.executable, shutil.which("python3"), shutil.which("python")):
if not python:
continue
probe = subprocess.run([python, "-c", "import huggingface_hub"], capture_output=True)
if probe.returncode == 0:
return [python, "-m", "huggingface_hub.cli.hf"], None
for site_packages in windows_user_site_candidates(python):
if not os.path.isdir(os.path.join(site_packages, "huggingface_hub")):
continue
env = {**os.environ, "PYTHONPATH": os.pathsep.join([site_packages, os.environ.get("PYTHONPATH", "")])}
probe = subprocess.run([python, "-c", "import huggingface_hub"], capture_output=True, env=env)
if probe.returncode == 0:
return [python, "-m", "huggingface_hub.cli.hf"], env
for candidate in (shutil.which("hf"), str(Path.home() / ".local/bin/hf")):
if candidate and Path(candidate).is_file():
return candidate
return [candidate], None
raise ReleaseError("Hugging Face CLI not found; install/authenticate `hf` first")
def hf_copy(source: Path, bucket: str, remote_path: str) -> None:
hf = find_hf()
command, env = find_hf()
destination = f"hf://buckets/{bucket}/{remote_path}"
run([hf, "buckets", "cp", str(source), destination, "--format", "quiet"])
run([*command, "buckets", "cp", str(source), destination, "--format", "quiet"], env=env)
def refresh_huggingface_manifest(manifest: Path, bucket: str) -> dict:
@@ -704,7 +762,8 @@ def refresh_huggingface_manifest(manifest: Path, bucket: str) -> dict:
manifest.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".model-release-", dir=manifest.parent) as temporary_dir:
candidate = Path(temporary_dir) / manifest.name
run([find_hf(), "buckets", "cp", source, str(candidate), "--format", "quiet"])
hf_command, hf_env = find_hf()
run([*hf_command, "buckets", "cp", source, str(candidate), "--format", "quiet"], env=hf_env)
try:
payload = json.loads(candidate.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
@@ -812,6 +871,13 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("commit", nargs="?", help="A single openpilot commit SHA; metadata is resolved from GitHub.")
parser.add_argument("--text", help="Release text, otherwise paste it into stdin.")
parser.add_argument("--text-file", type=Path, help="Read the pasted release text from a file.")
parser.add_argument("--source-file", type=Path,
help="Use this local .onnx instead of downloading one from an openpilot commit/branch. "
"For releases whose commit only ships a precompiled artifact (e.g. an eGPU pkl) "
"and the real ONNX must be fetched by hand from elsewhere. Skips the runtime-change "
"scan since there is no commit history to scan; review the source yourself first. "
"Requires --model-id or a v-numbered name in --display-name.")
parser.add_argument("--display-name", help="Display name to record in the manifest when using --source-file.")
parser.add_argument("--model-id", help="Override the ID parsed from the model name.")
parser.add_argument("--behavior-version", default=DEFAULT_BEHAVIOR_VERSION, help="Runtime behavior version (default: v16).")
parser.add_argument("--ip", help="Comma IP; prompted interactively when omitted.")
@@ -837,21 +903,32 @@ def main() -> int:
try:
if not re.fullmatch(r"v\d+", args.behavior_version.strip(), flags=re.IGNORECASE):
raise ReleaseError("--behavior-version must look like v16")
text = read_release_text(args)
if not text.strip():
raise ReleaseError("No release text was supplied")
info = parse_pasted_release(text, args.model_id, args.behavior_version.strip().lower())
using_source_file = args.source_file is not None
text = ""
if using_source_file:
info = release_from_source_file(args.source_file.expanduser().resolve(), args.model_id,
args.display_name, args.behavior_version.strip().lower())
else:
text = read_release_text(args)
if not text.strip():
raise ReleaseError("No release text was supplied")
info = parse_pasted_release(text, args.model_id, args.behavior_version.strip().lower())
if args.gpu is not None:
info.uses_external_gpu = args.gpu
print_summary(info)
findings = scan_runtime_changes(info)
if findings:
print_runtime_warning(findings)
if not args.allow_runtime_changes:
return 2
if using_source_file:
print("\nRuntime scan skipped: --source-file has no commit history to scan.")
print("Confirm yourself that no tinygrad/modeld/Chestnut runtime changes are needed before proceeding.")
else:
print("Runtime scan: no tinygrad/modeld runtime files changed in the supplied commits.")
findings = scan_runtime_changes(info)
if findings:
print_runtime_warning(findings)
if not args.allow_runtime_changes:
return 2
else:
print("Runtime scan: no tinygrad/modeld runtime files changed in the supplied commits.")
if args.dry_run:
print("Dry run complete; no device or repository changes made.")
@@ -862,7 +939,15 @@ def main() -> int:
for relative in ("onnx", "compiled", "logs", "results"):
(workspace / relative).mkdir(parents=True, exist_ok=True)
source = workspace / "onnx" / f"{info.model_id}_driving_supercombo.onnx"
source_result = download_source(info.source_ref, info.source_path, source, args.force)
if using_source_file:
local_source = Path(info.source_path)
if source.exists() and not args.force:
raise ReleaseError(f"Source already exists: {source}; use --force to replace it")
shutil.copy2(local_source, source)
source_result = {"path": str(source), "size": source.stat().st_size, "sha256": sha256_file(source),
"url": str(local_source), "ref": "", "git_path": ""}
else:
source_result = download_source(info.source_ref, info.source_path, source, args.force)
(workspace / "release.txt").write_text(text, encoding="utf-8")
(workspace / "source.json").write_text(json.dumps({**source_result, "model": info.__dict__}, indent=2) + "\n", encoding="utf-8")
+78 -12
View File
@@ -467,7 +467,53 @@ def make_run_supercombo(model_runner, metadata, frame_skip, image_history_pipeli
return run_policy
def compile_jit(jit, make_random_inputs, input_keys, make_queues):
def stateful_image_shapes(metadata):
shape = tuple(metadata['input_shapes']['new_img'])
if len(shape) != 4 or shape[:2] != (2, 6):
raise ValueError(f"Unsupported stateful image shape: {shape}")
return dict.fromkeys(('img', 'big_img'), (1, *shape[1:]))
def stateful_host_shapes(metadata):
return {name: shape for name, shape in metadata['input_shapes'].items()
if name != 'new_img' and name not in metadata['state_pairs']}
def make_stateful_input_queues(metadata, device):
queues, npy = make_warp_input_queues(stateful_image_shapes(metadata), 1, device)
shapes = stateful_host_shapes(metadata)
sizes = [math.prod(shape) for shape in shapes.values()]
packed = np.zeros(sum(sizes), dtype=np.float32)
npy.update({name: value.reshape(shape) for (name, shape), value in
zip(shapes.items(), np.split(packed, np.cumsum(sizes[:-1])), strict=True)})
queues['packed_npy_inputs'] = Tensor(packed, device='NPY').realize()
for name in metadata['state_pairs']:
queues[name] = Tensor(np.zeros(metadata['input_shapes'][name], dtype=metadata['input_dtypes'][name]),
device=device).contiguous().realize()
return queues, npy
def make_run_stateful_supercombo(model_runner, metadata):
shapes = stateful_host_shapes(metadata)
sizes = [math.prod(shape) for shape in shapes.values()]
def run_policy(warped, packed_npy_inputs, **state):
packed = packed_npy_inputs.to(Device.DEFAULT).realize()
inputs = {name: value.reshape(shape).cast(model_runner.graph_inputs[name].dtype)
for (name, shape), value in zip(shapes.items(), packed.split(sizes), strict=True)}
inputs['new_img'] = warped.to(Device.DEFAULT).cast(model_runner.graph_inputs['new_img'].dtype)
outputs = {name: value.contiguous() for name, value in model_runner(inputs | state).items()}
for name, next_name in metadata['state_pairs'].items():
if outputs[next_name].dtype != state[name].dtype:
raise ValueError(f'State dtype mismatch: {name} -> {next_name}')
Tensor.realize(*outputs.values())
Tensor.realize(*(state[name].assign(outputs[next_name]) for name, next_name in metadata['state_pairs'].items()))
return outputs['outputs'].cast('float32'),
return run_policy
def compile_jit(jit, make_random_inputs, input_keys, make_queues, validation_runs=1):
seed = 42
def random_inputs_run(fn, current_seed, test_values=None, test_buffers=None, expect_match=True):
@@ -475,7 +521,8 @@ def compile_jit(jit, make_random_inputs, input_keys, make_queues):
np.random.seed(current_seed)
Tensor.manual_seed(current_seed)
testing = test_values is not None or test_buffers is not None
run_count = 1 if testing else 3
run_count = validation_runs if testing else max(3, validation_runs)
values, buffers = [], []
for index in range(run_count):
for value in npy.values():
@@ -489,9 +536,9 @@ def compile_jit(jit, make_random_inputs, input_keys, make_queues):
end = time.perf_counter()
print(f" [{index + 1}/{run_count}] enqueue {(mid - start) * 1e3:6.2f} ms -- total {(end - start) * 1e3:6.2f} ms")
if index == 0:
values = [np.copy(value.numpy()) for value in outputs]
buffers = [np.copy(value.numpy()) for value in input_queues.values()]
if index < validation_runs:
values.extend(np.copy(value.numpy()) for value in outputs)
buffers.extend(np.copy(value.numpy()) for value in input_queues.values())
if not all(np.isfinite(value).all() for value in values):
raise ValueError("Compiled JIT produced non-finite outputs")
@@ -602,13 +649,31 @@ def main():
output["metadata"]["model"] = make_metadata_dict(model_path)
validate_metadata(output["metadata"]["model"])
policy_shapes = output["metadata"]["model"]["input_shapes"]
frame_skip = args.frame_skip or derive_frame_skip(policy_shapes)
make_policy_queues = partial(make_supercombo_input_queues, policy_shapes, frame_skip)
run_policy = make_run_supercombo(
model_runner, output["metadata"], frame_skip, args.image_history_pipeline,
)
image_shapes = policy_shapes
policy_input_keys = FAST_POLICY_INPUTS if args.image_history_pipeline == IMAGE_HISTORY_IN_POLICY else SUPERCOMBO_POLICY_INPUTS
if 'new_img' in policy_shapes:
if args.image_history_pipeline != IMAGE_HISTORY_IN_POLICY:
parser.error('ONNX-managed history requires --image-history-pipeline policy')
metadata = output['metadata']['model']
metadata['state_pairs'] = {name: f'next_{name}' for name in policy_shapes
if f'next_{name}' in metadata['output_shapes']}
if not metadata['state_pairs']:
raise ValueError('Stateful supercombo is missing next-state outputs')
metadata['input_dtypes'] = {name: np.dtype(spec.dtype.fmt).name for name, spec in model_runner.graph_inputs.items()}
for name, next_name in metadata['state_pairs'].items():
if policy_shapes[name] != metadata['output_shapes'][next_name]:
raise ValueError(f'State shape mismatch: {name} -> {next_name}')
frame_skip = 1
make_policy_queues = partial(make_stateful_input_queues, metadata)
run_policy = make_run_stateful_supercombo(model_runner, metadata)
image_shapes = stateful_image_shapes(metadata)
policy_input_keys = ('packed_npy_inputs', *metadata['state_pairs'])
else:
frame_skip = args.frame_skip or derive_frame_skip(policy_shapes)
make_policy_queues = partial(make_supercombo_input_queues, policy_shapes, frame_skip)
run_policy = make_run_supercombo(
model_runner, output["metadata"], frame_skip, args.image_history_pipeline,
)
image_shapes = policy_shapes
policy_input_keys = FAST_POLICY_INPUTS if args.image_history_pipeline == IMAGE_HISTORY_IN_POLICY else SUPERCOMBO_POLICY_INPUTS
else:
if not args.vision_onnx:
parser.error("--vision-onnx is required for split models")
@@ -675,6 +740,7 @@ def main():
)
output["run_policy"] = compile_jit(
run_policy_jit, make_random_model_inputs, policy_input_keys, make_policy_queues,
validation_runs=5 if output['metadata'].get('model', {}).get('state_pairs') else 1,
)
model_w, model_h = args.model_size
+14 -2
View File
@@ -48,6 +48,9 @@ from openpilot.selfdrive.modeld.compile_modeld import (
derive_frame_skip,
make_split_input_queues,
make_supercombo_input_queues,
make_stateful_input_queues,
stateful_host_shapes,
stateful_image_shapes,
)
from openpilot.selfdrive.modeld.helpers import get_tg_input_devices, load_oob, tinygrad_dev_config, usbgpu_present
from openpilot.selfdrive.modeld.usbgpu_link import wait_usbgpu_link
@@ -587,8 +590,15 @@ class ModelState:
self.run_policy = artifact["run_policy"]
self.warp_enqueue = artifact[(cam_w, cam_h)]
self.can_prepare_only = self.image_history_pipeline == IMAGE_HISTORY_IN_WARP
self.onnx_history = self.model_type == 'supercombo' and bool(self.metadata['model'].get('state_pairs'))
if self.model_type == "supercombo":
if self.onnx_history:
metadata = self.metadata['model']
input_shapes = stateful_image_shapes(metadata)
self.output_slices = metadata['output_slices']
self.input_queues, self.npy = make_stateful_input_queues(metadata, self.QUEUE_DEV)
self.policy_input_shapes = stateful_host_shapes(metadata)
elif self.model_type == "supercombo":
input_shapes = self.metadata["model"]["input_shapes"]
self.output_slices = self.metadata["model"]["output_slices"]
self.input_queues, self.npy = make_supercombo_input_queues(input_shapes, self.frame_skip, self.QUEUE_DEV)
@@ -698,7 +708,9 @@ class ModelState:
return parsed
def _reset_state(self) -> None:
if self.model_type == "supercombo":
if self.onnx_history:
self.input_queues, self.npy = make_stateful_input_queues(self.metadata['model'], self.QUEUE_DEV)
elif self.model_type == "supercombo":
self.input_queues, self.npy = make_supercombo_input_queues(
self.policy_input_shapes, self.frame_skip, self.QUEUE_DEV,
)
@@ -56,6 +56,8 @@ class BluetoothDeviceButton(BigButton):
capabilities.append("audio")
if self.device.controller:
capabilities.append("controller")
if self.device.serial:
capabilities.append("serial")
self.set_value("connected" + (f" / {' / '.join(capabilities)}" if capabilities else ""))
self.set_enabled(True)
elif self.device.paired:
@@ -67,6 +69,8 @@ class BluetoothDeviceButton(BigButton):
capabilities.append("audio")
if self.device.controller:
capabilities.append("controller")
if self.device.serial:
capabilities.append("serial")
self.set_value("pair" + (f" / {' / '.join(capabilities)}" if capabilities else ""))
self.set_enabled(self._offroad)
@@ -79,6 +83,10 @@ class BluetoothDeviceButton(BigButton):
super().set_touch_valid_callback(lambda: touch_callback() and not self._forget_btn.is_pressed)
self._forget_btn.set_touch_valid_callback(touch_callback)
def set_touch_event_valid_callback(self, touch_callback):
super().set_touch_event_valid_callback(touch_callback)
self._forget_btn.set_touch_event_valid_callback(touch_callback)
def _draw_content(self, btn_y: float):
self._label.set_color(LABEL_COLOR)
label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING,
@@ -260,7 +268,7 @@ class BluetoothLayoutMici(NavScroller):
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),
tuple((device.address, device.name, device.paired, device.connected, device.audio, device.controller, device.serial) for device in status.devices),
)
if signature != self._last_signature:
self._last_signature = signature
@@ -7,7 +7,7 @@ from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.mici.layouts.settings.network.action_state import should_show_forget_button
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationDialog
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
from openpilot.system.ui.lib.application import gui_app, MousePos, MouseEvent, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid
@@ -194,6 +194,10 @@ class WifiButton(BigButton):
super().set_touch_valid_callback(lambda: touch_callback() and not self._forget_btn.is_pressed)
self._forget_btn.set_touch_valid_callback(touch_callback)
def set_touch_event_valid_callback(self, touch_callback: Callable[[MouseEvent], bool]) -> None:
super().set_touch_event_valid_callback(touch_callback)
self._forget_btn.set_touch_event_valid_callback(touch_callback)
@property
def _is_saved(self):
return self._wifi_manager.is_connection_saved(self._network.ssid)
+1 -1
View File
@@ -349,7 +349,7 @@ class BigMultiOptionDialog(NavWidget):
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
if not self._scroll_inner.scroll_panel.is_touch_valid():
if not self._scroll_inner.scroll_panel.is_event_touch_valid(mouse_event):
self._can_click = False
def _handle_mouse_release(self, mouse_pos: MousePos):
@@ -194,14 +194,16 @@ class FavoriteRadialMenu:
def render(self, rect: rl.Rectangle) -> None:
self._layout(rect)
self._collapse_if_idle()
if self._state == self.STATE_COLLAPSED:
self._draw_corner_hint()
elif self._state == self.STATE_RADIAL:
self._draw_corner_hint()
if self._state == self.STATE_RADIAL:
self._draw_radial_menu()
else:
elif self._state == self.STATE_PICKER:
self._draw_picker()
def render_corner_hint(self, rect: rl.Rectangle) -> None:
self._rect = rect
if not self.is_picker_open:
self._draw_corner_hint()
def blocks_pointer(self, mouse_pos: Any) -> bool:
"""Whether a parent click at ``mouse_pos`` belongs to this menu."""
return self.is_open or self._corner_press is not None or self._contains(self._corner_touch_zone, mouse_pos)
@@ -755,8 +757,8 @@ class FavoriteRadialMenu:
rl.draw_triangle(v_tb, v_ra, v_rb, col)
# 3. Ultra-Polished Frosted-Glass Vector Arrow (Nestled deep in purple corner)
cx = x0 + 34.0 * scale
cy = y0 - 34.0 * scale
center = self.corner_center(self._rect)
cx, cy = center.x, center.y
tip = rl.Vector2(cx + 15.0 * scale, cy - 15.0 * scale)
tail = rl.Vector2(cx - 15.0 * scale, cy + 15.0 * scale)
@@ -11,7 +11,6 @@ from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.lib.starpilot_status import (
CEM_OVERRIDE_COLOR, ENGAGED_COLOR, EXPERIMENTAL_COLOR, TRAFFIC_COLOR
)
from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_roundness
@@ -221,7 +220,8 @@ def get_traffic_border_colors() -> tuple[rl.Color, rl.Color] | None:
def render_background_effects(rect: rl.Rectangle, border_width: float):
global _smoothed_steer
sm = ui_state.sm
border_roundness = get_border_roundness(rect, border_width)
border_rect = rl.Rectangle(rect.x + border_width, rect.y + border_width,
rect.width - 2 * border_width, rect.height - 2 * border_width)
# 1. Turn Signal and Blind Spot indicators
colors = get_traffic_border_colors()
@@ -229,11 +229,11 @@ def render_background_effects(rect: rl.Rectangle, border_width: float):
left_color, right_color = colors
if left_color.a > 0:
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width // 2), int(rect.height))
rl.draw_rectangle_rounded(rect, border_roundness, 10, left_color)
rl.draw_rectangle_rounded_lines_ex(border_rect, 0.12, 10, border_width, left_color)
rl.end_scissor_mode()
if right_color.a > 0:
rl.begin_scissor_mode(int(rect.x + rect.width // 2), int(rect.y), int(rect.width // 2), int(rect.height))
rl.draw_rectangle_rounded(rect, border_roundness, 10, right_color)
rl.draw_rectangle_rounded_lines_ex(border_rect, 0.12, 10, border_width, right_color)
rl.end_scissor_mode()
# 2. Steering Torque Border
@@ -266,7 +266,7 @@ def render_background_effects(rect: rl.Rectangle, border_width: float):
else:
rl.begin_scissor_mode(int(rect.x + rect.width - border_width), y_pos, int(border_width), int(visible_height))
rl.draw_rectangle_rounded(rect, border_roundness, 10, col)
rl.draw_rectangle_rounded_lines_ex(border_rect, 0.12, 10, border_width, col)
rl.end_scissor_mode()
@@ -21,7 +21,6 @@ from openpilot.selfdrive.ui.onroad.starpilot.weather_icon import render_weather_
from openpilot.selfdrive.ui.lib.starpilot_status import (
get_screen_edge_color,
)
from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_roundness
from openpilot.starpilot.common.favorite_slots import (
build_favorite_slot_options,
filter_favorite_slot_options,
@@ -101,10 +100,6 @@ class StarPilotOnroadView(AugmentedRoadView):
def _render(self, rect: rl.Rectangle):
border_width = self._get_border_width()
border_roundness = get_border_roundness(rect, border_width)
border_color = get_pulse_glide_border_color(ui_state.sm, get_screen_edge_color(ui_state))
rl.draw_rectangle_rounded(rect, border_roundness, 10, border_color)
render_background_effects(rect, border_width)
# The favorite menu has first claim on the lower-left gesture. Filtering
# its events before the inherited HUD/widgets render prevents a corner tap
@@ -161,10 +156,14 @@ class StarPilotOnroadView(AugmentedRoadView):
def _draw_border(self, rect: rl.Rectangle):
border_width = self._get_border_width()
border_roundness = get_border_roundness(rect, border_width)
rl.draw_rectangle_rounded_lines_ex(rect, border_roundness, 10, border_width, rl.BLACK)
if self._draw_hud_controls and not self._full_alert_showing():
self._favorite_radial_menu.render_corner_hint(self._content_rect)
rl.draw_rectangle_lines_ex(rect, border_width, rl.BLACK)
border_rect = rl.Rectangle(rect.x + border_width, rect.y + border_width,
rect.width - 2 * border_width, rect.height - 2 * border_width)
border_color = get_pulse_glide_border_color(ui_state.sm, get_screen_edge_color(ui_state))
rl.draw_rectangle_rounded_lines_ex(border_rect, 0.12, 10, border_width, border_color)
render_background_effects(rect, border_width)
render_overlay(border_rect, border_width)
def _render_slc(self):
@@ -103,9 +103,11 @@ def test_settings_constructs_a_dedicated_bluetooth_panel(monkeypatch):
def test_device_status_prioritizes_operations_then_connection_and_capabilities():
device = make_device(paired=True, connected=True, audio=True, controller=True)
serial_device = make_device(serial=True)
assert device_status_text(device, "connecting", ADDRESS) == "Connecting..."
assert device_status_text(device, "", ADDRESS) == "Connected / audio output / controller"
assert device_status_text(serial_device, "", "") == "Tap to pair / serial"
def test_device_action_policy_matches_the_daemon_onroad_rules():
+3 -2
View File
@@ -235,7 +235,7 @@ class BlueZClient:
continue
props = interfaces[DEVICE_IFACE]
uuids = [str(value).lower() for value in props.get("UUIDs", [])]
audio, controller = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", "")))
audio, controller, serial = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", "")))
device = {
"path": path,
"address": str(props.get("Address", "")),
@@ -248,9 +248,10 @@ class BlueZClient:
"uuids": uuids,
"audio": audio,
"controller": controller,
"serial": serial,
}
if include_hidden or show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"],
device["blocked"], audio, controller, include_discovering):
device["blocked"], audio, controller, serial, include_discovering):
devices.append(device)
return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower()))
+8 -4
View File
@@ -16,6 +16,7 @@ BLUETOOTH_RADIO_HELPER = "/usr/comma/bluetooth-radio"
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
HID_UUID = "00001124-0000-1000-8000-00805f9b34fb"
HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb"
SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb"
COMMAND_TIMEOUTS = {
"set_power": 90.0,
"start_scan": 20.0,
@@ -40,6 +41,7 @@ class BluetoothDevice:
uuids: tuple[str, ...] = ()
audio: bool = False
controller: bool = False
serial: bool = False
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "BluetoothDevice":
@@ -54,6 +56,7 @@ class BluetoothDevice:
uuids=tuple(str(uuid).lower() for uuid in value.get("uuids", ())),
audio=bool(value.get("audio", False)),
controller=bool(value.get("controller", False)),
serial=bool(value.get("serial", False)),
)
@@ -86,21 +89,22 @@ class BluetoothStatus:
)
def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool]:
def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool, bool]:
normalized = {str(uuid).lower() for uuid in uuids}
major_class = (int(bluetooth_class) >> 8) & 0x1F
audio = A2DP_SINK_UUID in normalized or major_class == 0x04 or icon in {"audio-card", "audio-headphones", "audio-headset"}
controller = HID_UUID in normalized or HOG_UUID in normalized or major_class == 0x05 or icon in {"input-gaming", "input-mouse", "input-keyboard"}
return audio, controller
serial = SPP_UUID in normalized
return audio, controller, serial
def show_pairing_device(address: str, name: str, paired: bool, trusted: bool, connected: bool, blocked: bool,
audio: bool, controller: bool, discovering: bool = False) -> bool:
audio: bool, controller: bool, serial: bool, discovering: bool = False) -> bool:
known = paired or trusted or connected
normalized_address = "".join(character for character in address.upper() if character.isalnum())
normalized_name = "".join(character for character in name.upper() if character.isalnum())
named = bool(name) and name != "Unknown device" and normalized_name != normalized_address
return known or (named and not blocked and (audio or controller))
return known or (named and not blocked and (audio or controller or serial))
class _DesktopFakeBluetooth:
@@ -8,7 +8,7 @@ import pytest
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink
from openpilot.starpilot.system.bluetooth.bluez import PairingAgent
from openpilot.starpilot.system.bluetooth.daemon import BluetoothController
from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus,
from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, SPP_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus,
device_capabilities, show_pairing_device)
from openpilot.system import hardware
from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager
@@ -169,23 +169,26 @@ class FakeProcess:
def test_protocol_round_trip_and_capabilities():
audio, controller = device_capabilities([A2DP_SINK_UUID, HID_UUID])
assert audio and controller
audio, controller, serial = device_capabilities([A2DP_SINK_UUID, HID_UUID, SPP_UUID])
assert audio and controller and serial
status = BluetoothStatus.from_dict({
"available": True,
"enabled": True,
"devices": [{"address": "00:11:22:33:44:55", "name": "Combo", "uuids": [A2DP_SINK_UUID, HID_UUID], "audio": True, "controller": True}],
"devices": [{"address": "00:11:22:33:44:55", "name": "Combo", "uuids": [A2DP_SINK_UUID, HID_UUID, SPP_UUID],
"audio": True, "controller": True, "serial": True}],
})
assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True),)
assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID, SPP_UUID),
audio=True, controller=True, serial=True),)
def test_pairing_list_filters_anonymous_and_irrelevant_advertisements():
assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False)
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False)
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True)
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, True)
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, True)
assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False)
assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False, False)
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, False)
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, False)
assert show_pairing_device("00:11:22:33:44:55", "Serial Adapter", False, False, False, False, False, False, True)
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, False, True)
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, False, True)
assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False, False)
def test_desktop_fake_bluetooth_is_stateful_and_interactive(monkeypatch, tmp_path):
+1
View File
@@ -0,0 +1 @@
+171
View File
@@ -0,0 +1,171 @@
import socket
import threading
import time
from typing import Any
DEFAULT_COMMAND_TIMEOUT = 5.0
DEFAULT_CONNECT_TIMEOUT = 10.0
ELM_IDENTITY_MARKERS = ("ELM", "STN", "OBD")
class RFCOMMTransport:
def __init__(self, connect_timeout: float = DEFAULT_CONNECT_TIMEOUT):
self.connect_timeout = connect_timeout
self.channel: int | None = None
self._socket: socket.socket | None = None
def connect(self, mac: str) -> None:
self.close()
address_family = getattr(socket, "AF_BLUETOOTH", None)
protocol = getattr(socket, "BTPROTO_RFCOMM", None)
if address_family is None or protocol is None:
raise RuntimeError("Native Bluetooth RFCOMM sockets are unavailable")
channels = tuple(dict.fromkeys((self.channel, 1, 2))) if self.channel is not None else (1, 2)
last_error: OSError | None = None
for channel in channels:
sock = socket.socket(address_family, socket.SOCK_STREAM, protocol)
try:
sock.settimeout(self.connect_timeout)
sock.connect((mac, channel))
except OSError as error:
last_error = error
sock.close()
continue
self._socket = sock
self.channel = channel
return
raise ConnectionError(f"Unable to connect to {mac} on RFCOMM channels 1 or 2") from last_error
def close(self) -> None:
sock = self._socket
self._socket = None
if sock is not None:
sock.close()
def read(self, size: int = 4096, timeout: float | None = None) -> bytes:
if self._socket is None:
raise ConnectionError("RFCOMM transport is not connected")
self._socket.settimeout(timeout)
data = self._socket.recv(size)
if not data:
self.close()
raise ConnectionError("RFCOMM device disconnected")
return data
def write(self, data: bytes) -> None:
if self._socket is None:
raise ConnectionError("RFCOMM transport is not connected")
try:
self._socket.sendall(data)
except OSError:
self.close()
raise
class ELM327:
def __init__(self, mac: str, transport: RFCOMMTransport | None = None, command_timeout: float = DEFAULT_COMMAND_TIMEOUT,
reconnect_attempts: int = 3, reconnect_backoff: float = 1.0, sleep=time.sleep):
self.mac = mac
self.command_timeout = command_timeout
self.reconnect_attempts = reconnect_attempts
self.reconnect_backoff = reconnect_backoff
self._transport: Any = transport or RFCOMMTransport()
self._lock = threading.RLock()
self._connected = False
self._initialized = False
self._sleep = sleep
def connect(self) -> str:
with self._lock:
self.close()
try:
return self._open_and_identify()
except Exception:
self.close()
raise
def close(self) -> None:
with self._lock:
self._transport.close()
self._connected = False
self._initialized = False
def command(self, cmd: str, timeout: float | None = None) -> str:
with self._lock:
try:
return self._command_once(cmd, self.command_timeout if timeout is None else timeout)
except OSError:
initialized = self._initialized
self.close()
self._reconnect(initialized)
raise
def initialize(self) -> None:
with self._lock:
try:
self._initialize_once()
except OSError:
self.close()
self._reconnect(initialize=True)
raise
def _open_and_identify(self) -> str:
self._transport.connect(self.mac)
self._connected = True
identity = self._command_once("ATI", self.command_timeout)
if not any(marker in identity.upper() for marker in ELM_IDENTITY_MARKERS):
raise RuntimeError("RFCOMM device did not identify as an ELM-compatible adapter")
return identity
def _initialize_once(self) -> None:
for command in ("ATZ", "ATE0", "ATL0", "ATI", "ATSP0"):
self._command_once(command, self.command_timeout)
self._initialized = True
def _reconnect(self, initialize: bool) -> None:
for attempt in range(self.reconnect_attempts):
if attempt:
self._sleep(self.reconnect_backoff * (2 ** (attempt - 1)))
try:
self._open_and_identify()
if initialize:
self._initialize_once()
return
except OSError:
self.close()
def _command_once(self, cmd: str, timeout: float) -> str:
if not self._connected:
raise ConnectionError("ELM327 is not connected")
command = cmd.rstrip("\r\n")
self._transport.write(command.encode("ascii") + b"\r")
deadline = time.monotonic() + timeout
response = bytearray()
while b">" not in response:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"Timed out waiting for ELM327 response to {command!r}")
try:
chunk = self._transport.read(4096, timeout=remaining)
except TimeoutError as error:
raise TimeoutError(f"Timed out waiting for ELM327 response to {command!r}") from error
if not chunk:
raise ConnectionError("ELM327 disconnected before sending a prompt")
response.extend(chunk)
raw = bytes(response).split(b">", 1)[0]
return self._clean_response(raw, command)
@staticmethod
def _clean_response(raw: bytes, command: str) -> str:
lines = raw.decode("ascii", errors="replace").replace("\r\n", "\n").replace("\r", "\n").split("\n")
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
if lines and lines[0].strip().upper() == command.upper():
lines.pop(0)
while lines and not lines[0].strip():
lines.pop(0)
return "\n".join(lines)
+1
View File
@@ -0,0 +1 @@
+226
View File
@@ -0,0 +1,226 @@
from collections import deque
import pytest
import openpilot.starpilot.system.obd.elm327 as elm327_module
from openpilot.starpilot.system.obd.elm327 import ELM327, RFCOMMTransport
class ScriptedTransport:
def __init__(self, responses):
self.responses = deque(responses)
self.current = deque()
self.connects = []
self.writes = []
self.connected = False
def connect(self, mac):
self.connects.append(mac)
self.connected = True
def close(self):
self.connected = False
def write(self, data):
if not self.connected:
raise ConnectionError("transport is closed")
self.writes.append(data)
self.current = deque(self.responses.popleft())
def read(self, _size=4096, timeout=None):
item = self.current.popleft()
if isinstance(item, BaseException):
raise item
return item
class FakeRFCOMMSocket:
def __init__(self, attempts, failing_channels):
self.attempts = attempts
self.failing_channels = failing_channels
self.closed = False
self.timeout = None
def settimeout(self, timeout):
self.timeout = timeout
def connect(self, address):
self.attempts.append(address)
if address[1] in self.failing_channels:
raise OSError("channel unavailable")
def close(self):
self.closed = True
def test_command_returns_clean_normal_response():
transport = ScriptedTransport([
[b"ATI\rELM327 v1.5\r>"],
[b"41 00 BE 3E B8 13\r>"],
])
elm = ELM327("00:11:22:33:44:55", transport=transport)
assert elm.connect() == "ELM327 v1.5"
assert elm.command("0100") == "41 00 BE 3E B8 13"
assert transport.writes == [b"ATI\r", b"0100\r"]
def test_command_handles_echo_crlf_chunks_and_multiline_response():
transport = ScriptedTransport([
[b"ATI\r\rELM327 v1.5\r\r>"],
[b"010C\r\rSEARCHING...\r\n", b"41 0C 1A F8\r\n>"],
])
elm = ELM327("00:11:22:33:44:55", transport=transport)
elm.connect()
assert elm.command("010C") == "SEARCHING...\n41 0C 1A F8"
def test_command_preserves_substantive_spacing_and_blank_lines():
transport = ScriptedTransport([
[b"ELM327 v1.5\r>"],
[b"0100\r\r VALUE \r\rSECOND\r>"],
])
elm = ELM327("00:11:22:33:44:55", transport=transport)
elm.connect()
assert elm.command("0100") == " VALUE \n\nSECOND"
@pytest.mark.parametrize(("raw", "expected"), [
(b"NO DATA\r>", "NO DATA"),
(b"?\r>", "?"),
])
def test_command_returns_valid_adapter_states(raw, expected):
transport = ScriptedTransport([
[b"ELM327 v1.5\r>"],
[raw],
])
elm = ELM327("00:11:22:33:44:55", transport=transport)
elm.connect()
assert elm.command("0100") == expected
def test_initialize_uses_the_minimal_obd_command_sequence():
transport = ScriptedTransport([
[b"ELM327 v1.5\r>"],
[b"ATZ\rELM327 v1.5\r>"],
[b"ATE0\rOK\r>"],
[b"OK\r>"],
[b"ELM327 v1.5\r>"],
[b"OK\r>"],
])
elm = ELM327("00:11:22:33:44:55", transport=transport)
elm.connect()
elm.initialize()
assert transport.writes == [b"ATI\r", b"ATZ\r", b"ATE0\r", b"ATL0\r", b"ATI\r", b"ATSP0\r"]
@pytest.mark.parametrize("identity", [b"OK\r>", b"NO DATA\r>", b"CAN ERROR\r>", b"garbage\r>"])
def test_connect_requires_a_sensible_elm_identity(identity):
transport = ScriptedTransport([[identity]])
elm = ELM327("00:11:22:33:44:55", transport=transport)
with pytest.raises(RuntimeError, match="identify"):
elm.connect()
assert not transport.connected
def test_timeout_is_an_exception_and_reconnects_the_session():
transport = ScriptedTransport([
[b"ELM327 v1.5\r>"],
[TimeoutError("read timed out")],
[b"ELM327 v1.5\r>"],
[b"NO DATA\r>"],
])
elm = ELM327("00:11:22:33:44:55", transport=transport)
elm.connect()
with pytest.raises(TimeoutError, match="0100"):
elm.command("0100", timeout=0.01)
assert transport.connects == ["00:11:22:33:44:55", "00:11:22:33:44:55"]
assert elm.command("0100") == "NO DATA"
def test_disconnect_before_prompt_reconnects_and_reinitializes():
initialization = [
[b"ELM327 v1.5\r>"],
[b"OK\r>"],
[b"OK\r>"],
[b"ELM327 v1.5\r>"],
[b"OK\r>"],
]
transport = ScriptedTransport([
[b"ELM327 v1.5\r>"],
*initialization,
[b"41 00 BE", b""],
[b"ELM327 v1.5\r>"],
*initialization,
[b"41 00 BE 3E B8 13\r>"],
])
elm = ELM327("00:11:22:33:44:55", transport=transport)
elm.connect()
elm.initialize()
with pytest.raises(ConnectionError, match="prompt"):
elm.command("0100")
assert transport.connects == ["00:11:22:33:44:55", "00:11:22:33:44:55"]
assert transport.writes[-6:] == [b"ATI\r", b"ATZ\r", b"ATE0\r", b"ATL0\r", b"ATI\r", b"ATSP0\r"]
assert elm.command("0100") == "41 00 BE 3E B8 13"
def test_initialize_failure_reconnects_and_restarts_initialization():
transport = ScriptedTransport([
[b"ELM327 v1.5\r>"],
[b"ELM327 v1.5\r>"],
[b""],
[b"ELM327 v1.5\r>"],
[b"ELM327 v1.5\r>"],
[b"OK\r>"],
[b"OK\r>"],
[b"ELM327 v1.5\r>"],
[b"OK\r>"],
[b"NO DATA\r>"],
])
elm = ELM327("00:11:22:33:44:55", transport=transport)
elm.connect()
with pytest.raises(ConnectionError, match="prompt"):
elm.initialize()
assert transport.writes[-6:] == [b"ATI\r", b"ATZ\r", b"ATE0\r", b"ATL0\r", b"ATI\r", b"ATSP0\r"]
assert elm.command("0100") == "NO DATA"
def test_rfcomm_transport_falls_back_then_reuses_the_working_channel(monkeypatch):
attempts = []
failing_channels = {1}
def socket_factory(*_args):
return FakeRFCOMMSocket(attempts, failing_channels)
monkeypatch.setattr(elm327_module.socket, "AF_BLUETOOTH", 31, raising=False)
monkeypatch.setattr(elm327_module.socket, "BTPROTO_RFCOMM", 3, raising=False)
monkeypatch.setattr(elm327_module.socket, "socket", socket_factory)
transport = RFCOMMTransport()
transport.connect("00:11:22:33:44:55")
assert transport.channel == 2
transport.close()
failing_channels.clear()
transport.connect("00:11:22:33:44:55")
assert attempts == [
("00:11:22:33:44:55", 1),
("00:11:22:33:44:55", 2),
("00:11:22:33:44:55", 2),
]
@@ -98,6 +98,7 @@
--appbar-height: 64px;
--bottomnav-height: 72px;
--bottomnav-clearance: calc(var(--bottomnav-height) + env(safe-area-inset-bottom, 0px) + var(--sp-5));
--touch-target: 44px;
--z-appbar: 100;
--z-drawer: 200;
@@ -1387,17 +1388,29 @@ button.gx-chip:hover {
}
}
.gx-content:has(.gx-navigation-view) {
margin-top: calc(-1 * (var(--appbar-height) + env(safe-area-inset-top, 0px) + var(--sp-1)));
padding: 0;
}
.gx-navigation-view {
min-height: 0;
position: relative;
width: 100%;
}
.gx-navigation-stage {
background: var(--surface-container-low);
min-height: calc(100dvh - var(--appbar-height));
height: 100dvh;
min-height: 0;
overflow: hidden;
position: relative;
width: 100%;
}
.gx-navigation-map {
height: calc(100dvh - var(--appbar-height));
min-height: 520px;
height: 100%;
min-height: 0;
width: 100%;
}
@@ -1422,7 +1435,10 @@ button.gx-chip:hover {
.gx-navigation-overlay {
inset: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--sp-4);
padding-top: calc(var(--appbar-height) + env(safe-area-inset-top, 0px) + var(--sp-4));
pointer-events: none;
position: absolute;
z-index: 2;
@@ -1432,6 +1448,19 @@ button.gx-chip:hover {
pointer-events: auto;
}
.gx-navigation-tabs {
pointer-events: none;
position: absolute;
right: var(--sp-4);
top: calc(var(--appbar-height) + env(safe-area-inset-top, 0px) + var(--sp-4));
z-index: 3;
}
.gx-navigation-tabs .gx-tabs {
margin-bottom: 0;
pointer-events: auto;
}
.gx-navigation-search,
.gx-navigation-summary,
.gx-navigation-recent {
@@ -1527,87 +1556,98 @@ button.gx-chip:hover {
}
.gx-navigation-summary {
margin: var(--sp-4) 0 0;
padding: var(--sp-3);
margin: var(--sp-3) 0 0;
padding: var(--sp-2);
}
.gx-navigation-summary__title {
background: var(--surface-container-high);
border-radius: var(--radius-md);
font-size: var(--fs-lg);
align-items: center;
display: flex;
gap: var(--sp-1);
padding: 0 var(--sp-1);
}
.gx-navigation-summary__name {
font-size: var(--fs-base);
font-weight: var(--fw-bold);
min-width: 0;
overflow: hidden;
padding: var(--sp-2) var(--sp-3);
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.gx-navigation-summary__rows {
display: grid;
gap: var(--sp-1);
padding: var(--sp-3) var(--sp-2);
.gx-navigation-summary__fav {
color: var(--text-muted);
flex: none;
}
.gx-navigation-summary__rows > div {
align-items: center;
.gx-navigation-summary__fav.active { color: var(--accent-rose); }
.gx-navigation-metrics {
display: grid;
gap: var(--sp-2);
grid-template-columns: 28px 90px 1fr;
min-height: 28px;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: var(--sp-2) 0;
}
.gx-navigation-summary__rows strong {
font-weight: var(--fw-bold);
}
.gx-navigation-summary__icon {
font-size: 1.15rem;
.gx-navigation-metric {
align-items: center;
background: var(--surface-container-high);
border-radius: var(--radius-sm);
display: grid;
gap: 2px;
justify-items: center;
min-width: 0;
padding: var(--sp-2) var(--sp-1);
text-align: center;
}
.gx-navigation-route-picker {
border-top: 1px solid var(--outline);
display: grid;
gap: var(--sp-2);
margin-top: var(--sp-2);
padding: var(--sp-3) var(--sp-2) 0;
.gx-navigation-metric i {
color: var(--primary);
font-size: 1rem;
}
.gx-navigation-route-picker__title {
.gx-navigation-metric span {
color: var(--text-muted);
font-size: var(--fs-xs);
}
.gx-navigation-metric strong {
font-size: var(--fs-sm);
font-weight: var(--fw-bold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.gx-navigation-route-picker {
display: flex;
gap: var(--sp-2);
overflow-x: auto;
padding: var(--sp-1) 0 var(--sp-2);
scrollbar-width: none;
}
.gx-navigation-route-picker::-webkit-scrollbar { display: none; }
.gx-navigation-route-option {
align-items: center;
background: var(--surface-container);
border: 1px solid var(--outline);
border-radius: var(--radius-sm);
border-radius: var(--radius-full);
color: var(--on-surface);
cursor: pointer;
display: flex;
gap: var(--sp-3);
justify-content: space-between;
min-height: 52px;
padding: var(--sp-2) var(--sp-3);
text-align: left;
width: 100%;
}
.gx-navigation-route-option > span {
display: grid;
gap: 2px;
min-width: 0;
display: inline-flex;
flex: 0 0 auto;
gap: 6px;
min-height: 36px;
padding: 0 var(--sp-3);
}
.gx-navigation-route-option small {
color: var(--text-muted);
font-size: var(--fs-xs);
}
.gx-navigation-route-option > strong:last-child {
white-space: nowrap;
}
@@ -1618,19 +1658,15 @@ button.gx-chip:hover {
border-color: var(--primary);
}
.gx-navigation-route-option.selected > strong:first-child {
color: var(--primary);
}
.gx-navigation-route-option.selected { color: var(--primary); }
.gx-navigation-summary__actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
justify-content: center;
}
.gx-navigation-summary__actions .gx-btn {
flex: 1 1 190px;
flex: 1;
min-width: 0;
}
@@ -1638,9 +1674,6 @@ button.gx-chip:hover {
.gx-btn--success:hover { background: #6df3e9; }
.gx-btn--danger { background: var(--error); color: var(--on-error); }
.gx-btn--danger:hover { background: #ff7492; }
.gx-btn--favorite { background: var(--accent-rose); color: #fff; }
.gx-btn--favorite:hover,
.gx-btn--favorite.active { background: #ed4f94; }
.gx-navigation-recent {
margin: var(--sp-4) 0 0;
@@ -1658,7 +1691,7 @@ button.gx-chip:hover {
gap: var(--sp-3);
justify-content: center;
margin: 0;
min-height: calc(100dvh - var(--appbar-height));
min-height: 100%;
padding: var(--sp-6);
text-align: center;
}
@@ -1671,11 +1704,29 @@ button.gx-chip:hover {
@media (max-width: 767px) {
.gx-navigation-overlay {
padding: var(--sp-3);
padding-top: calc(var(--appbar-height) + env(safe-area-inset-top, 0px) + var(--sp-3));
padding-bottom: calc(var(--bottomnav-clearance) + 56px);
}
.gx-navigation-summary__rows > div {
grid-template-columns: 28px 82px 1fr;
.gx-navigation-tabs {
bottom: var(--bottomnav-clearance);
display: flex;
justify-content: center;
left: var(--sp-3);
right: var(--sp-3);
top: auto;
}
.gx-navigation-tabs .gx-tabs {
flex-wrap: nowrap;
justify-content: flex-start;
max-width: 100%;
overflow-x: auto;
padding: 2px;
scrollbar-width: none;
}
.gx-navigation-tabs .gx-tabs::-webkit-scrollbar { display: none; }
}
.gx-nav-section { margin-bottom: var(--sp-3); }
@@ -6,7 +6,7 @@ import { Tools } from "./views/Tools.js"
import { Recordings } from "./views/Recordings.js"
import { Logs } from "./views/Logs.js"
import { Tuning } from "./views/Tuning.js"
import { Navigation } from "./views/Navigation.js?v=nav-destination-4"
import { Navigation } from "./views/Navigation.js?v=nav-destination-6"
import { Vehicle } from "./views/Vehicle.js"
import { Bluetooth } from "./views/Bluetooth.js"
import { SystemTools } from "./views/SystemTools.js"
@@ -447,26 +447,29 @@ export const NavigationDestinationPanel = {
</section>
<section v-if="destination" class="gx-navigation-summary gx-card">
<div class="gx-navigation-summary__title">{{ destination.name || query || 'Destination' }}</div>
<div v-if="routeSummary" class="gx-navigation-summary__rows">
<div><span class="gx-navigation-summary__icon">🛣️</span><span>Distance:</span><strong>{{ formatDistance(routeSummary.distance) }}</strong></div>
<div><span class="gx-navigation-summary__icon">⌛</span><span>Duration:</span><strong>{{ formatDuration(routeSummary.duration) }}</strong></div>
<div><span class="gx-navigation-summary__icon">🕗</span><span>ETA:</span><strong>{{ formatEta(routeSummary.duration) }}</strong></div>
<div class="gx-navigation-summary__title">
<span class="gx-navigation-summary__name">{{ destination.name || query || 'Destination' }}</span>
<button type="button" class="gx-icon-btn gx-navigation-summary__fav" :class="{ active: isFavorite }" :aria-pressed="isFavorite" :title="isFavorite ? 'Remove from favorites' : 'Add to favorites'" @click="toggleFavorite">
<i class="bi" :class="isFavorite ? 'bi-heart-fill' : 'bi-heart'"></i>
</button>
</div>
<div v-if="routeSummary" class="gx-navigation-metrics">
<div class="gx-navigation-metric"><i class="bi bi-signpost-2" aria-hidden="true"></i><span>Distance</span><strong>{{ formatDistance(routeSummary.distance) }}</strong></div>
<div class="gx-navigation-metric"><i class="bi bi-clock" aria-hidden="true"></i><span>Duration</span><strong>{{ formatDuration(routeSummary.duration) }}</strong></div>
<div class="gx-navigation-metric"><i class="bi bi-clock-history" aria-hidden="true"></i><span>ETA</span><strong>{{ formatEta(routeSummary.duration) }}</strong></div>
</div>
<div v-if="routes.length > 1" class="gx-navigation-route-picker" aria-label="Choose a route">
<div class="gx-navigation-route-picker__title">Routes</div>
<button v-for="(route, index) in routes" :key="routeId(index)" type="button"
class="gx-navigation-route-option" :class="{ selected: selectedRouteId === routeId(index) }"
:aria-pressed="selectedRouteId === routeId(index)" :aria-label="'Select route ' + (index + 1)"
@click="selectRoute(route, routeId(index))">
<span><strong>Route {{ index + 1 }}</strong><small>{{ selectedRouteId === routeId(index) ? 'Selected' : (index === 0 ? 'Recommended' : 'Alternative') }}</small></span>
<strong>{{ formatDistance(route.distance) }} · {{ formatDuration(route.duration) }}</strong>
<strong>Route {{ index + 1 }}</strong>
<small>{{ formatDistance(route.distance) }} · {{ formatDuration(route.duration) }}</small>
</button>
</div>
<div class="gx-navigation-summary__actions">
<button v-if="navigationStarted" type="button" class="gx-btn gx-btn--danger" @click="cancelNavigation"><i class="bi bi-x-lg"></i> Cancel Navigation</button>
<button v-else type="button" class="gx-btn gx-btn--success" :disabled="loadingRoute || !hasRoutingKey" :title="hasRoutingKey ? 'Start Navigation' : 'A Mapbox secret key is required to start navigation'" @click="setDestination(destination)"><i class="bi bi-sign-turn-right"></i> {{ loadingRoute ? 'Calculating...' : 'Start Navigation' }}</button>
<button type="button" class="gx-btn gx-btn--favorite" :class="{ active: isFavorite }" @click="toggleFavorite"><i class="bi" :class="isFavorite ? 'bi-heart-fill' : 'bi-heart'"></i> {{ isFavorite ? 'Unfavorite' : 'Favorite' }}</button>
</div>
</section>
@@ -1,4 +1,4 @@
import { NavigationDestinationPanel } from "../components/NavigationDestinationPanel.js?v=nav-destination-4"
import { NavigationDestinationPanel } from "../components/NavigationDestinationPanel.js?v=nav-destination-6"
import { MapsPanel } from "../components/MapsPanel.js"
import { NavigationKeysPanel } from "../components/NavigationKeysPanel.js"
import { SpeedLimitsPanel } from "../components/SpeedLimitsPanel.js"
@@ -22,10 +22,15 @@ export const Navigation = {
})
},
template: `
<div class="gx-view">
<template v-if="tab === 'nav'">
<div class="gx-navigation-view">
<NavigationDestinationPanel />
<div class="gx-navigation-tabs"><GalaxyTabs :items="TABS" :active="tab" @select="selectTab" /></div>
</div>
</template>
<div v-else class="gx-view">
<h2 style="margin-top:0;">Navigation & Maps</h2>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<template v-if="tab === 'nav'"><NavigationDestinationPanel /></template>
<template v-if="tab === 'maps'"><MapsPanel /></template>
<template v-if="tab === 'keys'"><NavigationKeysPanel /></template>
<template v-if="tab === 'speeds'"><SpeedLimitsPanel /></template>
@@ -99,6 +99,7 @@ export const Recordings = {
if (this.sub === "screen" && !this.recordings.length && !this.screenLoading) this.loadScreenRecordings()
},
screenDisplayName(rec) {
if (!rec) return ""
return rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenDate(rec.timestamp)
},
async loadRoutes() {
@@ -502,8 +502,8 @@ def test_ui_all_remaining_classic_tools_native_no_embed():
assert "GalaxyEmbed" not in tuning and "LateralTuningPanel" in tuning
assert _read("js/components/MapsPanel.js") and _read("js/components/NavigationKeysPanel.js")
destination = _read("js/components/NavigationDestinationPanel.js")
assert '"./views/Navigation.js?v=nav-destination-4"' in _read("js/app.js")
assert '"../components/NavigationDestinationPanel.js?v=nav-destination-4"' in _read("js/views/Navigation.js")
assert '"./views/Navigation.js?v=nav-destination-6"' in _read("js/app.js")
assert '"../components/NavigationDestinationPanel.js?v=nav-destination-6"' in _read("js/views/Navigation.js")
assert "mapboxSuggest" in destination and "mapboxRetrieve" in destination
assert "mapboxGeocode" in destination and "mapboxDirections" in destination
assert "ref=\"map\"" in destination and "setNavigation(this.destination)" in destination
@@ -618,6 +618,7 @@ def test_ui_mobile_polish_regressions():
assert 'localDeviceUrl(status?.lanIp, "/recordings")' in recordings
assert 'localDeviceUrl(status?.lanIp, "/galaxy")' in galaxy
assert "gx-btn gx-btn--tonal" in recordings and "Open Recordings Locally" in recordings
assert "screenDisplayName(rec) {\n if (!rec) return" in recordings
assert "gx-btn gx-btn--tonal" in galaxy and "Open Galaxy Locally" in galaxy
home = _read("js/views/Home.js")
@@ -856,3 +857,33 @@ console.log("hierarchy logic OK")
result = subprocess.run([node, str(script)], capture_output=True, text=True)
assert result.returncode == 0, f"node failed:\n{result.stdout}\n{result.stderr}"
def test_ui_navigation_map_first_layout_regressions():
nav = _read("js/views/Navigation.js")
css = _read("css/material.css")
destination = _read("js/components/NavigationDestinationPanel.js")
# Destination tab is a map-first screen: the map fills the content area and
# runs behind the top appbar and the tab switcher floats above the bottom nav.
assert 'class="gx-navigation-view"' in nav and "gx-navigation-tabs" in nav
assert ".gx-content:has(.gx-navigation-view)" in css
assert "--bottomnav-clearance:" in css
assert "margin-top: calc(-1 * (var(--appbar-height) + env(safe-area-inset-top, 0px) + var(--sp-1)))" in css
assert "height: 100dvh" in css
assert "padding-top: calc(var(--appbar-height) + env(safe-area-inset-top, 0px) + var(--sp-4))" in css
assert ".gx-navigation-tabs {" in css
assert "bottom: var(--bottomnav-clearance)" in css
assert "padding-bottom: calc(var(--bottomnav-clearance) + 56px)" in css
# Route metrics use Bootstrap Icons consistently, not emoji.
assert "bi-signpost-2" in destination and "bi-clock-history" in destination
for emoji in ("🛣️", "⌛", "🕗"):
assert emoji not in destination
# The route summary stays compact so the map remains visible: metrics are a
# horizontal strip and route options are compact chips, not full-width rows.
assert 'class="gx-navigation-metrics"' in destination
assert destination.count('class="gx-navigation-metric"') == 3
assert "gx-navigation-summary__rows" not in destination and "gx-navigation-summary__rows" not in css
assert "grid-template-columns: repeat(3, minmax(0, 1fr))" in css
+80 -4
View File
@@ -17,6 +17,67 @@ try:
except Exception:
TimezoneFinder = None
try:
from zoneinfo import ZoneInfo
except Exception:
ZoneInfo = None
# StarPilot variables
# Hyundai/Kia cluster wall clock, local time. Fallback when GPS has no fix.
CAR_CLOCK_ADDR = 0x4F0
CAR_CLOCK_BUS = 1
def parse_car_clock(dat: bytes):
# cluster sends all 0xFF until initialized
if len(dat) < 7:
return None
if all(b == 0xFF for b in dat) or all(b == 0 for b in dat):
return None
hour, minute, second = dat[1], dat[2], dat[3]
month = (dat[4] >> 2) & 0x0F
year = 2000 + dat[5]
day = dat[6]
try:
return datetime.datetime(year, month, day, hour, minute, second)
except ValueError:
return None
def read_car_clock(can_sock, timezone):
latest = None
for msg in messaging.drain_sock(can_sock):
for frame in msg.can:
if frame.address == CAR_CLOCK_ADDR and frame.src == CAR_CLOCK_BUS:
parsed = parse_car_clock(bytes(frame.dat))
if parsed is not None:
latest = parsed
if latest is None:
return None
utc = car_clock_to_utc(latest, timezone)
if utc is None or not (min_date() <= utc <= MAX_DATE):
return None
return utc
def car_clock_to_utc(local_dt, timezone):
# timezone comes from the last GPS fix, so this needs one to have happened once
if ZoneInfo is None or not timezone:
return None
if isinstance(timezone, bytes):
timezone = timezone.decode("utf-8", errors="replace")
try:
tz = ZoneInfo(timezone)
except Exception:
cloudlog.exception("timed.bad_timezone")
return None
# fold=0: on the duplicated hour of a DST fall back, assume the first pass
return local_dt.replace(tzinfo=tz, fold=0).astimezone(datetime.UTC).replace(tzinfo=None)
def set_time(new_time):
diff = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - new_time
@@ -64,10 +125,13 @@ def main() -> NoReturn:
pm = messaging.PubMaster(['clocks'])
sm = messaging.SubMaster([gps_location_service])
# StarPilot variables
can_sock = messaging.sub_sock('can', timeout=100)
# StarPilot variables
tf = TimezoneFinder() if TimezoneFinder is not None else None
timezonefinder_logged = False
car_clock_tz_logged = False
last_timezone = params.get("Timezone")
if last_timezone is not None:
@@ -83,11 +147,23 @@ def main() -> NoReturn:
gps = sm[gps_location_service]
gps_time = datetime.datetime.fromtimestamp(gps.unixTimestampMillis / 1000., datetime.UTC).replace(tzinfo=None)
if not sm.updated[gps_location_service] or (time.monotonic() - sm.logMonoTime[gps_location_service] / 1e9) > 2.0:
gps_usable = (sm.updated[gps_location_service] and
(time.monotonic() - sm.logMonoTime[gps_location_service] / 1e9) <= 2.0 and
gps.hasFix and min_date() <= gps_time <= MAX_DATE)
# StarPilot variables
# only corrects an invalid clock, so GPS and NTP always win
if not gps_usable and not system_time_valid():
if not last_timezone and not car_clock_tz_logged:
cloudlog.warning("timed: no saved timezone, cannot use car clock fallback")
car_clock_tz_logged = True
car_time = read_car_clock(can_sock, last_timezone)
if car_time is not None:
cloudlog.warning(f"timed: setting time from car clock: {car_time}")
set_time(car_time)
continue
if not gps.hasFix:
continue
if gps_time < min_date() or gps_time > MAX_DATE:
if not gps_usable:
continue
set_time(gps_time)
+6 -3
View File
@@ -72,9 +72,7 @@ class GuiScrollPanel2:
bounds_size = bounds.width if self._horizontal else bounds.height
for mouse_event in gui_app.mouse_events:
self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
self._previous_mouse_event = mouse_event
self._process_mouse_events(bounds, bounds_size, content_size)
wheel_move = rl.get_mouse_wheel_move()
if wheel_move != 0 and self.enabled and rl.check_collision_point_rec(rl.get_mouse_position(), bounds):
@@ -93,6 +91,11 @@ class GuiScrollPanel2:
print()
return self.get_offset()
def _process_mouse_events(self, bounds: rl.Rectangle, bounds_size: float, content_size: float) -> None:
for mouse_event in gui_app.mouse_events:
self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
self._previous_mouse_event = mouse_event
def _get_offset_bounds(self, bounds_size: float, content_size: float) -> tuple[float, float]:
"""Returns (max_offset, min_offset) for the given bounds and content size."""
return 0.0, min(0.0, bounds_size - content_size)
@@ -0,0 +1,363 @@
import pyray as rl
import pytest
from openpilot.system.ui import widgets
from openpilot.system.ui.lib import scroll_panel2
from openpilot.system.ui.lib.application import MouseEvent, MousePos, gui_app
from openpilot.system.ui.widgets.scroller import _Scroller, _MiciScrollPanel
class Item(widgets.Widget):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 402, 180))
self.clicks = 0
self.set_click_callback(self._clicked)
def _clicked(self):
self.clicks += 1
def _render(self, _):
pass
@pytest.fixture
def make_scroller(monkeypatch):
monkeypatch.setattr(rl, "begin_scissor_mode", lambda *args: None)
monkeypatch.setattr(rl, "end_scissor_mode", lambda: None)
monkeypatch.setattr(rl, "get_frame_time", lambda: 1 / 60)
monkeypatch.setattr(rl, "get_time", lambda: 1.0)
monkeypatch.setattr(rl, "get_mouse_wheel_move", lambda: 0)
monkeypatch.setattr(gui_app, "texture", lambda *args: rl.Texture())
monkeypatch.setattr(gui_app, "_show_touches", False)
monkeypatch.setattr(gui_app, "_mouse_events", [])
monkeypatch.setattr(widgets, "PC", False)
monkeypatch.setattr(widgets.device, "awake", True)
monkeypatch.setattr(scroll_panel2, "TICI", True)
def make(items=None, **kwargs):
scroller = _Scroller(items if items is not None else [Item() for _ in range(8)],
scroll_indicator=False, edge_shadows=False, **kwargs)
scroller.set_rect(rl.Rectangle(0, 0, 536, 240))
scroller.scroll_panel.set_offset(-500)
scroller.render()
return scroller
return make
def frame(scroller, *, pressed=False, released=False, pos=(200, 100), t=1.0):
gui_app._mouse_events = [MouseEvent(MousePos(*pos), 0, pressed, released, not released, t)]
scroller.render()
def test_tap_interrupts_programmatic_scroll_tail(make_scroller):
scroller = make_scroller()
scroller.scroll_to(3, smooth=True)
frame(scroller, pressed=True)
frame(scroller, released=True, t=1.05)
assert not scroller.is_auto_scrolling
assert sum(item.clicks for item in scroller.items) == 1
def test_blocking_programmatic_scroll_still_rejects_taps(make_scroller):
scroller = make_scroller()
scroller.scroll_to(30, smooth=True, block_interaction=True)
frame(scroller, pressed=True)
frame(scroller, released=True, t=1.05)
assert scroller.is_auto_scrolling
assert sum(item.clicks for item in scroller.items) == 0
@pytest.mark.parametrize("horizontal", [True, False])
def test_touch_down_immediately_stops_snapping(make_scroller, horizontal):
scroller = make_scroller(snap_items=True, horizontal=horizontal)
offset = scroller.scroll_panel.get_offset()
frame(scroller, pressed=True)
assert scroller.scroll_panel.state == scroll_panel2.ScrollState.PRESSED
assert scroller.scroll_panel.get_offset() == offset
def test_drag_outside_viewport_does_not_resume_snapping(make_scroller):
scroller = make_scroller(snap_items=True)
frame(scroller, pressed=True)
frame(scroller, pos=(180, 100), t=1.02)
frame(scroller, pos=(160, 250), t=1.04)
offset = scroller.scroll_panel.get_offset()
frame(scroller, pos=(140, 250), t=1.06)
assert scroller.scroll_panel.state == scroll_panel2.ScrollState.MANUAL_SCROLL
assert scroller.scroll_panel.get_offset() == pytest.approx(offset - 20)
@pytest.mark.parametrize("speed, expected_clicks", [(25, 1), (119, 1), (121, 0)])
@pytest.mark.parametrize("single_batch", [True, False])
def test_inertial_scroll_click_threshold_is_unchanged(make_scroller, speed, expected_clicks, single_batch):
scroller = make_scroller()
scroller.scroll_panel._state = scroll_panel2.ScrollState.AUTO_SCROLL
scroller.scroll_panel._velocity = -speed
if single_batch:
batch(scroller, event(pressed=True), event(released=True, t=1.05))
else:
frame(scroller, pressed=True)
frame(scroller, released=True, t=1.05)
assert sum(item.clicks for item in scroller.items) == expected_clicks
def event(x=200, y=100, *, pressed=False, released=False, slot=0, t=1.0):
return MouseEvent(MousePos(x, y), slot, pressed, released, not released, t)
def batch(scroller, *events):
gui_app._mouse_events = list(events)
scroller.render()
@pytest.mark.parametrize("horizontal", [True, False])
@pytest.mark.parametrize("single_batch", [True, False])
def test_drag_release_never_clicks(make_scroller, horizontal, single_batch):
scroller = make_scroller(horizontal=horizontal)
move = (lambda value, **kw: event(x=value, **kw)) if horizontal else (lambda value, **kw: event(y=value, **kw))
start = 200 if horizontal else 100
events = [move(start, pressed=True), move(start - 20, t=1.01), move(start - 40, t=1.21),
move(start - 40, released=True, t=1.22)]
if single_batch:
batch(scroller, *events)
else:
batch(scroller, events[0])
batch(scroller, *events[1:])
assert sum(item.clicks for item in scroller.items) == 0
assert not any(item.is_pressed for item in scroller.items)
@pytest.mark.parametrize("same_batch", [True, False])
@pytest.mark.parametrize("pc", [True, False])
def test_drag_then_tap_activates_only_the_tap(make_scroller, monkeypatch, same_batch, pc):
scroller = make_scroller()
monkeypatch.setattr(widgets, "PC", pc)
batch(scroller, event(pressed=True))
drag = [event(180, t=1.01), event(160, t=1.21), event(160, released=True, t=1.22)]
tap = [event(160, pressed=True, t=1.30), event(160, released=True, t=1.35)]
if same_batch:
batch(scroller, *drag, *tap)
else:
batch(scroller, *drag)
batch(scroller, *tap)
assert sum(item.clicks for item in scroller.items) == 1
def test_tap_before_drag_in_same_batch_is_preserved(make_scroller):
scroller = make_scroller()
batch(scroller, event(pressed=True), event(released=True, t=1.05),
event(pressed=True, t=1.10), event(180, t=1.12))
assert sum(item.clicks for item in scroller.items) == 1
assert scroller.scroll_panel.state == scroll_panel2.ScrollState.MANUAL_SCROLL
def test_batched_tap_cancels_programmatic_scroll(make_scroller):
scroller = make_scroller()
scroller.scroll_to(3, smooth=True)
batch(scroller, event(pressed=True), event(released=True, t=1.05))
assert not scroller.is_auto_scrolling
assert sum(item.clicks for item in scroller.items) == 1
def test_secondary_touch_does_not_change_drag_or_velocity(make_scroller):
scroller = make_scroller()
batch(scroller, event(pressed=True), event(180, t=1.02))
offset = scroller.scroll_panel.get_offset()
batch(scroller, event(400, slot=1, pressed=True, t=1.025), event(160, t=1.04),
event(420, slot=1, t=1.045), event(420, slot=1, released=True, t=1.05))
assert scroller.scroll_panel.state == scroll_panel2.ScrollState.MANUAL_SCROLL
assert scroller.scroll_panel.get_offset() == pytest.approx(offset - 20)
assert scroller.scroll_panel._velocity == pytest.approx(-1000)
def test_secondary_touch_does_not_cancel_primary_tap(make_scroller):
scroller = make_scroller()
batch(scroller, event(pressed=True), event(400, slot=1, pressed=True, t=1.01),
event(400, slot=1, released=True, t=1.02), event(released=True, t=1.05))
assert sum(item.clicks for item in scroller.items) == 1
def test_outside_press_does_not_capture_coasting_panel(make_scroller):
scroller = make_scroller()
panel = scroller.scroll_panel
panel._state = scroll_panel2.ScrollState.AUTO_SCROLL
panel._velocity = -500
offset = panel.get_offset()
batch(scroller, event(600, pressed=True), event(200, t=1.01), event(200, released=True, t=1.02))
assert panel.state == scroll_panel2.ScrollState.AUTO_SCROLL
assert panel.get_offset() == pytest.approx(offset - 500 / 60)
assert sum(item.clicks for item in scroller.items) == 0
def test_outside_press_does_not_clear_snap_target(make_scroller):
scroller = make_scroller()
panel = scroller.scroll_panel
panel._state = scroll_panel2.ScrollState.AUTO_SCROLL
panel.snap_interval = 100
panel._snap_target = -600
batch(scroller, event(600, pressed=True))
assert panel._snap_target == -600
assert panel.state == scroll_panel2.ScrollState.AUTO_SCROLL
def test_release_crossing_drag_threshold_does_not_leave_drag_active(make_scroller):
scroller = make_scroller()
batch(scroller, event(pressed=True), event(160, released=True, t=1.05))
assert sum(item.clicks for item in scroller.items) == 0
assert scroller.scroll_panel.state == scroll_panel2.ScrollState.STEADY
batch(scroller, event(pressed=True, t=1.10), event(released=True, t=1.15))
assert sum(item.clicks for item in scroller.items) == 1
def test_disabling_scrolling_preserves_control_taps(make_scroller):
scroller = make_scroller()
scroller.set_scrolling_enabled(False)
offset = scroller.scroll_panel.get_offset()
batch(scroller, event(pressed=True), event(released=True, t=1.05))
assert sum(item.clicks for item in scroller.items) == 1
assert scroller.scroll_panel.get_offset() == offset
@pytest.mark.parametrize("restart", ["show", "enable"])
def test_interrupted_drag_does_not_poison_next_tap(make_scroller, restart):
scroller = make_scroller()
batch(scroller, event(pressed=True), event(180, t=1.02))
if restart == "show":
scroller.set_reset_scroll_at_show(False)
scroller.hide_event()
scroller.show_event()
else:
scroller.set_enabled(False)
batch(scroller)
scroller.set_enabled(True)
batch(scroller, event(pressed=True, t=1.10), event(released=True, t=1.15))
assert sum(item.clicks for item in scroller.items) == 1
def test_mici_panel_is_not_used_by_shared_panel_callers(make_scroller):
assert isinstance(make_scroller().scroll_panel, _MiciScrollPanel)
# Shared callers retain the original event handling, not Mici's slot filter.
panel = scroll_panel2.GuiScrollPanel2()
gui_app._mouse_events = [event(slot=1, pressed=True)]
panel.update(rl.Rectangle(0, 0, 536, 240), 1000)
assert panel.state == scroll_panel2.ScrollState.PRESSED
@pytest.mark.parametrize("kind", ["wifi", "bluetooth"])
def test_nested_forget_button_rejects_swipe_and_accepts_next_tap(make_scroller, monkeypatch, kind):
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiButton
from openpilot.selfdrive.ui.mici.layouts.settings.bluetooth import BluetoothDeviceButton
cls = WifiButton if kind == "wifi" else BluetoothDeviceButton
button = object.__new__(cls)
widgets.Widget.__init__(button)
button.set_rect(rl.Rectangle(0, 0, 402, 180))
button._grow_animation_until = None
button._shake_start = None
button._forget_btn = Item()
# Exercise the real callback forwarding and touch dispatch without network
# services or drawing; use the full card as the nested button's hit area.
monkeypatch.setattr(button, "_update_state", lambda: None)
monkeypatch.setattr(button, "_render", lambda rect: button._forget_btn.render(rect))
monkeypatch.setattr(button, "_handle_mouse_release", lambda pos: None)
scroller = make_scroller(items=[Item(), button] + [Item() for _ in range(6)])
batch(scroller, event(pressed=True))
batch(scroller, event(180, t=1.01), event(160, t=1.21), event(160, released=True, t=1.22))
assert button._forget_btn.clicks == 0
batch(scroller, event(160, pressed=True, t=1.30), event(160, released=True, t=1.35))
assert button._forget_btn.clicks == 1
@pytest.mark.parametrize("tap_after_drag", [False, True])
def test_option_picker_uses_event_cancellation(make_scroller, monkeypatch, tap_after_drag):
from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog
from openpilot.system.ui.widgets.nav_widget import NavWidget
scroller = make_scroller()
dialog = object.__new__(BigMultiOptionDialog)
widgets.Widget.__init__(dialog)
dialog.set_rect(scroller.rect)
dialog._scroll_inner = scroller
dialog._selected_option = "option"
scroller.items[0].option = "option"
selections = []
monkeypatch.setattr(dialog, "_on_option_selected", selections.append)
monkeypatch.setattr(NavWidget, "_handle_mouse_event", lambda *args: None)
gui_app._mouse_events = [event(pressed=True)]
scroller.render()
dialog._process_mouse_events()
gui_app._mouse_events = [event(180, t=1.01), event(160, t=1.21), event(160, released=True, t=1.22)]
if tap_after_drag:
gui_app._mouse_events += [event(160, pressed=True, t=1.30), event(160, released=True, t=1.35)]
scroller.render()
dialog._process_mouse_events()
assert selections == (["option"] if tap_after_drag else [])
@pytest.mark.parametrize("guard", ["disabled", "moving", "original_callback", "original_event_callback"])
def test_existing_item_guards_still_reject_taps(make_scroller, monkeypatch, guard):
items = [Item() for _ in range(8)]
if guard == "original_callback":
items[1].set_touch_valid_callback(lambda: False)
elif guard == "original_event_callback":
items[1].set_touch_event_valid_callback(lambda ev: False)
scroller = make_scroller(items=items)
if guard == "disabled":
items[1].set_enabled(False)
elif guard == "moving":
monkeypatch.setattr(rl, "draw_rectangle_rec", lambda *args: None)
scroller.move_item(1, 2)
batch(scroller, event(pressed=True), event(released=True, t=1.05))
assert sum(item.clicks for item in items) == 0
@pytest.mark.parametrize("horizontal", [True, False])
def test_nav_scroller_swipe_to_dismiss_still_works(make_scroller, horizontal):
from openpilot.system.ui.widgets.scroller import NavScroller, NavRawScrollPanel
class RawPanel(NavRawScrollPanel):
def _render(self, _):
pass
nav = NavScroller() if horizontal else RawPanel()
nav.set_rect(rl.Rectangle(0, 0, 536, 240))
if horizontal:
nav._scroller._show_scroll_indicator = False
nav._scroller._edge_shadows = False
nav._scroller.add_widgets([Item() for _ in range(8)])
for ev in (event(y=50, pressed=True), event(y=150, t=1.1), event(y=150, released=True, t=1.15)):
gui_app._mouse_events = [ev]
# Same child-before-parent dispatch order as rendering, without drawing.
if horizontal:
nav._scroller.render(nav.rect)
else:
nav._scroll_panel.update(nav.rect, 1000)
nav._process_mouse_events()
assert nav._playing_dismiss_animation
if horizontal:
assert sum(item.clicks for item in nav._scroller.items) == 0
+8 -2
View File
@@ -39,6 +39,7 @@ class Widget(abc.ABC):
# if current mouse/touch down started within the widget's rectangle
self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
self._touch_valid_callback: Callable[[], bool] | None = None
self._touch_event_valid_callback: Callable[[MouseEvent], bool] | None = None
self._click_delay: float | None = None # seconds to hold is_pressed after release
self._click_release_time: float | None = None
self._click_callback: Callable[[], None] | None = None
@@ -91,6 +92,10 @@ class Widget(abc.ABC):
"""Check if the widget can be touched."""
return self._touch_valid_callback() if self._touch_valid_callback else True
def set_touch_event_valid_callback(self, touch_callback: Callable[[MouseEvent], bool]) -> None:
"""Validate individual events when a parent has already processed the touch batch."""
self._touch_event_valid_callback = touch_callback
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height)
@@ -146,10 +151,11 @@ class Widget(abc.ABC):
if not self._multi_touch and mouse_event.slot != 0:
continue
event_touch_valid = touch_valid and (self._touch_event_valid_callback is None or self._touch_event_valid_callback(mouse_event))
mouse_in_rect = rl.check_collision_point_rec(mouse_event.pos, hit_rect)
# Ignores touches/presses that start outside our rect
# Allows touch to leave the rect and come back in focus if mouse did not release
if mouse_event.left_pressed and touch_valid:
if mouse_event.left_pressed and event_touch_valid:
if mouse_in_rect:
self._handle_mouse_press(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = True
@@ -157,7 +163,7 @@ class Widget(abc.ABC):
self._handle_mouse_event(mouse_event)
# Callback such as scroll panel signifies user is scrolling
elif not touch_valid:
elif not event_touch_valid:
self.__is_pressed[mouse_event.slot] = False
self.__tracking_is_pressed[mouse_event.slot] = False
+2
View File
@@ -45,6 +45,8 @@ def device_status_text(device: BluetoothDevice, operation: str, selected_audio:
capabilities.append(tr("audio output") if selected_audio.upper() == device.address.upper() else tr("audio"))
if device.controller:
capabilities.append(tr("controller"))
if device.serial:
capabilities.append(tr("serial"))
capability_text = " / ".join(capabilities)
if device.connected:
+75 -6
View File
@@ -4,8 +4,8 @@ from collections.abc import Callable
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
from openpilot.common.swaglog import cloudlog
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
from openpilot.system.ui.lib.application import gui_app, MouseEvent
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState, MIN_DRAG_PIXELS
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.nav_widget import NavWidget
@@ -25,6 +25,63 @@ DO_ZOOM = False
DO_JELLO = False
class _MiciScrollPanel(GuiScrollPanel2):
"""Mici gesture ownership, using the shared scrolling physics unchanged."""
def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True):
super().__init__(horizontal, handle_out_of_bounds)
self._event_touch_valid: dict[MouseEvent, bool] = {}
self.reset()
def reset(self) -> None:
"""Forget touch history without changing the scroll position."""
self._state = ScrollState.STEADY
self._velocity = 0.0
self._velocity_buffer.clear()
self._initial_click_event = None
self._previous_mouse_event = None
self._snap_target = None
self._touch_active = False
self.touch_started = False
self._event_touch_valid.clear()
def is_event_touch_valid(self, event: MouseEvent) -> bool:
return self._event_touch_valid.get(event, False)
def _process_mouse_events(self, bounds: rl.Rectangle, bounds_size: float, content_size: float) -> None:
self._event_touch_valid.clear()
self.touch_started = False
if not self.enabled:
self.reset()
# Disabling scrolling alone must not disable the controls inside it.
self._event_touch_valid.update((event, True) for event in gui_app.mouse_events if event.slot == 0)
return
for event in gui_app.mouse_events:
if event.slot != 0:
continue
if event.left_pressed:
if not rl.check_collision_point_rec(event.pos, bounds):
continue
self._touch_active = True
self.touch_started = True
if not self._touch_active:
continue
was_dragging = self._state == ScrollState.MANUAL_SCROLL
if self._state == ScrollState.PRESSED and self._initial_click_event is not None:
was_dragging |= abs(self._get_mouse_pos(event) - self._get_mouse_pos(self._initial_click_event)) > MIN_DRAG_PIXELS
super()._handle_mouse_event(event, bounds, bounds_size, content_size)
# Preserve drag cancellation through release, even if another tap follows
# in this batch. Children consume these events after the panel updates.
self._event_touch_valid[event] = not (was_dragging or self._state == ScrollState.MANUAL_SCROLL)
self._previous_mouse_event = event
if event.left_released:
self._touch_active = False
if self._state == ScrollState.MANUAL_SCROLL:
# A release can itself cross the drag threshold. Finish that drag now.
super()._handle_mouse_event(event, bounds, bounds_size, content_size)
class ScrollIndicator(Widget):
HORIZONTAL_MARGIN = 4
@@ -95,7 +152,7 @@ class _Scroller(Widget):
# when not pressed, snap to closest item to be center
self._scroll_snap_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items)
self.scroll_panel = _MiciScrollPanel(self._horizontal, handle_out_of_bounds=not self._snap_items)
self._scroll_enabled: bool | Callable[[], bool] = True
self._show_scroll_indicator = scroll_indicator and self._horizontal
@@ -148,9 +205,12 @@ class _Scroller(Widget):
# preserve original touch valid callback
original_touch_valid_callback = item._touch_valid_callback
item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and self._scrolling_to[0] is None
item.set_touch_valid_callback(lambda: self.enabled and not self._scrolling_to[1]
and not self.moving_items and (original_touch_valid_callback() if
original_touch_valid_callback else True))
original_event_callback = item._touch_event_valid_callback
item.set_touch_event_valid_callback(lambda event: self.scroll_panel.is_event_touch_valid(event) and
(original_event_callback(event) if original_event_callback else True))
def add_widgets(self, items: list[Widget]) -> None:
for item in items:
@@ -189,6 +249,8 @@ class _Scroller(Widget):
scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled
self.scroll_panel.set_enabled(scroll_enabled and self.enabled and not self._scrolling_to[1])
self.scroll_panel.update(self._rect, content_size)
if self.scroll_panel.touch_started and not self._scrolling_to[1]:
self._scrolling_to = None, False
if not self._snap_items:
return self.scroll_panel.get_offset()
@@ -207,7 +269,7 @@ class _Scroller(Widget):
if scroll_snap_idx is not None:
snap_item = visible_items[scroll_snap_idx]
if self.is_pressed:
if self.scroll_panel.state in (ScrollState.PRESSED, ScrollState.MANUAL_SCROLL):
# no snapping until released
self._scroll_snap_filter.x = 0
else:
@@ -398,6 +460,7 @@ class _Scroller(Widget):
def show_event(self):
super().show_event()
self.scroll_panel.reset()
for item in self._items:
item.show_event()
@@ -414,6 +477,7 @@ class _Scroller(Widget):
def hide_event(self):
super().hide_event()
self.scroll_panel.reset()
for item in self._items:
item.hide_event()
@@ -450,12 +514,17 @@ class NavRawScrollPanel(NavWidget):
def __init__(self):
super().__init__()
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._scroll_panel = _MiciScrollPanel(horizontal=False)
self._scroll_panel.set_enabled(lambda: self.enabled and not self.is_dismissing)
def show_event(self):
super().show_event()
self._scroll_panel.reset()
self._scroll_panel.set_offset(0)
def hide_event(self):
super().hide_event()
self._scroll_panel.reset()
def _back_enabled(self) -> bool:
return self._scroll_panel.get_offset() >= -20