oo buddy boy

This commit is contained in:
firestar5683
2026-07-20 13:18:27 -05:00
parent 0eab89c8d9
commit 8b7c2b5f36
44 changed files with 1038 additions and 89 deletions
Binary file not shown.
+2 -1
View File
@@ -2264,7 +2264,8 @@ struct DriverMonitoringStateDEPRECATED @0xb83cda094a1da284 {
struct DriverMonitoringState {
lockout @0 :Bool;
lockoutRecoveryPercent @11 :Int8;
lockoutCount @15 :Int8;
lockoutMinutesRemaining @11 :Int8;
alert3Count @12 :Int8;
noResponseCount @13 :Int8;
noResponseForceDecel @14 :Bool;
+61 -1
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env python3
import io
import math
import os
import sys
from pathlib import Path
CHUNK_SIZE = 45 * 1024 * 1024 # 45MB, under GitHub's 50MB limit
@@ -13,9 +16,16 @@ def get_manifest_path(name):
return f"{name}.chunkmanifest"
def _chunk_paths(path, num_chunks):
return [get_manifest_path(path)] + [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)]
def get_chunk_paths(path, file_size):
num_chunks = math.ceil(file_size / CHUNK_SIZE)
return [get_manifest_path(path)] + [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)]
return _chunk_paths(path, num_chunks)
get_chunk_targets = get_chunk_paths
def chunk_file(path, targets):
@@ -31,6 +41,51 @@ def chunk_file(path, targets):
os.remove(path)
def get_existing_chunks(path):
if os.path.isfile(path):
return [path]
manifest_path = get_manifest_path(path)
if os.path.isfile(manifest_path):
num_chunks = int(Path(manifest_path).read_text().strip())
return _chunk_paths(path, num_chunks)
raise FileNotFoundError(path)
def file_chunked_exists(path) -> bool:
return os.path.isfile(path) or os.path.isfile(get_manifest_path(path))
class ChunkStream(io.RawIOBase):
def __init__(self, paths):
self._paths = iter(paths)
self._buffer = memoryview(b"")
def readable(self):
return True
def readinto(self, buffer):
count = 0
while count < len(buffer):
if not self._buffer:
path = next(self._paths, None)
if path is None:
break
self._buffer = memoryview(Path(path).read_bytes())
continue
take = min(len(buffer) - count, len(self._buffer))
buffer[count:count + take] = self._buffer[:take]
self._buffer = self._buffer[take:]
count += take
return count
def open_file_chunked(path):
chunks = get_existing_chunks(path)
if chunks and chunks[0] == get_manifest_path(path):
chunks = chunks[1:]
return io.BufferedReader(ChunkStream(chunks))
def read_file_chunked(path):
manifest_path = get_manifest_path(path)
if os.path.isfile(manifest_path):
@@ -39,3 +94,8 @@ def read_file_chunked(path):
if os.path.isfile(path):
return Path(path).read_bytes()
raise FileNotFoundError(path)
if __name__ == "__main__":
file_path = sys.argv[1]
chunk_file(file_path, get_chunk_targets(file_path, os.path.getsize(file_path)))
Binary file not shown.
+6
View File
@@ -37,6 +37,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"DoShutdown", {CLEAR_ON_MANAGER_START, BOOL}},
{"DoUninstall", {CLEAR_ON_MANAGER_START, BOOL}},
{"DriverTooDistracted", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}},
{"DriverLockoutCount", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, INT, "0"}},
{"EcuDisableFailed", {CLEAR_ON_MANAGER_START, BOOL}},
{"AlphaLongitudinalEnabled", {PERSISTENT, BOOL}},
{"ExperimentalLongitudinalEnabled", {PERSISTENT, BOOL}},
@@ -148,6 +149,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"UpdaterLastFetchTime", {PERSISTENT, TIME}},
{"UptimeOffroad", {PERSISTENT, FLOAT, "0.0"}},
{"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}},
{"UsbGpuActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
{"UsbGpuCompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
{"UsbGpuPresent", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
{"Version", {PERSISTENT, STRING}},
// StarPilot variables
@@ -457,6 +461,8 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"OneLaneChange", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}},
{"OnroadDistanceButton", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}},
{"OnroadDistanceButtonPressed", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}},
{"FavoriteVirtualAccelCruiseCounter", {CLEAR_ON_MANAGER_START, INT, "0", "0"}},
{"FavoriteVirtualDecelCruiseCounter", {CLEAR_ON_MANAGER_START, INT, "0", "0"}},
{"WheelButtonBookmarkCounter", {CLEAR_ON_MANAGER_START, INT, "0", "0"}},
{"openpilotMinutes", {PERSISTENT, INT, "0", "0", 0}},
{"OverpassRequests", {PERSISTENT, JSON, "{}", "{}"}},
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
from openpilot.common import file_chunker
def test_chunked_stream_round_trip(tmp_path, monkeypatch):
monkeypatch.setattr(file_chunker, "CHUNK_SIZE", 7)
path = tmp_path / "artifact.pkl"
payload = b"a model artifact spanning several chunks"
path.write_bytes(payload)
targets = file_chunker.get_chunk_targets(path, len(payload))
file_chunker.chunk_file(path, targets)
assert file_chunker.file_chunked_exists(path)
assert file_chunker.read_file_chunked(path) == payload
with file_chunker.open_file_chunked(path) as stream:
assert stream.read(9) + stream.read() == payload
+20 -1
View File
@@ -6,7 +6,7 @@ This workflow rebuilds StarPilot driving and driver-monitoring artifacts for the
- The supported build device is `comma@192.168.3.110`.
- Never run these commands against `192.168.3.109`.
- Do not compile normal and big-GPU artifacts together. This workflow builds normal QCOM artifacts only.
- Normal artifacts target QCOM. External-GPU artifacts must be compiled explicitly and tagged in the manifest.
- Keep source ONNX files and compiled PKLs on the T5 workspace, not the comma.
## Workspace
@@ -82,6 +82,23 @@ The lower-level device compiler also supports direct use:
./models --model deeprl3v2 --input-format supercombo --version v15
```
For a model that cannot run on the device GPU, compile with the USB AMD GPU attached:
```bash
./models --model heavyweight --input-format supercombo --version v15 --external-gpu
```
This emits a streaming out-of-band pickle and keeps QCOM available for camera warps. Its manifest entry must include:
```json
{
"id": "heavyweight",
"uses_external_gpu": true
}
```
Only tagged models activate the external GPU. If the GPU or artifact is unavailable, runtime falls back to the built-in model; all untagged models retain the existing QCOM path.
`--version` records behavioral semantics only. It does not change artifact layout.
If the compiled PKL exceeds 100 MiB, `./models` automatically keeps the full
@@ -139,6 +156,8 @@ The generator preserves existing IDs and behavioral metadata and adds
Repository-hosted multipart files are discovered by naming convention, so no
size, hash, format, or part-count metadata is required.
`uses_external_gpu` is optional and defaults to `false`.
## Runtime Verification
Compilation validates JIT capture/replay, pickle round-trip, finite outputs, metadata slices, and both camera warps. Before release:
+18 -2
View File
@@ -80,6 +80,7 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument("--list", action="store_true", help="List staged models and exit.")
parser.add_argument("--force", action="store_true", help="Accepted for compatibility; selected outputs are always replaced.")
parser.add_argument("--external-gpu", action="store_true", help="Compile the driving artifact for the USB AMD GPU.")
parser.add_argument("--split-artifact", type=Path, help="Split an existing oversized PKL without compiling.")
parser.add_argument("--chunk-size-mib", type=int, default=95, help="Multipart size in MiB; must be below 100.")
parser.add_argument(
@@ -355,6 +356,7 @@ def compile_driving(
version: str,
output_dir: Path,
image_history_pipeline: str,
external_gpu: bool = False,
) -> Path:
model_type, source_args = driving_compile_args(files, input_format)
output_path = output_dir / f"{model_key}_driving_tinygrad.pkl"
@@ -390,7 +392,20 @@ def compile_driving(
]
if version:
command += ["--behavior-version", version]
subprocess.run(command, cwd=REPO_ROOT, env=build_compile_env(), check=True)
compile_env = build_compile_env()
if external_gpu:
for qcom_only_flag in ("IMAGE", "NOLOCALS", "OPENPILOT_HACKS"):
compile_env.pop(qcom_only_flag, None)
compile_env.update({
"DEBUG": "2",
"DEV": "USB+AMD:LLVM",
"WARP_DEV": "QCOM",
"FLOAT16": "1",
"JIT_BATCH_SIZE": "0",
"GMMU": "0",
})
command.append("--out-of-band")
subprocess.run(command, cwd=REPO_ROOT, env=compile_env, check=True)
return output_path
@@ -487,7 +502,8 @@ def main() -> int:
version = "v15"
version_label = version or "unspecified behavior"
print(f"Compiling {model_key} ({input_format}, {version_label}) from {args.input_dir} -> {args.output_dir}")
output = compile_driving(model_key, files, input_format, version, args.output_dir, args.image_history_pipeline)
output = compile_driving(model_key, files, input_format, version, args.output_dir,
args.image_history_pipeline, args.external_gpu)
print(f" saved {output.name}")
multipart_outputs = split_oversized_artifact(output)
if multipart_outputs:
+13 -7
View File
@@ -236,11 +236,14 @@ def compile_model(model_id: str, source: dict, version: str, workspace: Path, fo
run(["rsync", "-az", "--exclude=._*", f"{source_dir}/", f"{REMOTE}:{remote_input}/"])
log_path = workspace / "logs" / f"{model_id}.log"
command = (
f"cd {REMOTE_ROOT} && ./models --model {model_id} "
f"--input-dir {remote_input} --output-dir {REMOTE_ROOT}/compiledmodels "
f"--input-format {source['input_format']} --version {version}"
)
command_parts = [
f"cd {REMOTE_ROOT} && ./models --model {model_id}",
f"--input-dir {remote_input} --output-dir {REMOTE_ROOT}/compiledmodels",
f"--input-format {source['input_format']} --version {version}",
]
if source.get("uses_external_gpu"):
command_parts.append("--external-gpu")
command = " ".join(command_parts)
with open(log_path, "wb") as log_file:
process = subprocess.run(["ssh", REMOTE, command], stdout=log_file, stderr=subprocess.STDOUT)
if process.returncode != 0:
@@ -288,7 +291,7 @@ def validate_model(model_id: str, version: str, workspace: Path) -> dict:
return payload
def update_manifest(base_manifest: Path, workspace: Path) -> dict:
def update_manifest(base_manifest: Path, workspace: Path, source_map: dict) -> dict:
payload = load_json(base_manifest)
models = payload["models"] if isinstance(payload, dict) else payload
if not any(model.get("id") == "deeprl3v2" for model in models):
@@ -302,6 +305,9 @@ def update_manifest(base_manifest: Path, workspace: Path) -> dict:
})
multipart_handoff = []
for model in models:
source = source_map.get(model["id"], {})
if "uses_external_gpu" in source:
model["uses_external_gpu"] = bool(source["uses_external_gpu"])
artifact = workspace / "compiled" / f"{model['id']}_driving_tinygrad.pkl"
model.pop("artifact_format", None)
model.pop("artifact_size", None)
@@ -350,7 +356,7 @@ def main() -> int:
if args.command == "manifest":
if args.base_manifest is None:
parser.error("--base-manifest is required")
update_manifest(args.base_manifest, args.workspace)
update_manifest(args.base_manifest, args.workspace, source_map)
return 0
model_ids = [args.model] if args.model else list(source_map)
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import statistics
import time
from pathlib import Path
import cv2
import numpy as np
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Benchmark fixed-shape ONNX forwards through OpenCV DNN.")
parser.add_argument("models", nargs="+", type=Path, help="ONNX model paths to benchmark.")
parser.add_argument("--input-size", type=int, required=True, help="Square model input size.")
parser.add_argument("--iterations", type=int, default=50)
parser.add_argument("--warmup", type=int, default=5)
return parser.parse_args()
def percentile(values: list[float], quantile: float) -> float:
ordered = sorted(values)
index = min(round((len(ordered) - 1) * quantile), len(ordered) - 1)
return ordered[index]
def benchmark(path: Path, input_size: int, iterations: int, warmup: int) -> dict[str, object]:
net = cv2.dnn.readNetFromONNX(str(path))
blob = np.zeros((1, 3, input_size, input_size), dtype=np.float32)
net.setInput(blob)
output = net.forward()
for _ in range(max(warmup - 1, 0)):
net.setInput(blob)
output = net.forward()
durations_ms = []
for _ in range(max(iterations, 1)):
net.setInput(blob)
started_at = time.perf_counter()
output = net.forward()
durations_ms.append((time.perf_counter() - started_at) * 1000.0)
return {
"model": str(path.resolve()),
"input_size": input_size,
"output_shape": list(output.shape),
"iterations": len(durations_ms),
"median_ms": round(statistics.median(durations_ms), 3),
"p95_ms": round(percentile(durations_ms, 0.95), 3),
"mean_ms": round(statistics.mean(durations_ms), 3),
}
def main() -> int:
args = parse_args()
results = [
benchmark(path.expanduser().resolve(), args.input_size, args.iterations, args.warmup)
for path in args.models
]
print(json.dumps(results, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -86,6 +86,13 @@ def parse_read(text: str) -> tuple[str, str]:
return (speed, confidence) if separator else ("", "")
def nonzero_value(text: str) -> str:
try:
return text if float(text) > 0.0 else ""
except (TypeError, ValueError):
return ""
def queue_row(row: dict[str, str]) -> dict[str, str]:
key = record_key(row)
route, dongle_id, log_id = route_identity(row)
@@ -99,8 +106,17 @@ def queue_row(row: dict[str, str]) -> dict[str, str]:
read_sources += ";logged_vision_publish"
review_reasons = ["corrected_source_timing"]
review_priority = float(row.get("score") or 0.0)
if row.get("event_type", "") == "visionPublish":
review_reasons.extend(("route_vision_publish", f"published_{row.get('published_speed', '')}"))
published_speed = row.get("published_speed", "")
review_reasons.extend(("route_vision_publish", f"published_{published_speed}"))
if published_speed and candidate_speed and published_speed != candidate_speed:
review_reasons.append("logged_current_value_disagreement")
review_priority += 5.0
map_speed = nonzero_value(row.get("map_speed", ""))
if map_speed and published_speed and map_speed != published_speed:
review_reasons.append("logged_map_disagreement")
review_priority += 3.0
else:
review_reasons.append("route_bookmark")
@@ -129,9 +145,9 @@ def queue_row(row: dict[str, str]) -> dict[str, str]:
"read_sources": read_sources,
"read_support_count": "1",
"is_regulatory": row.get("is_regulatory", ""),
"map_current_speed_limit_mph": row.get("map_speed", ""),
"map_next_speed_limit_mph": row.get("next_speed", ""),
"review_priority": row.get("score", ""),
"map_current_speed_limit_mph": nonzero_value(row.get("map_speed", "")),
"map_next_speed_limit_mph": nonzero_value(row.get("next_speed", "")),
"review_priority": f"{review_priority:.4f}",
"review_reasons": ";".join(review_reasons),
})
return item
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import torch
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import EXTENDED_CLASSIFIER_SPEED_VALUES # type: ignore # noqa: TID251
else:
from .common import EXTENDED_CLASSIFIER_SPEED_VALUES
DEFAULT_CLASSES = tuple(str(value) for value in EXTENDED_CLASSIFIER_SPEED_VALUES) + ("reject",)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Expand a YOLO classification head while preserving existing class rows.")
parser.add_argument("--model", type=Path, required=True, help="Existing Ultralytics classification checkpoint.")
parser.add_argument("--output", type=Path, required=True, help="Expanded checkpoint path.")
parser.add_argument("--classes", nargs="+", default=DEFAULT_CLASSES, help="New output classes in dataset index order.")
return parser.parse_args()
def main() -> int:
from ultralytics import YOLO
args = parse_args()
model = YOLO(str(args.model.expanduser().resolve()))
old_names = {int(index): str(name) for index, name in model.names.items()}
new_names = dict(enumerate(args.classes))
if len(set(new_names.values())) != len(new_names):
raise ValueError("--classes contains duplicate names")
if not set(old_names.values()).issubset(new_names.values()):
missing = sorted(set(old_names.values()) - set(new_names.values()))
raise ValueError(f"Expanded classes omit existing outputs: {missing}")
head = model.model.model[-1]
old_linear = head.linear
new_linear = torch.nn.Linear(
old_linear.in_features,
len(new_names),
bias=old_linear.bias is not None,
device=old_linear.weight.device,
dtype=old_linear.weight.dtype,
)
new_index_by_name = {name: index for index, name in new_names.items()}
with torch.no_grad():
for old_index, name in old_names.items():
new_index = new_index_by_name[name]
new_linear.weight[new_index].copy_(old_linear.weight[old_index])
if old_linear.bias is not None:
new_linear.bias[new_index].copy_(old_linear.bias[old_index])
head.linear = new_linear
head.np = sum(parameter.numel() for parameter in head.parameters())
model.model.names = new_names
model.model.yaml["nc"] = len(new_names)
model.model.args["classes"] = None
output = args.output.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
model.save(str(output))
print(f"Expanded {len(old_names)} outputs to {len(new_names)}: {output}")
print(new_names)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -222,7 +222,11 @@ def save_classifier_crop(base_dir: Path, split: str, speed_value: int, image_bgr
def collect_backgrounds(background_dir: Path) -> list[Path]:
if not background_dir.is_dir():
return []
return sorted(path for path in background_dir.iterdir() if path.suffix.lower() in {".jpg", ".jpeg", ".png"})
return sorted(
path
for path in background_dir.iterdir()
if not path.name.startswith("._") and path.suffix.lower() in {".jpg", ".jpeg", ".png"}
)
def main():
@@ -233,6 +237,7 @@ def main():
parser.add_argument("--val-count", type=int, default=1200, help="Number of synthetic validation detector images.")
parser.add_argument("--negative-ratio", type=float, default=0.18, help="Share of detector images with no sign.")
parser.add_argument("--speed-values", nargs="+", type=int, default=list(DEFAULT_SPEED_VALUES), help="Posted values to synthesize.")
parser.add_argument("--regulatory-only", action="store_true", help="Generate only regulatory signs for the requested values.")
parser.add_argument("--seed", type=int, default=20260330, help="Random seed.")
args = parser.parse_args()
@@ -265,7 +270,11 @@ def main():
detector_lines: list[str] = []
if rng.random() >= args.negative_ratio:
sign_spec = choose_sign_spec(rng, speed_values)
sign_spec = (
SignSpec(detector_class=0, style="regulatory", speed_value=rng.choice(speed_values))
if args.regulatory_only
else choose_sign_spec(rng, speed_values)
)
if sign_spec.style == "advisory":
sign_image = render_advisory_sign(sign_spec.speed_value or 25, seed=rng.randint(0, 1_000_000))
else:
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import shutil
from collections import Counter
from pathlib import Path
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import EXTENDED_CLASSIFIER_SPEED_VALUES # type: ignore # noqa: TID251
else:
from .common import EXTENDED_CLASSIFIER_SPEED_VALUES
IMAGE_SUFFIXES = frozenset((".jpg", ".jpeg", ".png", ".bmp", ".webp"))
SUPPORTED_CLASSES = frozenset(str(value) for value in EXTENDED_CLASSIFIER_SPEED_VALUES) | {"reject"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Merge image-folder classifier datasets using hard links when possible.")
parser.add_argument("sources", nargs="+", type=Path, help="Classifier roots containing train/ and val/.")
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--overwrite", action="store_true")
return parser.parse_args()
def link_or_copy(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
try:
os.link(source, destination)
except OSError:
shutil.copy2(source, destination)
def main() -> int:
args = parse_args()
sources = [path.expanduser().resolve() for path in args.sources]
output = args.output.expanduser().resolve()
if output.exists():
if not args.overwrite:
raise FileExistsError(f"Output already exists: {output}")
shutil.rmtree(output)
counts: Counter[str] = Counter()
for source_index, source_root in enumerate(sources):
for split in ("train", "val"):
split_root = source_root / split
if not split_root.is_dir():
raise FileNotFoundError(split_root)
for class_dir in sorted(split_root.iterdir()):
if not class_dir.is_dir() or class_dir.name.startswith("._"):
continue
if class_dir.name not in SUPPORTED_CLASSES:
raise ValueError(f"Unsupported classifier class {class_dir.name!r} in {source_root}")
for image_path in sorted(class_dir.iterdir()):
if image_path.name.startswith("._") or image_path.suffix.lower() not in IMAGE_SUFFIXES or not image_path.is_file():
continue
destination = output / split / class_dir.name / f"s{source_index:02d}_{image_path.name}"
link_or_copy(image_path, destination)
counts[f"{split}/{class_dir.name}"] += 1
summary = {
"sources": [str(path) for path in sources],
"output": str(output),
"counts": dict(sorted(counts.items())),
}
(output / "merge_summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="ascii")
print(json.dumps(summary, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -21,7 +21,7 @@ VIDEO_SUFFIXES = {".hevc", ".mp4", ".mov", ".mkv", ".avi"}
def iter_source_files(paths: list[Path]):
for input_path in paths:
if input_path.is_file():
if input_path.is_file() and not input_path.name.startswith("._"):
yield input_path
continue
@@ -29,7 +29,7 @@ def iter_source_files(paths: list[Path]):
continue
for path in sorted(input_path.rglob("*")):
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES | VIDEO_SUFFIXES:
if path.is_file() and not path.name.startswith("._") and path.suffix.lower() in IMAGE_SUFFIXES | VIDEO_SUFFIXES:
yield path
@@ -321,19 +321,32 @@ function setSpeed(speed, shouldSave) {
function handleDigitShortcut(digit) {
speedBuffer += digit;
if (speedBuffer.length > 2) speedBuffer = speedBuffer.slice(-2);
if (speedBuffer.length > 3) speedBuffer = digit;
if (speedBufferTimer) clearTimeout(speedBufferTimer);
speedBufferTimer = setTimeout(clearSpeedBuffer, 1000);
if (speedBuffer.length < 2) return;
if (speedBuffer === "10") {
clearTimeout(speedBufferTimer);
speedBufferTimer = setTimeout(() => {
if (speedBuffer === "10") {
clearSpeedBuffer();
setSpeed(10, true);
}
}, 400);
return;
}
const speed = Number(speedBuffer);
clearSpeedBuffer();
if (speeds.includes(speed)) {
clearSpeedBuffer();
setSpeed(speed, true);
return;
}
if (speedBuffer.length < 3) return;
speedBuffer = digit;
speedBufferTimer = setTimeout(clearSpeedBuffer, 1000);
}
+29
View File
@@ -29,6 +29,10 @@ from openpilot.selfdrive.car.cruise import (
from openpilot.selfdrive.car.redneck_cruise import RedneckCruise, select_redneck_target_speed
from openpilot.selfdrive.car.car_specific import MockCarState
from openpilot.starpilot.common.favorite_slots import (
FAVORITE_ACTION_ACCEL_COUNTER,
FAVORITE_ACTION_DECEL_COUNTER,
)
from openpilot.starpilot.common.starpilot_variables import get_starpilot_toggles, update_starpilot_toggles
from openpilot.starpilot.controls.starpilot_card import StarPilotCard
@@ -93,6 +97,9 @@ class Car:
self.params = Params()
self.params_memory = Params(memory=True)
self._favorite_virtual_accel_counter = self.params_memory.get_int(FAVORITE_ACTION_ACCEL_COUNTER)
self._favorite_virtual_decel_counter = self.params_memory.get_int(FAVORITE_ACTION_DECEL_COUNTER)
self._favorite_virtual_releases = []
self.can_callbacks = can_comm_callbacks(self.can_sock, self.pm.sock['sendcan'])
@@ -205,6 +212,27 @@ class Car:
self.sm = self.sm.extend(['starpilotOnroadEvents', 'starpilotPlan', 'starpilotSelfdriveState', 'liveCalibration', 'selfdriveState'])
self.pm = self.pm.extend(['starpilotCarState'])
def _inject_favorite_virtual_cruise_events(self, CS: car.CarState) -> None:
virtual_events = [
structs.CarState.ButtonEvent(pressed=False, type=button_type)
for button_type in self._favorite_virtual_releases
]
self._favorite_virtual_releases = []
for counter_key, counter_attr, button_type in (
(FAVORITE_ACTION_ACCEL_COUNTER, "_favorite_virtual_accel_counter", ButtonType.accelCruise),
(FAVORITE_ACTION_DECEL_COUNTER, "_favorite_virtual_decel_counter", ButtonType.decelCruise),
):
counter = self.params_memory.get_int(counter_key)
if counter == getattr(self, counter_attr):
continue
setattr(self, counter_attr, counter)
virtual_events.append(structs.CarState.ButtonEvent(pressed=True, type=button_type))
self._favorite_virtual_releases.append(button_type)
if virtual_events:
CS.buttonEvents = list(CS.buttonEvents) + virtual_events
def state_update(self) -> tuple[car.CarState, structs.RadarDataT | None]:
"""carState update loop, driven by can"""
@@ -215,6 +243,7 @@ class Car:
CS, FPCS = self.CI.update(can_list, self.starpilot_toggles)
if self.CP.brand == 'mock':
CS, FPCS = self.mock_carstate.update(CS, FPCS)
self._inject_favorite_virtual_cruise_events(CS)
# Update radar tracks from CAN
RD: structs.RadarDataT | None = self.RI.update(can_list)
+20 -2
View File
@@ -43,6 +43,8 @@ from tinygrad.device import Device
from tinygrad.engine.jit import TinyJit
from tinygrad.helpers import Context
from tinygrad.tensor import Tensor
from openpilot.selfdrive.modeld.helpers import dump_oob
from openpilot.selfdrive.modeld.usbgpu_link import wait_usbgpu_link
ARTIFACT_FORMAT_VERSION = 1
@@ -59,6 +61,7 @@ WARP_INPUTS = LEGACY_WARP_INPUTS
SPLIT_POLICY_INPUTS = BASE_POLICY_INPUTS
SUPERCOMBO_POLICY_INPUTS = BASE_POLICY_INPUTS
WARP_DEV = os.getenv("WARP_DEV")
OOB_PICKLE = False
def _detect_desire_key(input_shapes):
@@ -451,7 +454,14 @@ def compile_jit(jit, make_random_inputs, input_keys, make_queues):
print("capture + replay")
test_values, test_buffers = random_inputs_run(jit, seed)
print("pickle round trip")
jit = pickle.loads(pickle.dumps(jit))
if OOB_PICKLE:
with tempfile.TemporaryFile(dir=".") as artifact_file:
dump_oob(jit, artifact_file)
artifact_file.seek(0)
from openpilot.selfdrive.modeld.helpers import load_oob
jit = load_oob(artifact_file)
else:
jit = pickle.loads(pickle.dumps(jit))
random_inputs_run(jit, seed, test_values, test_buffers, expect_match=True)
random_inputs_run(jit, seed + 1, test_values, test_buffers, expect_match=False)
return jit
@@ -486,6 +496,7 @@ def validate_metadata(metadata):
def main():
global OOB_PICKLE
from tinygrad.nn.onnx import OnnxRunner
from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict
@@ -498,6 +509,7 @@ def main():
parser.add_argument("--frame-skip", type=int)
parser.add_argument("--behavior-version")
parser.add_argument("--output", required=True)
parser.add_argument("--out-of-band", action="store_true", help="Stream model weights outside pickle opcodes for large artifacts.")
parser.add_argument("--vision-onnx")
parser.add_argument("--policy-onnx")
parser.add_argument("--off-policy-onnx")
@@ -510,6 +522,9 @@ def main():
help="Where img/big_img history queues are updated. 'policy' is the newer faster ABI; 'warp' reproduces legacy v22 artifacts.",
)
args = parser.parse_args()
OOB_PICKLE = args.out_of_band
if "USB+AMD" in os.environ.get("DEV", ""):
wait_usbgpu_link()
output = {
"format_version": ARTIFACT_FORMAT_VERSION,
@@ -619,7 +634,10 @@ def main():
)
with open(args.output, "wb") as artifact_file:
pickle.dump(output, artifact_file)
if args.out_of_band:
dump_oob(output, artifact_file)
else:
pickle.dump(output, artifact_file)
print(f"Saved JITs to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB)")
return 0
+53 -2
View File
@@ -1,5 +1,10 @@
import io
import json
import os
import pickle
import shutil
import struct
import tempfile
from pathlib import Path
from tinygrad.device import Device
@@ -13,7 +18,8 @@ USBGPU_PID = 0x0001
def _default_tinygrad_backend() -> str:
env_dev = os.getenv("DEV", "").strip()
if env_dev:
return env_dev.split(":", 1)[0].split("+", 1)[-1] or env_dev
default_target = env_dev.split(";", 1)[0]
return default_target.split(":", 1)[0].split("+", 1)[-1] or default_target
try:
default = Device.DEFAULT
@@ -57,7 +63,7 @@ def get_tg_input_devices(process_name: str, usbgpu: bool) -> dict[str, str]:
return _fallback_tg_devices(process_name, usbgpu)
def modeld_pkl_path(usbgpu: bool):
def modeld_pkl_path(usbgpu: bool) -> Path:
prefix = "big_" if usbgpu else ""
return MODELS_DIR / f"{prefix}driving_tinygrad.pkl"
@@ -71,3 +77,48 @@ def usbgpu_present() -> bool:
except Exception:
pass
return False
def tinygrad_dev_config(usbgpu: bool, tici: bool) -> str:
default = "QCOM" if tici else ("CPU:LLVM" if usbgpu else "LLVM")
return f"{default};USB+AMD:LLVM" if usbgpu else default
def dump_oob(obj, output) -> None:
"""Pickle large buffers out-of-band so model weights are never duplicated in RAM."""
with tempfile.TemporaryFile(dir=".") as buffers:
def buffer_callback(pickle_buffer: pickle.PickleBuffer):
view = pickle_buffer.raw()
buffers.write(struct.pack("<q", view.nbytes))
buffers.write(view)
pickle_buffer.release()
opcodes = io.BytesIO()
pickle.Pickler(opcodes, protocol=5, buffer_callback=buffer_callback).dump(obj)
opcode_data = opcodes.getvalue()
output.write(struct.pack("<q", len(opcode_data)))
output.write(opcode_data)
buffers.seek(0)
shutil.copyfileobj(buffers, output)
def load_oob(source):
header = source.read(8)
if len(header) != 8:
raise EOFError("truncated out-of-band pickle header")
opcodes = source.read(struct.unpack("<q", header)[0])
def buffers():
previous = None
while header := source.read(8):
if len(header) != 8:
raise EOFError("truncated out-of-band buffer header")
if previous is not None:
previous.release()
buffer = bytearray(struct.unpack("<q", header)[0])
if source.readinto(buffer) != len(buffer):
raise EOFError("truncated out-of-band buffer")
previous = pickle.PickleBuffer(buffer)
yield previous
return pickle.load(io.BytesIO(opcodes), buffers=buffers())
+64 -12
View File
@@ -16,7 +16,7 @@ from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf
from openpilot.common.swaglog import cloudlog
from openpilot.common.params import Params
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.file_chunker import read_file_chunked
from openpilot.common.file_chunker import file_chunked_exists, open_file_chunked, read_file_chunked
from openpilot.common.realtime import config_realtime_process, DT_MDL
from openpilot.common.transformations.camera import DEVICE_CAMERAS
from openpilot.common.transformations.model import get_warp_matrix
@@ -38,8 +38,9 @@ from openpilot.selfdrive.modeld.compile_modeld import (
make_split_input_queues,
make_supercombo_input_queues,
)
from openpilot.selfdrive.modeld.helpers import get_tg_input_devices
from openpilot.starpilot.assets.model_manager import ModelManager
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
from openpilot.starpilot.assets.model_manager import ModelManager, model_uses_external_gpu
from openpilot.starpilot.common.model_versions import is_tinygrad_model_version
from openpilot.starpilot.common.starpilot_variables import get_starpilot_toggles, MODELS_PATH, params_memory
@@ -171,6 +172,21 @@ class FrameMeta:
if vipc is not None:
self.frame_id, self.timestamp_sof, self.timestamp_eof = vipc.frame_id, vipc.timestamp_sof, vipc.timestamp_eof
def _load_model_artifact(path: Path):
"""Load legacy pickle artifacts and the streaming OOB format used by large GPU models."""
with open_file_chunked(path) as artifact_file:
pickle_header = artifact_file.peek(2)[:2]
legacy_pickle = (
len(pickle_header) == 2 and pickle_header[0] == 0x80 and pickle_header[1] <= pickle.HIGHEST_PROTOCOL
)
if legacy_pickle:
return pickle.load(artifact_file)
with open_file_chunked(path) as artifact_file:
return load_oob(artifact_file)
class ModelState:
prev_desire: np.ndarray
@@ -190,9 +206,13 @@ class ModelState:
)
return numpy_inputs, prev_desired_curv_key
def __init__(self, cam_w: int, cam_h: int):
def __init__(self, cam_w: int, cam_h: int, external_gpu_active: bool = False):
params = Params()
model_id = _canonical_model_id(_resolve_mirrored_param(params, "Model", "DrivingModel") or BUILTIN_MODEL_KEY)
requires_external_gpu = model_uses_external_gpu(model_id)
if requires_external_gpu and not external_gpu_active:
cloudlog.error(f"Model {model_id} requires an external GPU; falling back to {BUILTIN_MODEL_KEY}")
model_id = BUILTIN_MODEL_KEY
use_builtin = model_id == BUILTIN_MODEL_KEY
loaded_builtin = use_builtin
if use_builtin:
@@ -200,22 +220,25 @@ class ModelState:
else:
model_path = MODELS_PATH / f"{model_id}_driving_tinygrad.pkl"
if not model_path.is_file() and not use_builtin:
if not file_chunked_exists(model_path) and not use_builtin:
cloudlog.error(f"Missing model artifact {model_path}, downloading {model_id}...")
try:
ModelManager(params, params_memory).download_model(model_id)
except Exception:
cloudlog.exception(f"Failed to download model {model_id}")
if not model_path.is_file() and not use_builtin:
if not file_chunked_exists(model_path) and not use_builtin:
fallback_path = Path(__file__).parent / "models" / "driving_tinygrad.pkl"
if fallback_path.is_file():
if file_chunked_exists(fallback_path):
cloudlog.error(f"Falling back to builtin model artifact after {model_id} download failed")
model_path = fallback_path
loaded_builtin = True
if not model_path.is_file():
requires_external_gpu = False
if not file_chunked_exists(model_path):
raise FileNotFoundError(model_path)
artifact = pickle.loads(read_file_chunked(str(model_path)))
self.uses_external_gpu = external_gpu_active and requires_external_gpu and not loaded_builtin
artifact = (_load_model_artifact(model_path) if self.uses_external_gpu
else pickle.loads(read_file_chunked(str(model_path))))
if artifact.get("format_version") != ARTIFACT_FORMAT_VERSION:
raise ValueError(
f"Unsupported model artifact format {artifact.get('format_version')!r}; "
@@ -294,7 +317,7 @@ class ModelState:
@property
def QUEUE_DEV(self) -> str:
if not hasattr(self, "_queue_dev"):
devices = get_tg_input_devices(PROCESS_NAME, usbgpu=False)
devices = get_tg_input_devices(PROCESS_NAME, usbgpu=self.uses_external_gpu)
self._warp_dev = devices["WARP_DEV"]
self._queue_dev = devices["QUEUE_DEV"]
return self._queue_dev
@@ -415,6 +438,22 @@ def main(demo=False):
setproctitle(PROCESS_NAME)
config_realtime_process(7, 54)
params = Params()
selected_model = _canonical_model_id(_resolve_mirrored_param(params, "Model", "DrivingModel") or BUILTIN_MODEL_KEY)
usbgpu_present_now = usbgpu_present()
external_model_selected = model_uses_external_gpu(selected_model)
external_artifact = MODELS_PATH / f"{selected_model}_driving_tinygrad.pkl"
external_artifact_ready = external_model_selected and file_chunked_exists(external_artifact)
external_gpu_requested = usbgpu_present_now and external_model_selected
params.put_bool("UsbGpuPresent", usbgpu_present_now)
params.put_bool("UsbGpuCompiled", external_artifact_ready)
params.put_bool("UsbGpuActive", False)
if external_gpu_requested:
from tinygrad.helpers import DEV
device_config = tinygrad_dev_config(True, TICI)
DEV.value = device_config
os.environ["DEV"] = device_config
# visionipc clients
while True:
available_streams = VisionIpcClient.available_streams("camerad", block=False)
@@ -440,7 +479,21 @@ def main(demo=False):
start_time = time.monotonic()
cloudlog.warning("loading model")
model = ModelState(vipc_client_main.width, vipc_client_main.height)
if external_gpu_requested:
wait_usbgpu_link()
try:
model = ModelState(vipc_client_main.width, vipc_client_main.height, external_gpu_requested)
except Exception:
if not external_gpu_requested:
raise
cloudlog.exception(f"Failed to load external-GPU model {selected_model}; falling back to {BUILTIN_MODEL_KEY}")
device_config = tinygrad_dev_config(False, TICI)
DEV.value = device_config
os.environ["DEV"] = device_config
model = ModelState(vipc_client_main.width, vipc_client_main.height, False)
external_gpu_active = model.uses_external_gpu
params.put_bool("UsbGpuCompiled", external_model_selected and file_chunked_exists(external_artifact))
params.put_bool("UsbGpuActive", external_gpu_active)
cloudlog.warning(f"model loaded in {time.monotonic() - start_time:.1f}s, modeld starting")
# messaging
@@ -448,7 +501,6 @@ def main(demo=False):
sm = SubMaster(["deviceState", "carState", "roadCameraState", "liveCalibration", "driverMonitoringState", "carControl", "liveDelay", "starpilotPlan"])
publish_state = PublishState()
params = Params()
# setup filter to track dropped frames
frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_FREQ)
frame_id = 0
@@ -0,0 +1,22 @@
import io
import numpy as np
from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob, tinygrad_dev_config
def test_external_gpu_keeps_the_native_device_available():
assert tinygrad_dev_config(True, tici=True) == "QCOM;USB+AMD:LLVM"
assert tinygrad_dev_config(False, tici=True) == "QCOM"
assert tinygrad_dev_config(True, tici=False) == "CPU:LLVM;USB+AMD:LLVM"
def test_out_of_band_artifact_round_trip():
artifact = {"weights": np.arange(32, dtype=np.float32), "metadata": {"version": 1}}
stream = io.BytesIO()
dump_oob(artifact, stream)
stream.seek(0)
restored = load_oob(stream)
assert restored["metadata"] == artifact["metadata"]
np.testing.assert_array_equal(restored["weights"], artifact["weights"])
+35
View File
@@ -0,0 +1,35 @@
import time
from pathlib import Path
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware.usb import CHESTNUT_PRODUCT_ID, CHESTNUT_VENDOR_ID, controller, read_int, usb_devices
STABLE_SECONDS = 2.0
STABLE_THRESHOLD = 5.0
def _chestnut_portli() -> Path | None:
for device in usb_devices():
if read_int(device / "idVendor", 16) != CHESTNUT_VENDOR_ID or \
read_int(device / "idProduct", 16) != CHESTNUT_PRODUCT_ID:
continue
usb_controller = controller(device)
if usb_controller is not None and (usb_controller / "portli").exists():
return usb_controller / "portli"
return None
def wait_usbgpu_link(timeout: float = 30.0) -> None:
portli = _chestnut_portli()
if portli is None:
return
start_time = time.monotonic()
while time.monotonic() - start_time < timeout:
start_errors = read_int(portli, 0)
time.sleep(STABLE_SECONDS)
error_rate = (read_int(portli, 0) - start_errors) / STABLE_SECONDS
if error_rate <= STABLE_THRESHOLD:
return
cloudlog.warning(f"usbgpu link not stable: {error_rate:.0f} errors/s")
cloudlog.error("usbgpu link never stabilized")
+19 -12
View File
@@ -61,7 +61,7 @@ class DRIVER_MONITOR_SETTINGS:
# lockout specs
self._MAX_ALERT_3 = 2
self._MAX_NO_RESPONSE = 1
self._LOCKOUT_TIME = int(1800 / DT_DMON)
self._LOCKOUT_TIMES = [int(60 * n_min / DT_DMON) for n_min in [1, 5, 15, 30]]
self._TIMEOUT_RECOVERY_FACTOR_MAX = 5.
self._TIMEOUT_RECOVERY_FACTOR_MIN = 1.25
@@ -173,7 +173,10 @@ class DriverMonitoring:
self.cnt_since_alert_3 = 0
self.no_response_timeout = int(self.settings._NO_RESPONSE_TIMEOUT / DT_DMON)
self.no_response_cnt = 0
self.lockout_time = 0
self.lockout_active = Params().get_bool("DriverTooDistracted")
self.lockout_count = Params().get("DriverLockoutCount") or 0
self.lockout_duration = self.settings._LOCKOUT_TIMES[min(max(self.lockout_count - 1, 0), len(self.settings._LOCKOUT_TIMES) - 1)]
self.lockout_time_elapsed = 0
self.step_change = 0.
self.active_policy = MonitoringPolicy.vision
self.driver_interacting = False
@@ -184,8 +187,6 @@ class DriverMonitoring:
self.threshold_alert_2 = 0.
self.dcam_uncertain_cnt = 0
self.dcam_reset_cnt = 0
self.too_distracted = Params().get_bool("DriverTooDistracted")
self._reset_awareness()
self._set_policy(MonitoringPolicy.vision)
@@ -334,16 +335,20 @@ class DriverMonitoring:
self.driver_interacting = driver_engaged
if self.alert_3_cnt >= self.settings._MAX_ALERT_3 or self.no_response_cnt >= self.settings._MAX_NO_RESPONSE:
self.too_distracted = True
if not self.lockout_active:
self.lockout_count += 1
self.lockout_duration = self.settings._LOCKOUT_TIMES[min(self.lockout_count - 1, len(self.settings._LOCKOUT_TIMES) - 1)]
Params().put("DriverLockoutCount", self.lockout_count)
self.lockout_active = True
if self.too_distracted:
self.lockout_time += 1
if self.lockout_time > self.settings._LOCKOUT_TIME:
self.too_distracted = False
if self.lockout_active:
self.lockout_time_elapsed += 1
if self.lockout_time_elapsed > self.lockout_duration:
self.lockout_active = False
self.alert_3_cnt = 0
self.cnt_since_alert_3 = 0
self.no_response_cnt = 0
self.lockout_time = 0
self.lockout_time_elapsed = 0
always_on_valid = self.always_on and not wrong_gear
if (self.driver_interacting and self.awareness > 0 and self.active_policy == MonitoringPolicy.wheeltouch) or \
@@ -403,8 +408,10 @@ class DriverMonitoring:
dat = messaging.new_message('driverMonitoringState', valid=valid)
dm = dat.driverMonitoringState
dm.lockout = self.too_distracted
dm.lockoutRecoveryPercent = to_percent(self.lockout_time / self.settings._LOCKOUT_TIME)
dm.lockout = self.lockout_active
dm.lockoutCount = self.lockout_count
if self.lockout_active:
dm.lockoutMinutesRemaining = max(1, round((self.lockout_duration - self.lockout_time_elapsed) * DT_DMON / 60.))
dm.alert3Count = self.alert_3_cnt
dm.noResponseCount = self.no_response_cnt
dm.noResponseForceDecel = self.alert_level == AlertLevel.three and self.cnt_since_alert_3 >= self.no_response_timeout
+5 -9
View File
@@ -113,21 +113,17 @@ class TestMonitoring:
# engaged, distracted past red and beyond the no-response window -> unavailability response + lockout
def test_distracted_lockout(self):
alert_lvls, d_status = self._run_seq(always_distracted, always_false, always_true, always_false)
s = d_status.settings
assert alert_lvls[int(DISTRACTED_SECONDS_TO_RED / DT_DMON)] == 3
assert d_status.alert_3_cnt == 1
assert d_status.no_response_cnt == s._MAX_NO_RESPONSE
assert d_status.too_distracted
assert d_status.lockout_time > 0
assert d_status.lockout_active
assert d_status.lockout_time_elapsed > 0
assert d_status.lockout_count >= 1
# no face -> wheeltouch red, sustained past the no-response timeout -> unavailability response + lockout
def test_invisible_lockout(self):
_, d_status = self._run_seq(always_no_face, always_false, always_true, always_false)
s = d_status.settings
assert d_status.active_policy == log.DriverMonitoringState.MonitoringPolicy.wheeltouch
assert d_status.alert_3_cnt == 1
assert d_status.no_response_cnt == s._MAX_NO_RESPONSE
assert d_status.too_distracted
assert d_status.lockout_active
assert d_status.lockout_count >= 1
# engaged, no face detected the whole time, no action
def test_fully_invisible_driver(self):
+2 -6
View File
@@ -11,10 +11,9 @@ import cereal.messaging as messaging
from openpilot.common.constants import CV
from openpilot.common.git import get_short_branch
from openpilot.common.params import Params
from openpilot.common.realtime import DT_CTRL, DT_DMON
from openpilot.common.realtime import DT_CTRL
from openpilot.selfdrive.controls.lib.desire_helper import LaneChangeDirection
from openpilot.selfdrive.locationd.calibrationd import MIN_SPEED_FILTER
from openpilot.selfdrive.monitoring.policy import DRIVER_MONITOR_SETTINGS
from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER
from openpilot.selfdrive.ui.feedback.feedbackd import FEEDBACK_MAX_DURATION
from openpilot.system.hardware import HARDWARE
@@ -29,9 +28,6 @@ StarPilotAlertStatus = custom.StarPilotSelfdriveState.AlertStatus
StarPilotAudibleAlert = custom.StarPilotCarControl.HUDControl.AudibleAlert
StarPilotEventName = custom.StarPilotOnroadEvent.EventName
DMON_LOCKOUT_TIME = DRIVER_MONITOR_SETTINGS()._LOCKOUT_TIME
# Alert priorities
class Priority(IntEnum):
LOWEST = 0
@@ -288,7 +284,7 @@ def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messag
def too_distracted_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality, starpilot_toggles: SimpleNamespace) -> Alert:
if sm['driverMonitoringState'].lockout:
mins_left = max(1, round((100 - sm['driverMonitoringState'].lockoutRecoveryPercent) / 100 * DMON_LOCKOUT_TIME * DT_DMON / 60.))
mins_left = sm['driverMonitoringState'].lockoutMinutesRemaining
return NoEntryAlert("Too Distracted", f"{mins_left} minute{'s' if mins_left != 1 else ''} Left", priority=Priority.HIGH)
return NoEntryAlert("Pay Attention to Engage", priority=Priority.HIGH)
+8
View File
@@ -96,12 +96,18 @@ class MiciHomeLayout(Widget):
self._current_model_name = "default"
self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48))
self._egpu_label = UnifiedLabel("eGPU", font_size=30, text_color=rl.Color(0, 255, 96, 255),
font_weight=FontWeight.SEMI_BOLD, max_width=76, wrap_text=False)
self._egpu_label_gray = UnifiedLabel("eGPU", font_size=30, text_color=rl.GRAY,
font_weight=FontWeight.SEMI_BOLD, max_width=76, wrap_text=False)
self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46))
self._status_bar_layout = HBoxLayout([
IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9),
NetworkIcon(),
self._experimental_icon,
self._egpu_label,
self._egpu_label_gray,
self._mic_icon,
], spacing=18)
@@ -211,6 +217,8 @@ class MiciHomeLayout(Widget):
# ***** Center-aligned bottom section icons *****
self._experimental_icon.set_visible(self._experimental_mode)
self._egpu_label.set_visible(ui_state.usbgpu_active)
self._egpu_label_gray.set_visible(ui_state.usbgpu and not ui_state.usbgpu_active)
self._mic_icon.set_visible(ui_state.recording_audio)
footer_rect = rl.Rectangle(self.rect.x + HOME_PADDING, self.rect.y + self.rect.height - 48, self.rect.width - HOME_PADDING, 48)
@@ -21,7 +21,7 @@ from openpilot.selfdrive.ui.mici.onroad.starpilot_status import (
)
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_width
from openpilot.starpilot.common.favorite_slots import load_favorite_slots, toggle_favorite_slot
from openpilot.starpilot.common.favorite_slots import is_favorite_action_key, load_favorite_slots, toggle_favorite_slot
from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text
@@ -262,7 +262,7 @@ class FavoriteSlotsOverlay(Widget):
rl.draw_rectangle_rounded_lines_ex(panel_rect, 0.16, 12, 3, accent)
label = slot.get("label") or slot.get("key") or "Favorite"
state_text = "ON" if self._feedback_value else "OFF"
state_text = "PRESS" if is_favorite_action_key(slot.get("key")) else ("ON" if self._feedback_value else "OFF")
lines, font_size = self._fit_label(label, panel_rect.width - 20, panel_rect.height - 46)
line_height = font_size * 1.08
label_height = len(lines) * line_height
@@ -310,7 +310,7 @@ class FavoriteSlotsOverlay(Widget):
self._feedback_started_at = rl.get_time()
slot = dict(self._visible_slots()).get(self._pressed_slot, {})
key = slot.get("key")
self._feedback_value = not ui_state.params.get_bool(key) if key else None
self._feedback_value = None if is_favorite_action_key(key) else (not ui_state.params.get_bool(key) if key else None)
self._interacting = True
def _handle_mouse_event(self, mouse_event: MouseEvent):
+6
View File
@@ -81,6 +81,9 @@ class UIState:
self.is_metric: bool = self.params.get_bool("IsMetric")
self.is_release = self.params.get_bool("IsReleaseBranch")
self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM")
self.usbgpu: bool = self.params.get_bool("UsbGpuPresent")
self.usbgpu_compiled: bool = self.params.get_bool("UsbGpuCompiled")
self.usbgpu_active: bool = self.params.get_bool("UsbGpuActive")
self.started: bool = False
self.ignition: bool = False
self.recording_audio: bool = False
@@ -182,6 +185,9 @@ class UIState:
self.is_metric = self.params.get_bool("IsMetric")
self.always_on_dm = self.params.get_bool("AlwaysOnDM")
self.usbgpu = self.params.get_bool("UsbGpuPresent")
self.usbgpu_compiled = self.params.get_bool("UsbGpuCompiled")
self.usbgpu_active = self.params.get_bool("UsbGpuActive")
self.switchback_mode_enabled = self.params_memory.get_bool("SwitchbackModeEnabled") if self.started else False
if self.sm.valid.get("starpilotCarState", False):
starpilot_car_state = self.sm["starpilotCarState"]
+14
View File
@@ -68,6 +68,19 @@ def model_key_aliases(model_key: str) -> list[str]:
return [alias for alias in dict.fromkeys(alias for alias in aliases if alias)]
def load_model_artifact_metadata(model_key: str) -> dict:
try:
payload = json.loads((MODELS_PATH / ARTIFACT_METADATA_CACHE).read_text())
metadata = payload.get(canonical_model_key(model_key), {}) if isinstance(payload, dict) else {}
return metadata if isinstance(metadata, dict) else {}
except (OSError, ValueError, TypeError):
return {}
def model_uses_external_gpu(model_key: str) -> bool:
return bool(load_model_artifact_metadata(model_key).get("uses_external_gpu", False))
class ModelManager:
def __init__(self, params, params_memory, boot_run=False):
self.params = params
@@ -291,6 +304,7 @@ class ModelManager:
"artifact_size": int(model.get("artifact_size") or 0),
"artifact_sha256": str(model.get("artifact_sha256") or "").strip().lower(),
"artifact_url": str(model.get("artifact_url") or model.get("download_url") or "").strip(),
"uses_external_gpu": bool(model.get("uses_external_gpu", False)),
}
return metadata
@@ -1,7 +1,10 @@
import hashlib
import json
from scripts import model_compiler
from scripts.model_compiler import split_oversized_artifact
from openpilot.starpilot.assets import download_functions
from openpilot.starpilot.assets import model_manager
from openpilot.starpilot.assets.model_manager import MANIFEST_CANDIDATES, ModelManager
from openpilot.starpilot.common.model_versions import UNIFIED_ARTIFACT_FORMAT
@@ -21,6 +24,42 @@ def test_behavior_version_does_not_control_artifact_layout():
assert manager._required_files("example", "split") == []
def test_external_gpu_requirement_is_cached_from_manifest(tmp_path, monkeypatch):
monkeypatch.setattr(model_manager, "MODELS_PATH", tmp_path)
manager = object.__new__(ModelManager)
metadata = manager._build_artifact_metadata_map([
{"id": "large", "artifact_format": UNIFIED_ARTIFACT_FORMAT, "uses_external_gpu": True},
{"id": "normal", "artifact_format": UNIFIED_ARTIFACT_FORMAT},
])
(tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata))
assert model_manager.model_uses_external_gpu("large")
assert not model_manager.model_uses_external_gpu("normal")
assert not model_manager.model_uses_external_gpu("missing")
def test_external_gpu_compilation_is_opt_in(tmp_path, monkeypatch):
invocations = []
monkeypatch.setattr(model_compiler, "build_compile_env", lambda: {
"DEV": "QCOM", "IMAGE": "2", "NOLOCALS": "1", "OPENPILOT_HACKS": "1",
})
monkeypatch.setattr(model_compiler.subprocess, "run", lambda command, **kwargs: invocations.append((command, kwargs)))
files = {"driving_supercombo": tmp_path / "model.onnx"}
model_compiler.compile_driving("normal", files, "supercombo", "v15", tmp_path, "policy")
model_compiler.compile_driving("large", files, "supercombo", "v15", tmp_path, "policy", external_gpu=True)
normal_command, normal_kwargs = invocations[0]
external_command, external_kwargs = invocations[1]
assert "--out-of-band" not in normal_command
assert normal_kwargs["env"]["DEV"] == "QCOM"
assert normal_kwargs["env"]["IMAGE"] == "2"
assert "--out-of-band" in external_command
assert external_kwargs["env"]["DEV"] == "USB+AMD:LLVM"
assert external_kwargs["env"]["WARP_DEV"] == "QCOM"
assert all(flag not in external_kwargs["env"] for flag in ("IMAGE", "NOLOCALS", "OPENPILOT_HACKS"))
def test_dropbox_urls_are_direct_downloads():
url = "https://www.dropbox.com/scl/fi/id/model.pkl?rlkey=key&st=value&dl=0"
normalized = download_functions.normalize_download_url(url)
+68 -3
View File
@@ -10,6 +10,29 @@ from openpilot.common.params import ParamKeyType, Params
FAVORITE_SLOTS_PARAM = "StarPilotFavoriteSlots"
FAVORITE_SLOT_COUNT = 3
FAVORITE_ACTION_PREFIX = "__starpilot_favorite_action__:"
FAVORITE_ACTION_DISTANCE_DECREASE = f"{FAVORITE_ACTION_PREFIX}distance_decrease"
FAVORITE_ACTION_DISTANCE_INCREASE = f"{FAVORITE_ACTION_PREFIX}distance_increase"
FAVORITE_ACTION_DECEL_COUNTER = "FavoriteVirtualDecelCruiseCounter"
FAVORITE_ACTION_ACCEL_COUNTER = "FavoriteVirtualAccelCruiseCounter"
FAVORITE_ACTION_OPTIONS = (
{
"key": FAVORITE_ACTION_DISTANCE_DECREASE,
"label": "Distance - / SET",
"description": "Acts like a short press of the car's SET/- cruise button.",
"section": "Actions",
"action": "decelCruise",
},
{
"key": FAVORITE_ACTION_DISTANCE_INCREASE,
"label": "Distance + / RES",
"description": "Acts like a short press of the car's RES/+ cruise button.",
"section": "Actions",
"action": "accelCruise",
},
)
FAVORITE_ACTION_KEYS = {option["key"] for option in FAVORITE_ACTION_OPTIONS}
FAVORITE_ACTION_LABELS = {option["key"]: option["label"] for option in FAVORITE_ACTION_OPTIONS}
def default_favorite_slots() -> list[dict[str, Any]]:
@@ -50,6 +73,23 @@ def _get_key_type(params: Params, key: str):
return None
def is_favorite_action_key(key: str | None) -> bool:
return bool(key) and key in FAVORITE_ACTION_KEYS
def favorite_key_is_valid(params: Params, key: str | None, eligible_keys: Iterable[str] | None = None) -> bool:
if not key:
return False
if is_favorite_action_key(key):
return True
if eligible_keys is not None and key not in set(eligible_keys):
return False
return _get_key_type(params, key) == ParamKeyType.BOOL
def is_bool_param(params: Params, key: str | None, eligible_keys: Iterable[str] | None = None) -> bool:
if not key:
return False
@@ -73,10 +113,15 @@ def normalize_favorite_slots(raw_slots: Any, params: Params | None = None,
if key is not None:
key = str(key).strip() or None
if key and ((eligible is not None and key not in eligible) or (params is not None and not is_bool_param(params, key))):
if key and is_favorite_action_key(key):
pass
elif key and (
(eligible is not None and key not in eligible) or
(params is not None and not is_bool_param(params, key))
):
key = None
label = str(raw_slot.get("label") or "").strip()
label = str(raw_slot.get("label") or FAVORITE_ACTION_LABELS.get(key, "")).strip()
if len(label) > 32:
label = label[:32].rstrip()
@@ -111,6 +156,20 @@ def request_starpilot_toggle_refresh(params_memory: Params | None = None) -> Non
params_memory.put_bool("StarPilotTogglesUpdated", True)
def trigger_favorite_action(key: str | None, params_memory: Params | None = None) -> bool:
if not is_favorite_action_key(key):
return False
params_memory = params_memory or Params(memory=True)
counter_key = (
FAVORITE_ACTION_ACCEL_COUNTER
if key == FAVORITE_ACTION_DISTANCE_INCREASE
else FAVORITE_ACTION_DECEL_COUNTER
)
params_memory.put_int(counter_key, params_memory.get_int(counter_key) + 1)
return True
def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_memory: Params | None = None) -> bool:
if slot_index < 0 or slot_index >= FAVORITE_SLOT_COUNT:
return False
@@ -119,7 +178,13 @@ def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_m
slots = load_favorite_slots(params)
slot = slots[slot_index]
key = slot.get("key")
if not slot.get("enabled") or not is_bool_param(params, key):
if not slot.get("enabled"):
return False
if is_favorite_action_key(key):
return trigger_favorite_action(key, params_memory)
if not is_bool_param(params, key):
return False
next_value = not params.get_bool(key)
@@ -1,5 +1,7 @@
from openpilot.common.params import ParamKeyType
from openpilot.starpilot.common.favorite_slots import (
FAVORITE_ACTION_ACCEL_COUNTER,
FAVORITE_ACTION_DISTANCE_INCREASE,
FAVORITE_SLOTS_PARAM,
default_favorite_slots,
load_favorite_slots,
@@ -31,6 +33,12 @@ class FakeParams:
def put_bool_nonblocking(self, key, value):
self.put_bool(key, value)
def get_int(self, key, default=0):
return int(self.store.get(key, default))
def put_int(self, key, value):
self.store[key] = int(value)
def get_type(self, key):
return self.types.get(key, ParamKeyType.STRING)
@@ -78,3 +86,14 @@ def test_toggle_favorite_slot_flips_bool_and_requests_refresh():
assert toggle_favorite_slot(0, params, memory) is True
assert params.get_bool("RedneckCruise") is True
assert memory.get_bool("StarPilotTogglesUpdated") is True
def test_toggle_favorite_slot_action_increments_virtual_button_counter():
params = FakeParams()
memory = FakeParams()
params.put(FAVORITE_SLOTS_PARAM, [
{"enabled": True, "show_onroad": True, "key": FAVORITE_ACTION_DISTANCE_INCREASE, "label": "Distance + / RES"},
])
assert toggle_favorite_slot(0, params, memory) is True
assert memory.get_int(FAVORITE_ACTION_ACCEL_COUNTER) == 1
@@ -3,7 +3,11 @@ from types import SimpleNamespace
from opendbc.car.chrysler.values import CAR as CHRYSLER_CAR
from openpilot.common.params import ParamKeyType
from openpilot.starpilot.common.favorite_slots import FAVORITE_SLOTS_PARAM
from openpilot.starpilot.common.favorite_slots import (
FAVORITE_ACTION_ACCEL_COUNTER,
FAVORITE_ACTION_DISTANCE_INCREASE,
FAVORITE_SLOTS_PARAM,
)
from openpilot.starpilot.controls import starpilot_card as spc
@@ -659,3 +663,18 @@ def test_favorite_wheel_action_toggles_hidden_onroad_slot(monkeypatch, tmp_path)
assert card.params.get_bool("RedneckCruise") is True
assert card.params_memory.get_bool("StarPilotTogglesUpdated") is True
def test_favorite_wheel_action_can_press_virtual_resume(monkeypatch, tmp_path):
monkeypatch.setattr(spc, "Params", FakeParams)
monkeypatch.setattr(spc, "is_FrogsGoMoo", lambda: False)
monkeypatch.setattr(spc, "ERROR_LOGS_PATH", tmp_path)
card = spc.StarPilotCard(SimpleNamespace(brand="gm"), SimpleNamespace(alternativeExperience=0))
card.params.put(FAVORITE_SLOTS_PARAM, [
{"enabled": True, "show_onroad": False, "key": FAVORITE_ACTION_DISTANCE_INCREASE, "label": "Distance + / RES"},
])
card.handle_button_event("lkas", make_sm(), make_toggles(favorite_1_via_lkas=True))
assert card.params_memory.get_int(FAVORITE_ACTION_ACCEL_COUNTER) == 1
@@ -1,7 +1,7 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js"
import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-live-values-1"
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-actions-1"
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
@@ -533,14 +533,19 @@
background: var(--color-gray-950);
border: 2px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
box-sizing: border-box;
color: inherit;
cursor: pointer;
display: flex;
font-family: var(--font-body);
gap: var(--gap-base);
justify-content: space-between;
min-height: 8.5rem;
padding: 1.1rem;
text-align: left;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
user-select: none;
width: 100%;
}
.ds-favorite-quick-card:hover {
@@ -607,6 +612,20 @@
transform: translateX(1.75rem);
}
.ds-favorite-action-chip {
align-items: center;
background: var(--main-fg);
border-radius: var(--border-radius-base);
color: var(--color-black);
display: inline-flex;
flex-shrink: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
justify-content: center;
min-width: 4.25rem;
padding: 0.55rem 0.75rem;
}
.ds-favorite-card {
background: var(--input-bg);
border: var(--border-style-main);
@@ -8,6 +8,7 @@ const COLOR_UI_DEFAULTS = {
PathColor: "#30ff9c",
}
const FAVORITE_OPTION_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" })
const FAVORITE_ACTION_PREFIX = "__starpilot_favorite_action__:"
const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode"
const HIDDEN_SECTION_NAMES = new Set(["Model & Customization"])
const HIDDEN_SETTING_KEYS = new Set(["DisableWideRoad", "HumanAcceleration", "ReverseCruise"])
@@ -566,6 +567,14 @@ function favoriteOptionMatchesFilter(option, filter) {
.some(value => String(value || "").toLowerCase().includes(q))
}
function isFavoriteActionKey(key) {
return String(key || "").startsWith(FAVORITE_ACTION_PREFIX)
}
function isFavoriteActionOption(option) {
return isFavoriteActionKey(option?.key) || !!option?.action
}
function filteredFavoriteOptions(index) {
const filter = state.favoriteFilters[index] || ""
return normalizeFavoriteOptions(state.favoriteOptions).filter(opt => favoriteOptionMatchesFilter(opt, filter))
@@ -738,6 +747,25 @@ async function updateFavoriteValue(key, checked, sourceEl = null) {
}
}
async function activateFavoriteAction(key) {
try {
const res = await fetch("/api/favorites/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
})
const data = await res.json()
if (res.ok) {
showParamSnackbar(data.message || "Favorite action sent.")
} else {
showParamSnackbar(data.error || "Failed to send favorite action", "error")
}
} catch (e) {
showParamSnackbar("Network error — is the device reachable?", "error")
}
}
function stepPrecision(step, explicitPrecision) {
if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") {
const parsed = Number.parseInt(explicitPrecision, 10)
@@ -1248,15 +1276,31 @@ function renderFavoriteSlotsPanel() {
const selectedOption = favorite.selectedOption
const selectedKey = favorite.selectedKey
const selectedValue = favorite.selectedValue
const isAction = isFavoriteActionOption(selectedOption)
const quickCopy = html`
<div class="ds-favorite-quick-copy">
<span class="ds-favorite-quick-slot">Favorite #${favorite.index + 1}</span>
<span class="ds-favorite-quick-title">${selectedOption.label || favorite.slot.label || selectedKey}</span>
${selectedOption.section ? html`<span class="ds-favorite-quick-section">${selectedOption.section}</span>` : ""}
${selectedOption.description ? html`<span class="ds-favorite-quick-desc">${selectedOption.description}</span>` : ""}
</div>
`
if (isAction) {
return html`
<button
type="button"
class="ds-favorite-quick-card ds-favorite-action-card"
@click="${() => activateFavoriteAction(selectedKey)}">
${quickCopy}
<span class="ds-favorite-action-chip">Press</span>
</button>
`
}
return html`
<label class="ds-favorite-quick-card">
<div class="ds-favorite-quick-copy">
<span class="ds-favorite-quick-slot">Favorite #${favorite.index + 1}</span>
<span class="ds-favorite-quick-title">${selectedOption.label || favorite.slot.label || selectedKey}</span>
${selectedOption.section ? html`<span class="ds-favorite-quick-section">${selectedOption.section}</span>` : ""}
${selectedOption.description ? html`<span class="ds-favorite-quick-desc">${selectedOption.description}</span>` : ""}
</div>
${quickCopy}
<input
type="checkbox"
class="ds-toggle ds-favorite-quick-toggle"
@@ -39,13 +39,13 @@
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
<link rel="stylesheet" href="/assets/components/tools/update_manager.css">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=flm-overrides-1">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=favorite-actions-1">
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
<link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css">
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script type="module">
import("/assets/components/router.js?v=flm-workspace-1").catch((err) => {
import("/assets/components/router.js?v=favorite-actions-1").catch((err) => {
console.error("[the_galaxy] bootstrap failed", err);
const target = document.getElementById("app") || document.body;
const pre = document.createElement("pre");
@@ -140,10 +140,39 @@ def _install_server_import_stubs():
sync_persist_chill_state=lambda *args, **kwargs: None,
sync_persist_experimental_state=lambda *args, **kwargs: None,
)
def _trigger_stub_favorite_action(key, params_memory=None):
if params_memory is None:
return False
counter_key = (
"FavoriteVirtualAccelCruiseCounter"
if str(key or "").endswith("distance_increase")
else "FavoriteVirtualDecelCruiseCounter"
)
params_memory.put_int(counter_key, params_memory.get_int(counter_key) + 1)
return True
sys.modules["openpilot.starpilot.common.favorite_slots"] = _simple_module(
"openpilot.starpilot.common.favorite_slots",
FAVORITE_ACTION_OPTIONS=(
{
"key": "__starpilot_favorite_action__:distance_decrease",
"label": "Distance - / SET",
"description": "Acts like a short press of the car's SET/- cruise button.",
"section": "Actions",
"action": "decelCruise",
},
{
"key": "__starpilot_favorite_action__:distance_increase",
"label": "Distance + / RES",
"description": "Acts like a short press of the car's RES/+ cruise button.",
"section": "Actions",
"action": "accelCruise",
},
),
FAVORITE_SLOTS_PARAM="FavoriteSlots",
is_favorite_action_key=lambda key: str(key or "").startswith("__starpilot_favorite_action__:"),
normalize_favorite_slots=lambda *args, **kwargs: "",
trigger_favorite_action=_trigger_stub_favorite_action,
)
sys.modules["openpilot.starpilot.common.starpilot_utilities"] = _simple_module(
"openpilot.starpilot.common.starpilot_utilities",
@@ -67,6 +67,13 @@ class WritableFakeParams:
self.writes.append((key, bool(value)))
self.values[key] = bool(value)
def get_int(self, key, default=0):
return int(self.values.get(key, default))
def put_int(self, key, value):
self.writes.append((key, int(value)))
self.values[key] = int(value)
def remove(self, key):
self.removals.append(key)
self.values.pop(key, None)
@@ -225,6 +232,28 @@ def test_favorite_values_endpoint_returns_current_selected_value(monkeypatch):
assert response.get_json() == {"values": {"UseOldUI": False}}
def test_favorite_slot_options_include_virtual_cruise_actions(monkeypatch):
monkeypatch.setattr(the_galaxy, "_favorite_slot_options", None)
monkeypatch.setattr(the_galaxy, "_get_param_type_info", lambda: (set(), {}))
options = the_galaxy._get_favorite_slot_options()
option_keys = {option["key"] for option in options}
assert "__starpilot_favorite_action__:distance_decrease" in option_keys
assert "__starpilot_favorite_action__:distance_increase" in option_keys
def test_favorite_action_endpoint_increments_virtual_button_counter(monkeypatch):
client, _ = _params_client(monkeypatch, {}, "tici")
fake_memory = WritableFakeParams()
monkeypatch.setattr(the_galaxy, "params_memory", fake_memory)
response = client.post("/api/favorites/action", json={"key": "__starpilot_favorite_action__:distance_increase"})
assert response.status_code == 200
assert fake_memory.get_int("FavoriteVirtualAccelCruiseCounter") == 1
def test_use_old_ui_is_noop_on_c4_mici(monkeypatch):
client, fake_params = _params_client(monkeypatch, {"UseOldUI": False, "IsOnroad": False}, "mici")
+21 -4
View File
@@ -62,7 +62,13 @@ from openpilot.starpilot.common.maps_catalog import (
schedule_param_value,
)
from openpilot.starpilot.common.experimental_state import sync_persist_chill_state, sync_persist_experimental_state
from openpilot.starpilot.common.favorite_slots import FAVORITE_SLOTS_PARAM, normalize_favorite_slots
from openpilot.starpilot.common.favorite_slots import (
FAVORITE_ACTION_OPTIONS,
FAVORITE_SLOTS_PARAM,
is_favorite_action_key,
normalize_favorite_slots,
trigger_favorite_action,
)
from openpilot.starpilot.common.lateral_delay import full_lateral_delay
from openpilot.starpilot.common.starpilot_utilities import delete_file, get_lock_status, run_cmd
from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH, ERROR_LOGS_PATH, EXCLUDED_KEYS, LEGACY_STARPILOT_PARAM_RENAMES, MAPS_PATH, MODELS_PATH, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH,\
@@ -2380,6 +2386,7 @@ def _get_favorite_slot_options():
allowed_keys, value_types = _get_param_type_info()
options = []
options.extend(dict(option) for option in FAVORITE_ACTION_OPTIONS)
try:
layout_path = os.path.join(os.path.dirname(__file__), "assets", "components", "tools", "device_settings_layout.json")
with open(layout_path) as f:
@@ -2415,14 +2422,14 @@ def _favorite_slot_values(options):
return {
option["key"]: _safe_params_get_bool(option["key"])
for option in options
if option.get("key")
if option.get("key") and not is_favorite_action_key(option.get("key"))
}
def _configured_favorite_slot_values(slots):
return {
slot["key"]: _safe_params_get_bool(slot["key"])
for slot in slots
if slot.get("key")
if slot.get("key") and not is_favorite_action_key(slot.get("key"))
}
_cached_allowed_keys = None
@@ -4211,7 +4218,7 @@ def setup(app):
continue
key = str(raw_slot.get("key") or "").strip()
if key and key not in eligible_keys:
return jsonify(error=f"Favorite #{idx + 1} must use a Galaxy-exposed boolean toggle."), 400
return jsonify(error=f"Favorite #{idx + 1} must use a Galaxy-exposed toggle or action."), 400
slots = normalize_favorite_slots(raw_slots, params=params, eligible_keys=eligible_keys)
@@ -4249,6 +4256,16 @@ def setup(app):
slots = normalize_favorite_slots(params.get(FAVORITE_SLOTS_PARAM), params=params, eligible_keys=eligible_keys)
return jsonify({"values": _configured_favorite_slot_values(slots)}), 200
@app.route("/api/favorites/action", methods=["POST"])
def favorite_action():
data = request.get_json() or {}
key = str(data.get("key") or "").strip()
if not is_favorite_action_key(key):
return jsonify({"error": "Unknown favorite action."}), 400
if not trigger_favorite_action(key, params_memory):
return jsonify({"error": "Favorite action failed."}), 400
return jsonify({"message": "Favorite action sent."}), 200
@app.route("/api/params", methods=["GET", "PUT"])
def get_param():
if request.method == "PUT":
+29 -4
View File
@@ -5,11 +5,16 @@
#include <QJsonObject>
#include <QPainter>
#include <QPainterPath>
#include <string>
namespace {
constexpr int favorite_btn_size = btn_size;
constexpr int favorite_indicator_size = 22;
constexpr int favorite_slots_count = 3;
const QString favorite_action_decrease = "__starpilot_favorite_action__:distance_decrease";
const QString favorite_action_increase = "__starpilot_favorite_action__:distance_increase";
const std::string favorite_action_decel_counter = "FavoriteVirtualDecelCruiseCounter";
const std::string favorite_action_accel_counter = "FavoriteVirtualAccelCruiseCounter";
QJsonArray parseFavoriteSlots(const std::string &raw_slots) {
QJsonParseError error;
@@ -117,8 +122,10 @@ FavoriteSlotState FavoriteButton::currentSlot() {
next_slot.show_onroad = slot_obj.value("show_onroad").toBool(false);
next_slot.key = slot_obj.value("key").toString().trimmed();
next_slot.label = slot_obj.value("label").toString().trimmed();
next_slot.action = next_slot.key == favorite_action_decrease || next_slot.key == favorite_action_increase;
if (next_slot.key.isEmpty() || !params.checkKey(next_slot.key.toStdString()) || params.getKeyType(next_slot.key.toStdString()) != ParamKeyType::BOOL) {
if (!next_slot.action &&
(next_slot.key.isEmpty() || !params.checkKey(next_slot.key.toStdString()) || params.getKeyType(next_slot.key.toStdString()) != ParamKeyType::BOOL)) {
next_slot.enabled = false;
next_slot.show_onroad = false;
next_slot.key.clear();
@@ -126,10 +133,12 @@ FavoriteSlotState FavoriteButton::currentSlot() {
return next_slot;
}
if (next_slot.label.isEmpty()) {
if (next_slot.label.isEmpty() && next_slot.action) {
next_slot.label = next_slot.key == favorite_action_increase ? "Distance + / RES" : "Distance - / SET";
} else if (next_slot.label.isEmpty()) {
next_slot.label = next_slot.key;
}
next_slot.value = params.getBool(next_slot.key.toStdString());
next_slot.value = !next_slot.action && params.getBool(next_slot.key.toStdString());
return next_slot;
}
@@ -137,6 +146,7 @@ void FavoriteButton::updateState() {
const FavoriteSlotState next_slot = currentSlot();
const bool changed = next_slot.enabled != slot.enabled ||
next_slot.show_onroad != slot.show_onroad ||
next_slot.action != slot.action ||
next_slot.value != slot.value ||
next_slot.key != slot.key ||
next_slot.label != slot.label;
@@ -157,6 +167,13 @@ void FavoriteButton::toggleFavorite() {
return;
}
if (slot.action) {
const std::string counter_key = slot.key == favorite_action_increase ? favorite_action_accel_counter : favorite_action_decel_counter;
params_memory.putInt(counter_key, params_memory.getInt(counter_key) + 1);
update();
return;
}
const bool next_value = !params.getBool(slot.key.toStdString());
params.putBool(slot.key.toStdString(), next_value);
params_memory.putBool("StarPilotTogglesUpdated", true);
@@ -193,12 +210,20 @@ void FavoriteButton::paintEvent(QPaintEvent *event) {
p.setPen(QPen(QColor(255, 255, 255, isDown() ? 130 : 95), 2));
p.drawPath(bg_path);
const QColor indicator_color = slot.value ? QColor(48, 255, 156) : QColor(135, 135, 135);
const QColor indicator_color = slot.action ? QColor(139, 108, 197) : (slot.value ? QColor(48, 255, 156) : QColor(135, 135, 135));
const int indicator_x = width() - 20 - favorite_indicator_size;
const int indicator_y = 20;
p.setPen(Qt::NoPen);
p.setBrush(indicator_color);
p.drawEllipse(indicator_x, indicator_y, favorite_indicator_size, favorite_indicator_size);
if (slot.action) {
QFont action_font = p.font();
action_font.setPixelSize(20);
action_font.setWeight(QFont::DemiBold);
p.setFont(action_font);
p.setPen(QColor(255, 255, 255, 230));
p.drawText(QRect(indicator_x, indicator_y - 1, favorite_indicator_size, favorite_indicator_size), Qt::AlignCenter, slot.key == favorite_action_increase ? "+" : "-");
}
QFont slot_font = p.font();
slot_font.setPixelSize(20);
@@ -7,6 +7,7 @@
struct FavoriteSlotState {
bool enabled = false;
bool show_onroad = false;
bool action = false;
bool value = false;
QString key;
QString label;
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
CHESTNUT_VENDOR_ID = 0xADD1
CHESTNUT_PRODUCT_ID = 0x0001
USB_DEVICES_PATH = Path("/sys/bus/usb/devices")
def read_int(path: Path, base: int = 10) -> int:
try:
return int(path.read_text(), base)
except (OSError, ValueError):
return 0
def usb_devices() -> list[Path]:
try:
devices = (path for path in USB_DEVICES_PATH.glob("*") if (path / "idVendor").exists())
return sorted(devices, key=lambda path: path.name)
except OSError:
return []
def controller(device: Path) -> Path | None:
try:
return next((parent for parent in device.resolve().parents if parent.name.endswith(".ssusb")), None)
except OSError:
return None