mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-01 11:53:46 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 089fd38fc2 | |||
| f15a1974d5 | |||
| 08139a021a | |||
| fbe982f47b | |||
| 5e6e978438 | |||
| b990a776b2 | |||
| 88cbf88756 | |||
| 373c411baa | |||
| 990e68804c | |||
| b295a57281 | |||
| 673ca37396 | |||
| 44beb5b778 | |||
| 00ac287223 |
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"
|
||||
|
||||
+12
-9
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user