diff --git a/.gitignore b/.gitignore index b9a4bfe3..80fb9e9e 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,5 @@ selfdrive/ui/tests/test_ui/nav_demo_report/ # fetcher: closed-source precompiled component — ship only the obfuscated .so /iqpilot/models_private_src/fetcher.py + +/tools/iqmacvisiond/macos/dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa47db0..484d284d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,7 @@ Volkswagen support got a significant overhaul: **Volkswagen MEB and MQBevo** -IQ.Pilot now **officially** supports the Volkswagen MEB and MQBevo platforms! Including the ID.4, ID.3, ID.5, Golf MK8, and Tiguan 2024+, up to model year 2026, as long as you have a compatible camera or gateway harness. Both LKAS and ACC are supported. This is the foundation of the `release-meb` branch, which is auto-synced from `release` and tailored specifically for these platforms. +IQ.Pilot now **officially** supports the Volkswagen MEB and MQBevo platforms! Including the ID.4, ID.3, ID.5, and Golf MK8 through model year 2025, as long as you have a compatible camera or gateway harness. Both LKAS and ACC are supported. This is the foundation of the `release-meb` branch, which is auto-synced from `release` and tailored specifically for these platforms. **Toyota/Lexus** @@ -98,9 +98,9 @@ IQ.Pilot's on-road UI got a number of improvements: - Revamped Branch switcher to properly switch branches, and updater has had bugfixes to fix install issues where the device claims to have updated but has not actually updated. -**IQ.OS 3.14** +**IQ.OS 4.9.1** -IQ.OS 3.14 is bundeled with IQ.Pilot 1.0c. IQ.OS is available for all supported devices: Comma 3, Comma 3x, Comma 4, Konik A1/M, and Mr.One C3/C3(X)Lite. It's a lightweight OS based on Ubuntu 24.04, includes Bluetooth (BLE), has a much smaller install footprint, and is continuously optimized for IQ.Pilot. +IQ.OS 4.9.1 is bundled with IQ.Pilot 1.0c. IQ.OS is available for all supported devices: Comma 3, Comma 3x, Comma 4, Konik A1/M, and Mr.One C3/C3 Lite. It's a lightweight OS based on Ubuntu 24.04, includes Bluetooth (BLE), has a much smaller install footprint, and is continuously optimized for IQ.Pilot. - Konn3kt now stays online regardless of IQ.Pilot's status. If IQ.Pilot fails to boot, Konn3kt remains available so you can switch branches, SSH in, and recover remotely without a physical connection, including over cellular. - Bug causing konn3kt setup time on a fresh install dropped from ~30 minutes fixed, setup dropped down to ~10 seconds. @@ -125,3 +125,106 @@ Connection stability between Konn3kt and IQ.Pilot (Konn3ktion) is greatly improv **eSIM** eSIM detection, provisioning, and profile management groundwork is now built into the device. The app can detect whether your device has an embedded SIM, provision it, and manage profiles without a physical SIM swap. eSIM support requires a compatible data plan; Contact IQ.Pilot support in the discord for known working eSIM carriers and plans. Note: eSIM is experimental and generally requires a hotspot-style data plan or an MVNO that does not IMEI filter. + +**Additional 1.0c Updates** + +**Navigate on IQ.Pilot and Offline Maps** + +- Added Home, Work, and Recent destination shortcuts, route cancellation, saved-destination management, and course-up map rotation. +- Added an interactive off-road map with panning and current-location display, plus on-screen maneuver artwork for turns, merges, forks, off-ramps, continuation instructions, and arrival. +- Added navigation-memory handling so important fork and exit guidance remains available when a model output briefly omits it. +- Added full offline routing through a packaged Valhalla runtime. Navigation can now operate without Mapbox or an active internet connection. +- Added simultaneous installation of multiple offline regions, including offline raster tiles for the displayed map as well as routing and road metadata. +- Added separate Online On-Screen Maps and Offline On-Screen Maps controls, resumable regional downloads, combined routing-and-tile download progress, automatic mapd v2 region recognition and missing-data restoration. +- Added a hosted regional tile-bundle service with a fallback source, background tile decoding, and bounded texture caching to keep map work off the UI render path. +- Reduced navigation CPU, GPU, and memory overhead so maps can remain open for long drives without competing with the driving model. + +**IQ Speed Assist and Camera Alerts** + +- Added the new IQ Speed Assist architecture, with TomTom data alongside dashboard, Mapbox, and offline OpenStreetMap sources. +- Added optional cruise-set-speed mirroring, percentage-based offset zones for low, medium, and higher speed ranges, and earlier, smoother upcoming-speed-limit reactions in the longitudinal cruise envelope. +- Added clearer on-road SLC source, state, pending-limit, and adaptation feedback. +- Added individual controls for speed, red-light, and Flock/ALPR alerts, plus optional speed-camera slowdown using the detected limit and a configurable safety factor. +- Added haptic speed-camera feedback on supported Hyundai, Kia, and Genesis vehicles. + +**Construction Zone Assist** + +- Added optional camera-based Construction Zone Assist, which detects bright-orange work-zone barrels and markers. +- Added an adjustable work-zone target speed (60 mph by default), plus daylight, road-speed, and active-zone checks to reduce unrelated reflective detections. +- Added lightweight processing designed to run alongside the driving and driver-monitoring models. + +**IQ.Dynamic, Stops, and Longitudinal Control** + +- Added separate IQ.Dynamic road-speed and lead-speed thresholds, configurable curve, low-speed, slower-lead, stopped-lead, and model-predicted-stop activation, and an adjustable model-stop prediction horizon. +- Added automatic Volkswagen stock-radar set-speed and following-gap synchronization when radar blending is enabled on supported PQ vehicles. +- Added an independent custom stopping-distance adjustment, smoother stopped-lead pull-away, and end-to-end launch assistance when the model predicts an opening path. +- Added dedicated departure chimes for an opening path and a pulling-away lead, Experimental Lead MPC, and end-to-end cruise convergence toward the selected speed as the road opens. +- Added configurable end-to-end set-speed selection using either a preset or the driver’s current target, per-platform stopping overrides, accelerator-override longitudinal control on supported vehicles, and predictive reactions to speed limits and curves. + +**Steering, Lane Changes, and Environment View** + +- Added an on-road Always-On Lateral control path for supported Hyundai LFA buttons and Bluetooth-controller commands for testing or controlling it in Joystick Mode. +- Added continuous lane changes while the blinker is held, while retaining driver confirmation for subsequent maneuvers. +- Added configurable model-action smoothing, angle-based lateral control, optional VW ALC torque blending, a dedicated Volkswagen PQ HCA7 torque controller, and live-learned curvature correction for supported Volkswagen MEB configurations. +- Added an optional Environment View that can replace the model scene and render 3D object boxes, a ground grid, lane lines, road edges, and the planned path. +- Added a precompiled Qualcomm IQ.Vision model and bandwidth-efficient camera preprocessing to reduce contention with the primary driving model. + +**Driving Models and IQModeld** + +- Added the unified IQModeld runtime and native bridge for current combined models and legacy split models. +- Added fused vision-and-policy execution for supported supercombo bundles, zero-copy camera-frame handling through the tinygrad runner, and unified combined-artifact, combined-split, fused, tinygrad, and ONNX runner support. +- Added current-model redownload, manifest refresh when published models change, a clear Driving Model Updating state, and engagement gating until the selected model is ready. + +**Home, On-Road UI, and Visuals** + +- Added dedicated Routes, Navigation, Video, and status surfaces, a selectable home-panel widget, 60 fps off-road BIG UI presentation, and device-specific frame pacing. +- Added Comma 4 anti-aliasing, augmented-road presentation, driver-camera orientation improvements, and an expanded status bar with temperature, vehicle state, Konn3kt status, and connected Wi-Fi network name. +- Added the installed IQ.OS version to Software settings, tappable branch information in the BIG UI header, smooth edge-swipe navigation, transitions, parallax, shadows, animated controls, and a seamless build-spinner-to-UI transition. +- Added a glowing primary-lead orb, IQ.Pilot teal and pink acceleration-bar styling, a top-bar Silent Mode bell, Night Mode display sleep after sunset, and screen recording through Konn3kt. + +**Dashcam and Route Viewer** + +- Increased qcamera resolution by 5× while balancing storage and upload use. +- Added crash-safe route writes, frequent durable checkpoints, power-loss preservation, and boot-time recovery for incomplete recordings. +- Added an on-device Routes screen with date, duration, camera availability, cloud-upload state, and local, uploading, uploaded, and cloud-only badges. +- Added local playback of road, wide, and driver video with synchronized audio; cloud-only route discovery and streaming through Konn3kt; and synchronized telemetry, model path, steering angle, driver-monitoring, vehicle speed, and cruise-set-speed overlays. +- Added qlog-only route visualization, fullscreen playback, auto-hiding controls, loading feedback, scrubbing, fast-forward speeds, hardware HEVC decode, local-time presentation, bounded playback buffering, and screen-awake behavior during playback. + +**Live View, Audio, and WebSSH** + +- Added a Live View indicator on the driving screen, dual-camera picture-in-picture streaming, and live model-path overlay while both the device and Konn3kt use cellular. +- Added instant keyframe requests, network-adaptive encoder bitrate, and full-resolution HDR driver-camera input on Comma 4. + +**Konn3kt Services, Bluetooth, and Backup** + +- Added independent device services that stay available when IQ.Pilot is stopped or cannot enter its main UI, including OS-level recovery over Wi-Fi and cellular. +- Added a dedicated `iquploaderd` route-and-log uploader, automatic IQ.Pilot crash-log uploads, Volkswagen and Tesla odometer display, backup-and-restore services, encrypted backup archives, backup status, and Konn3kt RPC support for compatible Volkswagen coding and diagnostics. +- Added direct Bluetooth Low Energy control across comma 3, comma 3X, comma 4, Konik, and Mr.One hardware, including authenticated requests, replay protection, settings synchronization, automatic discovery and pairing, and fallback when internet connectivity is unavailable. +- Added BLE control for supported IQ.Pilot, vehicle, display, network, navigation, and driving settings; live propagation to active services; and setup-stage Wi-Fi scanning, Wi-Fi connection, network status, channel selection, install start, setup progress, and installation status. + +**Volkswagen PQ and MQB** + +- Added Always-On Lateral for ECAN, non-ECAN/ACAN, CC-only, and CC-less PQ configurations; SEAT Alhambra TRW460i Stop-and-Go with automatic resume; and Stop-and-Go for additional electronic-parking-brake configurations. +- Added MQB-A0 automatic resume and steering-lockout options, standstill handling for supported non-EPB ACC vehicles, and ACC-command suppression while braking. +- Added dedicated PQ radar engagement, cancellation, set-speed, acceleration, and following-gap management; automatic PQ EPS-patch detection; and patch-aware minimum-steering-speed handling. +- Added a PQ firmware tool with backup, dump upload, patch detection, programming, and recovery; persistent PQ LKAS coding over TP2.0/KWP; model-year ECU fingerprinting; expanded Passat identification; distance-button mapping; dashboard personality presentation; and continued MQB lateral control while cruise is faulted. +- Added stock lateral handoff when IQ.Pilot lateral is inactive, explicit Volkswagen readiness handling, and Volkswagen odometer persistence with cloud backup. + +**Platform Support** + +- Added MEB/MQBevo steering ratios, steering behavior, zero-speed steering where supported, ACC-HUD handling, and Drive/Park state handling. Supported configurations include ID.3, ID.4, ID.5, and Golf Mk8 through model year 2025. +- Added Honda-specific final-stopping deceleration control, Subaru Creep from Standstill, and guarded Tesla vehicle-bus parsing for supported harnesses. +- Added more Hyundai/Kia/Genesis CAN-FD and HDA2 handling, radar-track and corner-radar controls, Camera SCC and platform-specific cruise controls, Auto Cruise Control and Auto Engage, custom steering and steering-rate controls, lane-change steering-rate controls, and expanded parameter/fingerprint diagnostics. + +**IQ.OS, Power, Network, and Recovery** + +- Updated the bundled platform to IQ.OS 4.9.1, with device-specific boot and display support, Comma 4 display calibration, HDR camera and display-color support, USB 3 logging, USB link-error recovery, 2 GB compressed zram swap, and consistent startup runtime packaging. +- Added FastSleep deep standby, including five-minute idle entry after parking, low-voltage entry, staged voltage shutdown thresholds, suspension of high-power services, Konn3kt recovery connectivity during standby, voltage-aware power decisions, and immediate wake on ignition or charging. +- Added zero-touch Bluetooth onboarding, Konn3kt pairing-code presentation, IQ.OS update confirmation, Bluetooth game-controller support for Joystick Mode, and Bluetooth operation independent of the main IQ.Pilot process. +- Added a redesigned Network settings experience, explicit Wi-Fi Disconnect, cellular reconnection after APN changes, Comma 3X SIM electrical-probe recovery, and QR/manual eSIM activation with profile listing, refresh, enable, disable, and deletion. +- Added Force On-Road for a temporary ten-minute parked diagnostic session, an Update & Reboot recovery-screen action, and USB Storage mode. + +**Updater, Reliability, and Performance** + +- Added Konn3kt confirmation before IQ.OS updates, interrupted submodule/artifact-preparation recovery, precompiled release preparation for comma 3, comma 3X, and comma 4, and crash-screen update-and-reboot without entering the full UI. +- Added independent model, navigation, map, uploader, backup, and perception services; navigation/map recovery that does not block engagement; power-state coordination; startup parameter ownership and permission normalization; message-queue dead-reader cleanup; lower shared-memory use; and clearer build, process-state, and diagnostic output. diff --git a/SConstruct b/SConstruct index 41e85615..4304e8d9 100644 --- a/SConstruct +++ b/SConstruct @@ -56,6 +56,13 @@ assert arch in [ "Darwin", # macOS arm64 (x86 not supported) ] +# ffmpeg comes from the system (brew on macOS, distro packages elsewhere) rather than +# a vendored wheel, so it always needs the static-link deps. Exported so tools/ can +# take upstream's `ffmpeg_libs` form instead of hand-listing codecs per SConscript. +ffmpeg_libs = ['avformat', 'avcodec', 'avutil', 'x264', 'z'] +if arch != "Darwin": + ffmpeg_libs += ['va', 'va-drm', 'drm'] + env = Environment( ENV={ "PATH": os.environ['PATH'], @@ -194,7 +201,7 @@ else: np_version = SCons.Script.Value(np.__version__) Export('envCython', 'np_version') -Export('env', 'arch') +Export('env', 'arch', 'ffmpeg_libs') # Setup cache dir default_cache_dir = os.environ.get('SCONS_CACHE_DIR') or ('/data/scons_cache' if arch == "larch64" else '/tmp/scons_cache') diff --git a/TOOLS_UPSTREAM_SYNC_PLAN.md b/TOOLS_UPSTREAM_SYNC_PLAN.md new file mode 100644 index 00000000..f701c70f --- /dev/null +++ b/TOOLS_UPSTREAM_SYNC_PLAN.md @@ -0,0 +1,433 @@ +# Upstream sync plan — `tools/cabana` and `tools/jotpluggler` + +Hand-port the current upstream (commaai/openpilot) versions of Cabana and jotpluggler into +iqpilot without losing konn3kt integration. iqpilot does not use comma connect: every route +list, route-file listing, JWT and live-CAN path goes through `api-iqlabs.konn3kt.com`, and the +DBC source is `iqdbc`, not `opendbc_repo`. + +Reference clone used for this analysis: `/tmp/openpilot-upstream` @ `e7b1ee3a5` +("jp: fix segment range parsing", 2026-08-05). + +--- + +## 1. Where we actually are + +Baselines were established by blob-matching every iqpilot file against upstream history, not by +reading commit messages. + +| Tool | iqpilot baseline | Upstream HEAD | Gap | +|---|---|---|---| +| `tools/cabana` | `bcdeec313` (2025-12-16, #36884) | `e7b1ee3a5` (2026-08-05) | **53 commits, ~8 months** | +| `tools/jotpluggler` | `61608db78` (2026-07-15, #38353) | `e7b1ee3a5` | **6 commits, ~3 weeks** | +| `tools/plotjuggler` | `a46ff01ca` (2026-01-19) | `e7b1ee3a5` | 14 commits (secondary scope) | + +Cabana is the real work. jotpluggler was hand-ported in May–July 2026 (`4b68073c4`, `b26218e85`, +`2cd89a198`) and is nearly current — most of its files are **byte-identical** to upstream HEAD. + +A critical structural fact: upstream moved the whole tree under a nested `openpilot/` package +(`#38219`/`#38220`/`#38223`, 2026-06-21) and moved the HAL to `common/hardware/` (`#38202`). +iqpilot has **not** adopted either. Every ported file needs a de-prefix pass: + +- `#include "openpilot/cereal/..."` → `"cereal/..."` +- `#include "common/hardware/hw.h"` → `"system/hardware/hw.h"` +- `#include "json11/json11.hpp"` → `"third_party/json11/json11.hpp"` +- `repo_root() / "openpilot" / "tools" / ...` → `repo_root() / "tools" / ...` +- SCons nodes `#openpilot/common`, `#openpilot/cereal` → `#common`, `#cereal` +- `opendbc_repo/opendbc/dbc` → `iqdbc/dbc`; `#opendbc_repo` → `#iqdbc_repo` + +--- + +## 2. konn3kt invariants — the contract the port must not break + +These are the only genuinely IQ-owned surfaces. Everything else in both trees is upstream code we +are behind on. **Anything not on this list should be taken from upstream verbatim** (modulo §1). + +### Cabana + +| File | What is IQ-owned | Disposition | +|---|---|---| +| `utils/api.{cc,h}` | `CommaApi::BASE_URL = API_HOST ?: https://api-iqlabs.konn3kt.com`; RSA-signed device JWT; Qt `HttpRequest` | **Delete** — fold into `tools/replay/api.{cc,h}` (§3.A) | +| `streams/routes.{cc,h}` | konn3kt device list + route listing | Rewrite on upstream HEAD shape, konn3kt endpoints | +| `konn3kt_canproxy.py` | `canlived → konn3kt relay → local ZMQ → Cabana` proxy | Keep; must stay wired to the Device/ZMQ stream (§3.C) | +| `SConscript` | iqdbc paths, non-nested paths, `PrettyAction`, extra libs | Rebase onto upstream, re-apply IQ deltas | +| `mainwin.cc` | "Load DBC from commaai/**iqdbc**" menu | Re-apply rename only | +| `dbc/generate_dbc_json.py` | `from iqdbc.car import ...` | Re-apply rename only | +| `assets/assets.cc` (tracked) | bootstrap-icons LFS bypass | See §3.K | +| `dbc/car_fingerprint_to_dbc.json` (+`.log`) | tracked build product | Keep tracked | +| `.gitignore`, `README.md` | binary name, iqdbc, konn3kt route examples | Re-apply | + +### jotpluggler + +All IQ deltas are already carrying `// IQ.Pilot patch:` comments — preserve that convention. + +| File | IQ patch | +|---|---| +| `SConscript` | `.venv` site-packages injection; `iqdbc.dbc.generator.generator.create_all()` instead of upstream `get_generated_dbcs()`; no `bootstrap_icons` module; explicit `avformat/avcodec/avutil/x264/yuv/z/bz2/zstd/curl/ssl/crypto` + `OpenCL`/`va`/`va-drm`/`drm`; `PrettyAction`; guards `imgui.MESA_DIR` existence | +| `icons.cc` | vendored `tools/jotpluggler/assets/bootstrap-icons.ttf` (third_party/bootstrap is LFS, TTF not checked in) | +| `common.cc` | iqpilot `common/util.h` has no `check_system`; inline `std::system` + stderr | +| `runtime.cc` | no `common/yuv.h`; vendored libyuv lacks `NV12ToABGR` → `NV12ToARGB` + `ARGBToABGR` | +| `sketch_layout.cc` | `PyDownloader::getRouteFiles` → `CommaApi2::httpGet(BASE_URL + "/v1/route//files")`; `androidLog` (cereal not renamed); iqdbc paths | +| `app.{cc,h}`, `layout.cc`, `layout_io.cc`, `map.cc`, `custom_series.cc` | path/include de-prefixing; `LogOrigin::Android`; iqdbc | +| `dbc.h` | empty-multiplex guard (`mux.empty() ? 0 : std::stoi(mux)`) — a genuine bugfix, **upstream this** | +| `generate_event_extractors.py` | `getBusTimeDEPRECATED()` (iqpilot cereal keeps a flat field, not upstream's `deprecated :group`) | +| `pluggle.py` | iqpilot-only launcher (upstream has none) | + +--- + +## 3. Conflict matrix — upstream change vs konn3kt + +### A. File downloading / API moved from C++ to Python (`#37497`, `#38430`) — **highest impact** + +Upstream cabana HEAD calls `PyDownloader::getDevices()`, `getDeviceRoutes()`, `getRouteFiles()` +(`tools/replay/py_downloader.h`), which shell into `openpilot/tools/lib/`. iqpilot's replay has +**no** `py_downloader`; it keeps a C++/libcurl path in `tools/replay/api.{cc,h}` +(`CommaApi2::BASE_URL`, `create_token(use_jwt)`, `httpGet`). jotpluggler already bridges this gap +in `sketch_layout.cc` — **reuse that pattern**, don't invent a second one. + +Resolution: extend `tools/replay/api.{cc,h}` with + +``` +std::string getDevices(); // GET /v1/me/devices/ +std::string getDeviceRoutes(dongle_id, start_ms, end_ms, preserved); + // GET /v1/devices//routes_segments?start=&end= + // GET /v1/devices//routes/preserved +std::string getRouteFiles(route); // GET /v1/route//files (already reachable via httpGet) +``` + +returning the same JSON shapes upstream's `PyDownloader` returns, then port upstream's `routes.cc` +verbatim with `PyDownloader::` → `CommaApi2::`. Upstream's HEAD `routes.cc` is already Qt-free +(json11 + `strptime`/`strftime` for the preserved-route ISO timestamps) — that code ports as-is. + +Auth semantics to preserve: `create_token(!Hardware::PC())` — device RSA-JWT on device, +`~/.comma/auth.json` `access_token` on a PC. Upstream's Python path only ever does the latter. + +Consequence: `tools/cabana/utils/api.{cc,h}` (Qt `QNetworkAccessManager` + duplicate JWT code) +**gets deleted**, which drops `Qt5Network` from the cabana link and converges with §3.B. + +### B. de-Qt: `#37519`, `#37521`, `#37522`, `#37523`, `#38357`, `#38359`, `#38360` + +The bulk of the 53-commit gap. `QString`→`std::string` through the DBC core, new Qt-free +`core/{can_data,color,message_id,settings}.h`, Qt/core split in `dbc/dbcqt.{cc,h}`, QtXml dropped +(bootstrap SVG symbol extraction hand-rolled in `utils/util.cc`), QtConcurrent dropped, QtSerialBus +dropped (SocketCAN now raw `linux/can` sockets, `#37553` excludes it on macOS). + +**Zero konn3kt content.** Pure upstream adoption. It is also why almost every cabana file shows a +large diff — those are not IQ changes, they are upstream changes we lack. + +Net effect on `SConscript`: `qt_modules` goes from +`["Widgets","Gui","Core","Network","Concurrent","DBus","Xml"]` → `["Widgets","Gui","Core"]`, +and `Qt5SerialBus` / `QtSerialBus` framework drop out. + +### C. `--zmq` bridge path (`#38484`) — **breaks konn3kt_canproxy if ported blind** + +Upstream HEAD `DeviceStream::start()` forks `cereal/messaging/bridge /"can/"` and +`streamThread()` then always subscribes on `127.0.0.1`. `bridge ` is +`zmq_to_msgq` (see `cereal/messaging/bridge.cc:60`) — it ZMQ-subscribes from `` and republishes +to local msgq. + +iqpilot's documented remote-CAN workflow is the opposite direction: +`konn3kt_canproxy.py` sets `ZMQ=1` and **publishes** capnp `Event` frames on a local ZMQ `can` +socket; the user then picks *Live → Device → ZMQ → 127.0.0.1*. Under upstream HEAD semantics that +forks a bridge which subscribes to the same loopback endpoint the proxy publishes on and +republishes into msgq, while cabana reads ZMQ — the proxy path stops being the thing cabana reads. + +Resolution: keep the pre-`#38484` direct-attach semantics as an explicit IQ escape hatch. Port +upstream's bridge-forking code, but subscribe to `zmq_address` directly (no bridge fork) when the +stream is started in "attach to an existing ZMQ publisher" mode — either by treating loopback +addresses as attach-mode, or by adding a third radio button next to MSGQ/ZMQ. Mark it +`// IQ.Pilot patch:` and state why (konn3kt_canproxy). **This must be re-verified end-to-end on a +real device before the sync is called done** (§5.3). + +### D. libyuv removed, `common/yuv.h` added (`#38306`) + +jotpluggler already patches around this. Cabana's `cameraview.cc` does NV12 conversion too. +Decision: port `common/yuv.h` into iqpilot once, then **delete** the `runtime.cc` IQ patch and take +upstream's `cameraview.cc` verbatim. One-time cost, removes a recurring patch site. + +### E. Vendored native deps via `comma-deps-*` wheels (`#37327`, `#37994`, `#37681`, `#38308`) + +Upstream `SConstruct` now exports `ffmpeg_libs` and pulls capnproto/ffmpeg/zstd/zeromq/json11/ +libusb/imgui/bootstrap-icons from pip wheels. iqpilot pins `imgui`/`libusb` from +`git.konn3kt.com/IQ.Lvbs/dependencies`, vendors json11 in `third_party/json11`, and has **no** +`bootstrap_icons` module and **no** `ffmpeg_libs` export. + +Resolution: add an `ffmpeg_libs` list + `Export` to iqpilot's `SConstruct` mirroring upstream's +(`avformat avcodec swresample avutil` [+ `x264 z` [+ `va va-drm drm`]]) so both tool SConscripts can +take upstream's form nearly verbatim instead of hand-listing libs. Keep `third_party/json11`. +Do **not** chase the wheels. + +### F. `androidLog` → `operatingSystemLog` (`#38209`) + +iqpilot cereal still has `androidLog`. Keep the existing `sketch_layout.cc` / `app.h` IQ patch. +Renaming cereal is out of scope (it is a schema change with device-side blast radius). + +### G. Tests: catch2 dropped (`#38408`), unittest conversion (`#38387`, `#38384`) + +Upstream deleted `tools/cabana/tests/test_runner.cc` and split a deliberately Qt-free +`tests/test_dbc_core` target (objects built from the **base** env, linking no Qt — it exists +specifically to stop Qt creeping back into the DBC core). Adopt that shape; drop `test_runner.cc`. +jotpluggler gains `test_jotpluggler.py`. + +### H. `cabana` launcher script + `_cabana` binary (`#37814`, `c02cf706a`, `05cf8023a`) + +Upstream builds `Program('_cabana')` and ships `tools/cabana/cabana` as a bash launcher that +installs Qt if missing and runs `scons -u openpilot/tools/cabana/_cabana openpilot/cereal/messaging/bridge`. +iqpilot builds `Program('cabana')` — so `tools/cabana/cabana` is currently a **committed binary**. +Adopting upstream's split means: `.gitignore` `cabana` → `_cabana`, and the launcher's scons +targets de-prefixed to `tools/cabana/_cabana cereal/messaging/bridge`. + +### I. `assets.cc` / bootstrap icons (`#37994`, `71290f380`, `15267e408`) + +Upstream generates `assets/assets.cc` at build time (`rcc` over an `assets.generated.qrc` that +interpolates the packaged bootstrap-icons SVG path) and gitignores it. iqpilot tracks the generated +14.9k-line `assets.cc` because the icons package isn't available. + +Recommended: vendor the bootstrap-icons **SVG** next to the already-vendored TTF +(`tools/jotpluggler/assets/bootstrap-icons.ttf`), add a tiny local shim module exposing +`SVG_PATH`/`TTF_PATH`, adopt upstream's generation, and untrack `assets.cc`. This also lets +jotpluggler's `icons.cc` IQ patch collapse back to upstream's `BOOTSTRAP_ICONS_TTF` define. +Acceptable fallback: keep tracking `assets.cc` and keep both patches. + +--- + +## Status + +**Phases 0–2 are done and landed.** Both tools are at upstream `e7b1ee3a5` (2026-08-05) with +konn3kt preserved. Departures from the plan as written, and what remains: + +- §3.I resolved better than planned: `third_party/bootstrap/bootstrap-icons.svg` turned out to be + **tracked already**, so upstream's build-time embedding was adopted as-is — no vendored SVG, and + the 14.9k-line generated `assets.cc` is gone (it was never tracked; it was a gitignored build + product). +- §3.D done both ways: `common/yuv.{cc,h}` ported, and `tools/replay/framereader.cc` moved off + libyuv too, so the jotpluggler `runtime.cc` patch disappeared entirely. +- Unplanned dependency found during the port: upstream's `#38430` also added download/decompress/ + parse instrumentation to `LogReader`, which jotpluggler's load-stats panel reads. Ported into + iqpilot's `LogReader` around its own FileReader/decompress steps (so decompress is timed + separately, unlike upstream where the Python downloader does it inline). +- §3.C landed as a **three-mode** `DeviceStream` (`Msgq` / `Zmq` / `Bridge`) rather than an + escape hatch. Upstream's ZMQ path forks a bridge that republishes to **msgq** while still + setting `ZMQ=1` and subscribing over **ZMQ** — that combination reads nothing. Keeping the modes + explicit preserves upstream's bridge convenience *and* the direct attach `konn3kt_canproxy.py` + needs, and fixes that inconsistency. `--bridge ` is a new flag. +- Also de-comma'd beyond the plan: jotpluggler's "Useradmin" / "comma connect" buttons became a + single **konn3kt** link (`KONN3KT_APP_HOST`, default `https://konn3kt.com`, same + `/` path shape `tools/lib/logreader.py` already parses). + +### Phase 3 verification actually performed (macOS arm64, live konn3kt) + +- **konn3kt API, live, real credentials** — every endpoint the port builds returns 200 with real + data: `/v1/me/devices/` (2 devices), `/v1/devices//routes_segments?start=&end=` (1776 + routes), `/v1/devices//routes/preserved` (16), `/v1/route//files` (48 logs / 48 qlogs + / 48 qcameras). +- **Real route in the real GUI** — `_cabana --qcam 0f53129ed44f6920|000001a5--07db1da02e` loaded + **48 valid segments** and came up as a foreground Cocoa app (not offscreen). +- **Live CAN over the ZMQ attach** — a local publisher mimicking `konn3kt_canproxy.py` fed + `_cabana --zmq 127.0.0.1`; cabana ingested **2179 CAN frames** and wrote them to + `~/cabana_live_stream/.../rlog`. This is the exact path `#38484` would have broken. +- **Fixed while verifying:** `utils::icon()` painted a null QPixmap whenever an empty icon id was + requested (`ToolButton("")` in `chartswidget.cc`), logging two `QPainter` warnings on every + dark-theme macOS start. Pre-existing upstream bug, not a port regression; guarded here and worth + upstreaming alongside the `dbc.h` mux fix. + +**Still outstanding:** +- **Phase 3.3 (device half):** the konn3kt relay accepts the websocket and authorizes the stream, + but `0f53129ed44f6920` emits no frames because `canlived` only runs while the `CanLiveStreaming` + param is set. Needs a device with that enabled to close the loop. +- **Phase 3.5:** cabana has not been built for larch64. +- Charts/video widgets were not visually inspected — `screencapture` is blocked by macOS + screen-recording permission for the terminal, so GUI rendering is evidenced by process state and + the ingested-frame log rather than by a screenshot. + +Tests added: `tools/replay/tests/test_api.cc` (konn3kt endpoint paths + error envelope), +`tools/cabana/test_cabana_konn3kt.py`, `tools/jotpluggler/test_jotpluggler.py`, and +`tools/test_tools_local_route.py` (synthesizes a local route and drives both tools headlessly). + +## 4. Execution plan + +Land each phase as its own commit. Build after every sub-step — do not batch §4.2. + +### Phase 0 — infrastructure (no tool code) + +0.1 `SConstruct`: add and `Export` `ffmpeg_libs`. +0.2 Port `common/yuv.h`; drop the `runtime.cc` libyuv patch. +0.3 Vendor bootstrap-icons SVG + local `bootstrap_icons` shim (`SVG_PATH`, `TTF_PATH`). +0.4 `tools/replay/api.{cc,h}`: add `getDevices()`, `getDeviceRoutes()`, `getRouteFiles()` against + the konn3kt endpoints, matching upstream `PyDownloader` JSON shapes. + +**Gate:** `scons -u -j8 tools/replay` clean; a scratch binary lists real devices from +`api-iqlabs.konn3kt.com` with both a device JWT and a PC `auth.json` token. + +### Phase 1 — jotpluggler (6 commits; do first, it validates the whole pattern cheaply) + +1.1 `3f49e2d33` thumbnail source — add `thumbnail.{cc,h}`; `common.h` `kSpecialItemSpecs` 5→6; + `common.cc` `PaneKind::Thumbnail`; `layout_io.cc` `"thumbnail"`; `app.{cc,h}` `ThumbnailView` + + `ThumbnailFrame`; `session.cc` `setThumbnails`. Re-apply the json11/path/`Android` patches. +1.2 `576de9c7e` build speedup — take upstream `generate_event_extractors.py` **wholesale** + (iqpilot's copy has diverged: `event_base_slots`, `static_enums`, single-use scalar getters) + and re-apply only the `getBusTimeDEPRECATED()` patch. +1.3 `e7b1ee3a5` segment-range parsing — empty-begin guard in `sketch_layout.cc`. +1.4 `6b47a5b6b` decompress-in-downloader — iqpilot has no `PyDownloader`; confirm the replay-side + decompression path is unaffected (expected no-op). +1.5 `fef29ad22`/`911f07ee8` — add `test_jotpluggler.py`. + +**Gate:** `scons -u -j8 tools/jotpluggler`; `./tools/jotpluggler/pluggle.py ` opens, +thumbnails render, CAN series decode against iqdbc, a saved layout round-trips. + +### Phase 2 — cabana (53 commits). Bottom-up, one layer per build. + +2.1 **DBC core** — `dbc/{dbc,dbcfile,dbcmanager}.{cc,h}`, new `core/{can_data,color,message_id,settings}.h`, + new `dbc/dbcqt.{cc,h}`. Re-apply iqdbc rename in `dbc/generate_dbc_json.py`. +2.2 **Streams** — `abstractstream`, `livestream`, `replaystream`, `pandastream`, `socketcanstream` + (raw `linux/can`, macOS-excluded), `devicestream`. Re-apply the §3.C canproxy escape hatch. +2.3 **Routes** — port upstream HEAD `streams/routes.{cc,h}`, swap `PyDownloader::` → `CommaApi2::`. + **Delete `utils/api.{cc,h}`.** +2.4 **Widgets** — `binaryview`, `historylog`, `videowidget`, `signalview`, `detailwidget`, + `messageswidget`, `mainwin` (re-apply the iqdbc menu label), `streamselector`, `commands`, + `settings`, `cameraview`, `chart/*`, `tools/*`, `utils/{util,export,elidedlabel}`. +2.5 **SConscript** — rebase on upstream; re-apply: non-nested paths, `iqdbc/dbc` + + `#iqdbc_repo`, `PrettyAction` wrappers, `third_party/json11`, `ffmpeg_libs` from Phase 0.1. + Qt modules collapse to `Widgets/Gui/Core` (+Charts). +2.6 **Tests** — adopt the Qt-free `tests/test_dbc_core` target; drop `tests/test_runner.cc`. +2.7 **Launcher** — adopt `cabana` bash script + `Program('_cabana')`; de-prefix its scons targets; + fix `.gitignore` (`cabana` → `_cabana`, `*.generated.qrc`, `bootstrap_icons.cc`). + +**Gate (each sub-step):** `scons -u -j8 tools/cabana` clean, no new warnings. + +### Phase 3 — konn3kt verification (the part that actually proves the sync) + +3.1 Remote-routes dialog against `api-iqlabs.konn3kt.com`: device list populates; all five periods + (7d / 14d / 30d / 6mo / **Preserved**) return routes with correct start times and durations. + Preserved uses ISO timestamps, the rest use `*_utc_millis` — both paths must be exercised. +3.2 Open a konn3kt route end-to-end: route-file listing, log download + decompression, qcam/fcam + video, iqdbc DBC load, chart, CSV export. +3.3 **Remote live CAN:** `canlived` on device → konn3kt relay → `konn3kt_canproxy.py` on laptop → + Cabana *Live → Device → ZMQ → 127.0.0.1*. Frames must decode. Per prior work this is the + untested leg of the canlive feature — run it on `gutek-a1`. +3.4 Local live CAN: panda stream, MSGQ device stream, SocketCAN (Linux only). +3.5 larch64 device build, if cabana is still expected to build on-device. + +### Phase 4 — plotjuggler (secondary, 14 commits) + +Only `juggle.py`, `layouts/`, `README.md` diverge, and the only IQ delta is the `iqdbc.car.fingerprints` +import plus non-nested paths. Cheap; fold in after Phase 3 or skip. + +--- + +## 5. Risks and rules + +- **Don't batch Phase 2.** ~40 files change; a single "port everything then build" pass produces an + unbisectable wall of link errors. One layer, one build. +- **`git add` only ported files.** The tree currently contains built artifacts (`*.o`, `*.a`, + `moc_*.cc`, `tools/cabana/cabana`, `tools/jotpluggler/jotpluggler`, + `generated_event_extractors.h`, `generated_dbcs/`). Never `git add -A` here. +- `dbc/car_fingerprint_to_dbc.json` is a tracked build product regenerated by scons — expect churn + on every build; keep it tracked so cabana doesn't need iqdbc importable at runtime. +- Dropping `Qt5Network/Concurrent/DBus/Xml/SerialBus` changes the larch64 link set; verify the + device build before assuming the SConscript is done. +- §3.C is the one place where a faithful upstream port actively regresses a konn3kt feature. If + Phase 3.3 can't be run on hardware in this pass, **do not** land 2.2 with upstream's bridge fork + as the only ZMQ path. +- Upstream candidate: the `dbc.h` empty-multiplex-indicator guard is a real upstream bug fix worth + sending back. + +## 6. Explicit non-goals + +- Adopting upstream's nested `openpilot/` tree layout. +- Migrating iqpilot's replay to `PyDownloader` / `tools/lib` Python downloading. +- Renaming cereal `androidLog` → `operatingSystemLog`. +- Converging on the `comma-deps-*` pip wheels. + +--- + +## Appendix A — cabana commits to port (`bcdeec313..e7b1ee3a5`, 53) + +``` +4cfdcea1d 2026-07-28 cabana: fix macOS build and --zmq bridge path (#38484) <- §3.C +6b47a5b6b 2026-07-23 tools: decompress in the python downloader (#38430) <- §3.A +75d590fb9 2026-07-21 replace catch2 tests for 20% faster builds (#38408) <- §3.G +e124d6df9 2026-07-19 more dead code gc +a04c045cd 2026-07-17 cabana: de-Qt, part 3 (#38360) <- §3.B +5d23a78c7 2026-07-16 cabana: de-Qt, part 2 (#38359) <- §3.B +06a73f538 2026-07-16 cabana: de-Qt, part 1 (#38357) <- §3.B +39117a587 2026-07-08 remove submodule symlinks (#38312) +f283f6703 2026-07-08 Revert "try removing submodule symlinks (#38310)" +b827c0f55 2026-07-08 try removing submodule symlinks (#38310) +1e49eac4d 2026-07-08 rm libyuv (#38306) <- §3.D +9877f6ac0 2026-07-08 ffmpeg: use shared libraries (#38308) <- §3.E +05cf8023a 2026-07-07 fix cabana <- §3.H +c02cf706a 2026-07-07 fix cabana launch script <- §3.H +5edc0bd89 2026-06-21 mv root dirs into nested openpilot (#38219) <- §1 (de-prefix) +20e0f21b5 2026-06-21 prefix paths with openpilot (#38223) <- §1 (de-prefix) +37eda06c9 2026-06-21 move cereal/ into nested openpilot (#38220) <- §1 (de-prefix) +bfd8d4868 2026-06-06 cabana: fix "seperated" typo in findsignal placeholders (#38142) +5408c86b7 2026-05-28 Cabana: Fixed internal typos and method casing (#38099) +15267e408 2026-05-11 cabana: gitignore generated file <- §3.I +bea893820 2026-05-10 use packaged bootstrap icons (#37994) <- §3.I +bd1c7f39e 2026-05-07 scons build cleanups (#37981) +0584a5f5e 2026-04-12 add bridge target to cabana run script (#37814) <- §3.H +31e4fe55a 2026-03-22 tools: setup ffmpeg hwaccel (#37718) +a8b5c7450 2026-03-21 prep for imgui tools (#37712) +240e0036d 2026-03-20 macOS: fix build (#37686) +a68ea44af 2026-03-14 cabana: use vendored libusb from commaai/dependencies (#37681) <- §3.E +5e7f5dd84 2026-03-14 replay/cabana: remove unused openssl dependency (#37680) +ee9da82aa 2026-03-13 cleanup build paths (#37667) +71290f380 2026-03-08 cabana: gitignore assets.cc <- §3.I +e42ee228c 2026-03-08 gitignore cleanups (#37615) +5e1a576f3 2026-03-07 cabana: exclude SocketCAN on macOS (#37553) <- §3.B +0c452dbaf 2026-03-03 cabana: fix right pane width limitation (#37527) +06b2c68e0 2026-03-01 macOS: fix cabana builds (#37518) +3478ac133 2026-03-01 cabana: remove QtSerialBus (#37523) <- §3.B +ce04d25f7 2026-03-01 cabana: remove QtConcurrent (#37522) <- §3.B +0c7abf385 2026-03-01 cabana: remove QtXml (#37521) <- §3.B +0b9ab8bb9 2026-03-01 cabana: replace Qt types with stdlib (#37519) <- §3.B (largest) +885658512 2026-02-28 new demo route (#37457) +e7cc70f3f 2026-02-28 consolidate file downloading from C++ to Python (#37497) <- §3.A +276713ddf 2026-02-27 add back bz2 support with vendored bzip2 (#37459) +238fca233 2026-02-25 tools: fix darwin compile errors (#37399) +8810948ec 2026-02-24 CI: ensure no brew (#37387) +76d084d87 2026-02-23 switch to system compilers (GCC on Linux, Apple Clang on macOS) (#37355) +f4a36f7f7 2026-02-22 rm cpp bz2 (#37332) +4bffe422e 2026-02-22 vendor capnproto and ffmpeg via dependencies repo (#37327) <- §3.E +c98ba4ff4 2026-02-20 Qt is optional (#37295) +037e6e749 2026-02-17 cabana: fix crash when zmq address is used (#37222) +af1583cdf 2026-02-12 Reapply tgwarp w NV12 fix (#37168) +45099e7fc 2026-02-10 Revert tgwarp again (#37161) +667f3bb32 2026-02-07 Revert "revert tg calib and opencl cleanup (#37113)" (#37115) +51312afd3 2026-02-07 revert tg calib and opencl cleanup (#37113) +d5cbb89d8 2026-02-06 Remove all the OpenCL (#37105) +``` + +## Appendix B — jotpluggler commits to port (`61608db78..e7b1ee3a5`, 6) + +``` +e7b1ee3a5 2026-08-05 jp: fix segment range parsing +6b47a5b6b 2026-07-23 tools: decompress in the python downloader (#38430) +576de9c7e 2026-07-21 speed up jotpluggler build (#38406) +911f07ee8 2026-07-21 convert tests to unittest (#38387) +fef29ad22 2026-07-19 start porting tests to unittest style (#38384) +3f49e2d33 2026-07-18 jp: add thumbnail source (#38363) +``` + +## Appendix C — how the baselines were established + +Blob-hash matching, not commit archaeology: for each iqpilot file, walk upstream history +newest-first and report the newest commit whose blob for that path is byte-identical. Files with no +match are IQ-modified or IQ-new; the minimum over the untouched files bounds the fork point. + +``` +cd /tmp/openpilot-upstream +git log --format='%H %ad %s' --date=short -30 -- openpilot/tools/cabana tools/cabana > /tmp/cab_commits.txt +for f in $(cd $LOCAL/tools/cabana && find . -type f \( -name '*.cc' -o -name '*.h' \) -not -name 'moc_*' | sed 's|^\./||' | sort); do + h=$(git hash-object "$LOCAL/tools/cabana/$f") || continue + while read -r c d rest; do + b=$(git rev-parse "$c:openpilot/tools/cabana/$f" 2>/dev/null || git rev-parse "$c:tools/cabana/$f" 2>/dev/null) + [ "$b" = "$h" ] && { echo "$f -> ${c:0:9} $d $rest"; break; } + done < /tmp/cab_commits.txt +done +``` + +Cross-checked against the last upstream-authored commits in iqpilot's own history +(`bcdeec313 Reduce pub-sub memory usage by 10x (#36884)` for cabana) and against feature markers +(`virtual std::string routeName` first appears in `0b9ab8bb9`, 2026-03-01 — absent from iqpilot, +confirming the fork predates it). diff --git a/artifacts/iqpilot_hephaestusd_private/manifest.json b/artifacts/iqpilot_hephaestusd_private/manifest.json index 1d6b467f..80bdbbd0 100644 --- a/artifacts/iqpilot_hephaestusd_private/manifest.json +++ b/artifacts/iqpilot_hephaestusd_private/manifest.json @@ -16,27 +16,27 @@ }, "python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "a46608f5471360235edb40285873868b3fdd11f2eaa03f68aa1446433fe849ba", + "sha256": "e79be61e50cd0830197f44f8a6840b86a74b8059da45da65a4696587168ac466", "size": 135536 }, "python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "db877b087839a133457b83ee01774a3cc6da58e184c5fb072533eae960f7a128", + "sha256": "ebfeedba6cc5cac6dca1cf8125879fcb2d6afdbaec31a98dd46c3d346f55c982", "size": 67664 }, "python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "063b95415029a679e6f730a26b3d29e466fdaad99a92ab8cbfca92b72c30faf2", + "sha256": "11ffe8f261b78990aa743b43aed3a5534105da848be1450026090ae873ad0a2e", "size": 204624 }, "python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "67d6f58f5019d267253fe0091eccf40e3f2b1179c203eb8cb9154fe533177354", + "sha256": "e62a6438c18b231f83cc873ac8170a64e670c8912ffba13edf47f163886aef19", "size": 69752 }, "python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "7fb204692c1b5ab529a81cd345b115115a06077e9f890e52ddabbdf08bee5ff3", + "sha256": "07195ca6d4e1ff26441e860d954e9c79e3da608244d110bfa62b3aa0818a79dc", "size": 203072 }, "python/iqpilot_private/konn3kt/flockd/__init__.py": { @@ -46,12 +46,12 @@ }, "python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "7f6ee4b3df65f08d12b869ea645ea24e3a634459df5fa1f5109a8bc993818979", + "sha256": "d18b8d67100f5906339dde577bc14f567168ff62e1b9166c011af853c016191e", "size": 202168 }, "python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "fbe39c9452aa5856b661a98fef006dc121412245fea94492cfc9e847fa9746dd", + "sha256": "1a601a0de826fb41fadb3cef9e8b8645d99e41507befc36e87d077d49efd3025", "size": 68080 }, "python/iqpilot_private/konn3kt/hephaestus/__init__.py": { @@ -61,67 +61,67 @@ }, "python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip": { "mode": 420, - "sha256": "7ba36031ba73ce90a9171663cf2e912264923151b3727224b6ac98a77cf7eabd", + "sha256": "c3063f3d380627f604b0ae1cdd20fbfdfa7b64d212c86f20abd470230af8e169", "size": 2100238 }, "python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "c81a547e5fc9db0a1bc87e4b120c877f60a1458817a7279af6509494a4d25d55", + "sha256": "57dbd02e130361d33c8a52635261ce85f3a91c577b70d5fad8a0e9e7557f9d46", "size": 268064 }, "python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "ec9ce5cff734f4d9e63c3e128615eea7abf6332f706b947c0d1144e9d4833b4f", - "size": 340584 + "sha256": "1febeeb24421c82a64416a7ab04def21d57a39acf0caaf2103b4ebfbae655fc2", + "size": 406216 }, "python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "d197938697bf0fc3f07f0635ccb0073581400b32c4c659166f5d2b59ede82f40", + "sha256": "42656c3b54f6392e2311e4039296e4906558fd8c63ba6be04b157445b96d38c9", "size": 68048 }, "python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "db333f1c2ca337a0481bfc8ff6e6dcc5500d7046f9f812e71cf75c24e242b1fa", + "sha256": "acdf51e90428e4487f0daf6e0e43110ef5fc1c7705d16aa3b9880618030f41f3", "size": 335728 }, "python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "7a86f26a85c418ab740b8bde8c8690edd2e0f002def8ff5a226b102bd09ea0c7", + "sha256": "95afe64579cac0148c3de23867a333651dfed4606c218208dcf37b40a1cc6450", "size": 271680 }, "python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "907d1c81687a9829ef427e6c5d2f3a9814269cc328a261bdaaa4c0896ab87b8b", + "sha256": "fd983039accfaa30abf3d46f41c70241d1c3fcf4e21a1de7d3270ab9fce9b03c", "size": 134408 }, "python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "bc8996624e05f52547fde24c8f13074ad3fc8fc66a6f4c9dac48b0b5536eec8e", - "size": 3581864 + "sha256": "cc299f0d560bbd611cab099923bb1817229df3cbc15cef22d6909091e9de826b", + "size": 3581928 }, "python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "db4c2d54c7ed02990208fbf50652d9ea66d4ee56369ad5b5876407e20ca7080a", + "sha256": "0dbcb5d9ec1af832bcaceaa3ae96a4785a432778c1fb3cdc8b74e62b5d915158", "size": 136448 }, "python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "94ebeccf5b102b898e0ac27cfb9abb32f71d66eafadc42c674c4d835c81f708e", + "sha256": "cabf37e151994a223f890627c0128990e56505bc704108f050d2422e118a273a", "size": 68136 }, "python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "518d5a8d62dfe7b2e384cbfc91d042c4c85c20747b3b366d366ed0a44183b81a", + "sha256": "10af02f5daf288e465bcaa64509972a1a0b268f779b1c12c58bf63c79e52eb96", "size": 67840 }, "python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "caea6a9d72c825d0612dcc588fcd0b10a6379f7b8239e3573ee3031be07f2d50", + "sha256": "855f128d248c94fbd00f550f7dd388ccf1a487935d7f11cac00e5240b526d674", "size": 135648 }, "python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "c5ba0105725860ed2ad5323790a0896c568cee3d20e0f1ea3f34cffd22741bb9", + "sha256": "8de50d50308777ff9dc22d4d5232cba38f2f332207313999ec8aaa67e5004f4e", "size": 269568 }, "python/iqpilot_private/konn3kt/uploaderd/__init__.py": { @@ -131,7 +131,7 @@ }, "python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "c63c9aa7894162630c1cba513e2a2b3ecbd56170c733056f55423a61a77daf3d", + "sha256": "11d16de5d29a37f64351acabfa6372966447a88cd7d799580a6f2f1fa745c15a", "size": 204184 }, "runtime": { @@ -171,25 +171,25 @@ } }, "signatures": { - "python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": "0VbxHAh2mTri3IYid3vuJRr2djX5Bs+cuqrz3PHP5IHK7pWguSdEgzW3tUXx6VDykG2OEHy+wTJsIOL+X5imCw==", - "python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": "tjABsrrulzA4MYSTuXT0EbMaNnFKhsLW4ET5uOBFx4INXvodCgxWWKuvP1yiPizunMbdf+ifaHtU1Y5O/02BCw==", - "python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": "dhlRKpxt1CGAqjoc513NEBJbXLbZlAJ+cmCK/Ch7TNVl9hkWH0VfIiC+o5h+/H/FviAxlUPKOjI4rvmODywzAw==", - "python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": "yESLGmZMIVOZxz1lfasGz+T1uz32VhdHC+MwFz1T941BNkwhrbSJruHCpJcagU1XLBGmDkRJ87XRuR6ahhQhAw==", - "python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": "BjAU29gJrL95toiQc/QsoZl9OXV7Iw9T+u7FUsRUCDDcbSzpRk2X6kNZ0e3EDi0vjj/xDTWXRZPvO47DoeguAQ==", - "python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": "7HPKLT0HrABmVBKCSTYoJdLsMHE+92SiefR+AzwUU6V6V8iXqvJ1ew+6k8q42ONuNSjjzPHQPyz9pdfdOV0oCQ==", - "python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": "R5WPYpdyhaaf05W96Doxr0rOra7eNTOu7Lpu+a07HUuzmZx4f1oRp0QJD8Nk1YB5W180X925FJjx5dquo6RiAg==", - "python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": "vra3W6bA1veYNJE/Tz7Ck6q5y2+51E8/ypdIUYFQCT1lEQE99xdCiMao/UupeQtz8NCWtdz/hHXAZiHvwPkAAQ==", - "python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": "ALFEQV2hIqlcvaEcC0UwFMCVL2NXS2DWuh7UfwVgNahxLbNtIpjogWUyrzSgJZSXwAg5Y/KnbgxWgxmQ+N8+Cg==", - "python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": "8fBrfWgtNy8p07ggeyxo9QbcCl+N5W8gGIhLJJEuLalZzqRkb2/rS2xNad2BQCoF4r5jR5BfsmzfQDjvVOQ2DQ==", - "python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": "ZEgb4IMjvp6d9HCEggvuL4o5U4YvQBw2cUKD+EKxdb5VmAzzfXp/ekov1jJqbub+/PVYCryVgn9xRpplzWY2CA==", - "python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": "cIUZNPCdDGDHJwHwgqrqmCV+Qwf1k19cz9JkA1I8rqt7eniOcjeAB8Z+RpL6WzZzWBlYTqGcCrUMTZeFpw47BQ==", - "python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": "qFG60RaEJezzZgMBeIzSHCxBl6Ko4i4zMudg0y6bTXJAlqSw7jLz3FqaZ/z4BiqkooUKqPba9mp/gD0+XBVOCQ==", - "python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": "meaDONzNucZ7mOxTgP8iSwqv0gpxwwgJf3R2f2V/PVYLpSyfU8GRrm81knEsqjC6K9boeWtX+L/xPhyvngHKCQ==", - "python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": "kluaZXQIafY5MNn5G0Gd3AN3WPdnqMm/uMP7FVn3iSB8mJJWMPyQY1DdSO3mn4iEf2J+tVQcx+bwSQA1OwlyAw==", - "python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": "Yt+FlpcONGVsklwY3/g/9lTW7/iekPUkY9b7/1uhh/5YopS7oGe3qBkNDThmgWADEm9oJBqHHShosQqDmzkPAA==", - "python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": "Y5pEAvGmZCLi0w1c8eQXakzyDUNDUVl31tdtEugHXTdqivCDuS9n09q32nwAZlqb4rR8Bn/a1ngy1Bl3zCprDQ==", - "python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": "PkkPxek8gAOexvlUz6YYihTt6IPwznAIjeAYBwBN3EIvxnzTts4cvZ6u0FGR6LLjBPmCmTMsHcQpmfzuom0nCA==", - "python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": "dbr4WtuAXTu/IC5C+dQQEgDl/tGP3abpeJ+JX/H2+rFvFe3IakpEbZ2azcu6rVN7aDH/qRfw7WfasgZYAjdYAA==", - "python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": "ImpCbB9fX10Ax/rmczgYoujMOzEaGgr4BgUkz9l+IbNRduVcbF6avrVk5qfGbg9ZoX3k/vPwiwRAXpUk+5GADA==" + "python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": "1DjE26Z6yX6VuP+Jx/DxxS2PjWXSXLd+bS1RgYEo9WNOEHENVDv1xeoyXjFmrXe6/QTILJpv+LnqWegfVSnFBQ==", + "python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": "ytsUI7NI3t/9m+zEfMCbXVCIggeKSk8lnTrVYWQr0uryX5tTtQXyR+f+rIPSZWzzHu80z3GE0jmnQEEN2m6XCA==", + "python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": "Y57ZaVfeeB1Mnu4vFIQNSZIuob+jmBkuuZ86Zc0sOWvqgkE8gT41pivlipr/R/du5+HSqXvTv/iuQS8ekTBpCg==", + "python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": "PGjuQc316L/r8sSGF0Hg5EnFWj/Bhk27UYBgGvQ8xxLvoVJIkRKVJ/ZFLFZo6rdEWrd0GQECUYeR9ewU6ufFAA==", + "python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": "CVrgu0dEypYq6IIA38Lh/w4KKVNpP5+dfV0dkOpDA4UkyH2IoX3Wiywpz3o1jfjrSXE3oiPdk52qRE+pzLLxDQ==", + "python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": "+grQAam3UiFIBqGcvuAI+7aYW6UCU83YLlxCkPoo6YaX9SitDVqQk6bNQwqxYUzuUZ2s8BdhlbgseTuImRZMBw==", + "python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": "yhxeGAidy3Auu+i2wZgxD1bK18VeWknYuQQpeiylNyWNfzN/xPUDGFyyV0CpNY3FqEqH8LcHLzsa8Bz4JTbWDw==", + "python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": "OX/9GfQQf/krppHPChnXqvvHCs+8LqdpnRtRxJ9AYeqRLwNMrTgCSLOsU3YGlHiF78U5jIldNdkr4aOGBHRVAg==", + "python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": "n06LgyR6cPqHLaGsiEXlSFiNKFkT3S14mPEz+xtLw0y3bAuNod5Jpp6KiVuoTcVdzHjXPFWwYLgqbtDxSZe/BQ==", + "python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": "JJ8eaFR2wkJW4/gw31a35LvyHLKTLfPwFwIC3cE5Y4AYbLkrVuVbizPefhUrcFMF0ZxzjQulU0YNAL7EMbzOAA==", + "python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": "Vr/pX91mN1D0Qcl9PNJiT2DUq9LxP13qjO5g7Ve/KDPwAqL9nKJXeAqbhKXIqFyRHp82PAHKaVhHsF39QtLRAA==", + "python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": "yjHeFmvqDWSEXIft0n93zTSZemP4L9hmXZtauDFL7bRnGHMnTVvrq7olJJoXvQITVGyGzDl+BuDF5pjOKau+AQ==", + "python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": "7sVJ5qadF1T48RP8FlwynsJEC0w6OtN+BGBf93YABFXgYDPY/YAMm5aAicR6YmGvgkFqtfCf5g8ozdrz5fIuBg==", + "python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": "54+MaaeOIEbj+uZrcphTbHQ8K1jm4gRe2JYZyYNkwxPcT6dkcWLM6NZiNVzXiD6Ph87hTxUbecJnhVeT80pDAw==", + "python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": "xJnkGTaVVWne7p83UhTU2C/miZg4XR7pLrd6A7j7TvetRCFvQPxyuAeFi5lJsKKwhaWV3dInGZcVWrGV+kV0AA==", + "python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": "P+LVQJc3vD5Z9i/jxNxziNbgCiGrI+5P9saUGHMPMKUoWhVEBAAVU8xuVyd6GjSd8M5VrximPVrJyMCE9fEfAw==", + "python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": "vfDMFKWuTD2XSE7JF/qmHxcOXgdXRZN9fi3FTSffmr6t22Tz0N7v8MwVayiPKrYNt2gHixFZ9aAkvF2SC+yDBg==", + "python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": "Orgr5aTB5g3NRH8AdsuNkpcmIfSQDBFHzegDN875SxuvHuCOfsVVYgx2TtRiT0wqSR9hlQQwhelyRHRQjcMCAQ==", + "python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": "tZNbbDyMxkFOcrvWCEBQz6ZTGdLZVesuz0dbUMO1OBaSYAG4MRBQ5y9fZwRQZAvcEa+NMlNZK2SGoC6r5BxgDA==", + "python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": "sgYjdE7ZRvdKlehs1/h3z/n14fksR+Z+KhCzi0BvFPcbMcx0Pod/L42IQeEDfcQfoFBURPNoI1OTwWc31H/NAQ==" } } diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so index 0a9324f4..86354b63 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so index 14ab0925..dd2ce74e 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so index b392a12f..30328bab 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so index aa9cd814..33eb1805 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so index 8774f48e..a1651000 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so index 89ff5a33..5713e2e1 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so index fd765993..c4be8a76 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip index ef7b5cfd..ffe48340 100644 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so index c89d1312..24d362d1 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so index aa26e9b3..520fa348 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so index af554aa6..9240a8d6 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so index 8bb2414d..a8bee2a8 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so index 41bdb98c..ea41b12e 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so index b87488e0..3be2eb08 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so index 8bb3437d..27b8f0a3 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so index 2adf7a01..ee371a8f 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so index 5e777a8d..2cf1d2e8 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so index 798b4c8f..bbf30475 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so index 92382d86..9eb411b7 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so index af1e9429..5c6e64cb 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so index 3b89e53c..1e5ca449 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/manifest.json b/artifacts/iqpilot_iqvd_private/manifest.json index 0a4cacf6..3f3bb174 100644 --- a/artifacts/iqpilot_iqvd_private/manifest.json +++ b/artifacts/iqpilot_iqvd_private/manifest.json @@ -6,7 +6,7 @@ }, "python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "d36af86bf1fe721522e729b6a48e3a962ce033dcfe89f9e00e3d6208b1801591", + "sha256": "48bad6268fabc23a9be62fcd7f61240ebcbabd5e3c6afcf7d3328bd33d93d3a2", "size": 67616 }, "python/iqpilot_private/iqvd/__init__.py": { @@ -16,12 +16,42 @@ }, "python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "f81036080eb7bc0c91bae10b11f52064041311a5fc67e7dc0fba32f585318e41", - "size": 201584 + "sha256": "674c4ecd09e36af6c2b9a41285e78914ee94a0e8c4de728904171dd59e02edda", + "size": 135576 + }, + "python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "7ce45dfb9a6ffe8e5426f261cf7e9b3a3825e3d284a0675013d43697e73d56d9", + "size": 67616 + }, + "python/iqpilot_private/iqvd/offload/__init__.py": { + "mode": 420, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0 + }, + "python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "439f88b68d220912db2f7e9a43d41827d298f68b113e7c5e5858adea3a05e963", + "size": 135296 + }, + "python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "0199f81ce365fb956d729c61c20c8eaa0120448171db6b83544f3dd1a0861877", + "size": 68232 + }, + "python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "678ebcdd9a97fe527970bbb93021d2ae8f4f9935444b6558dd61b399ab3fe3e9", + "size": 135976 + }, + "python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "a8d24107433a037d19876b2240e980b09c89f560ba960dda5e28c16665a03ca6", + "size": 68120 }, "python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "976be8806f006a175a14b0c63e9a1c2c2b845ee2dab77a41eeb6759c7feab641", + "sha256": "943e0ef218eed109289d208ea76407e223a4e8b81659f0ec79dc090fbd7ed329", "size": 204568 }, "runtime": { @@ -33,8 +63,13 @@ } }, "signatures": { - "python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so": "bMboPEsPzZjh0Y1Zk3Brvc6fyWFF5CxuzN9KQr2RK0Hn+l8XiD6l/ym2Y10nFxvj8+uLikIZ0xSAovJlJcRUAA==", - "python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so": "seyE6m33mqlKZ4H7b1JL6pxxz+r/+dKP1PzcfXxhdhHvsl8Ka8Noi6+ln5z+QSvBgt5ulxQeBJMBP5qdu6lOCw==", - "python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so": "RvzkJLNxPA+u7yEnH5jd2jecT02PJCQkIDj/LiWvBCE4lFmFFzOf1Q+ZCGGjrFQzhLQZlm75SvW80mlqJ9BJCw==" + "python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so": "YPDPJiSbuWXyg3MpOJXzZtejR5sazfra6c/AFVm+tzE+i9OA9/OPS5qvpFSaHOBPaVZYtsmxkOFq4Q5GJlYOAg==", + "python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so": "H+2OPwV0XS7biX05P6fBzRBCN1Cjnq9eH6YSLk+9BX8OPbdiyJTbDpt8YZYk4nU0LO5FF4dMkEXzFRsewB7BAw==", + "python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so": "vbv8RlHm7LGdrojZuRhs+2StjASHEC1GbkZspXh6FCbmyKmW7F9B5GzHjIwr7HVFSNX6u+ELPPKebfnIsOkmAQ==", + "python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so": "pfDu4/OifTpFWqMD/hyQN+p+9syWaiAavHCkvxG9++0VvHDwKsBPJFxRk8r3vBdylSz4zkIFXlqPPt/hNuzJAg==", + "python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so": "Ll6azxnD3jGmyPPjd+yTmAVCl+CwM2iSdSqpYtTtzqPIb6GFBp0FfF5w+FTpacKjoQ4i3cXV4gdUVOf3qRWzCg==", + "python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so": "ww5BVRLiGB+LkqREN1f7njOrrgOcWghyTXNXTJTJHYeLx5AgIrvsqg1PH7+nq1LuSvk+LHDTOSUAcs7JIDh3DQ==", + "python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so": "3OzAWo66E+qe/H+HtGEP1i73WLW9SPa9+zeb8fOWBe7Nlsx7WrgGcURBq0VTycv2v3QLNLEluei/rnQEYBRMCA==", + "python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so": "C64cBnuSM2wm6vCEO9W3cNqbB4P7Yuy78pFUXxn8iesCQD0hbZ1+w8IuH4VuxB88ubLRLQt2GZ8LYIESb+HYAg==" } } diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so index 6c32f315..d7a4479f 100755 Binary files a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so index 475aca1b..db25f80f 100755 Binary files a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 00000000..a5f6cbc7 Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.py b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 00000000..0e75f681 Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 00000000..f4e614ed Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 00000000..f34cba01 Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 00000000..a09d623e Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so index 42efbc04..2e355336 100755 Binary files a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so differ diff --git a/cereal/custom.capnp b/cereal/custom.capnp index a63212c9..a79382d3 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -333,6 +333,7 @@ struct IQCarParams @0xd4189b5c8aca9f78 { iqLateralNet @4 :LateralNet; longitudinalStoppingSpeedOverride @5 :Float32; # m/s; zero keeps the upstream default stoppingDecelRateOverride @6 :Float32; # m/s^3; zero keeps the upstream default + longActiveWithGasOverride @7 :Bool; # keep long control active while the driver is on the gas struct LateralNet { fuzzyFingerprint @0 :Bool; @@ -762,7 +763,33 @@ struct IQVehicleTracks @0xb877ef4b20a4ae22 { } } -struct CustomReserved13 @0xfd960244a79e2804 { +struct IQEnvironment @0xfd960244a79e2804 { + frameId @0 :UInt32; + offloaded @1 :Bool; + modelValid @2 :Bool; + objects @3 :List(Object); + + struct Object { + x @0 :Float32; + y @1 :Float32; + z @2 :Float32; + width @3 :Float32; + height @4 :Float32; + length @5 :Float32; + prob @6 :Float32; + label @7 :Label; + + enum Label { + car @0; + motorcycle @1; + bus @2; + truck @3; + person @4; + bicycle @5; + stopSign @6; + trafficLight @7; + } + } } struct CustomReserved14 @0xa6e5a1ce8ca5258e { diff --git a/cereal/log.capnp b/cereal/log.capnp index 590c92aa..056b9caf 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -177,6 +177,15 @@ struct InitData { wallTimeNanos @20 :UInt64; + ufsHealth @25 :UfsHealth; + + struct UfsHealth { + preEolInfo @0 :UInt8; + lifeTimeEstimateA @1 :UInt8; + lifeTimeEstimateB @2 :UInt8; + vendorHealthReport @3 :Data; + } + enum DeviceType { unknown @0; neo @1; @@ -2670,7 +2679,7 @@ struct Event { iqPerfTrace @136 :Custom.IQPerfTrace; iqConstructionZone @137 :Custom.IQConstructionZone; iqVehicleTracks @138 :Custom.IQVehicleTracks; - customReserved13 @139 :Custom.CustomReserved13; + iqEnvironment @139 :Custom.IQEnvironment; customReserved14 @140 :Custom.CustomReserved14; customReserved15 @141 :Custom.CustomReserved15; customReserved16 @142 :Custom.CustomReserved16; diff --git a/cereal/services.py b/cereal/services.py index 8504fd70..50f8e806 100755 --- a/cereal/services.py +++ b/cereal/services.py @@ -100,6 +100,7 @@ _services: dict[str, tuple] = { "iqLiveData": (True, 1., 1), "iqConstructionZone": (True, 2., 2), "iqVehicleTracks": (True, 4., 4), + "iqEnvironment": (True, 4., 4), "mapdOut": (True, 20., 20, QueueSize.MEDIUM), "mapdExtendedOut": (False, 1., -1, QueueSize.MEDIUM), "mapdIn": (False, 1., -1, QueueSize.MEDIUM), diff --git a/common/SConscript b/common/SConscript index a55532df..f05984b8 100644 --- a/common/SConscript +++ b/common/SConscript @@ -6,6 +6,7 @@ common_libs = [ 'util.cc', 'ratekeeper.cc', 'clutil.cc', + 'yuv.cc', ] _common = env.Library('common', common_libs, LIBS="json11") diff --git a/common/params_keys.h b/common/params_keys.h index 1a6bfc92..58a070cc 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -21,6 +21,7 @@ inline static std::unordered_map keys = { {"CalibrationParams", {PERSISTENT, BYTES}}, {"CameraDebugExpGain", {CLEAR_ON_MANAGER_START, STRING}}, {"CameraDebugExpTime", {CLEAR_ON_MANAGER_START, STRING}}, + {"CanLiveStreaming", {CLEAR_ON_MANAGER_START, BOOL}}, {"CarBatteryCapacity", {PERSISTENT, INT}}, {"CarParams", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BYTES}}, {"CarParamsCache", {CLEAR_ON_MANAGER_START, BYTES}}, @@ -199,6 +200,8 @@ inline static std::unordered_map keys = { {"OSMapsHeadingUp", {PERSISTENT, BOOL, "1"}}, {"OfflineTilesBaseUrl", {PERSISTENT, STRING}}, {"OnroadUploads", {PERSISTENT, BOOL, "1"}}, + {"IQAutoUnits", {PERSISTENT, BOOL, "1"}}, + {"IQAutoUnitsRegion", {PERSISTENT, STRING}}, {"IQAlertSilence", {PERSISTENT, BOOL, "0"}}, {"IQAccelMeter", {PERSISTENT, BOOL, "0"}}, {"IQBlinkerIndicators", {PERSISTENT, BOOL, "0"}}, @@ -349,6 +352,7 @@ inline static std::unordered_map keys = { {"ConstructionZoneAssist", {PERSISTENT, BOOL, "0"}}, {"VisionVehicleTracks", {PERSISTENT, BOOL, "0"}}, {"AmbientTrackDots", {PERSISTENT, BOOL, "1"}}, + {"EnvironmentView", {PERSISTENT, INT, "0"}}, {"ConstructionZoneSpeed", {PERSISTENT, INT, "60"}}, {"ShowSpeedLimits", {PERSISTENT, BOOL, "0"}}, {"SLCPolicy", {PERSISTENT, INT, "1"}}, diff --git a/common/tests/native_test.h b/common/tests/native_test.h new file mode 100644 index 00000000..d3dfbb9c --- /dev/null +++ b/common/tests/native_test.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +inline void native_test_check(bool condition, const char *expression, const char *file, int line) { + if (!condition) { + throw std::runtime_error(std::string(file) + ":" + std::to_string(line) + ": check failed: " + expression); + } +} + +#define CHECK(condition) native_test_check(static_cast(condition), #condition, __FILE__, __LINE__) +#define REQUIRE(...) CHECK((__VA_ARGS__)) + +template +int run_native_test(Function &&function) { + try { + function(); + return 0; + } catch (const std::exception &error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/common/yuv.cc b/common/yuv.cc new file mode 100644 index 00000000..a7a64fb5 --- /dev/null +++ b/common/yuv.cc @@ -0,0 +1,132 @@ +#include "common/yuv.h" + +#include +#include + +namespace yuv { + +namespace { + +inline uint8_t clamp_u8(int v) { + return static_cast(std::clamp(v, 0, 255)); +} + +void copy_plane(const uint8_t *src, int src_stride, + uint8_t *dst, int dst_stride, + int width, int height) { + if (src_stride == width && dst_stride == width) { + std::memcpy(dst, src, static_cast(width) * height); + return; + } + for (int y = 0; y < height; ++y) { + std::memcpy(dst + y * dst_stride, src + y * src_stride, width); + } +} + +void scale_plane_point(const uint8_t *src, int src_stride, int src_width, int src_height, + uint8_t *dst, int dst_stride, int dst_width, int dst_height) { + if (src_width == dst_width && src_height == dst_height) { + copy_plane(src, src_stride, dst, dst_stride, dst_width, dst_height); + return; + } + for (int y = 0; y < dst_height; ++y) { + const int sy = y * src_height / dst_height; + const uint8_t *src_row = src + sy * src_stride; + uint8_t *dst_row = dst + y * dst_stride; + for (int x = 0; x < dst_width; ++x) { + dst_row[x] = src_row[x * src_width / dst_width]; + } + } +} + +// BT.601 limited range → RGB (integer form used widely, incl. similar to libyuv). +inline void yuv_to_rgb(int y, int u, int v, uint8_t *r, uint8_t *g, uint8_t *b) { + const int c = (y - 16) * 298; + const int d = u - 128; + const int e = v - 128; + *r = clamp_u8((c + 409 * e + 128) >> 8); + *g = clamp_u8((c - 100 * d - 208 * e + 128) >> 8); + *b = clamp_u8((c + 516 * d + 128) >> 8); +} + +} // namespace + +void nv12_to_i420(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_uv, int src_stride_uv, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_u, int dst_stride_u, + uint8_t *dst_v, int dst_stride_v, + int width, int height) { + copy_plane(src_y, src_stride_y, dst_y, dst_stride_y, width, height); + + const int uv_width = width / 2; + const int uv_height = height / 2; + for (int y = 0; y < uv_height; ++y) { + const uint8_t *uv = src_uv + y * src_stride_uv; + uint8_t *u = dst_u + y * dst_stride_u; + uint8_t *v = dst_v + y * dst_stride_v; + for (int x = 0; x < uv_width; ++x) { + u[x] = uv[2 * x]; + v[x] = uv[2 * x + 1]; + } + } +} + +void i420_to_nv12(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_u, int src_stride_u, + const uint8_t *src_v, int src_stride_v, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_uv, int dst_stride_uv, + int width, int height) { + copy_plane(src_y, src_stride_y, dst_y, dst_stride_y, width, height); + + const int uv_width = width / 2; + const int uv_height = height / 2; + for (int y = 0; y < uv_height; ++y) { + const uint8_t *u = src_u + y * src_stride_u; + const uint8_t *v = src_v + y * src_stride_v; + uint8_t *uv = dst_uv + y * dst_stride_uv; + for (int x = 0; x < uv_width; ++x) { + uv[2 * x] = u[x]; + uv[2 * x + 1] = v[x]; + } + } +} + +void i420_scale(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_u, int src_stride_u, + const uint8_t *src_v, int src_stride_v, + int src_width, int src_height, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_u, int dst_stride_u, + uint8_t *dst_v, int dst_stride_v, + int dst_width, int dst_height) { + scale_plane_point(src_y, src_stride_y, src_width, src_height, + dst_y, dst_stride_y, dst_width, dst_height); + scale_plane_point(src_u, src_stride_u, src_width / 2, src_height / 2, + dst_u, dst_stride_u, dst_width / 2, dst_height / 2); + scale_plane_point(src_v, src_stride_v, src_width / 2, src_height / 2, + dst_v, dst_stride_v, dst_width / 2, dst_height / 2); +} + +void nv12_to_rgba(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_uv, int src_stride_uv, + uint8_t *dst_rgba, int dst_stride_rgba, + int width, int height) { + for (int y = 0; y < height; ++y) { + const uint8_t *y_row = src_y + y * src_stride_y; + const uint8_t *uv_row = src_uv + (y / 2) * src_stride_uv; + uint8_t *dst = dst_rgba + y * dst_stride_rgba; + for (int x = 0; x < width; ++x) { + const int uv_x = (x & ~1); + uint8_t r, g, b; + yuv_to_rgb(y_row[x], uv_row[uv_x], uv_row[uv_x + 1], &r, &g, &b); + dst[4 * x + 0] = r; + dst[4 * x + 1] = g; + dst[4 * x + 2] = b; + dst[4 * x + 3] = 255; + } + } +} + +} // namespace yuv diff --git a/common/yuv.h b/common/yuv.h new file mode 100644 index 00000000..7ab096f7 --- /dev/null +++ b/common/yuv.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +// NV12: Y plane + interleaved UV. I420: planar Y, U, V. + +namespace yuv { + +// Deinterleave NV12 UV into planar I420. +void nv12_to_i420(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_uv, int src_stride_uv, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_u, int dst_stride_u, + uint8_t *dst_v, int dst_stride_v, + int width, int height); + +// Interleave planar I420 UV into NV12. +void i420_to_nv12(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_u, int src_stride_u, + const uint8_t *src_v, int src_stride_v, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_uv, int dst_stride_uv, + int width, int height); + +// Point-sample scale I420 (equivalent to libyuv::I420Scale + kFilterNone). +void i420_scale(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_u, int src_stride_u, + const uint8_t *src_v, int src_stride_v, + int src_width, int src_height, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_u, int dst_stride_u, + uint8_t *dst_v, int dst_stride_v, + int dst_width, int dst_height); + +// Convert NV12 to packed RGBA (R,G,B,A bytes — suitable for GL_RGBA). +// BT.601 limited-range, matching common libyuv defaults. +void nv12_to_rgba(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_uv, int src_stride_uv, + uint8_t *dst_rgba, int dst_stride_rgba, + int width, int height); + +} diff --git a/iqpilot/common/auto_units.py b/iqpilot/common/auto_units.py new file mode 100644 index 00000000..db038d7a --- /dev/null +++ b/iqpilot/common/auto_units.py @@ -0,0 +1,58 @@ +import time + +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.iqpilot.common.geo_regions import UNKNOWN_REGION, region_for_position, region_is_metric + +CHECK_INTERVAL = 10.0 +CONFIRMATIONS = 3 + + +class AutoUnits: + def __init__(self, params: Params | None = None): + self.params = params or Params() + self._next_check = 0.0 + self._candidate = UNKNOWN_REGION + self._confirmations = 0 + + def _position(self) -> tuple[float, float, bool]: + from openpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position + + lat, lon, _, valid = current_or_last_gps_position(self.params) + return lat, lon, valid + + def update(self, now: float | None = None) -> None: + if not self.params.get_bool("IQAutoUnits"): + self._candidate = UNKNOWN_REGION + self._confirmations = 0 + return + + now = time.monotonic() if now is None else now + if now < self._next_check: + return + self._next_check = now + CHECK_INTERVAL + + lat, lon, valid = self._position() + region = region_for_position(lat, lon) if valid else UNKNOWN_REGION + if region == UNKNOWN_REGION: + self._confirmations = 0 + return + + if region != self._candidate: + self._candidate = region + self._confirmations = 1 + return + + self._confirmations += 1 + if self._confirmations < CONFIRMATIONS: + return + + if region == self.params.get("IQAutoUnitsRegion"): + return + + self.params.put("IQAutoUnitsRegion", region) + + metric = region_is_metric(region) + if metric != self.params.get_bool("IsMetric"): + self.params.put_bool("IsMetric", metric) + cloudlog.warning(f"auto units: {region} detected, switching to {'km/h' if metric else 'mph'}") diff --git a/iqpilot/common/geo_regions.py b/iqpilot/common/geo_regions.py new file mode 100644 index 00000000..ec47c99e --- /dev/null +++ b/iqpilot/common/geo_regions.py @@ -0,0 +1,140 @@ +MPH_REGIONS = ("US", "GB", "LR") +METRIC_REGION = "METRIC" +UNKNOWN_REGION = "" + +_US_CONUS = [ + (-123.32, 49.00), (-117.03, 49.00), (-110.00, 49.00), (-104.05, 49.00), (-97.23, 49.00), (-95.15, 49.00), + (-95.15, 49.38), (-94.82, 49.30), (-94.68, 48.77), (-93.85, 48.63), (-93.35, 48.62), (-92.72, 48.54), + (-92.30, 48.24), (-91.55, 48.10), (-90.84, 48.24), (-89.99, 48.02), (-89.60, 48.02), (-89.10, 48.32), + (-88.40, 48.30), (-87.00, 47.80), (-85.60, 47.15), (-84.60, 46.75), (-84.42, 46.56), (-84.30, 46.49), + (-84.12, 46.28), (-83.90, 46.05), (-83.40, 45.75), (-82.90, 45.05), (-82.55, 44.00), (-82.42, 43.00), + (-82.70, 42.47), (-82.93, 42.34), (-83.00, 42.33), (-83.05, 42.32), (-83.075, 42.312), (-83.13, 42.25), + (-83.15, 42.18), (-83.11, 42.10), (-83.09, 42.02), + (-82.50, 41.70), (-81.50, 42.00), (-80.20, 42.40), (-79.06, 42.85), (-79.05, 43.27), (-78.00, 43.45), + (-77.00, 43.65), (-76.40, 44.10), (-75.80, 44.50), (-74.75, 45.00), (-73.35, 45.01), (-71.50, 45.01), + (-71.29, 45.30), (-70.90, 45.30), (-70.72, 45.42), (-70.31, 45.86), (-70.05, 46.44), (-69.99, 46.70), + (-69.24, 47.46), (-68.90, 47.20), (-68.38, 47.29), (-67.79, 47.07), (-67.78, 45.94), (-67.42, 45.60), + (-67.03, 44.80), (-68.00, 44.30), (-69.06, 43.80), (-70.20, 43.60), (-70.80, 42.85), (-70.00, 41.90), + (-70.00, 41.55), (-71.20, 41.30), (-72.00, 41.05), (-73.90, 40.55), (-74.20, 39.60), (-75.05, 38.45), + (-75.90, 37.05), (-75.50, 35.20), (-78.50, 33.85), (-80.90, 32.00), (-81.40, 30.70), (-80.03, 26.80), + (-80.15, 25.15), (-81.20, 24.55), (-82.00, 26.40), (-82.80, 27.80), (-83.00, 29.15), (-84.30, 29.90), + (-85.30, 29.65), (-87.50, 30.25), (-89.00, 29.15), (-89.40, 28.95), (-91.30, 29.10), (-93.80, 29.65), + (-95.00, 29.10), (-97.10, 27.80), (-97.14, 25.96), (-98.30, 26.05), (-99.10, 26.40), (-99.50, 27.60), + (-100.40, 28.50), (-101.40, 29.77), (-102.30, 29.88), (-102.90, 29.30), (-103.30, 29.00), (-104.37, 29.56), + (-104.68, 30.13), (-105.30, 30.80), (-105.85, 31.30), (-106.15, 31.50), (-106.30, 31.68), (-106.45, 31.755), + (-106.53, 31.786), (-108.21, 31.783), (-108.21, 31.33), (-111.07, 31.33), (-114.72, 32.72), + (-117.13, 32.53), (-118.40, 33.75), (-119.80, 34.40), (-120.65, 35.10), (-121.90, 36.60), (-122.52, 37.78), + (-123.75, 39.40), (-124.20, 40.45), (-124.15, 42.00), (-124.05, 43.35), (-123.95, 46.25), (-124.75, 48.40), + (-123.30, 48.25), (-123.15, 48.70), +] + +_US_ALASKA = [ + (-141.00, 70.20), (-141.00, 60.30), (-139.05, 60.35), (-137.45, 58.95), (-136.47, 59.63), (-135.03, 59.57), + (-134.30, 58.90), (-133.40, 58.20), (-132.20, 56.90), (-130.60, 56.20), (-130.01, 54.80), (-131.80, 54.70), + (-133.80, 55.90), (-136.60, 58.20), (-140.00, 59.70), (-145.00, 60.00), (-149.20, 59.10), (-152.30, 57.30), + (-155.20, 55.60), (-160.00, 54.60), (-164.50, 54.40), (-162.00, 57.50), (-165.00, 60.20), (-167.50, 62.50), + (-164.00, 64.50), (-168.10, 65.60), (-166.00, 68.30), (-161.00, 70.30), (-156.50, 71.40), (-150.00, 70.50), +] + +_US_ALEUTIANS_EAST = [(-180.00, 51.00), (-158.50, 51.00), (-158.50, 56.00), (-180.00, 56.00)] +_US_ALEUTIANS_WEST = [(172.00, 51.00), (180.00, 51.00), (180.00, 54.00), (172.00, 54.00)] +_US_HAWAII = [(-160.50, 18.80), (-154.70, 18.80), (-154.70, 22.30), (-160.50, 22.30)] +_US_PUERTO_RICO = [(-67.35, 17.85), (-64.55, 17.85), (-64.55, 18.55), (-67.35, 18.55)] +_US_MARIANAS = [(144.50, 13.10), (146.20, 13.10), (146.20, 20.60), (144.50, 20.60)] +_US_SAMOA = [(-171.20, -14.60), (-168.10, -14.60), (-168.10, -11.00), (-171.20, -11.00)] + +_GB_BRITAIN = [ + (-5.72, 50.07), (-4.20, 50.32), (-3.41, 50.62), (-2.45, 50.52), (-1.80, 50.72), (-0.90, 50.77), + (0.58, 50.85), (1.35, 51.13), (1.38, 51.38), (1.15, 51.79), (1.35, 51.95), (1.75, 52.48), + (1.30, 52.94), (0.49, 52.94), (0.34, 53.15), (-0.08, 53.57), (-0.08, 54.12), (-0.61, 54.49), + (-1.18, 54.69), (-1.38, 54.91), (-1.50, 55.13), (-2.00, 55.77), (-2.52, 56.00), (-2.62, 56.28), + (-2.47, 56.55), (-2.21, 56.96), (-2.08, 57.14), (-1.77, 57.50), (-2.00, 57.70), (-2.96, 57.68), + (-3.90, 57.60), (-4.22, 57.48), (-4.05, 57.81), (-3.85, 58.01), (-3.65, 58.12), (-3.09, 58.44), + (-3.01, 58.67), (-3.35, 58.62), (-3.52, 58.60), (-4.99, 58.62), (-5.05, 58.45), (-5.16, 57.90), (-5.70, 57.72), + (-5.72, 57.28), (-5.83, 57.00), (-5.72, 56.65), (-5.47, 56.41), (-5.79, 55.60), (-5.62, 55.31), + (-4.82, 55.64), (-4.63, 55.46), (-4.85, 55.24), (-5.12, 54.84), (-4.86, 54.63), (-4.44, 54.87), + (-4.05, 54.83), (-3.26, 54.98), (-3.05, 54.90), (-3.50, 54.72), (-3.23, 54.07), (-3.05, 53.82), + (-3.40, 53.34), (-3.83, 53.33), (-4.63, 53.42), (-4.72, 53.28), (-4.35, 53.12), (-4.76, 52.80), + (-4.06, 52.72), (-4.09, 52.41), (-4.66, 52.09), (-5.31, 51.88), (-5.06, 51.70), (-4.70, 51.67), + (-4.30, 51.62), (-3.95, 51.56), (-3.70, 51.48), (-3.17, 51.45), (-2.99, 51.55), (-2.67, 51.62), + (-2.48, 51.72), (-2.30, 51.85), (-2.70, 51.50), (-2.98, 51.35), (-3.00, 51.20), (-3.47, 51.21), + (-4.12, 51.21), (-4.55, 50.83), (-5.08, 50.42), (-5.48, 50.21), +] + +_GB_NORTHERN_IRELAND = [ + (-6.03, 54.05), (-6.28, 54.10), (-6.65, 54.17), (-6.86, 54.33), (-7.16, 54.34), (-7.31, 54.12), + (-7.62, 54.14), (-8.00, 54.31), (-8.18, 54.47), (-8.20, 54.52), (-7.90, 54.55), (-7.85, 54.72), (-7.55, 54.75), + (-7.44, 54.94), (-7.25, 55.06), (-6.95, 55.22), (-6.50, 55.25), (-6.25, 55.31), (-6.03, 55.22), + (-5.43, 54.62), (-5.53, 54.24), +] + +_GB_ISLE_OF_MAN = [(-4.85, 54.03), (-4.30, 54.03), (-4.30, 54.42), (-4.85, 54.42)] +_GB_CHANNEL_ISLANDS = [(-2.75, 49.15), (-1.95, 49.15), (-1.95, 49.80), (-2.75, 49.80)] +_GB_ISLE_OF_WIGHT = [(-1.60, 50.55), (-1.05, 50.55), (-1.05, 50.80), (-1.60, 50.80)] +_GB_OUTER_HEBRIDES = [(-7.75, 56.75), (-6.05, 56.75), (-6.05, 58.55), (-7.75, 58.55)] +_GB_INNER_HEBRIDES = [(-7.00, 55.45), (-5.55, 55.45), (-5.55, 57.85), (-7.00, 57.85)] +_GB_ORKNEY = [(-3.50, 58.70), (-2.35, 58.70), (-2.35, 59.45), (-3.50, 59.45)] +_GB_SHETLAND = [(-1.85, 59.80), (-0.65, 59.80), (-0.65, 60.90), (-1.85, 60.90)] + +_LR_LIBERIA = [ + (-11.46, 6.77), (-11.30, 6.95), (-11.16, 7.15), (-11.05, 7.40), (-10.85, 7.75), (-10.60, 8.00), + (-10.28, 8.49), (-9.70, 8.54), (-9.35, 7.80), + (-8.85, 7.40), (-8.48, 7.55), (-8.30, 6.90), (-7.95, 6.20), (-7.60, 5.20), (-7.40, 4.55), + (-7.74, 4.33), (-8.46, 4.61), (-9.06, 4.97), (-9.52, 5.36), (-10.08, 5.85), (-10.40, 6.11), + (-10.83, 6.27), +] + +_REGION_RINGS = { + "US": (_US_CONUS, _US_ALASKA, _US_ALEUTIANS_EAST, _US_ALEUTIANS_WEST, _US_HAWAII, _US_PUERTO_RICO, + _US_MARIANAS, _US_SAMOA), + "GB": (_GB_BRITAIN, _GB_NORTHERN_IRELAND, _GB_ISLE_OF_MAN, _GB_CHANNEL_ISLANDS, _GB_ISLE_OF_WIGHT, + _GB_OUTER_HEBRIDES, _GB_INNER_HEBRIDES, _GB_ORKNEY, _GB_SHETLAND), + "LR": (_LR_LIBERIA,), +} + + +def _bounded(rings): + out = [] + for ring in rings: + lons = [p[0] for p in ring] + lats = [p[1] for p in ring] + out.append(((min(lons), min(lats), max(lons), max(lats)), ring)) + return tuple(out) + + +_REGIONS = tuple((region, _bounded(rings)) for region, rings in _REGION_RINGS.items()) + + +def _point_in_ring(lat: float, lon: float, ring) -> bool: + inside = False + count = len(ring) + j = count - 1 + for i in range(count): + lon_i, lat_i = ring[i] + lon_j, lat_j = ring[j] + if (lat_i > lat) != (lat_j > lat): + crossing = (lon_j - lon_i) * (lat - lat_i) / (lat_j - lat_i) + lon_i + if lon < crossing: + inside = not inside + j = i + return inside + + +def valid_position(lat: float, lon: float) -> bool: + return abs(lat) <= 90.0 and abs(lon) <= 180.0 and (abs(lat) > 1e-4 or abs(lon) > 1e-4) + + +def region_for_position(lat: float, lon: float) -> str: + if not valid_position(lat, lon): + return UNKNOWN_REGION + + for region, rings in _REGIONS: + for (min_lon, min_lat, max_lon, max_lat), ring in rings: + if min_lon <= lon <= max_lon and min_lat <= lat <= max_lat and _point_in_ring(lat, lon, ring): + return region + + return METRIC_REGION + + +def region_is_metric(region: str) -> bool: + return bool(region) and region not in MPH_REGIONS diff --git a/iqpilot/iq_maps/orchestrator.py b/iqpilot/iq_maps/orchestrator.py index fbe60d03..b8af353c 100755 --- a/iqpilot/iq_maps/orchestrator.py +++ b/iqpilot/iq_maps/orchestrator.py @@ -121,19 +121,13 @@ def _install_signal_handlers() -> None: signal.signal(signal.SIGTERM, _handle_shutdown_signal) -class _QuietSpinner: - def update(self, *args, **kwargs) -> None: - pass - - def close(self, *args, **kwargs) -> None: - pass - - def ensure_vendor_runtime() -> None: + # verify-only: a hash-mismatched binary is quarantined, never replaced from + # the network — the updater restores the checked-in one try: - VendorMapdInstaller(_QuietSpinner()).check_and_download() + VendorMapdInstaller().verify() except Exception: - cloudlog.exception("iq_maps: vendor runtime install/download failed") + cloudlog.exception("iq_maps: vendor runtime verification failed") params = Params() mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else params diff --git a/iqpilot/iq_maps/update_vendor_version.py b/iqpilot/iq_maps/update_vendor_version.py index 75735590..13391ec9 100755 --- a/iqpilot/iq_maps/update_vendor_version.py +++ b/iqpilot/iq_maps/update_vendor_version.py @@ -2,8 +2,10 @@ """ Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos -Maintainer utility: pin a new pfeiferj/mapd release tag and refresh the checked-in -binary hash. Not used at runtime. +Maintainer utility: pin a new mapd release tag and refresh the checked-in binary +hash. Not used at runtime. Binaries come from the gitlvb teal/mapd CI (built +against teal/gomsgq) — drop the artifact at third_party/mapd_pfeiferj/mapd, then +run this so the hash pin moves in the same commit. """ import argparse import os diff --git a/iqpilot/iq_maps/vendor_mapd_installer.py b/iqpilot/iq_maps/vendor_mapd_installer.py index 96090d4e..3c418005 100755 --- a/iqpilot/iq_maps/vendor_mapd_installer.py +++ b/iqpilot/iq_maps/vendor_mapd_installer.py @@ -2,36 +2,30 @@ """ Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos -Provisions the `mapd` routing binary authored by Jacob Pfeifer (github.com/pfeiferj/mapd). -The binary itself is his work; this module only fetches, verifies and stages it on-device. +Verifies the vendored `mapd` routing binary authored by Jacob Pfeifer +(github.com/pfeiferj/mapd), built from the gitlvb teal/mapd fork against +teal/gomsgq. The only accepted binary is the checked-in one matching the pinned +hash; nothing is ever downloaded at runtime. Jacob's stock release build embeds +a 15-reader msgq header layout — on this fork (NUM_READERS=32) its registration +writes land inside other processes' reader slots, so a wrong binary is +quarantined rather than left where manager could start it. """ import hashlib -import logging import os -import stat -import time -from pathlib import Path +import sys -import requests - -from cereal import messaging from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.spinner import Spinner -from openpilot.system.hardware.hw import Paths -from openpilot.system.version import is_prebuilt -from openpilot.iqpilot.iq_maps import VENDOR_MAPD_BIN_DIR, VENDOR_MAPD_PATH +from openpilot.common.swaglog import cloudlog +from openpilot.iqpilot.iq_maps import VENDOR_MAPD_PATH import openpilot.system.sentry as sentry -VENDOR_RELEASE_TAG = "v2.0.6" -VENDOR_RELEASE_URL = f"https://github.com/pfeiferj/mapd/releases/download/{VENDOR_RELEASE_TAG}/mapd" +VENDOR_RELEASE_TAG = "v2.0.6-iq1" _VERSION_PARAM = "MapdVersion" _HASH_FILE = os.path.join(BASEDIR, "iqpilot", "iq_maps", "tests", "mapd_hash") -_HTTP_TIMEOUT_S = 60 -_FETCH_ATTEMPTS = 5 -_NET_PROBE_ATTEMPTS = 10 -_NET_PROBE_INTERVAL_S = 2 +QUARANTINE_PATH = VENDOR_MAPD_PATH + ".quarantined" def sha256_of_file(path: str) -> str: @@ -48,45 +42,58 @@ def stamp_vendor_version(version: str, params: Params | None = None) -> None: class VendorMapdInstaller: - def __init__(self, spinner_ref: Spinner): + def __init__(self, spinner_ref: Spinner | None = None, params: Params | None = None): self._spinner = spinner_ref - self._params = Params() + self._params = params if params is not None else Params() - # --- externally consumed surface ----------------------------------------- def get_installed_version(self) -> str: return str(self._params.get(_VERSION_PARAM) or "") - @staticmethod - def ensure_directories_exist() -> None: - for directory in (Paths.mapd_root(), VENDOR_MAPD_BIN_DIR): - os.makedirs(directory, exist_ok=True) + def verify(self) -> bool: + """True iff the on-disk binary matches the pinned hash; quarantines a wrong one.""" + expected = self._expected_hash() + if not expected: + cloudlog.error("iq_maps: pinned mapd hash missing, vendor binary cannot be verified") + return False - def check_and_download(self) -> None: - if not self._binary_up_to_date(): - self._provision() - - def non_prebuilt_install(self) -> None: - if self._on_metered_link(): - self._say("Metered connection detected — offline maps engine will not download here.") - time.sleep(5) - return + if not os.path.isfile(VENDOR_MAPD_PATH): + # the binary is a tracked file: the updater/bundle restores it + self._say("Offline maps engine missing; it will be restored by the next update.") + self._params.remove(_VERSION_PARAM) + return False try: - self.ensure_directories_exist() - if self._binary_up_to_date(): - self._say("Offline maps engine already present and current.") - time.sleep(0.1) - return + current = sha256_of_file(VENDOR_MAPD_PATH) + except OSError: + cloudlog.exception("iq_maps: vendor mapd unreadable") + return False - if self._block_until_online(): - self._say(f"Retrieving offline maps engine [{self.get_installed_version() or 'none'}] -> [{VENDOR_RELEASE_TAG}]") - time.sleep(0.1) - self._provision() - self._spinner.close() - except Exception as exc: # noqa: BLE001 - self._announce_failure(exc) + if current == expected: + stamp_vendor_version(VENDOR_RELEASE_TAG, self._params) + try: + os.remove(QUARANTINE_PATH) + except OSError: + pass + self._say(f"Offline maps engine verified [{VENDOR_RELEASE_TAG}]") + return True + + # a foreign binary — e.g. a stock release download from the retired fetch + # path — must never run: quarantine it where manager can't start it + cloudlog.error(f"iq_maps: vendor mapd hash {current[:12]} != pinned {expected[:12]}, quarantining") + self._say("Offline maps engine failed verification; quarantined until the next update.") + try: + os.replace(VENDOR_MAPD_PATH, QUARANTINE_PATH) + except OSError: + cloudlog.exception("iq_maps: vendor mapd quarantine failed") + return False + self._params.remove(_VERSION_PARAM) + try: + raise RuntimeError(f"vendor mapd hash mismatch quarantined: {current}") + except RuntimeError as exc: + sentry.init(sentry.SentryProject.SELFDRIVE) + sentry.capture_exception(exc) + return False - # --- internal ------------------------------------------------------------ def _expected_hash(self) -> str: try: with open(_HASH_FILE) as f: @@ -94,88 +101,13 @@ class VendorMapdInstaller: except OSError: return "" - def _binary_up_to_date(self) -> bool: - if not os.path.exists(VENDOR_MAPD_PATH): - return False - if self.get_installed_version() != VENDOR_RELEASE_TAG: - return False - reference = self._expected_hash() - if not reference: - return True - try: - return sha256_of_file(VENDOR_MAPD_PATH) == reference - except OSError: - return False - - def _provision(self) -> None: - self.ensure_directories_exist() - if self._retrieve_binary(): - stamp_vendor_version(VENDOR_RELEASE_TAG, self._params) - - def _retrieve_binary(self) -> bool: - staging = Path(f"{VENDOR_MAPD_PATH}.part") - last_error: Exception | None = None - for attempt in range(1, _FETCH_ATTEMPTS + 1): - try: - with requests.get(VENDOR_RELEASE_URL, stream=True, timeout=_HTTP_TIMEOUT_S) as resp: - resp.raise_for_status() - with open(staging, "wb") as out: - for chunk in resp.iter_content(chunk_size=1 << 16): - out.write(chunk) - out.flush() - os.fsync(out.fileno()) - os.chmod(staging, os.lstat(staging).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - staging.replace(VENDOR_MAPD_PATH) - return True - except requests.exceptions.RequestException as exc: - last_error = exc - self._say(f"offline maps fetch attempt {attempt}/{_FETCH_ATTEMPTS} did not complete ({exc})") - time.sleep(0.5) - staging.unlink(missing_ok=True) - logging.error("offline maps engine could not be fetched after %d attempts: %s", _FETCH_ATTEMPTS, last_error) - return False - - def _on_metered_link(self) -> bool: - sm = messaging.SubMaster(["deviceState"]) - return bool(sm["deviceState"].networkMetered) - - def _block_until_online(self) -> bool: - for i in range(1, _NET_PROBE_ATTEMPTS + 1): - self._say(f"Waiting for a usable network connection... [{i}/{_NET_PROBE_ATTEMPTS}]") - if self._link_reachable(): - return True - time.sleep(_NET_PROBE_INTERVAL_S) - return False - - @staticmethod - def _link_reachable() -> bool: - try: - requests.head(VENDOR_RELEASE_URL, timeout=10, allow_redirects=True) - return True - except requests.exceptions.RequestException as exc: - logging.debug("network probe failed: %s", exc) - return False - - def _announce_failure(self, exc: Exception) -> None: - for remaining in range(5, 0, -1): - self._say(f"Offline maps engine unavailable; navigation stays online-only. Boot continues in {remaining}s...") - time.sleep(1) - logging.exception("vendor mapd install failed") - sentry.init(sentry.SentryProject.SELFDRIVE) - sentry.capture_exception(exc) - def _say(self, text: str) -> None: - self._spinner.update(text) + if self._spinner is not None: + self._spinner.update(text) if __name__ == "__main__": spinner = Spinner() - installer = VendorMapdInstaller(spinner) - installer.ensure_directories_exist() - if is_prebuilt(): - spinner.update(f"[DEBUG] Prebuilt build; vendor mapd install skipped. " - f"target [{VENDOR_RELEASE_TAG}], param [{installer.get_installed_version()}]") - stamp_vendor_version(VENDOR_RELEASE_TAG) - else: - spinner.update(f"Verifying vendor mapd install. prebuilt [{is_prebuilt()}]") - installer.non_prebuilt_install() + ok = VendorMapdInstaller(spinner).verify() + spinner.close() + sys.exit(0 if ok else 1) diff --git a/iqpilot/konn3kt/canlive/__init__.py b/iqpilot/konn3kt/canlive/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iqpilot/konn3kt/canlive/canlived.py b/iqpilot/konn3kt/canlive/canlived.py new file mode 100755 index 00000000..2cebdeec --- /dev/null +++ b/iqpilot/konn3kt/canlive/canlived.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +canlived — live CAN bridge to konn3kt. + +Streams the device's live CAN bus to the konn3kt server so it can be viewed remotely +in Cabana (via a local ZMQ proxy on the laptop). This is the remote analogue of running +`./cereal/messaging/bridge` locally: instead of re-publishing CAN over a LAN ZMQ socket, +canlived opens its OWN websocket to konn3kt and forwards the raw capnp `Event` frames. + +It is deliberately a separate daemon (not part of hephaestusd's control websocket): + * hephaestusd's send path fragments everything as TEXT frames through one queue, which + cannot carry binary capnp and would head-of-line-block the control plane at 100Hz. + * a dedicated socket means CAN traffic and control traffic never contend. + +Lifecycle: the manager launches canlived only while the `CanLiveStreaming` param is set. +hephaestusd sets/clears that param via startCanLive/stopCanLive, which the konn3kt server +calls when the first viewer connects / the last viewer disconnects. So canlived runs only +during an active debug session — no idle connections, no battery/data cost otherwise. + +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved. +""" +import os +import threading + +from websocket import ABNF, create_connection + +import cereal.messaging as messaging +from openpilot.common.api import Api +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog + +# Cabana's live "Device" stream subscribes only to "can", so that's all we forward to +# match the local experience exactly. (sendcan/TX is not shown by the live device view.) +CAN_SERVICES = ["can"] + +# Reconnect backoff bounds (seconds). +RECONNECT_MIN = 1.0 +RECONNECT_MAX = 10.0 + + +def _api_host() -> str: + # Same host hephaestusd talks to; force the websocket scheme. + host = os.getenv("HEPHAESTUS_HOST") or os.getenv("KONN3KT_API_HOST") or "wss://api-iqlabs.konn3kt.com" + host = host.rstrip("/") + if host.startswith("https://"): + host = "wss://" + host[len("https://"):] + elif host.startswith("http://"): + host = "ws://" + host[len("http://"):] + return host + + +def _stream_once(dongle_id: str, ws_uri: str, token: str, exit_event: threading.Event) -> None: + """Open one websocket and pump CAN until it drops or we're asked to exit.""" + ws = create_connection(ws_uri, cookie="jwt=" + token, enable_multithread=True, timeout=30.0) + cloudlog.info("canlived: connected to %s", ws_uri) + try: + # Blocking receive with a short timeout so we periodically re-check exit_event and + # the socket stays responsive to shutdown even when the bus is quiet. + socks = [messaging.sub_sock(s, conflate=False, timeout=100) for s in CAN_SERVICES] + while not exit_event.is_set(): + got_any = False + for sock in socks: + while True: + raw = sock.receive(non_blocking=True) + if raw is None: + break + got_any = True + # Forward the exact capnp Event bytes as a single binary frame. canlived owns + # this socket, so there is no fragmentation/interleaving to worry about. + ws.send_frame(ABNF.create_frame(raw, ABNF.OPCODE_BINARY, 1)) + if not got_any: + # Nothing pending across any sub — yield briefly instead of busy-spinning. + exit_event.wait(0.005) + finally: + try: + ws.close() + except Exception: + pass + + +def main(exit_event: threading.Event | None = None) -> None: + if exit_event is None: + exit_event = threading.Event() + + params = Params() + dongle_id = params.get("DongleId", encoding="utf-8") + if not dongle_id: + cloudlog.error("canlived: no DongleId, cannot stream") + return + + api = Api(dongle_id) + host = _api_host() + ws_uri = f"{host}/ws/can/{dongle_id}" + + backoff = RECONNECT_MIN + while not exit_event.is_set(): + try: + token = api.get_token(expiry_hours=1) + _stream_once(dongle_id, ws_uri, token, exit_event) + backoff = RECONNECT_MIN # clean disconnect, reset backoff + except Exception as e: + cloudlog.exception("canlived: stream error: %s", e) + exit_event.wait(backoff) + backoff = min(backoff * 2, RECONNECT_MAX) + + +if __name__ == "__main__": + main() diff --git a/iqpilot/tests/test_auto_units.py b/iqpilot/tests/test_auto_units.py new file mode 100644 index 00000000..c9fd94ca --- /dev/null +++ b/iqpilot/tests/test_auto_units.py @@ -0,0 +1,91 @@ +from openpilot.common.params import Params +from openpilot.iqpilot.common.auto_units import CONFIRMATIONS, AutoUnits + +SEATTLE = (47.6062, -122.3321) +BERLIN = (52.5200, 13.4050) +LONDON = (51.5074, -0.1278) + + +class StubAutoUnits(AutoUnits): + def __init__(self, params): + super().__init__(params) + self.position = (0.0, 0.0, False) + + def _position(self): + return self.position + + +def settle(auto_units, position, count=CONFIRMATIONS, start=0.0): + auto_units.position = position + for i in range(count): + auto_units.update(now=start + i * 100.0) + return start + count * 100.0 + + +class TestAutoUnits: + def setup_method(self): + self.params = Params() + self.params.put_bool("IQAutoUnits", True) + self.params.remove("IQAutoUnitsRegion") + self.params.put_bool("IsMetric", False) + self.auto_units = StubAutoUnits(self.params) + + def test_no_fix_does_nothing(self): + settle(self.auto_units, (0.0, 0.0, False)) + assert self.params.get("IQAutoUnitsRegion") is None + assert not self.params.get_bool("IsMetric") + + def test_disabled_does_nothing(self): + self.params.put_bool("IQAutoUnits", False) + settle(self.auto_units, (*BERLIN, True)) + assert self.params.get("IQAutoUnitsRegion") is None + assert not self.params.get_bool("IsMetric") + + def test_metric_region_switches_to_metric(self): + settle(self.auto_units, (*BERLIN, True)) + assert self.params.get("IQAutoUnitsRegion") == "METRIC" + assert self.params.get_bool("IsMetric") + + def test_mph_region_stays_imperial(self): + settle(self.auto_units, (*SEATTLE, True)) + assert self.params.get("IQAutoUnitsRegion") == "US" + assert not self.params.get_bool("IsMetric") + + def test_uk_stays_imperial(self): + self.params.put_bool("IsMetric", True) + settle(self.auto_units, (*LONDON, True)) + assert self.params.get("IQAutoUnitsRegion") == "GB" + assert not self.params.get_bool("IsMetric") + + def test_border_crossing_switches_units(self): + now = settle(self.auto_units, (*SEATTLE, True)) + assert not self.params.get_bool("IsMetric") + + settle(self.auto_units, (*BERLIN, True), start=now) + assert self.params.get("IQAutoUnitsRegion") == "METRIC" + assert self.params.get_bool("IsMetric") + + def test_manual_override_is_kept_within_a_region(self): + now = settle(self.auto_units, (*SEATTLE, True)) + self.params.put_bool("IsMetric", True) + + settle(self.auto_units, (*SEATTLE, True), start=now) + assert self.params.get_bool("IsMetric") + + def test_unconfirmed_region_is_not_applied(self): + settle(self.auto_units, (*BERLIN, True), count=CONFIRMATIONS - 1) + assert self.params.get("IQAutoUnitsRegion") is None + assert not self.params.get_bool("IsMetric") + + def test_flapping_region_resets_confirmations(self): + now = 0.0 + for position in (BERLIN, SEATTLE, BERLIN, SEATTLE): + now = settle(self.auto_units, (*position, True), count=1, start=now) + assert self.params.get("IQAutoUnitsRegion") is None + assert not self.params.get_bool("IsMetric") + + def test_rate_limited(self): + self.auto_units.position = (*BERLIN, True) + for _ in range(CONFIRMATIONS * 4): + self.auto_units.update(now=1.0) + assert self.params.get("IQAutoUnitsRegion") is None diff --git a/iqpilot/tests/test_geo_regions.py b/iqpilot/tests/test_geo_regions.py new file mode 100644 index 00000000..ef9ba30e --- /dev/null +++ b/iqpilot/tests/test_geo_regions.py @@ -0,0 +1,165 @@ +import pytest + +from openpilot.iqpilot.common.geo_regions import METRIC_REGION, UNKNOWN_REGION, region_for_position, region_is_metric + +US_POINTS = [ + (47.6062, -122.3321, "Seattle"), + (42.3314, -83.0458, "Detroit"), + (42.8864, -78.8784, "Buffalo"), + (25.7617, -80.1918, "Miami"), + (29.7604, -95.3698, "Houston"), + (32.7157, -117.1611, "San Diego"), + (34.0522, -118.2437, "Los Angeles"), + (61.2181, -149.9003, "Anchorage"), + (58.3019, -134.4197, "Juneau"), + (64.8378, -147.7164, "Fairbanks"), + (21.3069, -157.8583, "Honolulu"), + (18.4655, -66.1057, "San Juan"), + (13.4757, 144.7489, "Guam"), + (44.9778, -93.2650, "Minneapolis"), + (40.7128, -74.0060, "New York"), + (41.8781, -87.6298, "Chicago"), + (31.7900, -106.4300, "El Paso"), + (26.2034, -98.2300, "McAllen"), + (44.8016, -68.7712, "Bangor"), + (48.7519, -122.4787, "Bellingham"), + (46.8772, -96.7898, "Fargo"), + (48.6023, -93.4093, "International Falls"), + (46.4953, -84.3453, "Sault Ste. Marie MI"), + (47.1211, -88.5694, "Houghton"), + (41.6528, -83.5379, "Toledo"), + (42.1370, -83.1930, "Trenton MI"), + (39.7392, -104.9903, "Denver"), + (33.4484, -112.0740, "Phoenix"), + (30.3322, -81.6557, "Jacksonville"), + (42.3601, -71.0589, "Boston"), + (38.9072, -77.0369, "Washington DC"), +] + +GB_POINTS = [ + (51.5074, -0.1278, "London"), + (54.5973, -5.9301, "Belfast"), + (55.8642, -4.2518, "Glasgow"), + (51.4816, -3.1791, "Cardiff"), + (51.4545, -2.5879, "Bristol"), + (53.4084, -2.9916, "Liverpool"), + (53.4808, -2.2426, "Manchester"), + (55.9533, -3.1883, "Edinburgh"), + (52.4862, -1.8904, "Birmingham"), + (53.8008, -1.5491, "Leeds"), + (57.4778, -4.2247, "Inverness"), + (57.1497, -2.0943, "Aberdeen"), + (56.4620, -2.9707, "Dundee"), + (58.6373, -3.0689, "John o' Groats"), + (54.1509, -4.4814, "Douglas"), + (49.1858, -2.1064, "St Helier"), + (55.0000, -7.3200, "Derry"), + (54.3438, -7.6315, "Enniskillen"), + (54.1751, -6.3402, "Newry"), + (54.4783, -8.0906, "Belleek"), + (54.5973, -7.3095, "Omagh"), + (55.2053, -6.6570, "Portrush"), + (58.2090, -6.3890, "Stornoway"), + (58.9809, -2.9605, "Kirkwall"), + (60.1546, -1.1494, "Lerwick"), + (50.7184, -3.5339, "Exeter"), + (50.3755, -4.1427, "Plymouth"), + (52.6309, 1.2974, "Norwich"), + (54.9783, -1.6178, "Newcastle"), + (51.8642, -2.2382, "Gloucester"), + (52.4140, -4.0810, "Aberystwyth"), + (51.6214, -3.9436, "Swansea"), + (50.6938, -1.3040, "Newport IoW"), +] + +LR_POINTS = [ + (6.3005, -10.7969, "Monrovia"), + (4.3750, -7.7169, "Harper"), + (6.9956, -9.4722, "Gbarnga"), + (6.0667, -8.1333, "Zwedru"), + (8.4219, -9.7478, "Voinjama"), + (5.8808, -10.0467, "Buchanan"), + (5.0100, -9.0400, "Greenville"), + (7.3500, -8.7200, "Ganta"), +] + +METRIC_POINTS = [ + (49.2827, -123.1207, "Vancouver"), + (48.4284, -123.3656, "Victoria"), + (43.6532, -79.3832, "Toronto"), + (42.3149, -83.0364, "Windsor"), + (46.5136, -84.3358, "Sault Ste. Marie ON"), + (42.9745, -82.4066, "Sarnia"), + (43.2557, -79.8711, "Hamilton"), + (42.9849, -81.2453, "London ON"), + (45.5019, -73.5674, "Montreal"), + (45.4765, -75.7013, "Gatineau"), + (46.8139, -71.2080, "Quebec City"), + (46.0878, -64.7782, "Moncton"), + (44.6488, -63.5752, "Halifax"), + (49.8951, -97.1384, "Winnipeg"), + (51.0447, -114.0719, "Calgary"), + (53.5461, -113.4938, "Edmonton"), + (52.1332, -106.6700, "Saskatoon"), + (50.6745, -120.3273, "Kamloops"), + (32.5149, -117.0382, "Tijuana"), + (31.7000, -106.4700, "Ciudad Juarez"), + (25.6866, -100.3161, "Monterrey"), + (27.5060, -99.5075, "Nuevo Laredo"), + (19.4326, -99.1332, "Mexico City"), + (53.3498, -6.2603, "Dublin"), + (51.8985, -8.4756, "Cork"), + (53.2707, -9.0568, "Galway"), + (54.9503, -7.7345, "Letterkenny"), + (54.0000, -6.4000, "Dundalk"), + (54.2489, -6.9683, "Monaghan"), + (54.2766, -8.4761, "Sligo"), + (54.6538, -8.1096, "Donegal"), + (52.5200, 13.4050, "Berlin"), + (52.2297, 21.0122, "Warsaw"), + (48.8566, 2.3522, "Paris"), + (60.1699, 24.9384, "Helsinki"), + (50.4501, 30.5234, "Kyiv"), + (-33.8688, 151.2093, "Sydney"), + (35.6762, 139.6503, "Tokyo"), + (8.4844, -13.2299, "Freetown"), + (7.8767, -11.1875, "Kenema"), + (8.2783, -10.5733, "Kailahun"), + (9.6412, -13.5784, "Conakry"), + (7.7562, -8.8179, "Nzerekore"), + (7.4125, -7.5539, "Man"), + (5.3600, -4.0083, "Abidjan"), +] + + +@pytest.mark.parametrize("lat, lon, name", US_POINTS) +def test_us_positions(lat, lon, name): + assert region_for_position(lat, lon) == "US", name + + +@pytest.mark.parametrize("lat, lon, name", GB_POINTS) +def test_gb_positions(lat, lon, name): + assert region_for_position(lat, lon) == "GB", name + + +@pytest.mark.parametrize("lat, lon, name", LR_POINTS) +def test_lr_positions(lat, lon, name): + assert region_for_position(lat, lon) == "LR", name + + +@pytest.mark.parametrize("lat, lon, name", METRIC_POINTS) +def test_metric_positions(lat, lon, name): + assert region_for_position(lat, lon) == METRIC_REGION, name + + +@pytest.mark.parametrize("lat, lon", [(0.0, 0.0), (0.0, 0.00001), (91.0, 10.0), (10.0, 181.0)]) +def test_invalid_positions(lat, lon): + assert region_for_position(lat, lon) == UNKNOWN_REGION + + +def test_region_is_metric(): + assert not region_is_metric("US") + assert not region_is_metric("GB") + assert not region_is_metric("LR") + assert region_is_metric(METRIC_REGION) + assert not region_is_metric(UNKNOWN_REGION) diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index 5a103510..f9157915 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -128,6 +128,16 @@ function launch { fi fi + # Best-effort: install the verified runtime before anything starts the services that + # need it. This only succeeds on a tree that has built _verified_import.so, so on a + # prebuilt release install it is a no-op -- there the runtime arrives with the IQ.OS + # flash, whose image bakes it in. What actually fixes the 20+ minute first-install hang + # is --no-block on the service starts below: hephaestusd/ble-transportd/flockd each + # ExecStartPre-wait up to 600s for /usr/libexec/iqpilot/iqpilot_bundle_runner. + if [ -x "$DIR/system/proprietary_runtime/install_verified_runtime.sh" ]; then + "$DIR/system/proprietary_runtime/install_verified_runtime.sh" || true + fi + # Install/update proprietary runtime bundles. if [ -f "$DIR/artifacts/runtime/ensure_private_installed.sh" ]; then bash "$DIR/artifacts/runtime/ensure_private_installed.sh" || true @@ -161,10 +171,12 @@ function launch { sudo mount -o remount,ro / fi sudo systemctl enable "${service_name}.service" + # --no-block: these units ExecStartPre-wait for the verified runtime. Blocking here + # made a missing runner stall the whole install for the unit's 600s timeout (x3 units). if systemctl is-active --quiet "${service_name}.service"; then - sudo systemctl restart "${service_name}.service" + sudo systemctl restart --no-block "${service_name}.service" else - sudo systemctl start "${service_name}.service" + sudo systemctl start --no-block "${service_name}.service" fi elif [ -f "$service_src" ]; then if [ ! -f "$service_dst" ] || ! cmp -s "$service_src" "$service_dst"; then @@ -173,9 +185,9 @@ function launch { sudo systemctl daemon-reload sudo systemctl enable "${service_name}.service" sudo mount -o remount,ro / - sudo systemctl restart "${service_name}.service" + sudo systemctl restart --no-block "${service_name}.service" elif ! systemctl is-active --quiet "${service_name}.service"; then - sudo systemctl start "${service_name}.service" + sudo systemctl start --no-block "${service_name}.service" fi fi done diff --git a/launch_env.sh b/launch_env.sh index 72f201ad..6bd77962 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -23,7 +23,7 @@ if [ -z "$AGNOS_VERSION" ]; then case "$DEVICE_MODEL" in *) - export AGNOS_VERSION="IQ.OS 4.9.1" + export AGNOS_VERSION="IQ.OS 4.9.3" ;; esac fi diff --git a/panda/board/drivers/can_common.h b/panda/board/drivers/can_common.h index af1f5100..ced472c7 100644 --- a/panda/board/drivers/can_common.h +++ b/panda/board/drivers/can_common.h @@ -238,13 +238,36 @@ void ignition_can_hook(CANPacket_t *msg) { } // Volkswagen MEB exception + // GE_Fahrstufe: 5=P, 6=R, 7=N, 8/9=D, 10=E, 13/14=T; 0/1/15 transitional/init/error. + // Both gear messages are latched independently and OR'd: cars broadcast one + // authoritative source (Gateway_73 on ALT_GEAR platforms), and a stale parked + // reading on the unused one must not veto the real one. + static bool vw_meb_getriebe_out_of_p = false; + static bool vw_meb_gateway_out_of_p = false; + + // Getriebe_11->GE_Fahrstufe + if ((msg->addr == 0xADU) && (len == 8)) { + int fahrstufe = (msg->data[5] >> 2) & 0xFU; + vw_meb_getriebe_out_of_p = (fahrstufe >= 6) && (fahrstufe <= 14); + } + + // Gateway_73->GE_Fahrstufe + if ((msg->addr == 0x3DCU) && (len == 8)) { + int fahrstufe = msg->data[5] & 0xFU; + vw_meb_gateway_out_of_p = (fahrstufe >= 6) && (fahrstufe <= 14); + } + if ((msg->addr == 0x3C0U) && (len == 4)) { int counter = msg->data[1] & 0xFU; static int prev_counter_vw_meb = -1; if ((counter == ((prev_counter_vw_meb + 1) % 16)) && (prev_counter_vw_meb != -1)) { - // Klemmen_Status_01->ZAS_Kl_15 - ignition_can = ((msg->data[2] >> 1) & 1U) != 0U; + // Klemmen_Status_01->ZAS_Kl_15, gated on gear out of P: the gateway broadcasts + // ZAS_Kl_15=1 with a live counter during network wake with the car off (unlock, + // app poll), flapping onroad while parked. No gear message is broadcast while + // parked-and-asleep, so gear-unseen means parked. + bool vw_meb_out_of_park = vw_meb_getriebe_out_of_p || vw_meb_gateway_out_of_p; + ignition_can = (((msg->data[2] >> 1) & 1U) != 0U) && vw_meb_out_of_park; ignition_can_cnt = 0U; } prev_counter_vw_meb = counter; diff --git a/panda/board/drivers/harness.h b/panda/board/drivers/harness.h index 3ebf109f..883cb321 100644 --- a/panda/board/drivers/harness.h +++ b/panda/board/drivers/harness.h @@ -14,8 +14,10 @@ void set_intercept_relay(bool intercept, bool ignition_relay) { harness.relay_driven = true; } - // wait until we're not reading the analog voltages anymore - while (harness.sbu_adc_lock) {} + // The relay pins are separate from the SBU sense pins, so no need to wait for + // orientation sampling: it runs in thread context, sees relay_driven set, and + // discards its result. Spinning on sbu_adc_lock here would deadlock when called + // from interrupt context while thread-context sampling holds the lock. if (harness.status == HARNESS_STATUS_NORMAL) { set_gpio_output(current_board->harness_config->GPIO_relay_SBU1, current_board->harness_config->pin_relay_SBU1, !ignition_relay); @@ -31,20 +33,25 @@ void set_intercept_relay(bool intercept, bool ignition_relay) { } bool harness_check_ignition(void) { - bool ret = false; + // The SBU pins are in analog mode while orientation sampling is in flight + // (thread context), so a digital read would return 0. Return the last good + // value instead of spinning: this is called from interrupt context, where + // waiting for preempted thread-context sampling to finish would deadlock. + bool ret = harness.ignition_line; - // wait until we're not reading the analog voltages anymore - while (harness.sbu_adc_lock) {} - - switch(harness.status){ - case HARNESS_STATUS_NORMAL: - ret = !get_gpio_input(current_board->harness_config->GPIO_SBU1, current_board->harness_config->pin_SBU1); - break; - case HARNESS_STATUS_FLIPPED: - ret = !get_gpio_input(current_board->harness_config->GPIO_SBU2, current_board->harness_config->pin_SBU2); - break; - default: - break; + if (!harness.sbu_adc_lock) { + switch(harness.status){ + case HARNESS_STATUS_NORMAL: + ret = !get_gpio_input(current_board->harness_config->GPIO_SBU1, current_board->harness_config->pin_SBU1); + break; + case HARNESS_STATUS_FLIPPED: + ret = !get_gpio_input(current_board->harness_config->GPIO_SBU2, current_board->harness_config->pin_SBU2); + break; + default: + ret = false; + break; + } + harness.ignition_line = ret; } return ret; } @@ -81,6 +88,12 @@ static uint8_t harness_detect_orientation(void) { set_gpio_mode(current_board->harness_config->GPIO_SBU1, current_board->harness_config->pin_SBU1, MODE_INPUT); set_gpio_mode(current_board->harness_config->GPIO_SBU2, current_board->harness_config->pin_SBU2, MODE_INPUT); harness.sbu_adc_lock = false; + + // This runs in thread context and can be preempted by an interrupt driving + // the relay mid-sample, which changes the SBU line voltages; discard + if (harness.relay_driven) { + ret = harness.status; + } } #endif diff --git a/panda/board/drivers/harness_declarations.h b/panda/board/drivers/harness_declarations.h index 6f4327aa..6f7af28a 100644 --- a/panda/board/drivers/harness_declarations.h +++ b/panda/board/drivers/harness_declarations.h @@ -4,12 +4,15 @@ #define HARNESS_STATUS_NORMAL 1U #define HARNESS_STATUS_FLIPPED 2U +// orientation sampling runs in thread context and shares this state with +// interrupt handlers, so the fields are volatile struct harness_t { - uint8_t status; - uint16_t sbu1_voltage_mV; - uint16_t sbu2_voltage_mV; - bool relay_driven; - bool sbu_adc_lock; + volatile uint8_t status; + volatile uint16_t sbu1_voltage_mV; + volatile uint16_t sbu2_voltage_mV; + volatile bool ignition_line; + volatile bool relay_driven; + volatile bool sbu_adc_lock; }; extern struct harness_t harness; diff --git a/panda/board/drivers/interrupts.h b/panda/board/drivers/interrupts.h index 1188a8ec..58f89c12 100644 --- a/panda/board/drivers/interrupts.h +++ b/panda/board/drivers/interrupts.h @@ -74,6 +74,10 @@ void init_interrupts(bool check_rate_limit){ for(uint16_t i=0U; i NACK retry storms. +#define IRQ_PRIORITY_COMMS 0U +#define IRQ_PRIORITY_DEFAULT 2U + typedef struct interrupt { IRQn_Type irq_type; void (*handler)(void); diff --git a/panda/board/main.c b/panda/board/main.c index 0cfd7081..303552b2 100644 --- a/panda/board/main.c +++ b/panda/board/main.c @@ -117,6 +117,22 @@ static void __attribute__ ((noinline)) enable_fpu(void) { #define HEARTBEAT_IGNITION_CNT_OFF 2U // called at 8Hz +static volatile bool tick_sample_pending = false; + +// All runtime ADC sampling runs here, in thread context. ADC conversions +// busy-wait for tens to hundreds of microseconds, and all interrupts share one +// NVIC priority: sampling in the 8Hz tick interrupt delayed the SPI slave's +// RX DMA re-arm long enough for the host to clock into an unarmed peripheral +// (checksum failures -> NACK retry storms -> multi-second CAN blackouts). +static void tick_sample_poll(void) { + if (tick_sample_pending) { + tick_sample_pending = false; + harness_tick(); + voltage_mV = current_board->read_voltage_mV(); + current_mA = current_board->read_current_mA(); + } +} + static void tick_handler(void) { static uint32_t siren_countdown = 0; // siren plays while countdown > 0 static uint32_t controls_allowed_countdown = 0; @@ -131,7 +147,7 @@ static void tick_handler(void) { // tick drivers at 8Hz fan_tick(); - harness_tick(); + tick_sample_pending = true; // ADC sampling deferred to thread context (see tick_sample_poll) simple_watchdog_kick(); sound_tick(); @@ -176,7 +192,11 @@ static void tick_handler(void) { const bool recent_heartbeat = heartbeat_counter == 0U; // tick drivers at 1Hz - bool started = harness_check_ignition() || ignition_can; + // MEB/MQBevo have no harness ignition line; the SBU pin can sit asserted on a + // sleeping car (held true for days on an ID.4, pinning the device onroad against + // a silent bus). CAN ignition (0x3C0 Klemmen_Status_01) is the only valid source + // on this branch. + bool started = ignition_can; bootkick_tick(started, recent_heartbeat); // increase heartbeat counter and cap it at the uint32 limit @@ -304,6 +324,10 @@ int main(void) { current_board->set_can_mode(CAN_MODE_NORMAL); harness_init(); + // seed the ADC caches before interrupts are live + voltage_mV = current_board->read_voltage_mV(); + current_mA = current_board->read_current_mA(); + // panda has an FPU, let's use it! enable_fpu(); @@ -347,6 +371,7 @@ int main(void) { // LED should keep on blinking all the time while (true) { + tick_sample_poll(); if (power_save_status == POWER_SAVE_STATUS_DISABLED) { #ifdef DEBUG_FAULTS if (fault_status == FAULT_STATUS_NONE) { @@ -357,6 +382,7 @@ int main(void) { delay(fade >> 4); led_set(LED_RED, false); delay((MAX_LED_FADE - fade) >> 4); + tick_sample_poll(); } for (uint32_t fade = MAX_LED_FADE; fade > 0U; fade -= 1U) { @@ -364,6 +390,7 @@ int main(void) { delay(fade >> 4); led_set(LED_RED, false); delay((MAX_LED_FADE - fade) >> 4); + tick_sample_poll(); } #ifdef DEBUG_FAULTS diff --git a/panda/board/main_comms.h b/panda/board/main_comms.h index 6450c0f7..fd871346 100644 --- a/panda/board/main_comms.h +++ b/panda/board/main_comms.h @@ -9,10 +9,14 @@ static int get_health_pkt(void *dat) { struct health_t * health = (struct health_t*)dat; health->uptime_pkt = uptime_cnt; - health->voltage_pkt = current_board->read_voltage_mV(); - health->current_pkt = current_board->read_current_mA(); + // cached values sampled in thread context (see tick_sample_poll in main.c); + // reading the ADC here would busy-wait in interrupt context + health->voltage_pkt = voltage_mV; + health->current_pkt = current_mA; - health->ignition_line_pkt = (uint8_t)(harness_check_ignition()); + // no ignition line on MEB/MQBevo (see started in main.c); pandad ORs line with + // CAN ignition, so reporting the floating SBU read would pin the device onroad + health->ignition_line_pkt = 0U; health->ignition_can_pkt = ignition_can; health->controls_allowed_pkt = controls_allowed; diff --git a/panda/board/main_declarations.h b/panda/board/main_declarations.h index 52aaa6c1..e6737bbc 100644 --- a/panda/board/main_declarations.h +++ b/panda/board/main_declarations.h @@ -13,6 +13,10 @@ extern uint8_t hw_type; extern board *current_board; extern uint32_t uptime_cnt; +// ADC results, sampled in thread context (see tick_sample_poll in main.c) +extern uint32_t voltage_mV; +extern uint32_t current_mA; + // heartbeat state extern uint32_t heartbeat_counter; extern bool heartbeat_lost; diff --git a/panda/board/main_definitions.h b/panda/board/main_definitions.h index 83c91090..27d5eb38 100644 --- a/panda/board/main_definitions.h +++ b/panda/board/main_definitions.h @@ -5,6 +5,10 @@ uint8_t hw_type = 0; board *current_board; uint32_t uptime_cnt = 0; +// ADC results, sampled in thread context (see tick_sample_poll in main.c) +uint32_t voltage_mV = 0; +uint32_t current_mA = 0; + // heartbeat state uint32_t heartbeat_counter = 0; bool heartbeat_lost = false; diff --git a/panda/board/stm32h7/llspi.h b/panda/board/stm32h7/llspi.h index 05f8e22f..5b2d31f9 100644 --- a/panda/board/stm32h7/llspi.h +++ b/panda/board/stm32h7/llspi.h @@ -101,6 +101,12 @@ void llspi_init(void) { register_set(&(SPI4->CR1), SPI_CR1_SPE, 0xFFFFU); register_set(&(SPI4->CR2), 0, 0xFFFFU); + // preempt other handlers so the RX DMA is re-armed before the master clocks + // the next phase of a transfer + NVIC_SetPriority(DMA2_Stream2_IRQn, IRQ_PRIORITY_COMMS); + NVIC_SetPriority(DMA2_Stream3_IRQn, IRQ_PRIORITY_COMMS); + NVIC_SetPriority(SPI4_IRQn, IRQ_PRIORITY_COMMS); + NVIC_EnableIRQ(DMA2_Stream2_IRQn); NVIC_EnableIRQ(DMA2_Stream3_IRQn); NVIC_EnableIRQ(SPI4_IRQn); diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index 1bd7b312..ccc01463 100755 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -168,8 +168,11 @@ class Controls(IQControlsLayer): CC.latActive = _lat_active and not CS.steerFaultTemporary and not CS.steerFaultPermanent and \ (not standstill or self.CP.steerAtStandstill) + # long control may stay active through a gas override on platforms that opt in + override_longitudinal = any(e.overrideLongitudinal for e in self.sm['onroadEvents']) + long_through_override = self.CP_IQ.longActiveWithGasOverride and self.CP.openpilotLongitudinalControl CC.longActive = CC.enabled and not getattr(CS, 'cruiseFaultLateralMode', False) and \ - not any(e.overrideLongitudinal for e in self.sm['onroadEvents']) and \ + (not override_longitudinal or long_through_override) and \ (self.CP.openpilotLongitudinalControl or not self.CP_IQ.pcmCruiseSpeed) actuators = CC.actuators @@ -184,7 +187,7 @@ class Controls(IQControlsLayer): # accel PID loop pid_accel_limits = self.CI.get_pid_accel_limits(self.CP, self.CP_IQ, CS.vEgo, CS.vCruise * CV.KPH_TO_MS) actuators.accel = float(self.LoC.update(CC.longActive, CS, long_plan.aTarget, long_plan.shouldStop, pid_accel_limits, - long_plan.leadDistance, long_plan.hasLead)) + long_plan.leadDistance, long_plan.hasLead, gas_override=override_longitudinal)) # Steering PID loop and lateral MPC # Reset desired curvature to current to avoid violating the limits on engage diff --git a/selfdrive/controls/lib/longcontrol.py b/selfdrive/controls/lib/longcontrol.py index 4be1953d..31fc8784 100644 --- a/selfdrive/controls/lib/longcontrol.py +++ b/selfdrive/controls/lib/longcontrol.py @@ -53,7 +53,7 @@ class LongControl: def reset(self): self.pid.reset() - def update(self, active, CS, a_target, should_stop, accel_limits, lead_distance=0.0, has_lead=False): + def update(self, active, CS, a_target, should_stop, accel_limits, lead_distance=0.0, has_lead=False, gas_override=False): """Update longitudinal control. This updates the state machine and runs a PID loop""" self.pid.neg_limit = accel_limits[0] self.pid.pos_limit = accel_limits[1] @@ -87,8 +87,13 @@ class LongControl: else: error = a_target - CS.aEgo output_accel = self.pid.update(error, speed=CS.vEgo, - feedforward=a_target) + feedforward=a_target, + freeze_integrator=gas_override) self.smooth.reset() + if gas_override: + # safety blocks braking while the gas is pressed, and a blocked tx drops the whole frame + output_accel = max(output_accel, 0.0) + self.last_output_accel = np.clip(output_accel, accel_limits[0], accel_limits[1]) return self.last_output_accel diff --git a/selfdrive/modeld/models/prebuilt_check.json b/selfdrive/modeld/models/prebuilt_check.json index 6d0839d2..29388de8 100644 --- a/selfdrive/modeld/models/prebuilt_check.json +++ b/selfdrive/modeld/models/prebuilt_check.json @@ -4,6 +4,6 @@ "dmonitoring_model_metadata.pkl": "31a86ab7a92dc0af088b15787a440dd3b210aa662e445a15145900e559a1b5c3", "dmonitoring_model_tinygrad.pkl": "806c0ea75df6bf6dfeb81b832314c68e31df5865a52d0359e6eeb76d93ad2b52" }, - "signature": "e1eeb5ce45774a816c8da2394e6ee35ebf700b71dff345e0141dbab8ff592349" + "signature": "64740782bfd5b1720978aab24a2b1b37c99542f97db3c8637227c7412bb30bbf" } } diff --git a/selfdrive/modeld/prebuilt_models.py b/selfdrive/modeld/prebuilt_models.py index 73a16dee..00ab8884 100644 --- a/selfdrive/modeld/prebuilt_models.py +++ b/selfdrive/modeld/prebuilt_models.py @@ -29,10 +29,12 @@ def _file_sha256(path: Path) -> str: @functools.lru_cache(maxsize=1) def _tinygrad_digest() -> str: h = hashlib.sha256() - # mirror the SConscript glob: no hidden files/dirs (also excludes .git, which differs per clone), no pycache + # mirror the SConscript glob: no hidden files/dirs (also excludes .git, which differs per clone), no pycache. + # 'weights' holds untracked/gitignored model weights (e.g. iqvd yolov8n .safetensors) present on dev machines + # but absent from a clean CI/device clone — hashing them makes the signature unreproducible (false STALE). def _included(p: Path) -> bool: rel = p.relative_to(TINYGRAD_DIR).parts - return p.is_file() and '__pycache__' not in rel and not any(part.startswith('.') for part in rel) + return p.is_file() and '__pycache__' not in rel and 'weights' not in rel and not any(part.startswith('.') for part in rel) files = sorted(p for p in TINYGRAD_DIR.rglob('*') if _included(p)) for p in files: h.update(str(p.relative_to(BASEDIR)).encode()) diff --git a/selfdrive/selfdrived/helpers.py b/selfdrive/selfdrived/helpers.py index 684b6836..3e079fd1 100644 --- a/selfdrive/selfdrived/helpers.py +++ b/selfdrive/selfdrived/helpers.py @@ -26,7 +26,8 @@ class ExcessiveActuationCheck: # CS.aEgo can be noisy to bumps in the road, transitioning from standstill, losing traction, etc. # longitudinal accel_calibrated = calibrated_pose.acceleration.x - excessive_long_actuation = sm['carControl'].longActive and (accel_calibrated > ACCEL_MAX * 2 or accel_calibrated < ACCEL_MIN * 2) + excessive_long_actuation = sm['carControl'].longActive and ((not CS.gasPressed and accel_calibrated > ACCEL_MAX * 2) or + accel_calibrated < ACCEL_MIN * 2) # lateral yaw_rate = calibrated_pose.angular_velocity.yaw diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 69145bb9..87629992 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -41,6 +41,10 @@ DESCRIPTIONS = { "DashcamEnabled": tr_noop("Record and upload driving data and video. Disabling this stops all recording! No logs, no video, no audio."), 'RecordFront': tr_noop("Upload data from the driver facing camera and help improve the driver monitoring algorithm."), "IsMetric": tr_noop("Display speed in km/h instead of mph."), + "IQAutoUnits": tr_noop( + "Set the units from the device location. Speeds switch to km/h everywhere except the United States, " + + "the United Kingdom and Liberia, and are re-checked when you cross a border." + ), "RecordAudio": tr_noop("Record and store microphone audio while driving. The audio will be included in the dashcam video in Konn3kt."), "LongitudinalControlMode": tr_noop( "Choose longitudinal behavior: IQ.Pilot (IQ longitudinal + end-to-end), " @@ -95,6 +99,12 @@ class TogglesLayout(Widget): "metric.png", False, ), + "IQAutoUnits": ( + lambda: tr("Set Units From Location"), + DESCRIPTIONS["IQAutoUnits"], + "metric.png", + False, + ), } self._long_personality_setting = multiple_button_item( @@ -296,6 +306,8 @@ class TogglesLayout(Widget): def _toggle_callback(self, state: bool, param: str): self._params.put_bool(param, state) + if param == "IQAutoUnits" and state: + self._params.remove("IQAutoUnitsRegion") if self._toggle_defs[param][3]: self._params.put_bool("OnroadCycleRequested", True) diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index cee9df03..ea29bdc6 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -18,13 +18,15 @@ class TogglesLayoutMici(NavScroller): disengage = BigParamControl("disengage on accelerator", "DisengageOnAccelerator") ldw = BigParamControl("lane departure warnings", "IsLdwEnabled") is_metric = BigParamControl("use metric units", "IsMetric") + auto_units = BigParamControl("set units from location", "IQAutoUnits", toggle_callback=self._auto_units_callback) - self._scroller.add_widgets([disengage, ldw, is_metric]) + self._scroller.add_widgets([disengage, ldw, is_metric, auto_units]) self._refresh_toggles = ( ("DisengageOnAccelerator", disengage), ("IsLdwEnabled", ldw), ("IsMetric", is_metric), + ("IQAutoUnits", auto_units), ) if ui_state.params.get_bool("ShowDebugInfo"): @@ -35,6 +37,10 @@ class TogglesLayoutMici(NavScroller): super().show_event() self._update_toggles() + def _auto_units_callback(self, state: bool): + if state: + ui_state.params.remove("IQAutoUnitsRegion") + def _update_toggles(self): ui_state.update_params() for key, item in self._refresh_toggles: diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py index 89a4926c..87e1f167 100644 --- a/selfdrive/ui/mici/onroad/cameraview.py +++ b/selfdrive/ui/mici/onroad/cameraview.py @@ -4,7 +4,7 @@ import pyray as rl from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf from openpilot.common.swaglog import cloudlog -from openpilot.system.hardware import TICI +from openpilot.system.hardware import EGL_DMA_BUF_SUPPORTED from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget @@ -38,7 +38,7 @@ void main() { """ # Choose fragment shader based on platform capabilities -if TICI: +if EGL_DMA_BUF_SUPPORTED: FRAME_FRAGMENT_SHADER = """ #version 300 es #extension GL_OES_EGL_image_external_essl3 : enable @@ -121,7 +121,7 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not EGL_DMA_BUF_SUPPORTED else -1 self._engaged_loc = rl.get_shader_location(self.shader, "engaged") self._engaged_val = rl.ffi.new("int[1]", [1]) self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver") @@ -137,8 +137,8 @@ class CameraView(Widget): self._placeholder_color: rl.Color | None = None - # Initialize EGL for zero-copy rendering on TICI - if TICI: + # Initialize EGL for zero-copy rendering on comma 3/3X. + if EGL_DMA_BUF_SUPPORTED: if not init_egl(): raise RuntimeError("Failed to initialize EGL") @@ -189,7 +189,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if TICI and self.egl_texture: + if EGL_DMA_BUF_SUPPORTED and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -264,7 +264,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if TICI: + if EGL_DMA_BUF_SUPPORTED: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -387,12 +387,12 @@ class CameraView(Widget): self._initialize_textures() def _initialize_textures(self): - self._clear_textures() - if not TICI: - self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), - int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) - self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), - int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) + self._clear_textures() + if not EGL_DMA_BUF_SUPPORTED: + self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), + int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) + self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), + int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) def _clear_textures(self): if self.texture_y and self.texture_y.id: @@ -404,7 +404,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if TICI: + if EGL_DMA_BUF_SUPPORTED: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 7f058bce..329fd30d 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -9,6 +9,7 @@ from openpilot.selfdrive.ui.onroad.alert_renderer import AlertRenderer from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer as BaseDriverStateRenderer, BTN_SIZE from openpilot.selfdrive.ui.onroad.hud_renderer import HudRenderer as BaseHudRenderer from openpilot.selfdrive.ui.onroad.model_renderer import ModelRenderer +from openpilot.selfdrive.ui.onroad.environment_renderer import EnvironmentRenderer from openpilot.selfdrive.ui.onroad.cameraview import CameraView from openpilot.system.ui.lib.application import gui_app from openpilot.common.issue_debug import log_issue_limited @@ -55,6 +56,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewIQ): self._split_nav_available = False self.model_renderer = ModelRenderer() + self.environment_renderer = EnvironmentRenderer() self.alert_renderer = AlertRenderer() self._hud_renderer = IQHudRenderer() self.driver_state_renderer = DriverStateRendererIQ() @@ -114,6 +116,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewIQ): # Draw all UI overlays self.model_renderer.render(camera_rect) + self.environment_renderer.render(camera_rect) AugmentedRoadViewIQ.update_fade_out_bottom_overlay(self, camera_rect) self._hud_renderer.render(camera_rect) @@ -280,6 +283,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewIQ): ]) self.model_renderer.set_transform(video_transform @ calib_transform) self.model_renderer.set_frame_transform(video_transform, is_wide_camera) + self.environment_renderer.set_transform(video_transform @ calib_transform) return self._cached_matrix diff --git a/selfdrive/ui/onroad/cameraview.py b/selfdrive/ui/onroad/cameraview.py index 54439484..f552629d 100644 --- a/selfdrive/ui/onroad/cameraview.py +++ b/selfdrive/ui/onroad/cameraview.py @@ -4,7 +4,7 @@ import pyray as rl from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf from openpilot.common.swaglog import cloudlog -from openpilot.system.hardware import TICI +from openpilot.system.hardware import EGL_DMA_BUF_SUPPORTED from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget @@ -38,7 +38,7 @@ void main() { """ # Choose fragment shader based on platform capabilities -if TICI: +if EGL_DMA_BUF_SUPPORTED: FRAME_FRAGMENT_SHADER = """ #version 300 es #extension GL_OES_EGL_image_external_essl3 : enable @@ -82,7 +82,7 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not EGL_DMA_BUF_SUPPORTED else -1 self.frame: VisionBuf | None = None self.texture_y: rl.Texture | None = None @@ -94,8 +94,8 @@ class CameraView(Widget): self._placeholder_color: rl.Color | None = None - # Initialize EGL for zero-copy rendering on TICI - if TICI: + # Initialize EGL for zero-copy rendering on comma 3/3X. + if EGL_DMA_BUF_SUPPORTED: if not init_egl(): raise RuntimeError("Failed to initialize EGL") @@ -146,7 +146,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if TICI and self.egl_texture: + if EGL_DMA_BUF_SUPPORTED and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -220,7 +220,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if TICI: + if EGL_DMA_BUF_SUPPORTED: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -337,7 +337,7 @@ class CameraView(Widget): def _initialize_textures(self): self._clear_textures() - if not TICI: + if not EGL_DMA_BUF_SUPPORTED: self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), @@ -353,7 +353,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if TICI: + if EGL_DMA_BUF_SUPPORTED: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} diff --git a/selfdrive/ui/onroad/environment_renderer.py b/selfdrive/ui/onroad/environment_renderer.py new file mode 100644 index 00000000..f822b5dd --- /dev/null +++ b/selfdrive/ui/onroad/environment_renderer.py @@ -0,0 +1,141 @@ +import numpy as np +import pyray as rl +from cereal import custom + +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import Widget + +MODE_OFF = 0 +MODE_OVERLAY = 1 +MODE_REPLACE = 2 + +_ENV_LABEL = custom.IQEnvironment.Object.Label +_OBJECT_COLORS = { + _ENV_LABEL.car: (40, 210, 200), + _ENV_LABEL.truck: (40, 210, 200), + _ENV_LABEL.bus: (40, 210, 200), + _ENV_LABEL.motorcycle: (90, 220, 255), + _ENV_LABEL.bicycle: (90, 220, 255), + _ENV_LABEL.person: (255, 210, 90), + _ENV_LABEL.stopSign: (255, 60, 45), + _ENV_LABEL.trafficLight: (255, 190, 0), +} + +_BOX_EDGES = ( + (0, 1), (1, 3), (3, 2), (2, 0), + (4, 5), (5, 7), (7, 6), (6, 4), + (0, 4), (1, 5), (2, 6), (3, 7), +) + +GRID_HALF_WIDTH = 12.0 +GRID_MAX_DISTANCE = 90.0 +GRID_STEP = 6.0 + + +class EnvironmentRenderer(Widget): + def __init__(self): + Widget.__init__(self) + self._car_space_transform = np.zeros((3, 3), dtype=np.float32) + self._mode = MODE_OFF + self._counter = 0 + + def set_transform(self, transform: np.ndarray): + self._car_space_transform = transform.astype(np.float32) + + def _project(self, pt: np.ndarray): + p = self._car_space_transform @ pt + if abs(p[2]) < 1e-6: + return None + return p[0] / p[2], p[1] / p[2] + + def _in_rect(self, x: float, y: float) -> bool: + r = self._rect + return r.x - 400 <= x <= r.x + r.width + 400 and r.y - 400 <= y <= r.y + r.height + 400 + + def _render(self, rect: rl.Rectangle): + sm = ui_state.sm + if self._counter % 30 == 0: + self._mode = int(ui_state.params.get("EnvironmentView", return_default=True) or 0) if ui_state.active_bundle else 0 + self._counter += 1 + + if self._mode == MODE_OFF: + return + if sm.recv_frame["liveCalibration"] < ui_state.started_frame: + return + + if self._mode == MODE_REPLACE: + self._draw_backdrop(rect) + self._draw_ground_grid() + if sm.valid["modelV2"]: + self._draw_model_scene(sm["modelV2"]) + + if sm.alive["iqEnvironment"] and sm.valid["iqEnvironment"]: + self._draw_objects(sm["iqEnvironment"]) + + def _draw_backdrop(self, rect: rl.Rectangle): + rl.draw_rectangle_gradient_v(int(rect.x), int(rect.y), int(rect.width), int(rect.height), + rl.Color(14, 17, 22, 255), rl.Color(6, 8, 11, 255)) + + def _draw_ground_grid(self): + col = rl.Color(60, 70, 82, 90) + dist = GRID_STEP + while dist <= GRID_MAX_DISTANCE: + a = self._project(np.array([dist, -GRID_HALF_WIDTH, 0.0])) + b = self._project(np.array([dist, GRID_HALF_WIDTH, 0.0])) + if a and b and self._in_rect(*a) and self._in_rect(*b): + rl.draw_line_ex(rl.Vector2(*a), rl.Vector2(*b), 1.5, col) + dist += GRID_STEP + for off in np.arange(-GRID_HALF_WIDTH, GRID_HALF_WIDTH + 0.1, 3.0): + a = self._project(np.array([GRID_STEP, float(off), 0.0])) + b = self._project(np.array([GRID_MAX_DISTANCE, float(off), 0.0])) + if a and b and self._in_rect(*a) and self._in_rect(*b): + rl.draw_line_ex(rl.Vector2(*a), rl.Vector2(*b), 1.5, col) + + def _draw_polyline(self, xs, ys, zs, color, thick): + pts = [] + for x, y, z in zip(xs, ys, zs, strict=False): + if x < 0: + continue + s = self._project(np.array([x, y, z], dtype=np.float32)) + if s and self._in_rect(*s): + pts.append(rl.Vector2(*s)) + for i in range(len(pts) - 1): + rl.draw_line_ex(pts[i], pts[i + 1], thick, color) + + def _draw_model_scene(self, model): + for i, lane in enumerate(model.laneLines): + a = int(np.clip(model.laneLineProbs[i], 0.0, 0.9) * 255) + self._draw_polyline(lane.x, lane.y, lane.z, rl.Color(235, 235, 235, a), 3.0) + for edge in model.roadEdges: + self._draw_polyline(edge.x, edge.y, edge.z, rl.Color(230, 70, 70, 180), 3.0) + pos = model.position + self._draw_polyline(pos.x, pos.y, pos.z, rl.Color(40, 210, 200, 220), 6.0) + + def _draw_objects(self, env): + for obj in env.objects: + self._draw_box(obj) + + def _draw_box(self, obj): + hx, hy = obj.length / 2.0, obj.width / 2.0 + base = np.array([ + [obj.x - hx, obj.y - hy, obj.z], [obj.x - hx, obj.y + hy, obj.z], + [obj.x + hx, obj.y - hy, obj.z], [obj.x + hx, obj.y + hy, obj.z], + [obj.x - hx, obj.y - hy, obj.z + obj.height], [obj.x - hx, obj.y + hy, obj.z + obj.height], + [obj.x + hx, obj.y - hy, obj.z + obj.height], [obj.x + hx, obj.y + hy, obj.z + obj.height], + ], dtype=np.float32) + + screen = [] + for corner in base: + s = self._project(corner) + if s is None or not self._in_rect(*s): + return + screen.append(s) + + r, g, b = _OBJECT_COLORS.get(obj.label, (40, 210, 200)) + a = int(np.clip(obj.prob, 0.3, 1.0) * 210) + floor = [rl.Vector2(*screen[i]) for i in (0, 1, 3, 2)] + rl.draw_triangle(floor[0], floor[1], floor[2], rl.Color(r, g, b, a // 5)) + rl.draw_triangle(floor[0], floor[2], floor[3], rl.Color(r, g, b, a // 5)) + for i, j in _BOX_EDGES: + rl.draw_line_ex(rl.Vector2(*screen[i]), rl.Vector2(*screen[j]), 2.0, rl.Color(r, g, b, a)) diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index e956e73d..3c038be2 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -224,6 +224,17 @@ class Soundd(AlertSoundFilter): sm = messaging.SubMaster(['selfdriveState', 'soundPressure']) threading.Thread(target=self.webrtc_audio_thread, daemon=True).start() + while True: + try: + self._stream_loop(sd, sm) + except Exception: + # Some A1s wedge the audio DSP (ALSA EINVAL / ADSP_EFAILED until reboot). Dying here + # crash-loops the process and selfdrived raises a takeover alert mid-drive over alert + # sounds - stay alive and keep retrying instead; recovers if the DSP comes back. + cloudlog.exception("soundd: audio stream unavailable, retrying") + time.sleep(10) + + def _stream_loop(self, sd, sm): with self.get_stream(sd) as stream: rk = Ratekeeper(20) diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index c0cee138..0c398273 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -8,6 +8,7 @@ from cereal import messaging, car, log, custom from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog +from openpilot.iqpilot.common.auto_units import AutoUnits from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.system.hardware import HARDWARE, PC @@ -42,12 +43,13 @@ class IQUIState: self.update_params() + self.auto_units = AutoUnits(self.params) self.onroad_brightness_timer: int = 0 self.custom_interactive_timeout: int = self.params.get("InteractivityTimeout", return_default=True) self.reset_onroad_sleep_timer() def update(self) -> None: - pass + self.auto_units.update() def onroad_brightness_handle_alerts(self, started: bool, alert): # while an alert is on screen the dim countdown is frozen and re-armed; otherwise it ticks down @@ -254,6 +256,7 @@ class UIState(IQUIState): "radarState", "liveTracks", "iqVehicleTracks", + "iqEnvironment", "deviceState", "pandaStates", "carParams", diff --git a/system/camerad/cameras/spectra.cc b/system/camerad/cameras/spectra.cc index c1a89b72..37716a3f 100644 --- a/system/camerad/cameras/spectra.cc +++ b/system/camerad/cameras/spectra.cc @@ -596,7 +596,7 @@ void SpectraCamera::config_bps(int idx, int request_id) { tmp.header = CAM_ICP_CMD_GENERIC_BLOB_CLK; tmp.header |= (sizeof(cam_icp_clk_bw_request)) << 8; tmp.clk.budget_ns = 0x1fca058; - tmp.clk.frame_cycles = 2329024; // comes from the striping lib + tmp.clk.frame_cycles = 20000000; // force max BPS clock (600 MHz) tmp.clk.rt_flag = 0x0; tmp.clk.uncompressed_bw = 0x38512180; tmp.clk.compressed_bw = 0x38512180; @@ -843,7 +843,7 @@ void SpectraCamera::config_bps_downscale(int idx, int request_id) { tmp.header = CAM_ICP_CMD_GENERIC_BLOB_CLK; tmp.header |= (sizeof(cam_icp_clk_bw_request)) << 8; tmp.clk.budget_ns = 0x1fca058; - tmp.clk.frame_cycles = sensor->frame_width * sensor->frame_height; // matches striping lib pixelCount + tmp.clk.frame_cycles = 20000000; // force max BPS clock (600 MHz) tmp.clk.rt_flag = 0x0; tmp.clk.uncompressed_bw = 0x38512180; tmp.clk.compressed_bw = 0x38512180; diff --git a/system/hardware/__init__.py b/system/hardware/__init__.py index 99079b5e..0faef554 100644 --- a/system/hardware/__init__.py +++ b/system/hardware/__init__.py @@ -14,3 +14,9 @@ if TICI: HARDWARE = cast(HardwareBase, Tici()) else: HARDWARE = cast(HardwareBase, Pc()) + +# Only comma 3/3X expose the DMA-BUF EGL extensions used by the zero-copy +# camera renderer and the direct EGL frame-pacing calls. /TICI is also present +# on comma 4, so it identifies the AGNOS hardware family rather than this GPU +# capability. +EGL_DMA_BUF_SUPPORTED = TICI and HARDWARE.get_device_type() in ("tici", "tizi") diff --git a/system/hardware/base.h b/system/hardware/base.h index 3eded659..27fa4a8e 100644 --- a/system/hardware/base.h +++ b/system/hardware/base.h @@ -1,15 +1,25 @@ #pragma once +#include #include #include #include +#include #include +#include #include "cereal/gen/cpp/log.capnp.h" // no-op base hw class class HardwareNone { public: + struct UfsHealth { + uint8_t pre_eol_info; + uint8_t life_time_estimate_a; + uint8_t life_time_estimate_b; + std::vector vendor_health_report; + }; + static std::string get_name() { return ""; } static cereal::InitData::DeviceType get_device_type() { return cereal::InitData::DeviceType::UNKNOWN; } static int get_voltage() { return 0; } @@ -21,6 +31,8 @@ public: return {}; } + static std::optional get_ufs_health() { return std::nullopt; } + static void set_ir_power(int percentage) {} static bool PC() { return false; } diff --git a/system/hardware/fan_controller.py b/system/hardware/fan_controller.py index 73ee05fb..e4560bfb 100755 --- a/system/hardware/fan_controller.py +++ b/system/hardware/fan_controller.py @@ -6,7 +6,11 @@ class FanController: def __init__(self) -> None: self.last_ignition = False - def update(self, cur_temp: float, ignition: bool) -> int: + def update(self, cur_temp: float, ignition: bool, max_cool: bool = False) -> int: + if max_cool: + self.last_ignition = ignition + return 100 + if cur_temp < 70.0: fan_pwr_out = 0 elif cur_temp > 85.0: diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index 8a9a7e0e..e63186bf 100755 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -432,8 +432,6 @@ def hardware_thread(end_event, hw_queue) -> None: all_comp_temp = all_temp_filter.update(max(temp_sources)) msg.deviceState.maxTempC = all_comp_temp - msg.deviceState.fanSpeedPercentDesired = fan_controller.update(all_comp_temp, onroad_conditions["ignition"]) - is_offroad_for_5_min = (started_ts is None) and ((not started_seen) or (off_ts is None) or (time.monotonic() - off_ts > 60 * 5)) if is_offroad_for_5_min and offroad_comp_temp > OFFROAD_DANGER_TEMP: # if device is offroad and already hot without the extra onroad load, @@ -447,6 +445,10 @@ def hardware_thread(end_event, hw_queue) -> None: elif current_band.max_temp is not None and all_comp_temp > current_band.max_temp: thermal_status = list(THERMAL_BANDS.keys())[band_idx + 1] + # the car is running but temperature is blocking the start, so cool as fast as we can + max_cool = (started_ts is None) and onroad_conditions["ignition"] and thermal_status >= ThermalStatus.red + msg.deviceState.fanSpeedPercentDesired = fan_controller.update(all_comp_temp, onroad_conditions["ignition"], max_cool) + # **** starting logic **** startup_conditions["up_to_date"] = True diff --git a/system/hardware/tests/test_fan_controller.py b/system/hardware/tests/test_fan_controller.py index 854564b1..5b4fa9b1 100644 --- a/system/hardware/tests/test_fan_controller.py +++ b/system/hardware/tests/test_fan_controller.py @@ -41,6 +41,13 @@ class TestFanController: self.wind_up(controller, True) assert controller.update(100, True) == 100 + @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + def test_max_cool(self, mocker, controller_class): + controller = patched_controller(mocker, controller_class) + self.wind_down(controller) + assert controller.update(80, True, True) == 100 + assert controller.update(80, False, True) == 100 + @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) def test_windup_speed(self, mocker, controller_class): controller = patched_controller(mocker, controller_class) diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json index 3699e1a4..fd89de93 100644 --- a/system/hardware/tici/agnos.json +++ b/system/hardware/tici/agnos.json @@ -67,28 +67,28 @@ }, { "name": "boot", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-024e5c1f1785b2190520e57c7f88e41436fc22cae32a52d1d68dab65e1370500.img.xz", - "hash": "024e5c1f1785b2190520e57c7f88e41436fc22cae32a52d1d68dab65e1370500", - "hash_raw": "024e5c1f1785b2190520e57c7f88e41436fc22cae32a52d1d68dab65e1370500", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89.img.xz", + "hash": "50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89", + "hash_raw": "50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89", "size": 18216960, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "737126dfeaa85870b4615e6310d4686bd049eac8176fc54608b88ecde590f21a" + "ondevice_hash": "fdcb135e8412a896c4a882738a7022f6b7779939d8716f24b9a566b0b3ff3757" }, { "name": "system", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-640f36a79dc2a3237d9dd253e55cee3ea9cb55988468482d489f38f6b893fb3b.img.xz", - "hash": "e6f7a840437bae56ca49b1f1709f6ba9645ed83484cd02494c05327c6910761b", - "hash_raw": "640f36a79dc2a3237d9dd253e55cee3ea9cb55988468482d489f38f6b893fb3b", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd.img.xz", + "hash": "62a08bdf79f6dc0075eba8b93b55cbac6eeee0ecd2b6e8ab05d51ef61a9a880a", + "hash_raw": "a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd", "size": 6291456000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "f7ebe73504e5c2b5f9d3b1f9db5b120badfa313dc78b4d50e6cd26463697aa2c", + "ondevice_hash": "9ed5bc147a6d4c2e0a89562f31096a7802485ee8e740defeab7674abbf6d4baa", "alt": { - "hash": "640f36a79dc2a3237d9dd253e55cee3ea9cb55988468482d489f38f6b893fb3b", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-640f36a79dc2a3237d9dd253e55cee3ea9cb55988468482d489f38f6b893fb3b.img", + "hash": "a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd.img", "size": 6291456000 } } diff --git a/system/hardware/tici/agnos.json.sig b/system/hardware/tici/agnos.json.sig index 9c1a964f..c4c5a1bd 100644 --- a/system/hardware/tici/agnos.json.sig +++ b/system/hardware/tici/agnos.json.sig @@ -1 +1 @@ -bEKwMhJJ7QIhYyXKRLIVyH5xr5c9sjcUf8XS4+/Q9PN6BI23V8OXOkuuX0zREs1Hz4qPPQ9+kKjNoaKydrSwDg== +LxtrHkl5DFbSWnd1DZCkinmXiKFUVUausf0oYpa97dyAp3QGpWx7wrEAUZuJbrERQtdIV72ZU2eKOaQwGMBQBg== diff --git a/system/hardware/tici/agnos_tici_15_1.json b/system/hardware/tici/agnos_tici_15_1.json index aaa7ff85..77e093e0 100644 --- a/system/hardware/tici/agnos_tici_15_1.json +++ b/system/hardware/tici/agnos_tici_15_1.json @@ -56,28 +56,28 @@ }, { "name": "boot", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-024e5c1f1785b2190520e57c7f88e41436fc22cae32a52d1d68dab65e1370500.img.xz", - "hash": "024e5c1f1785b2190520e57c7f88e41436fc22cae32a52d1d68dab65e1370500", - "hash_raw": "024e5c1f1785b2190520e57c7f88e41436fc22cae32a52d1d68dab65e1370500", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89.img.xz", + "hash": "50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89", + "hash_raw": "50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89", "size": 18216960, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "737126dfeaa85870b4615e6310d4686bd049eac8176fc54608b88ecde590f21a" + "ondevice_hash": "fdcb135e8412a896c4a882738a7022f6b7779939d8716f24b9a566b0b3ff3757" }, { "name": "system", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-640f36a79dc2a3237d9dd253e55cee3ea9cb55988468482d489f38f6b893fb3b.img.xz", - "hash": "e6f7a840437bae56ca49b1f1709f6ba9645ed83484cd02494c05327c6910761b", - "hash_raw": "640f36a79dc2a3237d9dd253e55cee3ea9cb55988468482d489f38f6b893fb3b", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd.img.xz", + "hash": "62a08bdf79f6dc0075eba8b93b55cbac6eeee0ecd2b6e8ab05d51ef61a9a880a", + "hash_raw": "a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd", "size": 6291456000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "f7ebe73504e5c2b5f9d3b1f9db5b120badfa313dc78b4d50e6cd26463697aa2c", + "ondevice_hash": "9ed5bc147a6d4c2e0a89562f31096a7802485ee8e740defeab7674abbf6d4baa", "alt": { - "hash": "640f36a79dc2a3237d9dd253e55cee3ea9cb55988468482d489f38f6b893fb3b", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-640f36a79dc2a3237d9dd253e55cee3ea9cb55988468482d489f38f6b893fb3b.img", + "hash": "a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd.img", "size": 6291456000 } } diff --git a/system/hardware/tici/agnos_tici_15_1.json.sig b/system/hardware/tici/agnos_tici_15_1.json.sig index 49312c06..9b8485ab 100644 --- a/system/hardware/tici/agnos_tici_15_1.json.sig +++ b/system/hardware/tici/agnos_tici_15_1.json.sig @@ -1 +1 @@ -A9eBPajhAHUyx3wzPP/pZn2VRIMS2UNtSoeuIZTCAFkbV5HhAkD8HlLSemijmRQtMR62adjV+HUwZ0eRaZpdCg== +mKCTfzNHr1d0BYPdmXQRPGpnVuLaDLGZGLbq+86ZJQaGIK1ekcKeYeycoX8BTMNFr+89Tnd77M/CvmjM8yteCg== diff --git a/system/hardware/tici/hardware.h b/system/hardware/tici/hardware.h index d59b45ef..c53f43ce 100644 --- a/system/hardware/tici/hardware.h +++ b/system/hardware/tici/hardware.h @@ -1,17 +1,63 @@ #pragma once -#include +#include #include +#include +#include +#include +#include #include #include #include #include // for std::clamp +#include +#include #include "common/util.h" #include "system/hardware/base.h" class HardwareTici : public HardwareNone { public: + static std::optional get_ufs_health() { + constexpr unsigned long UFS_IOCTL_QUERY = 0x5388; + constexpr uint32_t UPIU_QUERY_OPCODE_READ_DESC = 0x1; + constexpr uint8_t QUERY_DESC_IDN_HEALTH = 0x9; + constexpr uint16_t QUERY_DESC_HEALTH_SIZE = 0x25; + + struct UfsQuery { + uint32_t opcode; + uint8_t idn; + uint8_t reserved; + uint16_t buf_size; + std::array buffer; + }; + static_assert(offsetof(UfsQuery, buffer) == 8); + + UfsQuery query = {}; + query.opcode = UPIU_QUERY_OPCODE_READ_DESC; + query.idn = QUERY_DESC_IDN_HEALTH; + query.buf_size = QUERY_DESC_HEALTH_SIZE; + + int fd = open("/dev/sda", O_RDONLY | O_CLOEXEC); + if (fd < 0) { + return std::nullopt; + } + + int ret = ioctl(fd, UFS_IOCTL_QUERY, &query); + close(fd); + if (ret != 0 || query.buf_size < 5 || query.buf_size > query.buffer.size() || + query.buffer[0] != query.buf_size || query.buffer[1] != QUERY_DESC_IDN_HEALTH) { + return std::nullopt; + } + + return UfsHealth{ + query.buffer[2], + query.buffer[3], + query.buffer[4], + std::vector(query.buffer.begin() + 5, query.buffer.begin() + query.buf_size), + }; + } + static std::string get_name() { std::string model = util::read_file("/sys/firmware/devicetree/base/model"); return util::strip(model.substr(std::string("comma ").size())); diff --git a/system/hardware/tici/hardware.py b/system/hardware/tici/hardware.py index e1b17c8f..cf40ba3a 100644 --- a/system/hardware/tici/hardware.py +++ b/system/hardware/tici/hardware.py @@ -85,8 +85,13 @@ def affine_irq(val, action): @lru_cache def get_device_type(): # lru_cache and cache can cause memory leaks when used in classes - with open("/sys/firmware/devicetree/base/model") as f: - model = f.read().strip('\x00') + try: + with open("/sys/firmware/devicetree/base/model") as f: + model = f.read().strip('\x00') + except FileNotFoundError: + # off-device (e.g. the prebuilt build container fakes /TICI but has no + # devicetree); import must not crash. Not a real device type. + return "unknown" return model.split('comma ')[-1] class Tici(HardwareBase): diff --git a/system/loggerd/encoder/v4l_encoder.cc b/system/loggerd/encoder/v4l_encoder.cc index 4a4e0fbf..d44e8b42 100644 --- a/system/loggerd/encoder/v4l_encoder.cc +++ b/system/loggerd/encoder/v4l_encoder.cc @@ -78,9 +78,10 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { uint32_t idx = -1; bool exit = false; - // POLLIN is capture, POLLOUT is frame + // POLLIN is capture, POLLOUT is frame. Qualcomm's reference client also + // requests the corresponding normal-data bits. struct pollfd pfd; - pfd.events = POLLIN | POLLOUT; + pfd.events = POLLIN | POLLRDNORM | POLLOUT | POLLWRNORM; pfd.fd = e->fd; // save the header @@ -105,7 +106,7 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { } int frame_id = -1; - if (pfd.revents & POLLIN) { + if (pfd.revents & (POLLIN | POLLRDNORM)) { unsigned int bytesused, flags, index; struct timeval timestamp; dequeue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, &index, &bytesused, &flags, ×tamp); @@ -136,7 +137,7 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { queue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, index, &e->buf_out[index]); } - if (pfd.revents & POLLOUT) { + if (pfd.revents & (POLLOUT | POLLWRNORM)) { unsigned int index; dequeue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, &index); e->free_buf_in.push(index); @@ -244,7 +245,7 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei { .id = V4L2_CID_MPEG_VIDEO_H264_LEVEL, .value = V4L2_MPEG_VIDEO_H264_LEVEL_UNKNOWN}, { .id = V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, .value = V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CABAC}, { .id = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL, .value = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL_0}, - { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, .value = 0}, + { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, .value = V4L2_MPEG_VIDEO_H264_LOOP_FILTER_MODE_ENABLED}, { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_ALPHA, .value = 0}, { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_BETA, .value = 0}, { .id = V4L2_CID_MPEG_VIDEO_MULTI_SLICE_MODE, .value = 0}, diff --git a/system/loggerd/logger.cc b/system/loggerd/logger.cc index 644da93f..2cd97d78 100644 --- a/system/loggerd/logger.cc +++ b/system/loggerd/logger.cc @@ -46,6 +46,14 @@ kj::Array logger_build_init_data() { init.setKernelVersion(util::read_file("/proc/version")); init.setOsVersion(util::read_file("/VERSION")); + if (auto health = Hardware::get_ufs_health()) { + auto ufs_health = init.initUfsHealth(); + ufs_health.setPreEolInfo(health->pre_eol_info); + ufs_health.setLifeTimeEstimateA(health->life_time_estimate_a); + ufs_health.setLifeTimeEstimateB(health->life_time_estimate_b); + ufs_health.setVendorHealthReport(capnp::Data::Reader(health->vendor_health_report.data(), health->vendor_health_report.size())); + } + // log params Params params(util::getenv("PARAMS_COPY_PATH", "")); std::map params_map = params.readAll(); diff --git a/system/loggerd/tests/test_loggerd.py b/system/loggerd/tests/test_loggerd.py index 13cbd6a0..7f3a6744 100644 --- a/system/loggerd/tests/test_loggerd.py +++ b/system/loggerd/tests/test_loggerd.py @@ -170,6 +170,15 @@ class TestLoggerd: assert initData.dirty != bool(os.environ["CLEAN"]) assert initData.version == get_version() + if TICI: + assert initData._has("ufsHealth") + assert initData.ufsHealth.preEolInfo in (1, 2, 3) + assert 1 <= initData.ufsHealth.lifeTimeEstimateA <= 11 + assert 1 <= initData.ufsHealth.lifeTimeEstimateB <= 11 + assert len(initData.ufsHealth.vendorHealthReport) == 32 + else: + assert not initData._has("ufsHealth") + if os.path.isfile("/proc/cmdline"): with open("/proc/cmdline") as f: assert list(initData.kernelArgs) == f.read().strip().split(" ") diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 34215658..43da8d71 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -98,7 +98,9 @@ def constructiond_onroad(started: bool, params: Params, CP: car.CarParams) -> bo return started and params.get_bool("ConstructionZoneAssist") def iqvd_onroad(started: bool, params: Params, CP: car.CarParams) -> bool: - return started and params.get_bool("VisionVehicleTracks") + # held for 1.0d: iqvd runs a detector per frame and the added load is not + # something 1.0c needs to carry. re-enable by restoring the param check. + return False def only_offroad(started: bool, params: Params, CP: car.CarParams) -> bool: return not started @@ -109,6 +111,12 @@ def livestream(started: bool, params: Params, CP: car.CarParams) -> bool: # down cleanly when the session ends — no subprocess management inside hephaestusd. return params.get_bool("IsLiveStreaming") +def canlive(started: bool, params: Params, CP: car.CarParams) -> bool: + # Remote live CAN debugging via konn3kt. hephaestusd sets CanLiveStreaming when a viewer + # connects (startCanLive) and clears it when the last one leaves (stopCanLive), so canlived + # runs only during an active debug session — no idle connection or battery cost otherwise. + return params.get_bool("CanLiveStreaming") + def is_tinygrad_model(started, params, CP: car.CarParams) -> bool: """Check if the active model runner is tinygrad.""" return bool(get_active_model_runner(params, not started) == custom.IQModelManager.Runner.tinygrad) @@ -179,6 +187,7 @@ procs = [ # debug procs NativeProcess("bridge", "cereal/messaging", ["./bridge"], notcar), PythonProcess("webrtcd", "system.webrtc.webrtcd", or_(iscar, livestream)), + PythonProcess("canlived", "iqpilot.konn3kt.canlive.canlived", canlive), PythonProcess("webjoystick", "tools.bodyteleop.web", notcar), ] diff --git a/system/micd.py b/system/micd.py index 033f70c3..814b8c3f 100755 --- a/system/micd.py +++ b/system/micd.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import numpy as np import os +import time from functools import cache import threading @@ -136,10 +137,18 @@ class Mic: self.last_device = f"{device}: {sd.query_devices(device)['name']}" if isinstance(device, int) else str(device) cloudlog.info(f"micd selecting input device {self.last_device}") - with self.get_stream(sd) as stream: - cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}") - while True: - self.update() + while True: + try: + with self.get_stream(sd) as stream: + cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}") + while True: + self.update() + except Exception: + # Some A1s wedge the audio DSP (ALSA EINVAL / ADSP_EFAILED until reboot). Dying here + # crash-loops the process and selfdrived raises a takeover alert mid-drive over a + # microphone - stay alive and keep retrying instead; recovers if the DSP comes back. + cloudlog.exception("micd: audio stream unavailable, retrying") + time.sleep(10) def main(): diff --git a/system/timed.py b/system/timed.py index b7131b04..3e2fe9d1 100755 --- a/system/timed.py +++ b/system/timed.py @@ -12,7 +12,7 @@ from openpilot.common.gps import get_gps_location_service def set_time(new_time): - diff = datetime.datetime.now() - new_time + diff = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - new_time if abs(diff) < datetime.timedelta(seconds=10): cloudlog.debug(f"Time diff too small: {diff}") return @@ -47,7 +47,7 @@ def main() -> NoReturn: pm.send('clocks', msg) gps = sm[gps_location_service] - gps_time = datetime.datetime.fromtimestamp(gps.unixTimestampMillis / 1000.) + 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: continue if not gps.hasFix: diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 404854a1..c0cd2ba2 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -22,7 +22,7 @@ from pathlib import Path from typing import NamedTuple from importlib.resources import as_file, files from openpilot.common.swaglog import cloudlog -from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.hardware import EGL_DMA_BUF_SUPPORTED, HARDWARE, PC from openpilot.system.ui.lib.multilang import multilang from openpilot.common.realtime import Ratekeeper @@ -326,7 +326,7 @@ class GuiApplication(IQAppHooks): rl.glfw_swap_interval(0) except Exception: pass - if not PC and rl.is_window_ready() and not OFFSCREEN: + if EGL_DMA_BUF_SUPPORTED and rl.is_window_ready() and not OFFSCREEN: try: from openpilot.system.ui.lib.egl import set_swap_interval set_swap_interval(0) @@ -349,7 +349,7 @@ class GuiApplication(IQAppHooks): glfw_vsync = False egl_vsync = False - if not PC: + if EGL_DMA_BUF_SUPPORTED: try: from openpilot.system.ui.lib.egl import set_swap_interval egl_vsync = set_swap_interval(interval) @@ -367,7 +367,7 @@ class GuiApplication(IQAppHooks): rl.rl_draw_render_batch_active() def _apply_display_sync_before_swap(self) -> None: - if PC or OFFSCREEN or not DISPLAY_SYNC_BEFORE_SWAP or self._display_sync_available is False: + if not EGL_DMA_BUF_SUPPORTED or OFFSCREEN or not DISPLAY_SYNC_BEFORE_SWAP or self._display_sync_available is False: return try: self._flush_raylib_batch() @@ -395,7 +395,7 @@ class GuiApplication(IQAppHooks): # intermittent screen tearing when scrolling (the content moves, so a mid-scanout swap is # visible). eglSwapInterval is a trivial call, so re-assert it every frame to keep FIFO vsync # pinned instead of relying on the slow (VSYNC_REAPPLY_INTERVAL) full re-apply below. - if self._paced_by_vsync and self._vsync_interval > 0 and not PC and rl.is_window_ready(): + if self._paced_by_vsync and self._vsync_interval > 0 and EGL_DMA_BUF_SUPPORTED and rl.is_window_ready(): try: from openpilot.system.ui.lib.egl import set_swap_interval set_swap_interval(self._vsync_interval) @@ -850,7 +850,7 @@ class GuiApplication(IQAppHooks): # full-res frame texture can make the Adreno/GBM driver silently reset the swap interval to 0 # *after* that call but before this swap, which reintroduced tearing. This is the swap that # actually matters, so pin the interval here too. - if (ENABLE_VSYNC and not OFFSCREEN and not PC and self._paced_by_vsync + if (ENABLE_VSYNC and not OFFSCREEN and EGL_DMA_BUF_SUPPORTED and self._paced_by_vsync and self._vsync_interval > 0 and rl.is_window_ready()): try: from openpilot.system.ui.lib.egl import set_swap_interval diff --git a/third_party/mapd_pfeiferj/mapd b/third_party/mapd_pfeiferj/mapd index cae2b402..815891cc 100755 Binary files a/third_party/mapd_pfeiferj/mapd and b/third_party/mapd_pfeiferj/mapd differ diff --git a/tinygrad_repo/tinygrad/runtime/ops_qcom.py b/tinygrad_repo/tinygrad/runtime/ops_qcom.py index 155449cc..9a6ddbf9 100644 --- a/tinygrad_repo/tinygrad/runtime/ops_qcom.py +++ b/tinygrad_repo/tinygrad/runtime/ops_qcom.py @@ -16,8 +16,19 @@ if getenv("IOCTL"): import extra.qcom_gpu_driver.opencl_ioctl # noqa: F401 # p BUFTYPE_BUF, BUFTYPE_TEX, BUFTYPE_IBO = 0, 1, 2 +# precompiled aarch64 machine code for the dcache_flush kernel below (dc cvac +# loop + dsb sy). Shipping the bytes avoids a runtime clang subprocess whose +# compile was getting killed by an msgq SIGUSR2 landing on the clang child's +# recycled TID -> modeld crash. Regenerate if the kernel source changes: +# prg = to_program(...); print(prg.src[4].arg.hex()) +_DCACHE_FLUSH_AARCH64 = bytes.fromhex("3f040071cb000054287c4092207a0bd500000191080500f1a1ffff549f3f03d5c0035fd6") + @functools.cache def dcache_flush(): + # fast path: load shipped machine code, no compile at all (device is aarch64) + if os.uname().machine in ("aarch64", "arm64"): + try: return Device["CPU"].runtime("dcache_flush", _DCACHE_FLUSH_AARCH64) + except Exception: pass from tinygrad.uop.ops import UOp, Ops, KernelInfo from tinygrad.codegen import to_program buf, n = UOp.param(0, dtypes.uint8.ptr()), UOp.param(1, dtypes.uint8.ptr()) diff --git a/tinygrad_repo/tinygrad/runtime/support/compiler_cpu.py b/tinygrad_repo/tinygrad/runtime/support/compiler_cpu.py index 2fc5ede2..125d2e1d 100644 --- a/tinygrad_repo/tinygrad/runtime/support/compiler_cpu.py +++ b/tinygrad_repo/tinygrad/runtime/support/compiler_cpu.py @@ -1,9 +1,13 @@ -import ctypes, subprocess +import ctypes, subprocess, signal from tinygrad.device import Compiler from tinygrad.helpers import getenv, capstone_flatdump, DEBUG, unwrap from tinygrad.runtime.support.elf import jit_loader from tinygrad.runtime.autogen import llvm +def _block_sigusr2(): + try: signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGUSR2}) + except (ValueError, OSError): pass + class ClangCompiler(Compiler): def __init__(self, arch:list[str], cachekey="compile_clang_jit"): assert len(arch) >= 2, f"invalid arch string: {','.join(arch)!r}, expected ',,[]' (eg. 'x86_64,znver2')" @@ -21,7 +25,8 @@ class ClangCompiler(Compiler): """Compile C source to ELF object file (before linking).""" # -fno-math-errno is required for __builtin_sqrt to become an instruction instead of a function call return subprocess.check_output([getenv("CC", 'clang'), '-c', '-x', 'c', '-O2', '-fPIC', '-ffreestanding', '-fno-math-errno', '-nostdlib', - '-fno-ident', f'--target={self.arch}-none-unknown-elf', *self.args, '-', '-o', '-'], input=src.encode('utf-8')) + '-fno-ident', f'--target={self.arch}-none-unknown-elf', *self.args, '-', '-o', '-'], input=src.encode('utf-8'), + preexec_fn=_block_sigusr2) def compile(self, src:str) -> bytes: return jit_loader(self.compile_to_obj(src)) diff --git a/tools/cabana/.gitignore b/tools/cabana/.gitignore index 362a51f5..7f9ac0fd 100644 --- a/tools/cabana/.gitignore +++ b/tools/cabana/.gitignore @@ -1,6 +1,11 @@ moc_* *.moc +*.generated.qrc -cabana +assets.cc +bootstrap_icons.cc + +_cabana dbc/car_fingerprint_to_dbc.json tests/test_cabana +tests/test_dbc_core diff --git a/tools/cabana/README.md b/tools/cabana/README.md index 2be92f4b..d8bc3055 100644 --- a/tools/cabana/README.md +++ b/tools/cabana/README.md @@ -1,6 +1,6 @@ # Cabana -Cabana is a tool developed to view raw CAN data. One use for this is creating and editing [CAN Dictionaries](http://socialledge.com/sjsu/index.php/DBC_Format) (DBC files), and the tool provides direct integration with [commaai/iqdbc](https://github.com/commaai/iqdbc) (a collection of DBC files), allowing you to load the DBC files direct from source, and save to your fork. In addition, you can load routes from [comma connect](https://connect.comma.ai). +Cabana is a tool developed to view raw CAN data. One use for this is creating and editing [CAN Dictionaries](http://socialledge.com/sjsu/index.php/DBC_Format) (DBC files), and the tool provides direct integration with iqdbc (a collection of DBC files), allowing you to load the DBC files direct from source, and save to your fork. In addition, you can load routes from [konn3kt](https://konn3kt.com). ## Usage Instructions @@ -28,7 +28,7 @@ Options: Arguments: route the drive to replay. find your drives at - connect.comma.ai + konn3kt.com ``` ## Examples @@ -45,17 +45,17 @@ cabana --demo To load a specific route for replay, provide the route as an argument: ```shell -cabana "a2a0ccea32023010|2023-07-27--13-01-19" +cabana "5beb9b58bd12b691/0000010a--a51155e496" ``` -Replace "0ccea32023010|2023-07-27--13-01-19" with your desired route identifier. +Replace "5beb9b58bd12b691/0000010a--a51155e496" with your desired route identifier. ### Running Cabana with multiple cameras To run Cabana with multiple cameras, use the following command: ```shell -cabana "a2a0ccea32023010|2023-07-27--13-01-19" --dcam --ecam +cabana "5beb9b58bd12b691/0000010a--a51155e496" --dcam --ecam ``` ### Streaming CAN Messages from a comma Device @@ -63,8 +63,8 @@ cabana "a2a0ccea32023010|2023-07-27--13-01-19" --dcam --ecam [SSH into your device](https://github.com/commaai/openpilot/wiki/SSH) and start the bridge with the following command: ```shell -cd /data/openpilot/cereal/messaging/ -./bridge & +cd /data/openpilot +./cereal/messaging/bridge & ``` Then Run Cabana with the device's IP address: @@ -73,7 +73,24 @@ Then Run Cabana with the device's IP address: cabana --zmq ``` -Replace <ipaddress> with your comma device's IP address. +Replace <ipaddress> with your device's IP address. + +If you can't run the bridge on the device, `--bridge ` runs +`cereal/messaging/bridge` locally against the device instead. + +### Streaming CAN Messages from a Remote Device over konn3kt + +To watch a device that isn't on your network, `tools/cabana/konn3kt_canproxy.py` +re-publishes its live CAN onto a local ZMQ socket: + +```shell +export KONN3KT_JWT="" +./tools/cabana/konn3kt_canproxy.py +cabana --zmq 127.0.0.1 +``` + +The device must have `CanLiveStreaming` enabled so `canlived` is running. See the +header of `konn3kt_canproxy.py` for the full topology. While streaming from the device, Cabana will log the CAN messages to a local directory. By default, this directory is ~/cabana_live_stream/. You can change the log directory in Cabana by navigating to menu -> tools -> settings. diff --git a/tools/cabana/SConscript b/tools/cabana/SConscript index 5976f354..b0ae4234 100644 --- a/tools/cabana/SConscript +++ b/tools/cabana/SConscript @@ -1,21 +1,40 @@ import subprocess import os +import shutil +import sys -Import('env', 'arch', 'common', 'messaging', 'visionipc', 'replay_lib', 'cereal') +venv_site_packages = os.path.join(Dir("#").abspath, ".venv", "lib", "python3.12", "site-packages") +if os.path.isdir(venv_site_packages) and venv_site_packages not in sys.path: + sys.path.insert(0, venv_site_packages) + +import libusb + +Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') + +# Detect Qt - skip build if not available +if arch == "Darwin": + try: + brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip() + has_qt = os.path.isdir(os.path.join(brew_prefix, "opt/qt@5")) + except (FileNotFoundError, subprocess.CalledProcessError): + has_qt = False +else: + has_qt = shutil.which('qmake') is not None +if not has_qt: + Return() qt_env = env.Clone() -qt_modules = ["Widgets", "Gui", "Core", "Network", "Concurrent", "DBus", "Xml"] +qt_modules = ["Widgets", "Gui", "Core"] qt_libs = [] if arch == "Darwin": - brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip() qt_env['QTDIR'] = f"{brew_prefix}/opt/qt@5" qt_dirs = [ os.path.join(qt_env['QTDIR'], "include"), ] qt_dirs += [f"{qt_env['QTDIR']}/include/Qt{m}" for m in qt_modules] qt_env["LINKFLAGS"] += ["-F" + os.path.join(qt_env['QTDIR'], "lib")] - qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] + ["OpenGL"] + qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin")) else: qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip() @@ -32,15 +51,10 @@ else: qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules] qt_libs = [f"Qt5{m}" for m in qt_modules] - if arch == "larch64": - qt_libs += ["GLESv2", "wayland-client"] - qt_env.PrependENVPath('PATH', Dir("#third_party/qt5/larch64/bin/").abspath) - elif arch != "Darwin": - qt_libs += ["GL"] qt_env['QT3DIR'] = qt_env['QTDIR'] qt_env.Tool('qt3') -qt_env['CPPPATH'] += qt_dirs + ["#third_party/qrcode"] +qt_env['CPPPATH'] += qt_dirs qt_flags = [ "-D_REENTRANT", "-DQT_NO_DEBUG", @@ -54,41 +68,74 @@ qt_env['LIBPATH'] += ['#selfdrive/ui', ] qt_env['LIBS'] = qt_libs base_frameworks = qt_env['FRAMEWORKS'] -base_libs = [common, messaging, cereal, visionipc, 'm', 'ssl', 'crypto', 'pthread'] + qt_env["LIBS"] +base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] if arch == "Darwin": - base_frameworks.append('OpenCL') - base_frameworks.append('QtCharts') - base_frameworks.append('QtSerialBus') -else: - base_libs.append('OpenCL') - base_libs.append('Qt5Charts') - base_libs.append('Qt5SerialBus') - -qt_libs = base_libs + base_frameworks += ['CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] cabana_env = qt_env.Clone() +cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] +cabana_env['LIBPATH'] += [libusb.LIB_DIR] -cabana_libs = [cereal, messaging, visionipc, replay_lib, 'avutil', 'avcodec', 'avformat', 'bz2', 'zstd', 'curl', 'yuv', 'usb-1.0'] + qt_libs -iqdbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../iqdbc/dbc").abspath) -cabana_env['CXXFLAGS'] += [iqdbc_path] +# IQ.Pilot patch: replay_lib fetches routes over libcurl and signs konn3kt JWTs with +# OpenSSL (tools/replay/api.cc), and visionipc references OpenCL — upstream gets all +# three transitively from its Python downloader / vendored wheels, we link them here. +cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + \ + ['bz2', 'zstd', 'curl', 'ssl', 'crypto', 'usb-1.0'] + base_libs +if arch == "Darwin": + base_frameworks += ['OpenCL'] +else: + cabana_libs += ['OpenCL'] +opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("#iqdbc/dbc").abspath) +cabana_env['CXXFLAGS'] += [opendbc_path] + +# embed the bootstrap icons SVG into the binary +def build_bootstrap_icons_src(target, source, env): + data = open(str(source[0]), 'rb').read() + with open(str(target[0]), 'w') as f: + f.write('#include \n') + f.write('extern const unsigned char bootstrap_icons_svg[];\n') + f.write('extern const size_t bootstrap_icons_svg_len;\n') + f.write('const unsigned char bootstrap_icons_svg[] = {\n') + for i in range(0, len(data), 32): + f.write(','.join(str(b) for b in data[i:i+32]) + ',\n') + f.write('};\n') + f.write('const size_t bootstrap_icons_svg_len = sizeof(bootstrap_icons_svg);\n') + return None + +# IQ.Pilot patch: no comma-deps-bootstrap-icons wheel here; the SVG is checked in +# under third_party/bootstrap (see its pull.sh). +bootstrap_icons_src = cabana_env.Command('assets/bootstrap_icons.cc', '#third_party/bootstrap/bootstrap-icons.svg', + cabana_env.PrettyAction(build_bootstrap_icons_src, 'GEN')) # build assets assets = "assets/assets.cc" -assets_src = "assets/assets.qrc" -cabana_env.Command(assets, assets_src, cabana_env.PrettyAction("rcc $SOURCES -o $TARGET", 'RCC')) -cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "assets/assets.o"])) +cabana_env.Command(assets, "assets/assets.qrc", cabana_env.PrettyAction("rcc $SOURCES -o $TARGET", 'RCC')) +cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) -cabana_lib = cabana_env.Library("cabana_lib", ['mainwin.cc', 'streams/socketcanstream.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', 'utils/api.cc', - 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) -cabana_env.Program('cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) +cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', + 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'dbc/dbcqt.cc', + 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', + 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', + 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', + 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] +if arch != "Darwin": + cabana_srcs += ['streams/socketcanstream.cc'] +cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs + [bootstrap_icons_src], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) +cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) if GetOption('extras'): - cabana_env.Program('tests/test_cabana', ['tests/test_runner.cc', 'tests/test_cabana.cc', cabana_lib], LIBS=[cabana_libs]) + # This target deliberately uses the base environment and links no Qt libraries. + # It prevents Qt dependencies from creeping back into the DBC core. + dbc_core_test_env = env.Clone() + dbc_core_test_env['CXXFLAGS'] += [opendbc_path] + dbc_core_test_objects = [ + dbc_core_test_env.Object('tests/dbc_core_tests', 'tests/test_cabana.cc'), + dbc_core_test_env.Object('tests/dbc_core_model', 'dbc/dbc.cc'), + dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), + dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), + ] + dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects) output_json_file = 'tools/cabana/dbc/car_fingerprint_to_dbc.json' generate_dbc = cabana_env.Command('#' + output_json_file, diff --git a/tools/cabana/assets/assets.qrc b/tools/cabana/assets/assets.qrc index 6a8e5a34..009d63f0 100644 --- a/tools/cabana/assets/assets.qrc +++ b/tools/cabana/assets/assets.qrc @@ -1,6 +1,5 @@ - ../../../third_party/bootstrap/bootstrap-icons.svg cabana-icon.png diff --git a/tools/cabana/binaryview.cc b/tools/cabana/binaryview.cc index b5a68c6b..5e919dc6 100644 --- a/tools/cabana/binaryview.cc +++ b/tools/cabana/binaryview.cc @@ -1,8 +1,10 @@ #include "tools/cabana/binaryview.h" +#include "tools/cabana/dbc/dbcqt.h" #include -#include +#include + #include #include #include @@ -34,20 +36,20 @@ BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { setMouseTracking(true); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &BinaryView::refresh); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &BinaryView::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &BinaryView::refresh); addShortcuts(); setWhatsThis(R"( Binary View
- + Shortcuts
Delete Signal:  x ,  Backspace ,  Delete 
Change endianness:  e 
- Change singedness:  s 
+ Change signedness:  s 
Open chart:  c ,  p , @@ -64,7 +66,7 @@ void BinaryView::addShortcuts() { QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{ if (hovered_sig != nullptr) { - UndoStack::push(new RemoveSigCommand(model->msg_id, hovered_sig)); + UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, hovered_sig)); hovered_sig = nullptr; } }); @@ -111,7 +113,8 @@ void BinaryView::highlight(const cabana::Signal *sig) { if (sig != hovered_sig) { for (int i = 0; i < model->items.size(); ++i) { auto &item_sigs = model->items[i].sigs; - if ((sig && item_sigs.contains(sig)) || (hovered_sig && item_sigs.contains(hovered_sig))) { + auto has = [](const auto &v, auto p) { return std::find(v.begin(), v.end(), p) != v.end(); }; + if ((sig && has(item_sigs, sig)) || (hovered_sig && has(item_sigs, hovered_sig))) { auto index = model->index(i / model->columnCount(), i % model->columnCount()); emit model->dataChanged(index, index, {Qt::DisplayRole}); } @@ -123,7 +126,7 @@ void BinaryView::highlight(const cabana::Signal *sig) { } void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) { - auto index = indexAt(viewport()->mapFromGlobal(QCursor::pos())); + auto index = indexAt(last_mouse_pos); if (!anchor_index.isValid() || !index.isValid()) return; @@ -138,7 +141,7 @@ void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionF void BinaryView::mousePressEvent(QMouseEvent *event) { resize_sig = nullptr; - if (auto index = indexAt(event->pos()); index.isValid() && index.column() != 8) { + if (auto index = indexAt(last_mouse_pos = event->pos()); index.isValid() && index.column() != 8) { anchor_index = index; auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); int bit_pos = get_bit_pos(anchor_index); @@ -155,15 +158,15 @@ void BinaryView::mousePressEvent(QMouseEvent *event) { } void BinaryView::highlightPosition(const QPoint &pos) { - if (auto index = indexAt(viewport()->mapFromGlobal(pos)); index.isValid()) { + if (auto index = indexAt(pos); index.isValid()) { auto item = (BinaryViewModel::Item *)index.internalPointer(); - const cabana::Signal *sig = item->sigs.isEmpty() ? nullptr : item->sigs.back(); + const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); highlight(sig); } } void BinaryView::mouseMoveEvent(QMouseEvent *event) { - highlightPosition(event->globalPos()); + highlightPosition(last_mouse_pos = event->pos()); QTableView::mouseMoveEvent(event); } @@ -176,7 +179,7 @@ void BinaryView::mouseReleaseEvent(QMouseEvent *event) { auto sig = resize_sig ? *resize_sig : cabana::Signal{}; std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); resize_sig ? emit editSignal(resize_sig, sig) - : UndoStack::push(new AddSigCommand(model->msg_id, sig)); + : UndoStack::instance()->push(new AddSigCommand(model->msg_id, sig)); } else { auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); if (item && item->sigs.size() > 0) @@ -205,15 +208,15 @@ void BinaryView::refresh() { resize_sig = nullptr; hovered_sig = nullptr; model->refresh(); - highlightPosition(QCursor::pos()); + if (underMouse()) highlightPosition(last_mouse_pos); } -QSet BinaryView::getOverlappingSignals() const { - QSet overlapping; +std::set BinaryView::getOverlappingSignals() const { + std::set overlapping; for (const auto &item : model->items) { if (item.sigs.size() > 1) { for (auto s : item.sigs) { - if (s->type == cabana::Signal::Type::Normal) overlapping += s; + if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); } } } @@ -258,7 +261,8 @@ void BinaryViewModel::refresh() { int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; int idx = column_count * (pos / 8) + pos % 8; if (idx >= items.size()) { - qWarning() << "signal " << sig->name << "out of bounds.start_bit:" << sig->start_bit << "size:" << sig->size; + fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n", + sig->name.c_str(), sig->start_bit, sig->size); break; } if (j == 0) sig->is_little_endian ? items[idx].is_lsb = true : items[idx].is_msb = true; @@ -333,7 +337,7 @@ void BinaryViewModel::updateState() { color.setAlpha(alpha); updateItem(i, j, bit_val, color); } - updateItem(i, 8, binary[i], last_msg.colors[i]); + updateItem(i, 8, binary[i], toQColor(last_msg.colors[i])); } } @@ -404,7 +408,9 @@ bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, con if (!index.isValid()) return false; auto model = (const BinaryViewModel*)(index.model()); int idx = (index.row() + dy) * model->columnCount() + index.column() + dx; - return (idx >=0 && idx < model->items.size()) ? model->items[idx].sigs.contains(sig) : false; + if (idx < 0 || idx >= (int)model->items.size()) return false; + auto &s = model->items[idx].sigs; + return std::find(s.begin(), s.end(), sig) != s.end(); } void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { @@ -418,14 +424,14 @@ void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op painter->fillRect(option.rect, item->bg_color); } } else if (option.state & QStyle::State_Selected) { - auto color = bin_view->resize_sig ? bin_view->resize_sig->color : option.palette.color(QPalette::Active, QPalette::Highlight); + auto color = bin_view->resize_sig ? toQColor(bin_view->resize_sig->color) : option.palette.color(QPalette::Active, QPalette::Highlight); painter->fillRect(option.rect, color); painter->setPen(option.palette.color(QPalette::BrightText)); - } else if (!bin_view->selectionModel()->hasSelection() || !item->sigs.contains(bin_view->resize_sig)) { // not resizing + } else if (!bin_view->selectionModel()->hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), bin_view->resize_sig) == item->sigs.end()) { // not resizing if (item->sigs.size() > 0) { for (auto &s : item->sigs) { if (s == bin_view->hovered_sig) { - painter->fillRect(option.rect, s->color.darker(125)); // 4/5x brightness + painter->fillRect(option.rect, toQColor(s->color.darker(125))); // 4/5x brightness } else { drawSignalCell(painter, option, index, s); } @@ -433,7 +439,7 @@ void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op } else if (item->valid && item->bg_color.alpha() > 0) { painter->fillRect(option.rect, item->bg_color); } - auto color_role = item->sigs.contains(bin_view->hovered_sig) ? QPalette::BrightText : QPalette::Text; + auto color_role = (std::find(item->sigs.begin(), item->sigs.end(), bin_view->hovered_sig) != item->sigs.end()) ? QPalette::BrightText : QPalette::Text; painter->setPen(option.palette.color(bin_view->is_message_active ? QPalette::Normal : QPalette::Disabled, color_role)); } @@ -480,14 +486,14 @@ void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionVie painter->setClipRegion(QRegion(rc).subtracted(subtract)); auto item = (const BinaryViewModel::Item *)index.internalPointer(); - QColor color = sig->color; + QColor color = toQColor(sig->color); color.setAlpha(item->bg_color.alpha()); // Mixing the signal color with the Base background color to fade it painter->fillRect(rc, option.palette.color(QPalette::Base)); painter->fillRect(rc, color); // Draw edges - color = sig->color.darker(125); + color = toQColor(sig->color.darker(125)); painter->setPen(QPen(color, 1)); if (draw_left) painter->drawLine(rc.topLeft(), rc.bottomLeft()); if (draw_right) painter->drawLine(rc.topRight(), rc.bottomRight()); diff --git a/tools/cabana/binaryview.h b/tools/cabana/binaryview.h index 920deb00..c49067a1 100644 --- a/tools/cabana/binaryview.h +++ b/tools/cabana/binaryview.h @@ -1,10 +1,9 @@ #pragma once +#include #include #include -#include -#include #include #include @@ -51,7 +50,7 @@ public: bool is_msb = false; bool is_lsb = false; uint8_t val; - QList sigs; + std::vector sigs; bool valid = false; }; std::vector items; @@ -68,7 +67,7 @@ public: BinaryView(QWidget *parent = nullptr); void setMessage(const MessageId &message_id); void highlight(const cabana::Signal *sig); - QSet getOverlappingSignals() const; + std::set getOverlappingSignals() const; void updateState() { model->updateState(); } void paintEvent(QPaintEvent *event) override { is_message_active = can->isMessageActive(model->msg_id); @@ -95,6 +94,7 @@ private: void highlightPosition(const QPoint &pt); QModelIndex anchor_index; + QPoint last_mouse_pos{-1, -1}; BinaryViewModel *model; BinaryItemDelegate *delegate; bool is_message_active = false; diff --git a/tools/cabana/cabana b/tools/cabana/cabana new file mode 100755 index 00000000..02b59658 --- /dev/null +++ b/tools/cabana/cabana @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +ROOT="$(cd "$DIR/../../" && pwd)" + +install_qt() { + if [[ "$(uname)" == "Darwin" ]]; then + brew install qt@5 + brew link qt@5 || true + else + SUDO="" + if [[ ! $(id -u) -eq 0 ]]; then + SUDO="sudo" + fi + $SUDO apt-get install -y --no-install-recommends \ + qtbase5-dev \ + qtbase5-dev-tools \ + qttools5-dev-tools \ + libqt5charts5-dev \ + libqt5svg5-dev \ + libqt5serialbus5-dev \ + libqt5x11extras5-dev \ + libqt5opengl5-dev + fi +} + +# Install Qt if not found +if ! command -v qmake &> /dev/null; then + echo "Qt not found, installing dependencies..." + install_qt +fi + +# Build _cabana +cd "$ROOT" +scons -u tools/cabana/_cabana cereal/messaging/bridge + +exec "$DIR/_cabana" "$@" diff --git a/tools/cabana/cabana.cc b/tools/cabana/cabana.cc index bc50afc0..28cca75a 100644 --- a/tools/cabana/cabana.cc +++ b/tools/cabana/cabana.cc @@ -1,82 +1,195 @@ +#include +#include +#include +#include + #include -#include #include "tools/cabana/mainwin.h" #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" +#ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" +#endif + +namespace { + +struct CabanaArgs { + bool demo = false; + bool auto_source = false; + bool qcam = false; + bool ecam = false; + bool dcam = false; + bool msgq = false; + bool panda = false; + bool no_vipc = false; + std::string panda_serial; + std::string socketcan; + std::string zmq; + std::string bridge; + std::string data_dir; + std::string dbc; + std::string route; +}; + +void printUsage(const char *argv0) { + fprintf(stderr, + "Usage: %s [options] [route]\n" + "\n" + " route the drive to replay. find your drives at konn3kt.com\n" + "\n" + "Options:\n" + " --help show this help\n" + " --demo use a demo route instead of providing your own\n" + " --auto Auto load the route from the best available source (no video):\n" + " internal, openpilotci, comma_api, car_segments, testing_closet\n" + " --qcam load qcamera\n" + " --ecam load wide road camera\n" + " --dcam load driver camera\n" + " --msgq read can messages from the msgq\n" + " --panda read can messages from panda\n" + " --panda-serial read can messages from panda with given serial\n" +#ifdef __linux__ + " --socketcan read can messages from given SocketCAN device\n" +#endif + " --zmq subscribe to a zmq 'can' publisher at the specified ip-address\n" + " (a device running cereal/messaging/bridge, or\n" + " tools/cabana/konn3kt_canproxy.py on 127.0.0.1)\n" + " --bridge run cereal/messaging/bridge locally against the device\n" + " --data_dir local directory with routes\n" + " --no-vipc do not output video\n" + " --dbc dbc file to open\n", + argv0); +} + +// Returns true if value was consumed from argv[i+1]. +bool takeValue(int argc, char *argv[], int &i, std::string &out) { + if (i + 1 >= argc) { + fprintf(stderr, "error: %s requires a value\n", argv[i]); + return false; + } + out = argv[++i]; + return true; +} + +// Returns 0 to continue, or a process exit code (0 for --help, 1 for errors). +int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { + ok = false; + for (int i = 1; i < argc; ++i) { + const char *a = argv[i]; + if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { + printUsage(argv[0]); + return 0; + } else if (std::strcmp(a, "--demo") == 0) { + args.demo = true; + } else if (std::strcmp(a, "--auto") == 0) { + args.auto_source = true; + } else if (std::strcmp(a, "--qcam") == 0) { + args.qcam = true; + } else if (std::strcmp(a, "--ecam") == 0) { + args.ecam = true; + } else if (std::strcmp(a, "--dcam") == 0) { + args.dcam = true; + } else if (std::strcmp(a, "--msgq") == 0) { + args.msgq = true; + } else if (std::strcmp(a, "--panda") == 0) { + args.panda = true; + } else if (std::strcmp(a, "--panda-serial") == 0) { + if (!takeValue(argc, argv, i, args.panda_serial)) return 1; + args.panda = true; + } else if (std::strcmp(a, "--socketcan") == 0) { + if (!takeValue(argc, argv, i, args.socketcan)) return 1; +#ifdef __linux__ +#else + fprintf(stderr, "error: --socketcan is only supported on Linux\n"); + return 1; +#endif + } else if (std::strcmp(a, "--zmq") == 0) { + if (!takeValue(argc, argv, i, args.zmq)) return 1; + } else if (std::strcmp(a, "--bridge") == 0) { + if (!takeValue(argc, argv, i, args.bridge)) return 1; + } else if (std::strcmp(a, "--data_dir") == 0) { + if (!takeValue(argc, argv, i, args.data_dir)) return 1; + } else if (std::strcmp(a, "--no-vipc") == 0) { + args.no_vipc = true; + } else if (std::strcmp(a, "--dbc") == 0) { + if (!takeValue(argc, argv, i, args.dbc)) return 1; + } else if (a[0] == '-') { + fprintf(stderr, "error: unknown option %s\n", a); + printUsage(argv[0]); + return 1; + } else if (args.route.empty()) { + args.route = a; + } else { + fprintf(stderr, "error: unexpected argument %s\n", a); + printUsage(argv[0]); + return 1; + } + } + ok = true; + return 0; +} + +} // namespace int main(int argc, char *argv[]) { QCoreApplication::setApplicationName("Cabana"); - QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); initApp(argc, argv, false); QApplication app(argc, argv); app.setApplicationDisplayName("Cabana"); - app.setWindowIcon(QIcon(":cabana-icon.png")); + //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui UnixSignalHandler signalHandler; utils::setTheme(settings.theme); - QCommandLineParser cmd_parser; - cmd_parser.addHelpOption(); - cmd_parser.addPositionalArgument("route", "the drive to replay. find your drives at connect.comma.ai"); - cmd_parser.addOption({"demo", "use a demo route instead of providing your own"}); - cmd_parser.addOption({"auto", "Auto load the route from the best available source (no video): internal, openpilotci, comma_api, car_segments, testing_closet"}); - cmd_parser.addOption({"qcam", "load qcamera"}); - cmd_parser.addOption({"ecam", "load wide road camera"}); - cmd_parser.addOption({"dcam", "load driver camera"}); - cmd_parser.addOption({"msgq", "read can messages from the msgq"}); - cmd_parser.addOption({"panda", "read can messages from panda"}); - cmd_parser.addOption({"panda-serial", "read can messages from panda with given serial", "panda-serial"}); - if (SocketCanStream::available()) { - cmd_parser.addOption({"socketcan", "read can messages from given SocketCAN device", "socketcan"}); + CabanaArgs args; + bool args_ok = false; + if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) { + return code; } - cmd_parser.addOption({"zmq", "read can messages from zmq at the specified ip-address", "ip-address"}); - cmd_parser.addOption({"data_dir", "local directory with routes", "data_dir"}); - cmd_parser.addOption({"no-vipc", "do not output video"}); - cmd_parser.addOption({"dbc", "dbc file to open", "dbc"}); - cmd_parser.process(app); AbstractStream *stream = nullptr; - if (cmd_parser.isSet("msgq")) { - stream = new DeviceStream(&app); - } else if (cmd_parser.isSet("zmq")) { - stream = new DeviceStream(&app, cmd_parser.value("zmq")); - } else if (cmd_parser.isSet("panda") || cmd_parser.isSet("panda-serial")) { + if (args.msgq) { + stream = new DeviceStream(&app, DeviceStream::Mode::Msgq); + } else if (!args.zmq.empty()) { + stream = new DeviceStream(&app, DeviceStream::Mode::Zmq, QString::fromStdString(args.zmq)); + } else if (!args.bridge.empty()) { + stream = new DeviceStream(&app, DeviceStream::Mode::Bridge, QString::fromStdString(args.bridge)); + } else if (args.panda || !args.panda_serial.empty()) { try { - stream = new PandaStream(&app, {.serial = cmd_parser.value("panda-serial")}); + stream = new PandaStream(&app, {.serial = args.panda_serial}); } catch (std::exception &e) { - qWarning() << e.what(); + fprintf(stderr, "%s\n", e.what()); return 0; } - } else if (SocketCanStream::available() && cmd_parser.isSet("socketcan")) { - stream = new SocketCanStream(&app, {.device = cmd_parser.value("socketcan")}); +#ifdef __linux__ + } else if (SocketCanStream::available() && !args.socketcan.empty()) { + stream = new SocketCanStream(&app, {.device = args.socketcan}); +#endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; - if (cmd_parser.isSet("ecam")) replay_flags |= REPLAY_FLAG_ECAM; - if (cmd_parser.isSet("qcam")) replay_flags |= REPLAY_FLAG_QCAMERA; - if (cmd_parser.isSet("dcam")) replay_flags |= REPLAY_FLAG_DCAM; - if (cmd_parser.isSet("no-vipc")) replay_flags |= REPLAY_FLAG_NO_VIPC; + if (args.ecam) replay_flags |= REPLAY_FLAG_ECAM; + if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; + if (args.dcam) replay_flags |= REPLAY_FLAG_DCAM; + if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; - const QStringList args = cmd_parser.positionalArguments(); QString route; - if (args.size() > 0) { - route = args.first(); - } else if (cmd_parser.isSet("demo")) { + if (!args.route.empty()) { + route = QString::fromStdString(args.route); + } else if (args.demo) { route = DEMO_ROUTE; } if (!route.isEmpty()) { auto replay_stream = std::make_unique(&app); - bool auto_source = cmd_parser.isSet("auto"); - if (!replay_stream->loadRoute(route, cmd_parser.value("data_dir"), replay_flags, auto_source)) { + if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { return 0; } stream = replay_stream.release(); } } - MainWindow w(stream, cmd_parser.value("dbc")); + MainWindow w(stream, QString::fromStdString(args.dbc)); return app.exec(); } diff --git a/tools/cabana/cameraview.cc b/tools/cabana/cameraview.cc index 13c838ef..a8df373d 100644 --- a/tools/cabana/cameraview.cc +++ b/tools/cabana/cameraview.cc @@ -1,137 +1,40 @@ #include "tools/cabana/cameraview.h" -#ifdef __APPLE__ -#include -#else -#include -#endif +#include +#include +#include +#include #include +#include -namespace { - -const char frame_vertex_shader[] = -#ifdef __APPLE__ - "#version 330 core\n" -#else - "#version 300 es\n" -#endif - "layout(location = 0) in vec4 aPosition;\n" - "layout(location = 1) in vec2 aTexCoord;\n" - "uniform mat4 uTransform;\n" - "out vec2 vTexCoord;\n" - "void main() {\n" - " gl_Position = uTransform * aPosition;\n" - " vTexCoord = aTexCoord;\n" - "}\n"; - -const char frame_fragment_shader[] = -#ifdef __APPLE__ - "#version 330 core\n" -#else - "#version 300 es\n" - "precision mediump float;\n" -#endif - "uniform sampler2D uTextureY;\n" - "uniform sampler2D uTextureUV;\n" - "in vec2 vTexCoord;\n" - "out vec4 colorOut;\n" - "void main() {\n" - " float y = texture(uTextureY, vTexCoord).r;\n" - " vec2 uv = texture(uTextureUV, vTexCoord).rg - 0.5;\n" - " float r = y + 1.402 * uv.y;\n" - " float g = y - 0.344 * uv.x - 0.714 * uv.y;\n" - " float b = y + 1.772 * uv.x;\n" - " colorOut = vec4(r, g, b, 1.0);\n" - "}\n"; - -} // namespace +#include "common/yuv.h" CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) : - stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QOpenGLWidget(parent) { + stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent); qRegisterMetaType>("availableStreams"); - QObject::connect(this, &CameraWidget::vipcThreadConnected, this, &CameraWidget::vipcConnected, Qt::BlockingQueuedConnection); QObject::connect(this, &CameraWidget::vipcThreadFrameReceived, this, &CameraWidget::vipcFrameReceived, Qt::QueuedConnection); QObject::connect(this, &CameraWidget::vipcAvailableStreamsUpdated, this, &CameraWidget::availableStreamsUpdated, Qt::QueuedConnection); QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread); } CameraWidget::~CameraWidget() { - makeCurrent(); stopVipcThread(); - if (isValid()) { - glDeleteVertexArrays(1, &frame_vao); - glDeleteBuffers(1, &frame_vbo); - glDeleteBuffers(1, &frame_ibo); - glDeleteTextures(2, textures); - shader_program_.reset(); - } - doneCurrent(); -} - -void CameraWidget::initializeGL() { - initializeOpenGLFunctions(); - - shader_program_ = std::make_unique(context()); - shader_program_->addShaderFromSourceCode(QOpenGLShader::Vertex, frame_vertex_shader); - shader_program_->addShaderFromSourceCode(QOpenGLShader::Fragment, frame_fragment_shader); - shader_program_->link(); - - GLint frame_pos_loc = shader_program_->attributeLocation("aPosition"); - GLint frame_texcoord_loc = shader_program_->attributeLocation("aTexCoord"); - - auto [x1, x2, y1, y2] = requested_stream_type == VISION_STREAM_DRIVER ? std::tuple(0.f, 1.f, 1.f, 0.f) : std::tuple(1.f, 0.f, 1.f, 0.f); - const uint8_t frame_indicies[] = {0, 1, 2, 0, 2, 3}; - const float frame_coords[4][4] = { - {-1.0, -1.0, x2, y1}, // bl - {-1.0, 1.0, x2, y2}, // tl - { 1.0, 1.0, x1, y2}, // tr - { 1.0, -1.0, x1, y1}, // br - }; - - glGenVertexArrays(1, &frame_vao); - glBindVertexArray(frame_vao); - glGenBuffers(1, &frame_vbo); - glBindBuffer(GL_ARRAY_BUFFER, frame_vbo); - glBufferData(GL_ARRAY_BUFFER, sizeof(frame_coords), frame_coords, GL_STATIC_DRAW); - glEnableVertexAttribArray(frame_pos_loc); - glVertexAttribPointer(frame_pos_loc, 2, GL_FLOAT, GL_FALSE, - sizeof(frame_coords[0]), (const void *)0); - glEnableVertexAttribArray(frame_texcoord_loc); - glVertexAttribPointer(frame_texcoord_loc, 2, GL_FLOAT, GL_FALSE, - sizeof(frame_coords[0]), (const void *)(sizeof(float) * 2)); - glGenBuffers(1, &frame_ibo); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, frame_ibo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(frame_indicies), frame_indicies, GL_STATIC_DRAW); - glBindBuffer(GL_ARRAY_BUFFER, 0); - glBindVertexArray(0); - - glGenTextures(2, textures); - - shader_program_->bind(); - shader_program_->setUniformValue("uTextureY", 0); - shader_program_->setUniformValue("uTextureUV", 1); - shader_program_->release(); } void CameraWidget::showEvent(QShowEvent *event) { - if (!vipc_thread) { + if (!vipc_thread.joinable()) { clearFrames(); - vipc_thread = new QThread(); - connect(vipc_thread, &QThread::started, [=]() { vipcThread(); }); - connect(vipc_thread, &QThread::finished, vipc_thread, &QObject::deleteLater); - vipc_thread->start(); + vipc_exit = false; + vipc_thread = std::thread(&CameraWidget::vipcThread, this); } } void CameraWidget::stopVipcThread() { - makeCurrent(); - if (vipc_thread) { - vipc_thread->requestInterruption(); - vipc_thread->quit(); - vipc_thread->wait(); - vipc_thread = nullptr; + vipc_exit = true; + if (vipc_thread.joinable()) { + vipc_thread.join(); } } @@ -139,74 +42,29 @@ void CameraWidget::availableStreamsUpdated(std::set streams) { available_streams = streams; } -void CameraWidget::paintGL() { - glClearColor(bg.redF(), bg.greenF(), bg.blueF(), bg.alphaF()); - glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT); +void CameraWidget::paintEvent(QPaintEvent *event) { + QPainter p(this); + p.fillRect(rect(), bg); std::lock_guard lk(frame_lock); - if (!current_frame_) return; + if (rgb_frame.isNull()) return; // Scale for aspect ratio float widget_ratio = (float)width() / height(); - float frame_ratio = (float)stream_width / stream_height; - float scale_x = std::min(frame_ratio / widget_ratio, 1.0f); - float scale_y = std::min(widget_ratio / frame_ratio, 1.0f); + float frame_ratio = (float)rgb_frame.width() / rgb_frame.height(); + int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f)); + int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f)); + QRect video_rect((width() - w) / 2, (height() - h) / 2, w, h); - glViewport(0, 0, width() * devicePixelRatio(), height() * devicePixelRatio()); - - shader_program_->bind(); - QMatrix4x4 transform; - transform.scale(scale_x, scale_y, 1.0f); - shader_program_->setUniformValue("uTransform", transform); - - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - - glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, textures[0]); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width, stream_height, GL_RED, GL_UNSIGNED_BYTE, current_frame_->y); - - glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride/2); - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, textures[1]); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width/2, stream_height/2, GL_RG, GL_UNSIGNED_BYTE, current_frame_->uv); - - glBindVertexArray(frame_vao); - glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_BYTE, nullptr); - glBindVertexArray(0); - - // Reset both texture units - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, 0); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, 0); - glPixelStorei(GL_UNPACK_ALIGNMENT, 4); - glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); - - shader_program_->release(); -} - -void CameraWidget::vipcConnected(VisionIpcClient *vipc_client) { - makeCurrent(); - stream_width = vipc_client->buffers[0].width; - stream_height = vipc_client->buffers[0].height; - stream_stride = vipc_client->buffers[0].stride; - - glBindTexture(GL_TEXTURE_2D, textures[0]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, stream_width, stream_height, 0, GL_RED, GL_UNSIGNED_BYTE, nullptr); - assert(glGetError() == GL_NO_ERROR); - - glBindTexture(GL_TEXTURE_2D, textures[1]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RG8, stream_width/2, stream_height/2, 0, GL_RG, GL_UNSIGNED_BYTE, nullptr); - assert(glGetError() == GL_NO_ERROR); + p.setRenderHint(QPainter::SmoothPixmapTransform); + if (active_stream_type == VISION_STREAM_DRIVER) { + // mirror driver camera horizontally + const qreal cx = video_rect.x() + video_rect.width() / 2.0; + p.translate(cx, 0); + p.scale(-1, 1); + p.translate(-cx, 0); + } + p.drawImage(video_rect, rgb_frame); } void CameraWidget::vipcFrameReceived() { @@ -218,10 +76,11 @@ void CameraWidget::vipcThread() { std::unique_ptr vipc_client; VisionIpcBufExtra frame_meta = {}; - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!vipc_exit) { if (!vipc_client || cur_stream != requested_stream_type) { clearFrames(); - qDebug().nospace() << "connecting to stream " << requested_stream_type << ", was connected to " << cur_stream; + fprintf(stderr, "connecting to stream %d, was connected to %d\n", + (int)requested_stream_type, (int)cur_stream); cur_stream = requested_stream_type; vipc_client.reset(new VisionIpcClient(stream_name, cur_stream, false)); } @@ -231,23 +90,27 @@ void CameraWidget::vipcThread() { clearFrames(); auto streams = VisionIpcClient::getAvailableStreams(stream_name, false); if (streams.empty()) { - QThread::msleep(100); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } emit vipcAvailableStreamsUpdated(streams); if (!vipc_client->connect(false)) { - QThread::msleep(100); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } - emit vipcThreadConnected(vipc_client.get()); } if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { + // NV12 -> RGBA once per frame on the receive thread; paint just draws the image + if (rgb_back.width() != (int)buf->width || rgb_back.height() != (int)buf->height) { + rgb_back = QImage(buf->width, buf->height, QImage::Format_RGBA8888); + } + yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, + rgb_back.bits(), rgb_back.bytesPerLine(), buf->width, buf->height); { std::lock_guard lk(frame_lock); - current_frame_ = buf; - frame_meta_ = frame_meta; + rgb_frame.swap(rgb_back); } emit vipcThreadFrameReceived(); } @@ -256,6 +119,7 @@ void CameraWidget::vipcThread() { void CameraWidget::clearFrames() { std::lock_guard lk(frame_lock); - current_frame_ = nullptr; + rgb_frame = QImage(); + rgb_back = QImage(); available_streams.clear(); } diff --git a/tools/cabana/cameraview.h b/tools/cabana/cameraview.h index 930b13d8..40d37760 100644 --- a/tools/cabana/cameraview.h +++ b/tools/cabana/cameraview.h @@ -1,23 +1,21 @@ #pragma once -#include +#include #include #include #include +#include #include -#include -#include -#include -#include +#include +#include #include "msgq/visionipc/visionipc_client.h" -class CameraWidget : public QOpenGLWidget, protected QOpenGLFunctions { +class CameraWidget : public QWidget { Q_OBJECT public: - using QOpenGLWidget::QOpenGLWidget; explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); ~CameraWidget(); void setStreamType(VisionStreamType type) { requested_stream_type = type; } @@ -26,37 +24,30 @@ public: signals: void clicked(); - void vipcThreadConnected(VisionIpcClient *); void vipcThreadFrameReceived(); void vipcAvailableStreamsUpdated(std::set); protected: - void paintGL() override; - void initializeGL() override; + void paintEvent(QPaintEvent *event) override; void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override { stopVipcThread(); } void mouseReleaseEvent(QMouseEvent *event) override { emit clicked(); } void vipcThread(); void clearFrames(); - GLuint frame_vao, frame_vbo, frame_ibo; - GLuint textures[2]; - std::unique_ptr shader_program_; QColor bg = Qt::black; + QImage rgb_frame; // written by vipc thread, drawn by GUI thread; guarded by frame_lock + QImage rgb_back; // vipc thread only std::string stream_name; - int stream_width = 0; - int stream_height = 0; - int stream_stride = 0; std::atomic active_stream_type; std::atomic requested_stream_type; std::set available_streams; - QThread *vipc_thread = nullptr; - std::recursive_mutex frame_lock; - VisionBuf* current_frame_ = nullptr; - VisionIpcBufExtra frame_meta_ = {}; + std::thread vipc_thread; + std::atomic vipc_exit = false; + std::mutex frame_lock; protected slots: - void vipcConnected(VisionIpcClient *vipc_client); void vipcFrameReceived(); void availableStreamsUpdated(std::set streams); }; diff --git a/tools/cabana/chart/chart.cc b/tools/cabana/chart/chart.cc index bc2380e5..8496c269 100644 --- a/tools/cabana/chart/chart.cc +++ b/tools/cabana/chart/chart.cc @@ -1,71 +1,50 @@ #include "tools/cabana/chart/chart.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include +#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include #include "tools/cabana/chart/chartswidget.h" -// ChartAxisElement's padding is 4 (https://codebrowser.dev/qt5/qtcharts/src/charts/axis/chartaxiselement_p.h.html) const int AXIS_X_TOP_MARGIN = 4; -const double MIN_ZOOM_SECONDS = 0.01; // 10ms +const int X_TICK_COUNT = 5; +const double MIN_ZOOM_SECONDS = 0.01; // 10ms // Define a small value of epsilon to compare double values const float EPSILON = 0.000001; static inline bool xLessThan(const QPointF &p, float x) { return p.x() < (x - EPSILON); } +static QMargins layoutMargins(const QStyle *style) { + return { + style->pixelMetric(QStyle::PM_LayoutLeftMargin), + style->pixelMetric(QStyle::PM_LayoutTopMargin), + style->pixelMetric(QStyle::PM_LayoutRightMargin), + style->pixelMetric(QStyle::PM_LayoutBottomMargin), + }; +} + ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) - : charts_widget(parent), QChartView(parent) { + : x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) { series_type = (SeriesType)settings.chart_series_type; - chart()->setBackgroundVisible(false); - axis_x = new QValueAxis(this); - axis_y = new QValueAxis(this); - chart()->addAxis(axis_x, Qt::AlignBottom); - chart()->addAxis(axis_y, Qt::AlignLeft); - chart()->legend()->layout()->setContentsMargins(0, 0, 0, 0); - chart()->legend()->setShowToolTips(true); - chart()->setMargins({0, 0, 0, 0}); - - axis_x->setRange(x_range.first, x_range.second); - + align_to = 50; + setMouseTracking(true); tip_label = new TipLabel(this); createToolButtons(); - setRubberBand(QChartView::HorizontalRubberBand); - setMouseTracking(true); - setTheme(utils::isDarkTheme() ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight); signal_value_font.setPointSize(9); - QObject::connect(axis_y, &QValueAxis::rangeChanged, this, &ChartView::resetChartCache); - QObject::connect(axis_y, &QAbstractAxis::titleTextChanged, this, &ChartView::resetChartCache); - QObject::connect(window()->windowHandle(), &QWindow::screenChanged, this, &ChartView::resetChartCache); - - QObject::connect(dbc(), &DBCManager::signalRemoved, this, &ChartView::signalRemoved); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &ChartView::signalUpdated); - QObject::connect(dbc(), &DBCManager::msgRemoved, this, &ChartView::msgRemoved); - QObject::connect(dbc(), &DBCManager::msgUpdated, this, &ChartView::msgUpdated); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &ChartView::signalRemoved); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &ChartView::signalUpdated); + QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &ChartView::msgRemoved); + QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &ChartView::msgUpdated); } void ChartView::createToolButtons() { - move_icon = new QGraphicsPixmapItem(utils::icon("grip-horizontal"), chart()); - move_icon->setToolTip(tr("Drag and drop to move chart")); - - QToolButton *remove_btn = new ToolButton("x", tr("Remove Chart")); - close_btn_proxy = new QGraphicsProxyWidget(chart()); - close_btn_proxy->setWidget(remove_btn); - close_btn_proxy->setZValue(chart()->zValue() + 11); + close_btn = new ToolButton("x", tr("Remove Chart"), this); menu = new QMenu(this); // series types @@ -83,17 +62,14 @@ void ChartView::createToolButtons() { menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals); split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); }); - QToolButton *manage_btn = new ToolButton("list", ""); + manage_btn = new ToolButton("list", "", this); manage_btn->setMenu(menu); manage_btn->setPopupMode(QToolButton::InstantPopup); manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - manage_btn_proxy = new QGraphicsProxyWidget(chart()); - manage_btn_proxy->setWidget(manage_btn); - manage_btn_proxy->setZValue(chart()->zValue() + 11); close_act = new QAction(tr("Close"), this); QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); }); - QObject::connect(remove_btn, &QToolButton::clicked, close_act, &QAction::triggered); + QObject::connect(close_btn, &QToolButton::clicked, close_act, &QAction::triggered); QObject::connect(change_series_group, &QActionGroup::triggered, [this](QAction *action) { setSeriesType((SeriesType)action->data().toInt()); }); @@ -103,29 +79,11 @@ QSize ChartView::sizeHint() const { return {CHART_MIN_WIDTH, settings.chart_height}; } -void ChartView::setTheme(QChart::ChartTheme theme) { - chart()->setTheme(theme); - if (theme == QChart::ChartThemeDark) { - axis_x->setTitleBrush(palette().text()); - axis_x->setLabelsBrush(palette().text()); - axis_y->setTitleBrush(palette().text()); - axis_y->setLabelsBrush(palette().text()); - chart()->legend()->setLabelColor(palette().color(QPalette::Text)); - } - axis_x->setLineVisible(false); - axis_y->setLineVisible(false); - for (auto &s : sigs) { - s.series->setColor(s.sig->color); - } -} - void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { if (hasSignal(msg_id, sig)) return; - QXYSeries *series = createSeries(series_type, sig->color); - sigs.push_back({.msg_id = msg_id, .sig = sig, .series = series}); + sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))}); updateSeries(sig); - updateSeriesPoints(); updateTitle(); emit charts_widget->seriesChanged(); } @@ -136,29 +94,21 @@ bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) co void ChartView::removeIf(std::function predicate) { int prev_size = sigs.size(); - for (auto it = sigs.begin(); it != sigs.end(); /**/) { - if (predicate(*it)) { - chart()->removeSeries(it->series); - it->series->deleteLater(); - it = sigs.erase(it); - } else { - ++it; - } - } + sigs.erase(std::remove_if(sigs.begin(), sigs.end(), predicate), sigs.end()); if (sigs.empty()) { charts_widget->removeChart(this); } else if (sigs.size() != prev_size) { emit charts_widget->seriesChanged(); updateAxisY(); - resetChartCache(); + updateTitle(); } } void ChartView::signalUpdated(const cabana::Signal *sig) { auto it = std::find_if(sigs.begin(), sigs.end(), [sig](auto &s) { return s.sig == sig; }); if (it != sigs.end()) { - if (it->series->color() != sig->color) { - setSeriesColor(it->series, sig->color); + if (it->color != toQColor(sig->color)) { + it->color = uniqueColor(toQColor(sig->color), sig); } updateTitle(); updateSeries(sig); @@ -188,98 +138,75 @@ void ChartView::manageSignals() { } void ChartView::resizeEvent(QResizeEvent *event) { - qreal left, top, right, bottom; - chart()->layout()->getContentsMargins(&left, &top, &right, &bottom); - move_icon->setPos(left, top); - close_btn_proxy->setPos(rect().right() - right - close_btn_proxy->size().width(), top); - int x = close_btn_proxy->pos().x() - manage_btn_proxy->size().width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing); - manage_btn_proxy->setPos(x, top); - if (align_to > 0) { - updatePlotArea(align_to, true); - } - QChartView::resizeEvent(event); + QWidget::resizeEvent(event); + const auto margins = layoutMargins(style()); + QPixmap grip = utils::icon("grip-horizontal"); + move_icon_rect = QRect(QPoint(margins.left(), margins.top()), grip.size() / grip.devicePixelRatio()); + close_btn->resize(close_btn->sizeHint()); + manage_btn->resize(manage_btn->sizeHint()); + close_btn->move(rect().right() - margins.right() - close_btn->width(), margins.top()); + manage_btn->move(close_btn->x() - manage_btn->width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), margins.top()); + updatePlotArea(align_to, true); } void ChartView::updatePlotArea(int left_pos, bool force) { if (align_to != left_pos || force) { align_to = left_pos; - qreal left, top, right, bottom; - chart()->layout()->getContentsMargins(&left, &top, &right, &bottom); - QSizeF legend_size = chart()->legend()->layout()->minimumSize(); - legend_size.setWidth(manage_btn_proxy->sceneBoundingRect().left() - move_icon->sceneBoundingRect().right()); - chart()->legend()->setGeometry({move_icon->sceneBoundingRect().topRight(), legend_size}); + const auto margins = layoutMargins(style()); + QFont bold_font = font(); + bold_font.setBold(true); + QFontMetrics fm(font()), bfm(bold_font); + const int marker_size = fm.height() - 4; + const int row_height = std::max(marker_size, fm.height()) + QFontMetrics(signal_value_font).height() + 3; + const int legend_left = move_icon_rect.right() + margins.left(); + const int legend_right = std::max(manage_btn->x() - margins.right(), legend_left + 10); - // add top space for signal value - int adjust_top = chart()->legend()->geometry().height() + QFontMetrics(signal_value_font).height() + 3; - adjust_top = std::max(adjust_top, manage_btn_proxy->sceneBoundingRect().height() + style()->pixelMetric(QStyle::PM_LayoutTopMargin)); + // layout legend entries left-to-right, wrapping between the move icon and the buttons + legend_rects.clear(); + int x = legend_left, y = margins.top(); + for (auto &s : sigs) { + int w = marker_size + 5 + bfm.horizontalAdvance(QString::fromStdString(s.sig->name)) + + fm.horizontalAdvance(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString())); + w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons + if (x + w > legend_right && x > legend_left) { + x = legend_left; + y += row_height; + } + legend_rects.emplace_back(x, y, w, std::max(marker_size, fm.height())); + x += w + 12; + } + + // add top space for the legend and signal values + int adjust_top = (y + row_height) - margins.top(); + adjust_top = std::max(adjust_top, manage_btn->geometry().bottom() + style()->pixelMetric(QStyle::PM_LayoutTopMargin)); // add right space for x-axis label - QSizeF x_label_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, QString::number(axis_x->max(), 'f', 2)); - x_label_size += QSizeF{5, 5}; - chart()->setPlotArea(rect().adjusted(align_to + left, adjust_top + top, -x_label_size.width() / 2 - right, -x_label_size.height() - bottom)); - chart()->layout()->invalidate(); + QSizeF x_label_size = fm.size(Qt::TextSingleLine, QString::number(x_max, 'f', xAxisPrecision())) + QSizeF{5, 5}; + plot_area = rect().adjusted(align_to + margins.left(), adjust_top + margins.top(), + -x_label_size.width() / 2 - margins.right(), + -x_label_size.height() - margins.bottom()); resetChartCache(); } } void ChartView::updateTitle() { - for (QLegendMarker *marker : chart()->legend()->markers()) { - QObject::connect(marker, &QLegendMarker::clicked, this, &ChartView::handleMarkerClicked, Qt::UniqueConnection); - } - - // Use CSS to draw titles in the WindowText color - auto tmp = palette().color(QPalette::WindowText); - auto titleColorCss = tmp.name(QColor::HexArgb); - // Draw message details in similar color, but slightly fade it to the background - tmp.setAlpha(180); - auto msgColorCss = tmp.name(QColor::HexArgb); - - for (auto &s : sigs) { - auto decoration = s.series->isVisible() ? "none" : "line-through"; - s.series->setName(QString("%3 %5 %6") - .arg(decoration, titleColorCss, s.sig->name, - msgColorCss, msgName(s.msg_id), s.msg_id.toString())); - } split_chart_act->setEnabled(sigs.size() > 1); - resetChartCache(); + updatePlotArea(align_to, true); } void ChartView::updatePlot(double cur, double min, double max) { cur_sec = cur; - if (min != axis_x->min() || max != axis_x->max()) { - axis_x->setRange(min, max); + if (min != x_min || max != x_max) { + x_min = min; + x_max = max; updateAxisY(); - updateSeriesPoints(); // update tooltip if (tooltip_x >= 0) { - showTip(chart()->mapToValue({tooltip_x, 0}).x()); + showTip(secondsAtPoint({tooltip_x, 0})); } resetChartCache(); } - viewport()->update(); -} - -void ChartView::updateSeriesPoints() { - // Show points when zoomed in enough - for (auto &s : sigs) { - auto begin = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto end = std::lower_bound(begin, s.vals.cend(), axis_x->max(), xLessThan); - if (begin != end) { - int num_points = std::max((end - begin), 1); - QPointF right_pt = end == s.vals.cend() ? s.vals.back() : *end; - double pixels_per_point = (chart()->mapToPosition(right_pt).x() - chart()->mapToPosition(*begin).x()) / num_points; - - if (series_type == SeriesType::Scatter) { - qreal size = std::clamp(pixels_per_point / 2.0, 2.0, 8.0); - if (s.series->useOpenGL()) { - size *= devicePixelRatioF(); - } - ((QScatterSeries *)s.series)->setMarkerSize(size); - } else { - s.series->setPointsVisible(num_points == 1 || pixels_per_point > 20); - } - } - } + update(); } void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, @@ -324,8 +251,6 @@ void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap * if (!can->liveStreaming()) { s.segment_tree.build(s.vals); } - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - s.series->replace(QVector(points.cbegin(), points.cend())); } } updateAxisY(); @@ -339,18 +264,18 @@ void ChartView::updateAxisY() { double min = std::numeric_limits::max(); double max = std::numeric_limits::lowest(); - QString unit = sigs[0].sig->unit; + QString unit = QString::fromStdString(sigs[0].sig->unit); for (auto &s : sigs) { - if (!s.series->isVisible()) continue; + if (!s.visible) continue; // Only show unit when all signals have the same unit - if (unit != s.sig->unit) { + if (unit != QString::fromStdString(s.sig->unit)) { unit.clear(); } - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan); + auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); + auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); s.min = std::numeric_limits::max(); s.max = std::numeric_limits::lowest(); if (can->liveStreaming()) { @@ -367,28 +292,28 @@ void ChartView::updateAxisY() { if (min == std::numeric_limits::max()) min = 0; if (max == std::numeric_limits::lowest()) max = 0; - if (axis_y->titleText() != unit) { - axis_y->setTitleText(unit); + if (y_unit != unit) { + y_unit = unit; y_label_width = 0; // recalc width } double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05; auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); - if (min_y != axis_y->min() || max_y != axis_y->max() || y_label_width == 0) { - axis_y->setRange(min_y, max_y); - axis_y->setTickCount(tick_count); + if (min_y != y_min || max_y != y_max || y_label_width == 0) { + y_min = min_y; + y_max = max_y; + y_tick_count = tick_count; + y_precision = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0); - int n = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0); + QFontMetrics fm(font()); int max_label_width = 0; - QFontMetrics fm(axis_y->labelsFont()); for (int i = 0; i < tick_count; i++) { qreal value = min_y + (i * (max_y - min_y) / (tick_count - 1)); - max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', n))); + max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', y_precision))); } - int title_spacing = unit.isEmpty() ? 0 : QFontMetrics(axis_y->titleFont()).size(Qt::TextSingleLine, unit).height(); + int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height(); y_label_width = title_spacing + max_label_width + 15; - axis_y->setLabelFormat(QString("%.%1f").arg(n)); emit axisYLabelWidthChanged(y_label_width); } } @@ -402,6 +327,10 @@ std::tuple ChartView::getNiceAxisNumbers(qreal min, qreal m return {min * step, max * step, tick_count}; } +int ChartView::xAxisPrecision() const { + return std::max(int(-std::floor(std::log10((x_max - x_min) / (X_TICK_COUNT - 1)))), 2); +} + // nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n qreal ChartView::niceNumber(qreal x, bool ceiling) { qreal z = std::pow(10, std::floor(std::log10(x))); //find corresponding number of the form of 10^n than is smaller than x @@ -420,45 +349,6 @@ qreal ChartView::niceNumber(qreal x, bool ceiling) { return q * z; } -QPixmap getBlankShadowPixmap(const QPixmap &px, int radius) { - QGraphicsDropShadowEffect *e = new QGraphicsDropShadowEffect; - e->setColor(QColor(40, 40, 40, 245)); - e->setOffset(0, 0); - e->setBlurRadius(radius); - - qreal dpr = px.devicePixelRatio(); - QPixmap blank(px.size()); - blank.setDevicePixelRatio(dpr); - blank.fill(Qt::white); - - QGraphicsScene scene; - QGraphicsPixmapItem item(blank); - item.setGraphicsEffect(e); - scene.addItem(&item); - - QPixmap shadow(px.size() + QSize(radius * dpr * 2, radius * dpr * 2)); - shadow.setDevicePixelRatio(dpr); - shadow.fill(Qt::transparent); - QPainter p(&shadow); - scene.render(&p, {QPoint(), shadow.size() / dpr}, item.boundingRect().adjusted(-radius, -radius, radius, radius)); - return shadow; -} - -static QPixmap getDropPixmap(const QPixmap &src) { - static QPixmap shadow_px; - const int radius = 10; - if (shadow_px.size() != src.size() + QSize(radius * 2, radius * 2)) { - shadow_px = getBlankShadowPixmap(src, radius); - } - QPixmap px = shadow_px; - QPainter p(&px); - QRectF target_rect(QPointF(radius, radius), src.size() / src.devicePixelRatio()); - p.drawPixmap(target_rect.topLeft(), src); - p.setCompositionMode(QPainter::CompositionMode_DestinationIn); - p.fillRect(target_rect, QColor(0, 0, 0, 200)); - return px; -} - void ChartView::contextMenuEvent(QContextMenuEvent *event) { QMenu context_menu(this); context_menu.addActions(menu->actions()); @@ -471,392 +361,410 @@ void ChartView::contextMenuEvent(QContextMenuEvent *event) { } void ChartView::mousePressEvent(QMouseEvent *event) { - if (event->button() == Qt::LeftButton && move_icon->sceneBoundingRect().contains(event->pos())) { - QMimeData *mimeData = new QMimeData; - mimeData->setData(CHART_MIME_TYPE, QByteArray::number((qulonglong)this)); - QPixmap px = grab().scaledToWidth(CHART_MIN_WIDTH * viewport()->devicePixelRatio(), Qt::SmoothTransformation); - charts_widget->stopAutoScroll(); - QDrag *drag = new QDrag(this); - drag->setMimeData(mimeData); - drag->setPixmap(getDropPixmap(px)); - drag->setHotSpot(-QPoint(5, 5)); - drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::MoveAction); - } else if (event->button() == Qt::LeftButton && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) { + press_pos = event->pos(); + if (event->button() == Qt::LeftButton && move_icon_rect.contains(event->pos())) { + charts_widget->startChartDrag(this, event->globalPos()); + } else if (event->button() == Qt::LeftButton && event->modifiers().testFlag(Qt::ShiftModifier)) { // Save current playback state when scrubbing resume_after_scrub = !can->isPaused(); if (resume_after_scrub) { can->pause(true); } - is_scrubbing = true; + mouse_mode = MouseMode::Scrub; + } else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) { + mouse_mode = MouseMode::Rubber; + rubber_rect = QRect(); } else { - QChartView::mousePressEvent(event); - } -} - -void ChartView::mouseReleaseEvent(QMouseEvent *event) { - auto rubber = findChild(); - if (event->button() == Qt::LeftButton && rubber && rubber->isVisible()) { - rubber->hide(); - auto rect = rubber->geometry().normalized(); - // Prevent zooming/seeking past the end of the route - double min = std::clamp(chart()->mapToValue(rect.topLeft()).x(), can->minSeconds(), can->maxSeconds()); - double max = std::clamp(chart()->mapToValue(rect.bottomRight()).x(), can->minSeconds(), can->maxSeconds()); - if (rubber->width() <= 0) { - // no rubber dragged, seek to mouse position - can->seekTo(min); - } else if (rubber->width() > 10 && (max - min) > MIN_ZOOM_SECONDS) { - charts_widget->zoom_undo_stack->push(new ZoomCommand({min, max})); - } else { - viewport()->update(); - } - event->accept(); - } else if (event->button() == Qt::RightButton) { - charts_widget->zoom_undo_stack->undo(); - event->accept(); - } else { - QGraphicsView::mouseReleaseEvent(event); - } - - // Resume playback if we were scrubbing - is_scrubbing = false; - if (resume_after_scrub) { - can->pause(false); - resume_after_scrub = false; + QWidget::mousePressEvent(event); } } void ChartView::mouseMoveEvent(QMouseEvent *ev) { - const auto plot_area = chart()->plotArea(); // Scrubbing - if (is_scrubbing && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) { + if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) { if (plot_area.contains(ev->pos())) { - can->seekTo(std::clamp(chart()->mapToValue(ev->pos()).x(), can->minSeconds(), can->maxSeconds())); + can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds())); } } - auto rubber = findChild(); - bool is_zooming = rubber && rubber->isVisible(); - clearTrackPoints(); + if (mouse_mode == MouseMode::Rubber) { + // horizontal selection, clamped to the plot area + int left = std::clamp(std::min(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); + int right = std::clamp(std::max(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); + rubber_rect = QRect(left, plot_area.top(), right - left, plot_area.height()); + update(); + } - if (!is_zooming && plot_area.contains(ev->pos()) && isActiveWindow()) { + clearTrackPoints(); + if (mouse_mode != MouseMode::Rubber && plot_area.contains(ev->pos()) && isActiveWindow()) { charts_widget->showValueTip(secondsAtPoint(ev->pos())); } else if (tip_label->isVisible()) { charts_widget->showValueTip(-1); } + QWidget::mouseMoveEvent(ev); +} - QChartView::mouseMoveEvent(ev); - if (is_zooming) { - QRect rubber_rect = rubber->geometry(); - rubber_rect.setLeft(std::max(rubber_rect.left(), (int)plot_area.left())); - rubber_rect.setRight(std::min(rubber_rect.right(), (int)plot_area.right())); - if (rubber_rect != rubber->geometry()) { - rubber->setGeometry(rubber_rect); +void ChartView::mouseReleaseEvent(QMouseEvent *event) { + if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::Rubber) { + mouse_mode = MouseMode::None; + // Prevent zooming/seeking past the end of the route + double min = std::clamp(secondsAtPoint(rubber_rect.topLeft()), can->minSeconds(), can->maxSeconds()); + double max = std::clamp(secondsAtPoint(rubber_rect.bottomRight()), can->minSeconds(), can->maxSeconds()); + if (rubber_rect.width() <= 0) { + // no rubber dragged, seek to mouse position + can->seekTo(std::clamp(secondsAtPoint(press_pos), can->minSeconds(), can->maxSeconds())); + } else if (rubber_rect.width() > 10 && (max - min) > MIN_ZOOM_SECONDS) { + charts_widget->zoom_undo_stack.push(new ZoomCommand({min, max})); + } + rubber_rect = QRect(); + update(); + } else if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::None && sigs.size() > 1) { + // toggle series visibility by clicking its legend entry + for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { + if (legend_rects[i].contains(press_pos) && legend_rects[i].contains(event->pos())) { + sigs[i].visible = !sigs[i].visible; + updateAxisY(); + updateTitle(); + break; + } + } + } else if (event->button() == Qt::RightButton) { + charts_widget->zoom_undo_stack.undo(); + } else { + QWidget::mouseReleaseEvent(event); + } + + // Resume playback if we were scrubbing + if (mouse_mode == MouseMode::Scrub) { + mouse_mode = MouseMode::None; + if (resume_after_scrub) { + can->pause(false); + resume_after_scrub = false; } - viewport()->update(); } } +void ChartView::takeSignalsFrom(ChartView *source) { + for (auto &s : source->sigs) { + sigs.push_back(std::move(s)); + sigs.back().color = uniqueColor(sigs.back().color, sigs.back().sig); + } + source->sigs.clear(); + updateAxisY(); + updateTitle(); + charts_widget->removeChart(source); +} + void ChartView::showTip(double sec) { - QRect tip_area(0, chart()->plotArea().top(), rect().width(), chart()->plotArea().height()); + QRect tip_area(0, plot_area.top(), rect().width(), plot_area.height()); QRect visible_rect = charts_widget->chartVisibleRect(this).intersected(tip_area); if (visible_rect.isEmpty()) { tip_label->hide(); return; } - tooltip_x = chart()->mapToPosition({sec, 0}).x(); + tooltip_x = xPos(sec); qreal x = -1; QStringList text_list; for (auto &s : sigs) { - if (s.series->isVisible()) { + if (s.visible) { QString value = "--"; // use reverse iterator to find last item <= sec. - auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double x) { return p.x() > x; }); - if (it != s.vals.crend() && it->x() >= axis_x->min()) { - value = s.sig->formatValue(it->y(), false); + auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double v) { return p.x() > v; }); + if (it != s.vals.crend() && it->x() >= x_min) { + value = QString::fromStdString(s.sig->formatValue(it->y(), false)); s.track_pt = *it; - x = std::max(x, chart()->mapToPosition(*it).x()); + x = std::max(x, xPos(it->x())); } - QString name = sigs.size() > 1 ? s.sig->name + ": " : ""; + QString name = sigs.size() > 1 ? QString::fromStdString(s.sig->name) + ": " : ""; QString min = s.min == std::numeric_limits::max() ? "--" : QString::number(s.min); QString max = s.max == std::numeric_limits::lowest() ? "--" : QString::number(s.max); text_list << QString("%2%3 (%4, %5)") - .arg(s.series->color().name(), name, value, min, max); + .arg(s.color.name(), name, value, min, max); } } if (x < 0) { x = tooltip_x; } - QPoint pt(x, chart()->plotArea().top()); - text_list.push_front(QString::number(chart()->mapToValue({x, 0}).x(), 'f', 3)); + QPoint pt(x, plot_area.top()); + text_list.push_front(QString::number(secondsAtPoint({x, 0}), 'f', 3)); QString text = "

" % text_list.join("
") % "

"; tip_label->showText(pt, text, this, visible_rect); - viewport()->update(); + update(); } void ChartView::hideTip() { clearTrackPoints(); tooltip_x = -1; tip_label->hide(); - viewport()->update(); -} - -void ChartView::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - drawDropIndicator(event->source() != this); - event->acceptProposedAction(); - } -} - -void ChartView::dragMoveEvent(QDragMoveEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - event->setDropAction(event->source() == this ? Qt::MoveAction : Qt::CopyAction); - event->accept(); - } - charts_widget->startAutoScroll(); -} - -void ChartView::dropEvent(QDropEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - if (event->source() != this) { - ChartView *source_chart = (ChartView *)event->source(); - for (auto &s : source_chart->sigs) { - source_chart->chart()->removeSeries(s.series); - addSeries(s.series); - } - sigs.insert(sigs.end(), std::move_iterator(source_chart->sigs.begin()), std::move_iterator(source_chart->sigs.end())); - updateAxisY(); - updateTitle(); - startAnimation(); - - source_chart->sigs.clear(); - charts_widget->removeChart(source_chart); - event->acceptProposedAction(); - } - can_drop = false; - } + update(); } void ChartView::resetChartCache() { chart_pixmap = QPixmap(); - viewport()->update(); -} - -void ChartView::startAnimation() { - QGraphicsOpacityEffect *eff = new QGraphicsOpacityEffect(this); - viewport()->setGraphicsEffect(eff); - QPropertyAnimation *a = new QPropertyAnimation(eff, "opacity"); - a->setDuration(250); - a->setStartValue(0.3); - a->setEndValue(1); - a->setEasingCurve(QEasingCurve::InBack); - a->start(QPropertyAnimation::DeleteWhenStopped); + update(); } void ChartView::paintEvent(QPaintEvent *event) { - if (!can->liveStreaming()) { - if (chart_pixmap.isNull()) { - const qreal dpr = viewport()->devicePixelRatioF(); - chart_pixmap = QPixmap(viewport()->size() * dpr); - chart_pixmap.setDevicePixelRatio(dpr); - QPainter p(&chart_pixmap); - p.setRenderHints(QPainter::Antialiasing); - drawBackground(&p, viewport()->rect()); - scene()->setSceneRect(viewport()->rect()); - scene()->render(&p, viewport()->rect()); - } + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing); - QPainter painter(viewport()); - painter.setRenderHints(QPainter::Antialiasing); - painter.drawPixmap(QPoint(), chart_pixmap); - if (can_drop) { - painter.setPen(QPen(palette().color(QPalette::Highlight), 4)); - painter.drawRect(viewport()->rect()); - } - QRectF exposed_rect = mapToScene(event->region().boundingRect()).boundingRect(); - drawForeground(&painter, exposed_rect); - } else { - QChartView::paintEvent(event); + // the static layer is invalidated on x-range change and data merge, so cache it in live mode too + const qreal dpr = devicePixelRatioF(); + if (chart_pixmap.isNull() || chart_pixmap.size() != size() * dpr) { + chart_pixmap = QPixmap(size() * dpr); + chart_pixmap.setDevicePixelRatio(dpr); + QPainter p(&chart_pixmap); + p.setRenderHints(QPainter::Antialiasing); + p.setFont(font()); + drawStaticLayer(&p); + } + painter.drawPixmap(QPoint(), chart_pixmap); + + if (can_drop) { + painter.setPen(QPen(palette().color(QPalette::Highlight), 4)); + painter.drawRect(rect()); + } + drawForeground(&painter); +} + +void ChartView::drawStaticLayer(QPainter *painter) { + painter->fillRect(rect(), palette().color(QPalette::Base)); + painter->drawPixmap(move_icon_rect.topLeft(), utils::icon("grip-horizontal")); + drawAxes(painter); + drawLegend(painter); + drawSeries(painter); +} + +void ChartView::drawAxes(QPainter *painter) { + const QColor text_color = palette().color(QPalette::Text); + QColor grid_color = text_color; + grid_color.setAlpha(50); + QFontMetrics fm(font()); + painter->setFont(font()); + + // y grid lines and tick labels + for (int i = 0; i < y_tick_count; ++i) { + double value = y_min + i * (y_max - y_min) / (y_tick_count - 1); + qreal y = yPos(value); + painter->setPen(grid_color); + painter->drawLine(QPointF(plot_area.left(), y), QPointF(plot_area.right(), y)); + painter->setPen(text_color); + QRectF label_rect(0, y - fm.height() / 2.0, plot_area.left() - 6, fm.height()); + painter->drawText(label_rect, Qt::AlignRight | Qt::AlignVCenter, QString::number(value, 'f', y_precision)); + } + + // rotated y axis title (unit) + if (!y_unit.isEmpty()) { + painter->save(); + painter->translate(plot_area.left() - y_label_width + fm.height() / 2.0, plot_area.center().y()); + painter->rotate(-90); + painter->drawText(QRectF(-plot_area.height() / 2.0, -fm.height() / 2.0, plot_area.height(), fm.height()), + Qt::AlignCenter, y_unit); + painter->restore(); + } + + // x grid lines and tick labels + const int x_precision = xAxisPrecision(); + for (int i = 0; i < X_TICK_COUNT; ++i) { + double sec = x_min + i * (x_max - x_min) / (X_TICK_COUNT - 1); + qreal x = xPos(sec); + painter->setPen(grid_color); + painter->drawLine(QPointF(x, plot_area.top()), QPointF(x, plot_area.bottom())); + painter->setPen(text_color); + QString label = QString::number(sec, 'f', x_precision); + QRectF label_rect(x - 100, plot_area.bottom() + AXIS_X_TOP_MARGIN, 200, fm.height()); + painter->drawText(label_rect, Qt::AlignHCenter | Qt::AlignTop, label); } } -void ChartView::drawBackground(QPainter *painter, const QRectF &rect) { - painter->fillRect(rect, palette().color(QPalette::Base)); +void ChartView::drawLegend(QPainter *painter) { + QColor title_color = palette().color(QPalette::WindowText); + // Draw message details in similar color, but slightly fade it to the background + QColor msg_color = title_color; + msg_color.setAlpha(180); + QFont bold_font = font(); + bold_font.setBold(true); + const int marker_size = QFontMetrics(font()).height() - 4; + + for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { + const auto &s = sigs[i]; + const QRect &r = legend_rects[i]; + painter->setPen(Qt::NoPen); + painter->setBrush(s.color); + QRectF marker_rect(r.left(), r.center().y() - marker_size / 2.0, marker_size, marker_size); + series_type == SeriesType::Scatter ? painter->drawEllipse(marker_rect) : painter->drawRect(marker_rect); + + bold_font.setStrikeOut(!s.visible); + QFont normal_font = font(); + normal_font.setStrikeOut(!s.visible); + + qreal x = r.left() + marker_size + 5; + painter->setFont(bold_font); + painter->setPen(title_color); + QString name = QFontMetrics(bold_font).elidedText(QString::fromStdString(s.sig->name), Qt::ElideRight, r.right() - x); + painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, name); + x += QFontMetrics(bold_font).horizontalAdvance(name); + painter->setFont(normal_font); + painter->setPen(msg_color); + QString msg = QFontMetrics(normal_font).elidedText(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString()), + Qt::ElideRight, r.right() - x); + painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, msg); + } } -void ChartView::drawForeground(QPainter *painter, const QRectF &rect) { +void ChartView::drawSeries(QPainter *painter) { + painter->save(); + painter->setClipRect(plot_area); + for (auto &s : sigs) { + if (!s.visible) continue; + + // visible points in vals to compute point density + auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); + auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); + int num_points = std::max(last - first, 1); + double pixels_per_point = 0; + if (first != last) { + const QPointF &right_pt = last == s.vals.cend() ? s.vals.back() : *last; + pixels_per_point = (xPos(right_pt.x()) - xPos(first->x())) / num_points; + } + + if (series_type == SeriesType::Scatter) { + qreal radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; + painter->setPen(Qt::NoPen); + painter->setBrush(s.color); + for (auto it = first; it != last; ++it) { + painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), radius, radius); + } + } else { + const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; + auto begin = std::lower_bound(points.cbegin(), points.cend(), x_min, xLessThan); + if (begin != points.cbegin()) --begin; + auto end = std::lower_bound(begin, points.cend(), x_max, xLessThan); + if (end != points.cend()) ++end; + if (begin == end) continue; + + std::vector polyline; + polyline.reserve(end - begin); + for (auto it = begin; it != end; ++it) { + polyline.emplace_back(xPos(it->x()), yPos(it->y())); + } + painter->setPen(QPen(s.color, 2)); + painter->setBrush(Qt::NoBrush); + painter->drawPolyline(polyline.data(), polyline.size()); + + // show points when zoomed in enough + if (num_points == 1 || pixels_per_point > 20) { + painter->setPen(Qt::NoPen); + painter->setBrush(s.color); + for (auto it = first; it != last; ++it) { + painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), 4, 4); + } + } + } + } + painter->restore(); +} + +void ChartView::drawForeground(QPainter *painter) { drawTimeline(painter); drawSignalValue(painter); // draw track points painter->setPen(Qt::NoPen); qreal track_line_x = -1; for (auto &s : sigs) { - if (!s.track_pt.isNull() && s.series->isVisible()) { - painter->setBrush(s.series->color().darker(125)); - QPointF pos = chart()->mapToPosition(s.track_pt); + if (!s.track_pt.isNull() && s.visible) { + painter->setBrush(s.color.darker(125)); + QPointF pos(xPos(s.track_pt.x()), yPos(s.track_pt.y())); painter->drawEllipse(pos, 5.5, 5.5); track_line_x = std::max(track_line_x, pos.x()); } } if (track_line_x > 0) { - auto plot_area = chart()->plotArea(); painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine)); - painter->drawLine(QPointF{track_line_x, plot_area.top()}, QPointF{track_line_x, plot_area.bottom()}); - } - - // paint points. OpenGL mode lacks certain features (such as showing points) - painter->setPen(Qt::NoPen); - for (auto &s : sigs) { - if (s.series->useOpenGL() && s.series->isVisible() && s.series->pointsVisible()) { - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan); - painter->setBrush(s.series->color()); - for (auto it = first; it != last; ++it) { - painter->drawEllipse(chart()->mapToPosition(*it), 4, 4); - } - } + painter->drawLine(QPointF{track_line_x, (qreal)plot_area.top()}, QPointF{track_line_x, (qreal)plot_area.bottom()}); } drawRubberBandTimeRange(painter); } void ChartView::drawRubberBandTimeRange(QPainter *painter) { - auto rubber = findChild(); - if (rubber && rubber->isVisible() && rubber->width() > 1) { - painter->setPen(Qt::white); - auto rubber_rect = rubber->geometry().normalized(); - for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) { - QString sec = QString::number(chart()->mapToValue(pt).x(), 'f', 2); - auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN); - pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2}); - painter->fillRect(r, Qt::gray); - painter->drawText(r, Qt::AlignCenter, sec); - } + if (rubber_rect.width() <= 1) return; + + // selection rect + QColor highlight = palette().color(QPalette::Highlight); + QColor fill = highlight; + fill.setAlpha(50); + painter->fillRect(rubber_rect, fill); + painter->setPen(highlight); + painter->setBrush(Qt::NoBrush); + painter->drawRect(rubber_rect); + + // time labels at the bottom corners + painter->setPen(Qt::white); + painter->setFont(font()); + for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) { + QString sec = QString::number(secondsAtPoint(pt), 'f', 2); + auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN); + pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2}); + painter->fillRect(r, Qt::gray); + painter->drawText(r, Qt::AlignCenter, sec); } } void ChartView::drawTimeline(QPainter *painter) { - const auto plot_area = chart()->plotArea(); // draw vertical time line - qreal x = std::clamp(chart()->mapToPosition(QPointF{cur_sec, 0}).x(), plot_area.left(), plot_area.right()); - painter->setPen(QPen(chart()->titleBrush().color(), 1)); - painter->drawLine(QPointF{x, plot_area.top() - 1}, QPointF{x, plot_area.bottom() + 1}); + qreal x = std::clamp(xPos(cur_sec), (qreal)plot_area.left(), (qreal)plot_area.right()); + painter->setPen(QPen(palette().color(QPalette::Text), 1)); + painter->drawLine(QPointF{x, plot_area.top() - 1.0}, QPointF{x, plot_area.bottom() + 1.0}); // draw current time under the axis-x QString time_str = QString::number(cur_sec, 'f', 2); - QSize time_str_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, time_str) + QSize(8, 2); + QSize time_str_size = QFontMetrics(font()).size(Qt::TextSingleLine, time_str) + QSize(8, 2); QRectF time_str_rect(QPointF(x - time_str_size.width() / 2.0, plot_area.bottom() + AXIS_X_TOP_MARGIN), time_str_size); QPainterPath path; path.addRoundedRect(time_str_rect, 3, 3); painter->fillPath(path, utils::isDarkTheme() ? Qt::darkGray : Qt::gray); painter->setPen(palette().color(QPalette::BrightText)); - painter->setFont(axis_x->labelsFont()); + painter->setFont(font()); painter->drawText(time_str_rect, Qt::AlignCenter, time_str); } void ChartView::drawSignalValue(QPainter *painter) { - auto item_group = qgraphicsitem_cast(chart()->legend()->childItems()[0]); - assert(item_group != nullptr); - auto legend_markers = item_group->childItems(); - assert(legend_markers.size() == sigs.size()); - painter->setFont(signal_value_font); - painter->setPen(chart()->legend()->labelColor()); - int i = 0; - for (auto &s : sigs) { + painter->setPen(palette().color(QPalette::Text)); + for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { + const auto &s = sigs[i]; auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), cur_sec, [](auto &p, double x) { return p.x() > x + EPSILON; }); - QString value = (it != s.vals.crend() && it->x() >= axis_x->min()) ? s.sig->formatValue(it->y()) : "--"; - QRectF marker_rect = legend_markers[i++]->sceneBoundingRect(); - QRectF value_rect(marker_rect.bottomLeft() - QPoint(0, 1), marker_rect.size()); + QString value = (it != s.vals.crend() && it->x() >= x_min) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--"; + QRectF value_rect(legend_rects[i].bottomLeft() - QPoint(0, 1), legend_rects[i].size()); QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width()); painter->drawText(value_rect, Qt::AlignHCenter | Qt::AlignTop, elided_val); } } -QXYSeries *ChartView::createSeries(SeriesType type, QColor color) { - QXYSeries *series = nullptr; - if (type == SeriesType::Line) { - series = new QLineSeries(this); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeRectangle); - } else if (type == SeriesType::StepLine) { - series = new QLineSeries(this); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeFromSeries); - } else { - series = new QScatterSeries(this); - static_cast(series)->setBorderColor(color); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeCircle); - } - series->setColor(color); - // TODO: Due to a bug in CameraWidget the camera frames - // are drawn instead of the graphs on MacOS. Re-enable OpenGL when fixed -#ifndef __APPLE__ - series->setUseOpenGL(true); - // Qt doesn't properly apply device pixel ratio in OpenGL mode - QPen pen = series->pen(); - pen.setWidthF(2.0 * devicePixelRatioF()); - series->setPen(pen); -#endif - addSeries(series); - return series; -} - -void ChartView::addSeries(QXYSeries *series) { - setSeriesColor(series, series->color()); - chart()->addSeries(series); - series->attachAxis(axis_x); - series->attachAxis(axis_y); - - // disables the delivery of mouse events to the opengl widget. - // this enables the user to select the zoom area when the mouse press on the data point. - auto glwidget = findChild(); - if (glwidget && !glwidget->testAttribute(Qt::WA_TransparentForMouseEvents)) { - glwidget->setAttribute(Qt::WA_TransparentForMouseEvents); - } -} - -void ChartView::setSeriesColor(QXYSeries *series, QColor color) { - auto existing_series = chart()->series(); - for (auto s : existing_series) { - if (s != series && std::abs(color.hueF() - qobject_cast(s)->color().hueF()) < 0.1) { +QColor ChartView::uniqueColor(QColor color, const cabana::Signal *exclude) const { + for (auto &s : sigs) { + if (s.sig != exclude && std::abs(color.hueF() - s.color.hueF()) < 0.1) { // use different color to distinguish it from others. - auto last_color = qobject_cast(existing_series.back())->color(); + auto last_color = sigs.back().color; + static thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution sat(35, 99); + std::uniform_int_distribution val(85, 99); color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0), - QRandomGenerator::global()->bounded(35, 100) / 100.0, - QRandomGenerator::global()->bounded(85, 100) / 100.0); + sat(rng) / 100.0, + val(rng) / 100.0); break; } } - series->setColor(color); + return color; } void ChartView::setSeriesType(SeriesType type) { if (type != series_type) { series_type = type; - for (auto &s : sigs) { - chart()->removeSeries(s.series); - s.series->deleteLater(); - } - for (auto &s : sigs) { - s.series = createSeries(series_type, s.sig->color); - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - s.series->replace(QVector(points.cbegin(), points.cend())); - } - updateSeriesPoints(); - updateTitle(); - menu->actions()[(int)type]->setChecked(true); - } -} - -void ChartView::handleMarkerClicked() { - auto marker = qobject_cast(sender()); - Q_ASSERT(marker); - if (sigs.size() > 1) { - auto series = marker->series(); - series->setVisible(!series->isVisible()); - marker->setVisible(true); - updateAxisY(); updateTitle(); } } diff --git a/tools/cabana/chart/chart.h b/tools/cabana/chart/chart.h index f9472bd4..b0362347 100644 --- a/tools/cabana/chart/chart.h +++ b/tools/cabana/chart/chart.h @@ -1,18 +1,11 @@ #pragma once +#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -using namespace QtCharts; #include "tools/cabana/chart/tiplabel.h" #include "tools/cabana/dbc/dbcmanager.h" @@ -25,7 +18,7 @@ enum class SeriesType { }; class ChartsWidget; -class ChartView : public QChartView { +class ChartView : public QWidget { Q_OBJECT public: @@ -38,13 +31,15 @@ public: void updatePlotArea(int left, bool force = false); void showTip(double sec); void hideTip(); - void startAnimation(); - double secondsAtPoint(const QPointF &pt) const { return chart()->mapToValue(pt).x(); } + double secondsAtPoint(const QPointF &pt) const { + return x_min + (pt.x() - plot_area.left()) * (x_max - x_min) / std::max(plot_area.width(), 1); + } struct SigItem { MessageId msg_id; const cabana::Signal *sig = nullptr; - QXYSeries *series = nullptr; + QColor color; + bool visible = true; std::vector vals; std::vector step_vals; QPointF track_pt{}; @@ -59,7 +54,6 @@ signals: private slots: void signalUpdated(const cabana::Signal *sig); void manageSignals(); - void handleMarkerClicked(); void msgUpdated(MessageId id); void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); } void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); } @@ -68,52 +62,65 @@ private: void appendCanEvents(const cabana::Signal *sig, const std::vector &events, std::vector &vals, std::vector &step_vals); void createToolButtons(); - void addSeries(QXYSeries *series); void contextMenuEvent(QContextMenuEvent *event) override; void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *ev) override; - void dragEnterEvent(QDragEnterEvent *event) override; - void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator(false); } - void dragMoveEvent(QDragMoveEvent *event) override; - void dropEvent(QDropEvent *event) override; void resizeEvent(QResizeEvent *event) override; QSize sizeHint() const override; void updateAxisY(); void updateTitle(); void resetChartCache(); - void setTheme(QChart::ChartTheme theme); void paintEvent(QPaintEvent *event) override; - void drawForeground(QPainter *painter, const QRectF &rect) override; - void drawBackground(QPainter *painter, const QRectF &rect) override; - void drawDropIndicator(bool draw) { if (std::exchange(can_drop, draw) != can_drop) viewport()->update(); } + void drawStaticLayer(QPainter *painter); + void drawAxes(QPainter *painter); + void drawLegend(QPainter *painter); + void drawSeries(QPainter *painter); + void drawForeground(QPainter *painter); void drawSignalValue(QPainter *painter); void drawTimeline(QPainter *painter); void drawRubberBandTimeRange(QPainter *painter); + int xAxisPrecision() const; std::tuple getNiceAxisNumbers(qreal min, qreal max, int tick_count); qreal niceNumber(qreal x, bool ceiling); - QXYSeries *createSeries(SeriesType type, QColor color); - void setSeriesColor(QXYSeries *, QColor color); - void updateSeriesPoints(); + QColor uniqueColor(QColor color, const cabana::Signal *exclude = nullptr) const; void removeIf(std::function predicate); + void takeSignalsFrom(ChartView *source); + void setDropHighlight(bool highlight) { if (std::exchange(can_drop, highlight) != highlight) update(); } inline void clearTrackPoints() { for (auto &s : sigs) s.track_pt = {}; } + inline qreal xPos(double sec) const { return plot_area.left() + (sec - x_min) / (x_max - x_min) * plot_area.width(); } + inline qreal yPos(double val) const { return plot_area.bottom() - (val - y_min) / (y_max - y_min) * plot_area.height(); } + // layout + QRect plot_area; + QRect move_icon_rect; + std::vector legend_rects; + // axes + double x_min; + double x_max; + double y_min = 0; + double y_max = 1; + int y_tick_count = 3; + int y_precision = 0; + QString y_unit; int y_label_width = 0; int align_to = 0; - QValueAxis *axis_x; - QValueAxis *axis_y; + // interaction + enum class MouseMode { None, Rubber, Scrub }; + MouseMode mouse_mode = MouseMode::None; + QPoint press_pos; + QRect rubber_rect; + bool resume_after_scrub = false; + QMenu *menu; QAction *split_chart_act; QAction *close_act; - QGraphicsPixmapItem *move_icon; - QGraphicsProxyWidget *close_btn_proxy; - QGraphicsProxyWidget *manage_btn_proxy; + ToolButton *manage_btn; + ToolButton *close_btn; TipLabel *tip_label; std::vector sigs; double cur_sec = 0; SeriesType series_type = SeriesType::Line; - bool is_scrubbing = false; - bool resume_after_scrub = false; QPixmap chart_pixmap; bool can_drop = false; double tooltip_x = -1; diff --git a/tools/cabana/chart/chartswidget.cc b/tools/cabana/chart/chartswidget.cc index aba25dcf..3144c513 100644 --- a/tools/cabana/chart/chartswidget.cc +++ b/tools/cabana/chart/chartswidget.cc @@ -1,13 +1,14 @@ #include "tools/cabana/chart/chartswidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include +#include #include -#include #include +#include #include #include -#include #include "tools/cabana/chart/chart.h" @@ -71,11 +72,14 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { range_slider_action = toolbar->addWidget(range_slider); // zoom controls - zoom_undo_stack = new QUndoStack(this); - toolbar->addAction(undo_zoom_action = zoom_undo_stack->createUndoAction(this)); - undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); - toolbar->addAction(redo_zoom_action = zoom_undo_stack->createRedoAction(this)); - redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); + undo_zoom_action = toolbar->addAction(utils::icon("arrow-counterclockwise"), tr("Undo Zoom"), [this]() { zoom_undo_stack.undo(); }); + redo_zoom_action = toolbar->addAction(utils::icon("arrow-clockwise"), tr("Redo Zoom"), [this]() { zoom_undo_stack.redo(); }); + undo_zoom_action->setEnabled(false); + redo_zoom_action->setEnabled(false); + zoom_undo_stack.setCallbacks({.index_changed = [this]() { + undo_zoom_action->setEnabled(zoom_undo_stack.canUndo()); + redo_zoom_action->setEnabled(zoom_undo_stack.canRedo()); + }}); reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom"))); reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); @@ -88,8 +92,6 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { tabbar->setAutoHide(true); tabbar->setExpanding(false); tabbar->setDrawBase(true); - tabbar->setAcceptDrops(true); - tabbar->setChangeCurrentOnDrag(true); tabbar->setUsesScrollButtons(true); main_layout->addWidget(tabbar); @@ -104,6 +106,11 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { charts_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); main_layout->addWidget(charts_scroll); + // chart drag preview + drag_preview = new QLabel(this); + drag_preview->setAttribute(Qt::WA_TransparentForMouseEvents); + drag_preview->hide(); + // init settings current_theme = settings.theme; column_count = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); @@ -115,7 +122,7 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { align_timer->setSingleShot(true); QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts); QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &ChartsWidget::removeAll); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &ChartsWidget::removeAll); QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged); QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState); QObject::connect(can, &AbstractStream::seeking, this, &ChartsWidget::updateState); @@ -166,15 +173,16 @@ void ChartsWidget::removeTab(int index) { void ChartsWidget::updateTabBar() { for (int i = 0; i < tabbar->count(); ++i) { const auto &charts_in_tab = tab_charts[tabbar->tabData(i).toInt()]; - tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg(charts_in_tab.count())); + tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg((int)charts_in_tab.size())); } } void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { - QFutureSynchronizer future_synchronizer; + std::vector> futures; for (auto c : charts) { - future_synchronizer.addFuture(QtConcurrent::run(c, &ChartView::updateSeries, nullptr, &new_events)); + futures.push_back(std::async(std::launch::async, &ChartView::updateSeries, c, nullptr, &new_events)); } + for (auto &f : futures) f.get(); } void ChartsWidget::timeRangeChanged(const std::optional> &time_range) { @@ -184,7 +192,7 @@ void ChartsWidget::timeRangeChanged(const std::optionalsetTimeRange(std::nullopt); - zoom_undo_stack->clear(); + zoom_undo_stack.clear(); } QRect ChartsWidget::chartVisibleRect(ChartView *chart) { @@ -203,7 +211,7 @@ void ChartsWidget::showValueTip(double sec) { } void ChartsWidget::updateState() { - if (charts.isEmpty()) return; + if (charts.empty()) return; const auto &time_range = can->timeRange(); const double cur_sec = can->currentSec(); @@ -247,17 +255,13 @@ void ChartsWidget::updateToolBar() { redo_zoom_action->setVisible(is_zoomed); reset_zoom_action->setVisible(is_zoomed); reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : ""); - remove_all_btn->setEnabled(!charts.isEmpty()); + remove_all_btn->setEnabled(!charts.empty()); } void ChartsWidget::settingChanged() { if (std::exchange(current_theme, settings.theme) != current_theme) { undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); - auto theme = utils::isDarkTheme() ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight; - for (auto c : charts) { - c->setTheme(theme); - } } if (range_slider->maximum() != settings.max_cached_minutes * 60) { range_slider->setRange(1, settings.max_cached_minutes * 60); @@ -281,9 +285,9 @@ ChartView *ChartsWidget::createChart(int pos) { chart->setMinimumWidth(CHART_MIN_WIDTH); chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed); QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start)); - pos = std::clamp(pos, 0, charts.size()); - charts.insert(pos, chart); - currentCharts().insert(pos, chart); + pos = std::clamp(pos, 0, (int)charts.size()); + charts.insert(charts.begin() + pos, chart); + currentCharts().insert(currentCharts().begin() + pos, chart); updateLayout(true); updateToolBar(); return chart; @@ -302,15 +306,11 @@ void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, boo void ChartsWidget::splitChart(ChartView *src_chart) { if (src_chart->sigs.size() > 1) { - int pos = charts.indexOf(src_chart) + 1; + int pos = std::find(charts.begin(), charts.end(), src_chart) - charts.begin() + 1; for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) { auto c = createChart(pos); - src_chart->chart()->removeSeries(it->series); - // Restore to the original color - it->series->setColor(it->sig->color); - - c->addSeries(it->series); + it->color = toQColor(it->sig->color); c->sigs.emplace_back(std::move(*it)); c->updateAxisY(); c->updateTitle(); @@ -318,6 +318,7 @@ void ChartsWidget::splitChart(ChartView *src_chart) { } src_chart->updateAxisY(); src_chart->updateTitle(); + updateState(); QTimer::singleShot(0, src_chart, &ChartView::resetChartCache); } } @@ -327,7 +328,7 @@ QStringList ChartsWidget::serializeChartIds() const { for (auto c : charts) { QStringList ids; for (const auto& s : c->sigs) - ids += QString("%1|%2").arg(s.msg_id.toString(), s.sig->name); + ids += QString("%1|%2").arg(QString::fromStdString(s.msg_id.toString()), QString::fromStdString(s.sig->name)); chart_ids += ids.join(','); } std::reverse(chart_ids.begin(), chart_ids.end()); @@ -340,9 +341,9 @@ void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) { for (const auto& part : chart_id.split(',')) { const auto sig_parts = part.split('|'); if (sig_parts.size() != 2) continue; - MessageId msg_id = MessageId::fromString(sig_parts[0]); + MessageId msg_id = MessageId::fromString(sig_parts[0].toStdString()); if (auto* msg = dbc()->msg(msg_id)) - if (auto* sig = msg->sig(sig_parts[1])) + if (auto* sig = msg->sig(sig_parts[1].toStdString())) showChart(msg_id, sig, true, index++ > 0); } } @@ -388,7 +389,87 @@ void ChartsWidget::updateLayout(bool force) { } } -void ChartsWidget::startAutoScroll() { +void ChartsWidget::startChartDrag(ChartView *chart, const QPoint &global_pos) { + stopAutoScroll(); + drag = {.source = chart, .press_pos = global_pos}; + QPixmap px = chart->grab().scaledToWidth(CHART_MIN_WIDTH * chart->devicePixelRatio(), Qt::SmoothTransformation); + drag_preview->setPixmap(px); + drag_preview->resize(px.size() / px.devicePixelRatio()); +} + +void ChartsWidget::dragChartMove(const QPoint &global_pos) { + if (!drag.active) { + if ((global_pos - drag.press_pos).manhattanLength() < QApplication::startDragDistance()) return; + drag.active = true; + drag_preview->show(); + drag_preview->raise(); + } + drag_preview->move(mapFromGlobal(global_pos) + QPoint(5, 5)); + + // hovering a tab switches to it so the chart can be dropped into another tab + int tab = tabbar->tabAt(tabbar->mapFromGlobal(global_pos)); + if (tab >= 0 && tab != tabbar->currentIndex()) { + tabbar->setCurrentIndex(tab); + } + + const QPoint container_pos = charts_container->mapFromGlobal(global_pos); + ChartView *target = nullptr; + for (auto c : currentCharts()) { + if (c != drag.source && c->isVisible() && c->geometry().contains(container_pos)) { + target = c; + break; + } + } + if (std::exchange(drop_target, target) != target) { + for (auto c : charts) c->setDropHighlight(c == target); + } + bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); + bool on_background = !target && in_viewport && !charts_container->childAt(container_pos); + charts_container->drawDropIndicator(on_background ? container_pos : QPoint()); + + if (in_viewport) { + startAutoScroll(global_pos); + } +} + +void ChartsWidget::cancelChartDrag() { + drag = {}; + stopAutoScroll(); + drag_preview->hide(); + charts_container->drawDropIndicator({}); + if (auto target = std::exchange(drop_target, nullptr)) target->setDropHighlight(false); +} + +void ChartsWidget::dragChartRelease(const QPoint &global_pos) { + ChartView *source = drag.source; + bool active = drag.active; + ChartView *target = drop_target; + cancelChartDrag(); + if (!active) return; + + const QPoint container_pos = charts_container->mapFromGlobal(global_pos); + bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); + if (target) { + // merge source into target + target->takeSignalsFrom(source); + } else if (in_viewport && !charts_container->childAt(container_pos)) { + // reorder within the current tab + auto w = charts_container->getDropAfter(container_pos); + if (w != source) { + for (auto &[_, list] : tab_charts) { + list.erase(std::remove(list.begin(), list.end(), source), list.end()); + } + auto &cur = currentCharts(); + int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; + cur.insert(cur.begin() + to, source); + updateLayout(true); + updateTabBar(); + } + } +} + +void ChartsWidget::startAutoScroll(const QPoint &global_pos) { + auto_scroll_pos = global_pos; auto_scroll_timer->start(50); } @@ -404,7 +485,7 @@ void ChartsWidget::doAutoScroll() { } int value = scroll->value(); - QPoint pos = charts_scroll->viewport()->mapFromGlobal(QCursor::pos()); + QPoint pos = charts_scroll->viewport()->mapFromGlobal(auto_scroll_pos); QRect area = charts_scroll->viewport()->rect(); if (pos.y() - area.top() < settings.chart_height / 2) { @@ -412,41 +493,39 @@ void ChartsWidget::doAutoScroll() { } else if (area.bottom() - pos.y() < settings.chart_height / 2) { scroll->setValue(value + auto_scroll_count); } - bool vertical_unchanged = value == scroll->value(); - if (vertical_unchanged) { + if (value == scroll->value()) { stopAutoScroll(); - } else { - // mouseMoveEvent to updates the drag-selection rectangle - const QPoint globalPos = charts_scroll->viewport()->mapToGlobal(pos); - const QPoint windowPos = charts_scroll->window()->mapFromGlobal(globalPos); - QMouseEvent mm(QEvent::MouseMove, pos, windowPos, globalPos, - Qt::NoButton, Qt::LeftButton, Qt::NoModifier, Qt::MouseEventSynthesizedByQt); - QApplication::sendEvent(charts_scroll->viewport(), &mm); + } else if (chartDragActive()) { + // refresh the drop indicator/target at the new scroll position + dragChartMove(auto_scroll_pos); } } QSize ChartsWidget::minimumSizeHint() const { - return QSize(CHART_MIN_WIDTH * 1.5 * qApp->devicePixelRatio(), QWidget::minimumSizeHint().height()); + return QSize(CHART_MIN_WIDTH * 1.5, QWidget::minimumSizeHint().height()); } void ChartsWidget::newChart() { SignalSelector dlg(tr("New Chart"), this); if (dlg.exec() == QDialog::Accepted) { auto items = dlg.seletedItems(); - if (!items.isEmpty()) { + if (!items.empty()) { auto c = createChart(); for (auto it : items) { c->addSignal(it->msg_id, it->sig); } + updateState(); } } } void ChartsWidget::removeChart(ChartView *chart) { - charts.removeOne(chart); + if (drag.source == chart) cancelChartDrag(); + if (drop_target == chart) drop_target = nullptr; + charts.erase(std::remove(charts.begin(), charts.end(), chart), charts.end()); chart->deleteLater(); for (auto &[_, list] : tab_charts) { - list.removeOne(chart); + list.erase(std::remove(list.begin(), list.end(), chart), list.end()); } updateToolBar(); updateLayout(true); @@ -460,7 +539,7 @@ void ChartsWidget::removeAll() { } tab_charts.clear(); - if (!charts.isEmpty()) { + if (!charts.empty()) { for (auto c : charts) { delete c; } @@ -482,6 +561,19 @@ void ChartsWidget::alignCharts() { } bool ChartsWidget::eventFilter(QObject *o, QEvent *e) { + // route all mouse events to the chart drag, even when the source chart is hidden by a tab switch + if (chartDragActive()) { + if (e->type() == QEvent::MouseMove) { + dragChartMove(static_cast(e)->globalPos()); + return true; + } else if (e->type() == QEvent::MouseButtonRelease && static_cast(e)->button() == Qt::LeftButton) { + dragChartRelease(static_cast(e)->globalPos()); + return false; // let the release through so Qt clears the implicit mouse grab + } else if (e->type() == QEvent::MouseButtonPress || e->type() == QEvent::MouseButtonRelease) { + return true; // swallow other buttons during the drag + } + } + if (!value_tip_visible_) return false; if (e->type() == QEvent::MouseMove) { @@ -490,7 +582,7 @@ bool ChartsWidget::eventFilter(QObject *o, QEvent *e) { for (const auto &c : charts) { auto local_pos = c->mapFromGlobal(global_pos); - if (c->chart()->plotArea().contains(local_pos)) { + if (c->plot_area.contains(local_pos)) { if (on_tip) { showValueTip(c->secondsAtPoint(local_pos)); } @@ -522,13 +614,14 @@ bool ChartsWidget::event(QEvent *event) { break; case QEvent::WindowDeactivate: case QEvent::FocusOut: + if (chartDragActive()) cancelChartDrag(); showValueTip(-1); default: break; } if (back_button) { - zoom_undo_stack->undo(); + zoom_undo_stack.undo(); return true; // Return true since the event has been handled } return QFrame::event(event); @@ -537,7 +630,6 @@ bool ChartsWidget::event(QEvent *event) { // ChartsContainer ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) { - setAcceptDrops(true); setBackgroundRole(QPalette::Window); QVBoxLayout *charts_main_layout = new QVBoxLayout(this); charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING); @@ -547,32 +639,6 @@ ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), charts_main_layout->addStretch(0); } -void ChartsContainer::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - event->acceptProposedAction(); - drawDropIndicator(event->pos()); - } -} - -void ChartsContainer::dropEvent(QDropEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - auto w = getDropAfter(event->pos()); - auto chart = qobject_cast(event->source()); - if (w != chart) { - for (auto &[_, list] : charts_widget->tab_charts) { - list.removeOne(chart); - } - int to = w ? charts_widget->currentCharts().indexOf(w) + 1 : 0; - charts_widget->currentCharts().insert(to, chart); - charts_widget->updateLayout(true); - charts_widget->updateTabBar(); - event->acceptProposedAction(); - chart->startAnimation(); - } - drawDropIndicator({}); - } -} - void ChartsContainer::paintEvent(QPaintEvent *ev) { if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) { QRect r = geometry(); diff --git a/tools/cabana/chart/chartswidget.h b/tools/cabana/chart/chartswidget.h index f87b1276..8b3003dc 100644 --- a/tools/cabana/chart/chartswidget.h +++ b/tools/cabana/chart/chartswidget.h @@ -8,15 +8,13 @@ #include #include #include -#include -#include #include "tools/cabana/chart/signalselector.h" +#include "tools/cabana/commands.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" const int CHART_MIN_WIDTH = 300; -const QString CHART_MIME_TYPE = "application/x-cabanachartview"; class ChartView; class ChartsWidget; @@ -24,9 +22,6 @@ class ChartsWidget; class ChartsContainer : public QWidget { public: ChartsContainer(ChartsWidget *parent); - void dragEnterEvent(QDragEnterEvent *event) override; - void dropEvent(QDropEvent *event) override; - void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator({}); } void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); } void paintEvent(QPaintEvent *ev) override; ChartView *getDropAfter(const QPoint &pos) const; @@ -69,7 +64,12 @@ private: void eventsMerged(const MessageEventsMap &new_events); void updateState(); void zoomReset(); - void startAutoScroll(); + void startChartDrag(ChartView *chart, const QPoint &global_pos); + void dragChartMove(const QPoint &global_pos); + void dragChartRelease(const QPoint &global_pos); + void cancelChartDrag(); + bool chartDragActive() const { return drag.source != nullptr; } + void startAutoScroll(const QPoint &global_pos); void stopAutoScroll(); void doAutoScroll(); void updateToolBar(); @@ -81,7 +81,7 @@ private: bool eventFilter(QObject *obj, QEvent *event) override; void newTab(); void removeTab(int index); - inline QList ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } + inline std::vector ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } ChartView *findChart(const MessageId &id, const cabana::Signal *sig); QLabel *title_label; @@ -97,11 +97,11 @@ private: QAction *redo_zoom_action; QAction *reset_zoom_action; ToolButton *reset_zoom_btn; - QUndoStack *zoom_undo_stack; + UndoStack zoom_undo_stack; ToolButton *remove_all_btn; - QList charts; - std::unordered_map> tab_charts; + std::vector charts; + std::unordered_map> tab_charts; TabBar *tabbar; ChartsContainer *charts_container; QScrollArea *charts_scroll; @@ -110,7 +110,15 @@ private: QAction *columns_action; int column_count = 1; int current_column_count = 0; + struct ChartDrag { + ChartView *source = nullptr; + QPoint press_pos; // global + bool active = false; + } drag; + QLabel *drag_preview; + ChartView *drop_target = nullptr; int auto_scroll_count = 0; + QPoint auto_scroll_pos; QTimer *auto_scroll_timer; QTimer *align_timer; int current_theme = 0; @@ -119,11 +127,10 @@ private: friend class ChartsContainer; }; -class ZoomCommand : public QUndoCommand { +class ZoomCommand : public UndoCommand { public: - ZoomCommand(std::pair range) : range(range), QUndoCommand() { + ZoomCommand(std::pair range) : range(range) { prev_range = can->timeRange(); - setText(QObject::tr("Zoom to %1-%2").arg(range.first, 0, 'f', 2).arg(range.second, 0, 'f', 2)); } void undo() override { can->setTimeRange(prev_range); } void redo() override { can->setTimeRange(range); } diff --git a/tools/cabana/chart/signalselector.cc b/tools/cabana/chart/signalselector.cc index 63f3a7d5..85832e79 100644 --- a/tools/cabana/chart/signalselector.cc +++ b/tools/cabana/chart/signalselector.cc @@ -1,6 +1,6 @@ #include "tools/cabana/chart/signalselector.h" +#include "tools/cabana/dbc/dbcqt.h" -#include #include #include #include @@ -20,8 +20,6 @@ SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) msgs_combo->setEditable(true); msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg...")); msgs_combo->setInsertPolicy(QComboBox::NoInsert); - msgs_combo->completer()->setCompletionMode(QCompleter::PopupCompletion); - msgs_combo->completer()->setFilterMode(Qt::MatchContains); main_layout->addWidget(available_list = new QListWidget(this), 2, 0); @@ -46,7 +44,7 @@ SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) for (const auto &[id, _] : can->lastMessages()) { if (auto m = dbc()->msg(id)) { - msgs_combo->addItem(QString("%1 (%2)").arg(m->name).arg(id.toString()), QVariant::fromValue(id)); + msgs_combo->addItem(QString("%1 (%2)").arg(QString::fromStdString(m->name)).arg(QString::fromStdString(id.toString())), QVariant::fromValue(id)); } } msgs_combo->model()->sort(0); @@ -92,8 +90,8 @@ void SignalSelector::updateAvailableList(int index) { } void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) { - QString text = QString(" %1").arg(sig->color.name(), sig->name); - if (show_msg_name) text += QString(" %0 %1").arg(msgName(id), id.toString()); + QString text = QString(" %1").arg(toQColor(sig->color).name(), QString::fromStdString(sig->name)); + if (show_msg_name) text += QString(" %0 %1").arg(QString::fromStdString(msgName(id)), QString::fromStdString(id.toString())); QLabel *label = new QLabel(text); label->setContentsMargins(5, 0, 5, 0); @@ -102,8 +100,8 @@ void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, cons parent->setItemWidget(new_item, label); } -QList SignalSelector::seletedItems() { - QList ret; +std::vector SignalSelector::seletedItems() { + std::vector ret; for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i)); return ret; } diff --git a/tools/cabana/chart/signalselector.h b/tools/cabana/chart/signalselector.h index f46779f0..5b6e37e5 100644 --- a/tools/cabana/chart/signalselector.h +++ b/tools/cabana/chart/signalselector.h @@ -15,7 +15,7 @@ public: }; SignalSelector(QString title, QWidget *parent); - QList seletedItems(); + std::vector seletedItems(); inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); } private: diff --git a/tools/cabana/chart/sparkline.cc b/tools/cabana/chart/sparkline.cc index 91435cd5..f5bef0fc 100644 --- a/tools/cabana/chart/sparkline.cc +++ b/tools/cabana/chart/sparkline.cc @@ -31,7 +31,7 @@ void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIt } freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0); - render(sig->color, range, size); + render(toQColor(sig->color), range, size); } void Sparkline::render(const QColor &color, int range, QSize size) { diff --git a/tools/cabana/commands.cc b/tools/cabana/commands.cc index 52861723..b47bf90b 100644 --- a/tools/cabana/commands.cc +++ b/tools/cabana/commands.cc @@ -1,25 +1,86 @@ -#include - #include "tools/cabana/commands.h" +#include + +// UndoStack + +void UndoStack::push(UndoCommand *cmd) { + commands_.resize(index_); // drop any redoable commands + if (clean_index_ > index_) clean_index_ = -1; + commands_.emplace_back(cmd); + cmd->redo(); + setIndex(index_ + 1); +} + +void UndoStack::undo() { + if (!canUndo()) return; + commands_[index_ - 1]->undo(); + setIndex(index_ - 1); +} + +void UndoStack::redo() { + if (!canRedo()) return; + commands_[index_]->redo(); + setIndex(index_ + 1); +} + +void UndoStack::clear() { + bool was_clean = isClean(); + commands_.clear(); + index_ = clean_index_ = 0; + if (callbacks_.index_changed) callbacks_.index_changed(); + if (!was_clean && callbacks_.clean_changed) callbacks_.clean_changed(true); +} + +void UndoStack::setClean() { + if (!isClean()) { + clean_index_ = index_; + if (callbacks_.clean_changed) callbacks_.clean_changed(true); + } +} + +void UndoStack::setIndex(int index) { + bool was_clean = isClean(); + index_ = index; + if (callbacks_.index_changed) callbacks_.index_changed(); + if (isClean() != was_clean && callbacks_.clean_changed) callbacks_.clean_changed(isClean()); +} + +UndoStack *UndoStack::instance() { + static UndoStack undo_stack; + return &undo_stack; +} + +QtUndoNotifier::QtUndoNotifier(QObject *parent) : QObject(parent) { + UndoStack::instance()->setCallbacks({ + .index_changed = [this]() { emit indexChanged(); }, + .clean_changed = [this](bool clean) { emit cleanChanged(clean); }, + }); +} + +QtUndoNotifier *undoNotifier() { + static QtUndoNotifier notifier; + return ¬ifier; +} + // EditMsgCommand -EditMsgCommand::EditMsgCommand(const MessageId &id, const QString &name, int size, - const QString &node, const QString &comment, QUndoCommand *parent) - : id(id), new_name(name), new_size(size), new_node(node), new_comment(comment), QUndoCommand(parent) { +EditMsgCommand::EditMsgCommand(const MessageId &id, const std::string &name, int size, + const std::string &node, const std::string &comment) + : id(id), new_name(name), new_size(size), new_node(node), new_comment(comment) { if (auto msg = dbc()->msg(id)) { old_name = msg->name; old_size = msg->size; old_node = msg->transmitter; old_comment = msg->comment; - setText(QObject::tr("edit message %1:%2").arg(name).arg(id.address)); + text = "edit message " + name + ":" + std::to_string(id.address); } else { - setText(QObject::tr("new message %1:%2").arg(name).arg(id.address)); + text = "new message " + name + ":" + std::to_string(id.address); } } void EditMsgCommand::undo() { - if (old_name.isEmpty()) + if (old_name.empty()) dbc()->removeMsg(id); else dbc()->updateMsg(id, old_name, old_size, old_node, old_comment); @@ -31,15 +92,15 @@ void EditMsgCommand::redo() { // RemoveMsgCommand -RemoveMsgCommand::RemoveMsgCommand(const MessageId &id, QUndoCommand *parent) : id(id), QUndoCommand(parent) { +RemoveMsgCommand::RemoveMsgCommand(const MessageId &id) : id(id) { if (auto msg = dbc()->msg(id)) { message = *msg; - setText(QObject::tr("remove message %1:%2").arg(message.name).arg(id.address)); + text = "remove message " + message.name + ":" + std::to_string(id.address); } } void RemoveMsgCommand::undo() { - if (!message.name.isEmpty()) { + if (!message.name.empty()) { dbc()->updateMsg(id, message.name, message.size, message.transmitter, message.comment); for (auto s : message.getSignals()) dbc()->addSignal(id, *s); @@ -47,15 +108,15 @@ void RemoveMsgCommand::undo() { } void RemoveMsgCommand::redo() { - if (!message.name.isEmpty()) + if (!message.name.empty()) dbc()->removeMsg(id); } // AddSigCommand -AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent) - : id(id), signal(sig), QUndoCommand(parent) { - setText(QObject::tr("add signal %1 to %2:%3").arg(sig.name).arg(msgName(id)).arg(id.address)); +AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig) + : id(id), signal(sig) { + text = "add signal " + sig.name + " to " + msgName(id) + ":" + std::to_string(id.address); } void AddSigCommand::undo() { @@ -75,8 +136,7 @@ void AddSigCommand::redo() { // RemoveSigCommand -RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent) - : id(id), QUndoCommand(parent) { +RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig) : id(id) { sigs.push_back(*sig); if (sig->type == cabana::Signal::Type::Multiplexor) { for (const auto &s : dbc()->msg(id)->sigs) { @@ -85,7 +145,7 @@ RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *si } } } - setText(QObject::tr("remove signal %1 from %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address)); + text = "remove signal " + sig->name + " from " + msgName(id) + ":" + std::to_string(id.address); } void RemoveSigCommand::undo() { for (const auto &s : sigs) dbc()->addSignal(id, s); } @@ -93,8 +153,8 @@ void RemoveSigCommand::redo() { for (const auto &s : sigs) dbc()->removeSignal(i // EditSignalCommand -EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent) - : id(id), QUndoCommand(parent) { +EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig) + : id(id) { sigs.push_back({*sig, new_sig}); if (sig->type == cabana::Signal::Type::Multiplexor && new_sig.type == cabana::Signal::Type::Normal) { // convert all multiplexed signals to normal signals @@ -108,17 +168,8 @@ EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal * } } } - setText(QObject::tr("edit signal %1 in %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address)); + text = "edit signal " + sig->name + " in " + msgName(id) + ":" + std::to_string(id.address); } void EditSignalCommand::undo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.second.name, s.first); } void EditSignalCommand::redo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.first.name, s.second); } - -namespace UndoStack { - -QUndoStack *instance() { - static QUndoStack *undo_stack = new QUndoStack(qApp); - return undo_stack; -} - -} // namespace UndoStack diff --git a/tools/cabana/commands.h b/tools/cabana/commands.h index 0736d9b8..200a4f2f 100644 --- a/tools/cabana/commands.h +++ b/tools/cabana/commands.h @@ -1,29 +1,82 @@ #pragma once +#include +#include +#include #include +#include -#include -#include +#include #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" -class EditMsgCommand : public QUndoCommand { +class UndoCommand { public: - EditMsgCommand(const MessageId &id, const QString &name, int size, const QString &node, - const QString &comment, QUndoCommand *parent = nullptr); + virtual ~UndoCommand() = default; + virtual void undo() = 0; + virtual void redo() = 0; + std::string text; +}; + +class UndoStack { +public: + struct Callbacks { + std::function index_changed; + std::function clean_changed; + }; + + void push(UndoCommand *cmd); // takes ownership and calls redo() + void undo(); + void redo(); + void clear(); + void setClean(); + bool isClean() const { return clean_index_ == index_; } + bool canUndo() const { return index_ > 0; } + bool canRedo() const { return index_ < (int)commands_.size(); } + std::string undoText() const { return canUndo() ? commands_[index_ - 1]->text : ""; } + std::string redoText() const { return canRedo() ? commands_[index_]->text : ""; } + void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } + static UndoStack *instance(); + +private: + void setIndex(int index); + std::vector> commands_; + int index_ = 0; + int clean_index_ = 0; + Callbacks callbacks_; +}; + +// emits Qt signals for the global undo stack +class QtUndoNotifier : public QObject { + Q_OBJECT + +public: + explicit QtUndoNotifier(QObject *parent = nullptr); + +signals: + void indexChanged(); + void cleanChanged(bool clean); +}; + +QtUndoNotifier *undoNotifier(); + +class EditMsgCommand : public UndoCommand { +public: + EditMsgCommand(const MessageId &id, const std::string &name, int size, const std::string &node, + const std::string &comment); void undo() override; void redo() override; private: const MessageId id; - QString old_name, new_name, old_comment, new_comment, old_node, new_node; + std::string old_name, new_name, old_comment, new_comment, old_node, new_node; int old_size = 0, new_size = 0; }; -class RemoveMsgCommand : public QUndoCommand { +class RemoveMsgCommand : public UndoCommand { public: - RemoveMsgCommand(const MessageId &id, QUndoCommand *parent = nullptr); + RemoveMsgCommand(const MessageId &id); void undo() override; void redo() override; @@ -32,9 +85,9 @@ private: cabana::Msg message; }; -class AddSigCommand : public QUndoCommand { +class AddSigCommand : public UndoCommand { public: - AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent = nullptr); + AddSigCommand(const MessageId &id, const cabana::Signal &sig); void undo() override; void redo() override; @@ -44,29 +97,24 @@ private: cabana::Signal signal = {}; }; -class RemoveSigCommand : public QUndoCommand { +class RemoveSigCommand : public UndoCommand { public: - RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent = nullptr); + RemoveSigCommand(const MessageId &id, const cabana::Signal *sig); void undo() override; void redo() override; private: const MessageId id; - QList sigs; + std::vector sigs; }; -class EditSignalCommand : public QUndoCommand { +class EditSignalCommand : public UndoCommand { public: - EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent = nullptr); + EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig); void undo() override; void redo() override; private: const MessageId id; - QList> sigs; // QList<{old_sig, new_sig}> -}; - -namespace UndoStack { - QUndoStack *instance(); - inline void push(QUndoCommand *cmd) { instance()->push(cmd); } + std::vector> sigs; // {old_sig, new_sig} }; diff --git a/tools/cabana/core/can_data.h b/tools/cabana/core/can_data.h new file mode 100644 index 00000000..38141b18 --- /dev/null +++ b/tools/cabana/core/can_data.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/core/color.h" +#include "tools/cabana/core/message_id.h" + +struct CanData { + void compute(const MessageId &msg_id, const uint8_t *data, int size, double current_sec, + double playback_speed, const std::vector &mask, double frequency = 0); + + double ts = 0.; + uint32_t count = 0; + double freq = 0; + std::vector dat; + std::vector colors; + + struct ByteLastChange { + double ts = 0; + int delta = 0; + int same_delta_counter = 0; + bool suppressed = false; + }; + std::vector last_changes; + std::vector> bit_flip_counts; + double last_freq_update_ts = 0; +}; + +struct CanEvent { + uint8_t src; + uint32_t address; + uint64_t mono_time; + uint8_t size; + uint8_t dat[]; +}; + +struct CompareCanEvent { + constexpr bool operator()(const CanEvent *const event, uint64_t ts) const { return event->mono_time < ts; } + constexpr bool operator()(uint64_t ts, const CanEvent *const event) const { return ts < event->mono_time; } +}; + +using MessageEventsMap = std::unordered_map>; +using CanEventIter = std::vector::const_iterator; diff --git a/tools/cabana/core/color.h b/tools/cabana/core/color.h new file mode 100644 index 00000000..c29704dd --- /dev/null +++ b/tools/cabana/core/color.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +struct CabanaColor { + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + uint8_t a = 255; + + constexpr CabanaColor() = default; + constexpr CabanaColor(uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha = 255) + : r(red), g(green), b(blue), a(alpha) {} + + static CabanaColor fromHsv(float hue, float saturation, float value, float alpha = 1.0f) { + const float h = hue - std::floor(hue); + const float c = value * saturation; + const float x = c * (1.0f - std::fabs(std::fmod(h * 6.0f, 2.0f) - 1.0f)); + const float m = value - c; + float red = 0, green = 0, blue = 0; + switch (static_cast(h * 6.0f) % 6) { + case 0: red = c; green = x; break; + case 1: red = x; green = c; break; + case 2: green = c; blue = x; break; + case 3: green = x; blue = c; break; + case 4: red = x; blue = c; break; + default: red = c; blue = x; break; + } + auto channel = [m](float v) { return static_cast(std::clamp((v + m) * 255.0f, 0.0f, 255.0f) + 0.5f); }; + return {channel(red), channel(green), channel(blue), + static_cast(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f)}; + } + + CabanaColor darker(int factor = 200) const { + if (factor <= 0) return *this; + if (factor < 100) return lighter(10000 / factor); + auto [hue, saturation, value] = hsv(); + return fromHsv(hue, saturation, value * 100.0f / factor, a / 255.0f); + } + + CabanaColor lighter(int factor = 150) const { + if (factor <= 0) return *this; + if (factor < 100) return darker(10000 / factor); + auto [hue, saturation, value] = hsv(); + const float scaled_value = value * factor / 100.0f; + if (scaled_value > 1.0f) saturation = std::max(0.0f, saturation - (scaled_value - 1.0f)); + return fromHsv(hue, saturation, std::min(1.0f, scaled_value), a / 255.0f); + } + + constexpr int red() const { return r; } + constexpr int green() const { return g; } + constexpr int blue() const { return b; } + constexpr int alpha() const { return a; } + float alphaF() const { return a / 255.0f; } + void setAlphaF(float alpha) { a = static_cast(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f); } + + constexpr bool operator==(const CabanaColor &other) const { + return r == other.r && g == other.g && b == other.b && a == other.a; + } + +private: + struct Hsv { float hue; float saturation; float value; }; + Hsv hsv() const { + const float red = r / 255.0f, green = g / 255.0f, blue = b / 255.0f; + const float maximum = std::max({red, green, blue}); + const float minimum = std::min({red, green, blue}); + const float delta = maximum - minimum; + float hue = 0; + if (delta > 0) { + if (maximum == red) hue = std::fmod((green - blue) / delta, 6.0f) / 6.0f; + else if (maximum == green) hue = ((blue - red) / delta + 2.0f) / 6.0f; + else hue = ((red - green) / delta + 4.0f) / 6.0f; + if (hue < 0) hue += 1.0f; + } + return {hue, maximum == 0 ? 0 : delta / maximum, maximum}; + } +}; diff --git a/tools/cabana/core/message_id.h b/tools/cabana/core/message_id.h new file mode 100644 index 00000000..ecac2796 --- /dev/null +++ b/tools/cabana/core/message_id.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include +#include +#include + +constexpr int INVALID_SOURCE = 0xff; + +struct MessageId { + uint8_t source = 0; + uint32_t address = 0; + std::string toString() const { char b[64]; snprintf(b, sizeof(b), "%u:%X", source, address); return b; } + static MessageId fromString(const std::string &s) { + const auto p = s.find(':'); + if (p == std::string::npos) return {}; + return {.source = static_cast(std::stoul(s.substr(0, p))), .address = static_cast(std::stoul(s.substr(p + 1), nullptr, 16))}; + } + bool operator==(const MessageId &o) const { return source == o.source && address == o.address; } + bool operator!=(const MessageId &o) const { return !(*this == o); } + bool operator<(const MessageId &o) const { return std::tie(source, address) < std::tie(o.source, o.address); } + bool operator>(const MessageId &o) const { return o < *this; } +}; + +template <> struct std::hash { + size_t operator()(const MessageId &id) const noexcept { return std::hash{}(id.source) ^ (std::hash{}(id.address) << 1); } +}; diff --git a/tools/cabana/core/settings.h b/tools/cabana/core/settings.h new file mode 100644 index 00000000..cad152de --- /dev/null +++ b/tools/cabana/core/settings.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +constexpr int LIGHT_THEME = 1; +constexpr int DARK_THEME = 2; + +struct CabanaSettingsState { + enum DragDirection { MsbFirst, LsbFirst, AlwaysLE, AlwaysBE }; + + bool absolute_time = false; + int fps = 10; + int max_cached_minutes = 30; + int chart_height = 200; + int chart_column_count = 1; + int chart_range = 3 * 60; + int chart_series_type = 0; + int theme = 0; + int sparkline_range = 15; + bool multiple_lines_hex = false; + bool log_livestream = true; + bool suppress_defined_signals = false; + std::string log_path; + std::string last_dir; + std::string last_route_dir; + std::vector recent_files; + DragDirection drag_direction = MsbFirst; + + std::string recent_dbc_file; + std::string active_msg_id; + std::vector selected_msg_ids; + std::vector active_charts; +}; diff --git a/tools/cabana/dbc/dbc.cc b/tools/cabana/dbc/dbc.cc index 9b0de922..c8a794c7 100644 --- a/tools/cabana/dbc/dbc.cc +++ b/tools/cabana/dbc/dbc.cc @@ -1,11 +1,17 @@ #include "tools/cabana/dbc/dbc.h" #include +#include -#include "tools/cabana/utils/util.h" - -uint qHash(const MessageId &item) { - return qHash(item.source) ^ qHash(item.address); +namespace { +int numDecimals(double value) { + int decimals = 0; + while (decimals < 6 && std::fabs(value - std::round(value)) > 1e-9) { + value *= 10.0; + ++decimals; + } + return decimals; +} } // cabana::Msg @@ -22,7 +28,7 @@ cabana::Signal *cabana::Msg::addSignal(const cabana::Signal &sig) { return s; } -cabana::Signal *cabana::Msg::updateSignal(const QString &sig_name, const cabana::Signal &new_sig) { +cabana::Signal *cabana::Msg::updateSignal(const std::string &sig_name, const cabana::Signal &new_sig) { auto s = sig(sig_name); if (s) { *s = new_sig; @@ -31,7 +37,7 @@ cabana::Signal *cabana::Msg::updateSignal(const QString &sig_name, const cabana: return s; } -void cabana::Msg::removeSignal(const QString &sig_name) { +void cabana::Msg::removeSignal(const std::string &sig_name) { auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; }); if (it != sigs.end()) { delete *it; @@ -57,7 +63,7 @@ cabana::Msg &cabana::Msg::operator=(const cabana::Msg &other) { return *this; } -cabana::Signal *cabana::Msg::sig(const QString &sig_name) const { +cabana::Signal *cabana::Msg::sig(const std::string &sig_name) const { auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; }); return it != sigs.end() ? *it : nullptr; } @@ -69,17 +75,17 @@ int cabana::Msg::indexOf(const cabana::Signal *sig) const { return -1; } -QString cabana::Msg::newSignalName() { - QString new_name; +std::string cabana::Msg::newSignalName() { + std::string new_name; for (int i = 1; /**/; ++i) { - new_name = QString("NEW_SIGNAL_%1").arg(i); + new_name = "NEW_SIGNAL_" + std::to_string(i); if (sig(new_name) == nullptr) break; } return new_name; } void cabana::Msg::update() { - if (transmitter.isEmpty()) { + if (transmitter.empty()) { transmitter = DEFAULT_NODE_NAME; } mask.assign(size, 0x00); @@ -129,21 +135,21 @@ void cabana::Msg::update() { void cabana::Signal::update() { updateMsbLsb(*this); - if (receiver_name.isEmpty()) { + if (receiver_name.empty()) { receiver_name = DEFAULT_NODE_NAME; } float h = 19 * (float)lsb / 64.0; h = fmod(h, 1.0); - size_t hash = qHash(name); + size_t hash = std::hash{}(name); float s = 0.25 + 0.25 * (float)(hash & 0xff) / 255.0; float v = 0.75 + 0.25 * (float)((hash >> 8) & 0xff) / 255.0; - color = QColor::fromHsvF(h, s, v); - precision = std::max(num_decimals(factor), num_decimals(offset)); + color = CabanaColor::fromHsv(h, s, v); + precision = std::max(numDecimals(factor), numDecimals(offset)); } -QString cabana::Signal::formatValue(double value, bool with_unit) const { +std::string cabana::Signal::formatValue(double value, bool with_unit) const { // Show enum string int64_t raw_value = round((value - offset) / factor); for (const auto &[val, desc] : val_desc) { @@ -152,8 +158,10 @@ QString cabana::Signal::formatValue(double value, bool with_unit) const { } } - QString val_str = QString::number(value, 'f', precision); - if (with_unit && !unit.isEmpty()) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", precision, value); + std::string val_str(buf); + if (with_unit && !unit.empty()) { val_str += " " + unit; } return val_str; diff --git a/tools/cabana/dbc/dbc.h b/tools/cabana/dbc/dbc.h index 134d88a9..585325d3 100644 --- a/tools/cabana/dbc/dbc.h +++ b/tools/cabana/dbc/dbc.h @@ -1,57 +1,21 @@ #pragma once +#include +#include +#include #include +#include #include #include -#include -#include -#include +#include "tools/cabana/core/color.h" +#include "tools/cabana/core/message_id.h" -const QString UNTITLED = "untitled"; -const QString DEFAULT_NODE_NAME = "XXX"; +const std::string UNTITLED = "untitled"; +const std::string DEFAULT_NODE_NAME = "XXX"; constexpr int CAN_MAX_DATA_BYTES = 64; -struct MessageId { - uint8_t source = 0; - uint32_t address = 0; - - QString toString() const { - return QString("%1:%2").arg(source).arg(QString::number(address, 16).toUpper()); - } - - inline static MessageId fromString(const QString &str) { - auto parts = str.split(':'); - if (parts.size() != 2) return {}; - return MessageId{.source = uint8_t(parts[0].toUInt()), .address = parts[1].toUInt(nullptr, 16)}; - } - - bool operator==(const MessageId &other) const { - return source == other.source && address == other.address; - } - - bool operator!=(const MessageId &other) const { - return !(*this == other); - } - - bool operator<(const MessageId &other) const { - return std::tie(source, address) < std::tie(other.source, other.address); - } - - bool operator>(const MessageId &other) const { - return std::tie(source, address) > std::tie(other.source, other.address); - } -}; - -uint qHash(const MessageId &item); -Q_DECLARE_METATYPE(MessageId); - -template <> -struct std::hash { - std::size_t operator()(const MessageId &k) const noexcept { return qHash(k); } -}; - -typedef std::vector> ValueDescription; +typedef std::vector> ValueDescription; namespace cabana { @@ -61,7 +25,7 @@ public: Signal(const Signal &other) = default; void update(); bool getValue(const uint8_t *data, size_t data_size, double *val) const; - QString formatValue(double value, bool with_unit = true) const; + std::string formatValue(double value, bool with_unit = true) const; bool operator==(const cabana::Signal &other) const; inline bool operator!=(const cabana::Signal &other) const { return !(*this == other); } @@ -72,19 +36,19 @@ public: }; Type type = Type::Normal; - QString name; + std::string name; int start_bit, msb, lsb, size; double factor = 1.0; double offset = 0; bool is_signed; bool is_little_endian; double min, max; - QString unit; - QString comment; - QString receiver_name; + std::string unit; + std::string comment; + std::string receiver_name; ValueDescription val_desc; int precision = 0; - QColor color; + CabanaColor color; // Multiplexed int multiplex_value = 0; @@ -97,20 +61,20 @@ public: Msg(const Msg &other) { *this = other; } ~Msg(); cabana::Signal *addSignal(const cabana::Signal &sig); - cabana::Signal *updateSignal(const QString &sig_name, const cabana::Signal &sig); - void removeSignal(const QString &sig_name); + cabana::Signal *updateSignal(const std::string &sig_name, const cabana::Signal &sig); + void removeSignal(const std::string &sig_name); Msg &operator=(const Msg &other); int indexOf(const cabana::Signal *sig) const; - cabana::Signal *sig(const QString &sig_name) const; - QString newSignalName(); + cabana::Signal *sig(const std::string &sig_name) const; + std::string newSignalName(); void update(); inline const std::vector &getSignals() const { return sigs; } uint32_t address; - QString name; + std::string name; uint32_t size; - QString comment; - QString transmitter; + std::string comment; + std::string transmitter; std::vector sigs; std::vector mask; @@ -123,4 +87,8 @@ public: double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig); void updateMsbLsb(cabana::Signal &s); inline int flipBitPos(int start_bit) { return 8 * (start_bit / 8) + 7 - start_bit % 8; } -inline QString doubleToString(double value) { return QString::number(value, 'g', std::numeric_limits::digits10); } +inline std::string doubleToString(double value) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*g", std::numeric_limits::digits10, value); + return buf; +} diff --git a/tools/cabana/dbc/dbcfile.cc b/tools/cabana/dbc/dbcfile.cc index 1c03c8a0..99a5b718 100644 --- a/tools/cabana/dbc/dbcfile.cc +++ b/tools/cabana/dbc/dbcfile.cc @@ -1,48 +1,86 @@ #include "tools/cabana/dbc/dbcfile.h" -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include -DBCFile::DBCFile(const QString &dbc_file_name) { - QFile file(dbc_file_name); - if (file.open(QIODevice::ReadOnly)) { - name_ = QFileInfo(dbc_file_name).baseName(); - filename = dbc_file_name; - parse(file.readAll()); - } else { - throw std::runtime_error("Failed to open file."); +namespace { + +std::string trim(const std::string &value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + return value.substr(first, value.find_last_not_of(" \t\r\n") - first + 1); +} + +bool startsWith(const std::string &value, const char *prefix) { + return value.rfind(prefix, 0) == 0; +} + +std::string unescapeComment(std::string value) { + for (size_t pos = 0; (pos = value.find("\\\"", pos)) != std::string::npos; ++pos) { + value.replace(pos, 2, "\""); } + return trim(value); } -DBCFile::DBCFile(const QString &name, const QString &content) : name_(name), filename("") { - parse(content); -} - -bool DBCFile::save() { - assert(!filename.isEmpty()); - return writeContents(filename); -} - -bool DBCFile::saveAs(const QString &new_filename) { - filename = new_filename; - return save(); -} - -bool DBCFile::writeContents(const QString &fn) { - QFile file(fn); - if (file.open(QIODevice::WriteOnly)) { - return file.write(generateDBC().toUtf8()) >= 0; +bool commentComplete(const std::string &line) { + bool escaped = false; + for (size_t i = 0; i < line.size(); ++i) { + if (line[i] == '\\' && !escaped) { + escaped = true; + continue; + } + if (line[i] == '"' && !escaped) { + size_t next = line.find_first_not_of(" \t\r\n", i + 1); + if (next != std::string::npos && line[next] == ';') return true; + } + escaped = false; } return false; } -void DBCFile::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) { +} // namespace + +DBCFile::DBCFile(const std::string &dbc_file_name) { + std::ifstream file(dbc_file_name, std::ios::binary); + if (!file) throw std::runtime_error("Failed to open file."); + filename = dbc_file_name; + name_ = std::filesystem::path(dbc_file_name).stem().string(); + parse(std::string(std::istreambuf_iterator(file), std::istreambuf_iterator())); +} + +DBCFile::DBCFile(const std::string &name, const std::string &content) : name_(name) { + parse(content); +} + +bool DBCFile::save() { + assert(!filename.empty()); + return writeContents(filename); +} + +bool DBCFile::saveAs(const std::string &new_filename) { + filename = new_filename; + return save(); +} + +bool DBCFile::writeContents(const std::string &fn) { + std::ofstream file(fn, std::ios::binary | std::ios::trunc); + if (!file) return false; + file << generateDBC(); + return file.good(); +} + +void DBCFile::updateMsg(const MessageId &id, const std::string &name, uint32_t size, + const std::string &node, const std::string &comment) { auto &m = msgs[id.address]; m.address = id.address; m.name = name; m.size = size; - m.transmitter = node.isEmpty() ? DEFAULT_NODE_NAME : node; + m.transmitter = node.empty() ? DEFAULT_NODE_NAME : node; m.comment = comment; } @@ -51,221 +89,180 @@ cabana::Msg *DBCFile::msg(uint32_t address) { return it != msgs.end() ? &it->second : nullptr; } -cabana::Msg *DBCFile::msg(const QString &name) { +cabana::Msg *DBCFile::msg(const std::string &name) { auto it = std::find_if(msgs.begin(), msgs.end(), [&name](auto &m) { return m.second.name == name; }); - return it != msgs.end() ? &(it->second) : nullptr; + return it != msgs.end() ? &it->second : nullptr; } -cabana::Signal *DBCFile::signal(uint32_t address, const QString &name) { +cabana::Signal *DBCFile::signal(uint32_t address, const std::string &name) { auto m = msg(address); - return m ? (cabana::Signal *)m->sig(name) : nullptr; + return m ? m->sig(name) : nullptr; } -void DBCFile::parse(const QString &content) { +void DBCFile::parse(const std::string &content) { msgs.clear(); - - int line_num = 0; - QString line; + header.clear(); + std::istringstream input(content); + std::string raw_line; cabana::Msg *current_msg = nullptr; int multiplexor_cnt = 0; + int line_num = 0; bool seen_first = false; - QTextStream stream((QString *)&content); - while (!stream.atEnd()) { + while (std::getline(input, raw_line)) { ++line_num; - QString raw_line = stream.readLine(); - line = raw_line.trimmed(); + const size_t first_nonspace = raw_line.find_first_not_of(" \t\r"); + std::string line = first_nonspace == std::string::npos ? std::string() : raw_line.substr(first_nonspace); + const int statement_line = line_num; + if ((startsWith(line, "CM_ BO_") || startsWith(line, "CM_ SG_ ")) && !commentComplete(line)) { + std::string continuation; + while (std::getline(input, continuation)) { + ++line_num; + line += "\n" + continuation; + if (commentComplete(line)) break; + } + } bool seen = true; try { - if (line.startsWith("BO_ ")) { + if (startsWith(line, "BO_ ")) { multiplexor_cnt = 0; current_msg = parseBO(line); - } else if (line.startsWith("SG_ ")) { + } else if (startsWith(line, "SG_ ")) { parseSG(line, current_msg, multiplexor_cnt); - } else if (line.startsWith("VAL_ ")) { + } else if (startsWith(line, "VAL_ ")) { parseVAL(line); - } else if (line.startsWith("CM_ BO_")) { - parseCM_BO(line, content, raw_line, stream); - } else if (line.startsWith("CM_ SG_ ")) { - parseCM_SG(line, content, raw_line, stream); + } else if (startsWith(line, "CM_ BO_")) { + parseCM_BO(line); + } else if (startsWith(line, "CM_ SG_ ")) { + parseCM_SG(line); } else { seen = false; } - } catch (std::exception &e) { - throw std::runtime_error(QString("[%1:%2]%3: %4").arg(filename).arg(line_num).arg(e.what()).arg(line).toStdString()); - } - - if (seen) { - seen_first = true; - } else if (!seen_first) { - header += raw_line + "\n"; + } catch (const std::exception &e) { + throw std::runtime_error("[" + filename + ":" + std::to_string(statement_line) + "]" + e.what() + ": " + line); } + if (seen) seen_first = true; + else if (!seen_first) header += raw_line + "\n"; } - - for (auto &[_, m] : msgs) { - m.update(); - } + for (auto &[_, message] : msgs) message.update(); } -cabana::Msg *DBCFile::parseBO(const QString &line) { - static QRegularExpression bo_regexp(R"(^BO_ (?
\w+) (?\w+) *: (?\w+) (?\w+))"); - - QRegularExpressionMatch match = bo_regexp.match(line); - if (!match.hasMatch()) - throw std::runtime_error("Invalid BO_ line format"); - - uint32_t address = match.captured("address").toUInt(); - if (msgs.count(address) > 0) - throw std::runtime_error(QString("Duplicate message address: %1").arg(address).toStdString()); - - // Create a new message object - cabana::Msg *msg = &msgs[address]; - msg->address = address; - msg->name = match.captured("name"); - msg->size = match.captured("size").toULong(); - msg->transmitter = match.captured("transmitter").trimmed(); - return msg; +cabana::Msg *DBCFile::parseBO(const std::string &line) { + static const std::regex pattern(R"(^BO_ ([[:alnum:]_]+) ([[:alnum:]_]+) *: ([[:alnum:]_]+) ([[:alnum:]_]+))"); + std::smatch match; + if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid BO_ line format"); + const uint32_t address = std::stoul(match[1].str()); + if (msgs.count(address)) throw std::runtime_error("Duplicate message address: " + std::to_string(address)); + auto &message = msgs[address]; + message.address = address; + message.name = match[2].str(); + message.size = std::stoul(match[3].str()); + message.transmitter = trim(match[4].str()); + return &message; } -void DBCFile::parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) { - static QRegularExpression msg_comment_regexp(R"(^CM_ BO_ *(?
\w+) *\"(?(?:[^"\\]|\\.)*)\"\s*;)"); +void DBCFile::parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt) { + static const std::regex pattern(R"dbc(^SG_ ([[:alnum:]_]+)(?: +([[:alnum:]_]+))? *: ([0-9]+)\|([0-9]+)@([0-9]+)([+-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] "(.*)" (.*))dbc"); + if (!current_msg) throw std::runtime_error("No Message"); + std::smatch match; + if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid SG_ line format"); + if (current_msg->sig(match[1].str())) throw std::runtime_error("Duplicate signal name"); - QString parse_line = line; - if (!parse_line.endsWith("\";")) { - int pos = stream.pos() - raw_line.length() - 1; - parse_line = content.mid(pos, content.indexOf("\";", pos)); - } - auto match = msg_comment_regexp.match(parse_line); - if (!match.hasMatch()) - throw std::runtime_error("Invalid message comment format"); - - if (auto m = (cabana::Msg *)msg(match.captured("address").toUInt())) - m->comment = match.captured("comment").trimmed().replace("\\\"", "\""); -} - -void DBCFile::parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt) { - static QRegularExpression sg_regexp(R"(^SG_ (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))"); - static QRegularExpression sgm_regexp(R"(^SG_ (\w+) (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))"); - - if (!current_msg) - throw std::runtime_error("No Message"); - - int offset = 0; - auto match = sg_regexp.match(line); - if (!match.hasMatch()) { - match = sgm_regexp.match(line); - offset = 1; - } - if (!match.hasMatch()) - throw std::runtime_error("Invalid SG_ line format"); - - QString name = match.captured(1); - if (current_msg->sig(name) != nullptr) - throw std::runtime_error("Duplicate signal name"); - - cabana::Signal s{}; - if (offset == 1) { - auto indicator = match.captured(2); + cabana::Signal signal{}; + const std::string indicator = match[2].str(); + if (!indicator.empty()) { if (indicator == "M") { - ++multiplexor_cnt; - // Only one signal within a single message can be the multiplexer switch. - if (multiplexor_cnt >= 2) - throw std::runtime_error("Multiple multiplexor"); - - s.type = cabana::Signal::Type::Multiplexor; + if (++multiplexor_cnt >= 2) throw std::runtime_error("Multiple multiplexor"); + signal.type = cabana::Signal::Type::Multiplexor; } else { - s.type = cabana::Signal::Type::Multiplexed; - s.multiplex_value = indicator.mid(1).toInt(); + signal.type = cabana::Signal::Type::Multiplexed; + signal.multiplex_value = indicator.size() > 1 ? std::stoi(indicator.substr(1)) : 0; } } - s.name = name; - s.start_bit = match.captured(offset + 2).toInt(); - s.size = match.captured(offset + 3).toInt(); - s.is_little_endian = match.captured(offset + 4).toInt() == 1; - s.is_signed = match.captured(offset + 5) == "-"; - s.factor = match.captured(offset + 6).toDouble(); - s.offset = match.captured(offset + 7).toDouble(); - s.min = match.captured(8 + offset).toDouble(); - s.max = match.captured(9 + offset).toDouble(); - s.unit = match.captured(10 + offset); - s.receiver_name = match.captured(11 + offset).trimmed(); - current_msg->sigs.push_back(new cabana::Signal(s)); + signal.name = match[1].str(); + signal.start_bit = std::stoi(match[3].str()); + signal.size = std::stoi(match[4].str()); + signal.is_little_endian = match[5].str() == "1"; + signal.is_signed = match[6].str() == "-"; + signal.factor = std::stod(match[7].str()); + signal.offset = std::stod(match[8].str()); + signal.min = std::stod(match[9].str()); + signal.max = std::stod(match[10].str()); + signal.unit = match[11].str(); + signal.receiver_name = trim(match[12].str()); + current_msg->sigs.push_back(new cabana::Signal(signal)); } -void DBCFile::parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) { - static QRegularExpression sg_comment_regexp(R"(^CM_ SG_ *(\w+) *(\w+) *\"((?:[^"\\]|\\.)*)\"\s*;)"); - - QString parse_line = line; - if (!parse_line.endsWith("\";")) { - int pos = stream.pos() - raw_line.length() - 1; - parse_line = content.mid(pos, content.indexOf("\";", pos)); +void DBCFile::parseCM_BO(const std::string &line) { + std::istringstream prefix(line.substr(7)); + uint32_t address = 0; + prefix >> address; + const size_t first_quote = line.find('"'); + const size_t last_quote = line.rfind('"'); + if (!prefix || first_quote == std::string::npos || last_quote <= first_quote) { + throw std::runtime_error("Invalid message comment format"); } - auto match = sg_comment_regexp.match(parse_line); - if (!match.hasMatch()) + if (auto message = msg(address)) message->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1)); +} + +void DBCFile::parseCM_SG(const std::string &line) { + std::istringstream prefix(line.substr(7)); + uint32_t address = 0; + std::string name; + prefix >> address >> name; + const size_t first_quote = line.find('"'); + const size_t last_quote = line.rfind('"'); + if (!prefix || name.empty() || first_quote == std::string::npos || last_quote <= first_quote) { throw std::runtime_error("Invalid CM_ SG_ line format"); - - if (auto s = signal(match.captured(1).toUInt(), match.captured(2))) { - s->comment = match.captured(3).trimmed().replace("\\\"", "\""); } + if (auto sig = signal(address, name)) sig->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1)); } -void DBCFile::parseVAL(const QString &line) { - static QRegularExpression val_regexp(R"(VAL_ (\w+) (\w+) (\s*[-+]?[0-9]+\s+\".+?\"[^;]*))"); - - auto match = val_regexp.match(line); - if (!match.hasMatch()) - throw std::runtime_error("invalid VAL_ line format"); - - if (auto s = signal(match.captured(1).toUInt(), match.captured(2))) { - QStringList desc_list = match.captured(3).trimmed().split('"'); - for (int i = 0; i < desc_list.size(); i += 2) { - auto val = desc_list[i].trimmed(); - if (!val.isEmpty() && (i + 1) < desc_list.size()) { - auto desc = desc_list[i + 1].trimmed(); - s->val_desc.push_back({val.toDouble(), desc}); - } +void DBCFile::parseVAL(const std::string &line) { + static const std::regex header_pattern(R"(^VAL_ ([[:alnum:]_]+) ([[:alnum:]_]+) (.*))"); + static const std::regex entry_pattern(R"dbc(([+-]?[0-9]+(?:\.[0-9]+)?)\s+"([^"]*)")dbc"); + std::smatch match; + if (!std::regex_search(line, match, header_pattern)) throw std::runtime_error("invalid VAL_ line format"); + if (auto sig = signal(std::stoul(match[1].str()), match[2].str())) { + const std::string entries = match[3].str(); + for (std::sregex_iterator it(entries.begin(), entries.end(), entry_pattern), end; it != end; ++it) { + sig->val_desc.emplace_back(std::stod((*it)[1].str()), trim((*it)[2].str())); } } } -QString DBCFile::generateDBC() { - QString dbc_string, comment, val_desc; +std::string DBCFile::generateDBC() { + std::string dbc_string, comment, val_desc; for (const auto &[address, m] : msgs) { - const QString transmitter = m.transmitter.isEmpty() ? DEFAULT_NODE_NAME : m.transmitter; - dbc_string += QString("BO_ %1 %2: %3 %4\n").arg(address).arg(m.name).arg(m.size).arg(transmitter); - if (!m.comment.isEmpty()) { - comment += QString("CM_ BO_ %1 \"%2\";\n").arg(address).arg(QString(m.comment).replace("\"", "\\\"")); + const std::string &transmitter = m.transmitter.empty() ? DEFAULT_NODE_NAME : m.transmitter; + dbc_string += "BO_ " + std::to_string(address) + " " + m.name + ": " + std::to_string(m.size) + " " + transmitter + "\n"; + if (!m.comment.empty()) { + std::string escaped = m.comment; + for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\""); + comment += "CM_ BO_ " + std::to_string(address) + " \"" + escaped + "\";\n"; } for (auto sig : m.getSignals()) { - QString multiplexer_indicator; - if (sig->type == cabana::Signal::Type::Multiplexor) { - multiplexer_indicator = "M "; - } else if (sig->type == cabana::Signal::Type::Multiplexed) { - multiplexer_indicator = QString("m%1 ").arg(sig->multiplex_value); - } - dbc_string += QString(" SG_ %1 %2: %3|%4@%5%6 (%7,%8) [%9|%10] \"%11\" %12\n") - .arg(sig->name) - .arg(multiplexer_indicator) - .arg(sig->start_bit) - .arg(sig->size) - .arg(sig->is_little_endian ? '1' : '0') - .arg(sig->is_signed ? '-' : '+') - .arg(doubleToString(sig->factor)) - .arg(doubleToString(sig->offset)) - .arg(doubleToString(sig->min)) - .arg(doubleToString(sig->max)) - .arg(sig->unit) - .arg(sig->receiver_name.isEmpty() ? DEFAULT_NODE_NAME : sig->receiver_name); - if (!sig->comment.isEmpty()) { - comment += QString("CM_ SG_ %1 %2 \"%3\";\n").arg(address).arg(sig->name).arg(QString(sig->comment).replace("\"", "\\\"")); + std::string mux; + if (sig->type == cabana::Signal::Type::Multiplexor) mux = "M "; + else if (sig->type == cabana::Signal::Type::Multiplexed) mux = "m" + std::to_string(sig->multiplex_value) + " "; + const std::string &receiver = sig->receiver_name.empty() ? DEFAULT_NODE_NAME : sig->receiver_name; + dbc_string += " SG_ " + sig->name + " " + mux + ": " + std::to_string(sig->start_bit) + "|" + std::to_string(sig->size) + "@" + + (sig->is_little_endian ? "1" : "0") + (sig->is_signed ? "-" : "+") + + " (" + doubleToString(sig->factor) + "," + doubleToString(sig->offset) + ")" + + " [" + doubleToString(sig->min) + "|" + doubleToString(sig->max) + "] \"" + sig->unit + "\" " + receiver + "\n"; + if (!sig->comment.empty()) { + std::string escaped = sig->comment; + for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\""); + comment += "CM_ SG_ " + std::to_string(address) + " " + sig->name + " \"" + escaped + "\";\n"; } if (!sig->val_desc.empty()) { - QStringList text; - for (auto &[val, desc] : sig->val_desc) { - text << QString("%1 \"%2\"").arg(val).arg(desc); + std::string text; + for (const auto &[value, description] : sig->val_desc) { + if (!text.empty()) text += " "; + text += doubleToString(value) + " \"" + description + "\""; } - val_desc += QString("VAL_ %1 %2 %3;\n").arg(address).arg(sig->name).arg(text.join(" ")); + val_desc += "VAL_ " + std::to_string(address) + " " + sig->name + " " + text + ";\n"; } } dbc_string += "\n"; diff --git a/tools/cabana/dbc/dbcfile.h b/tools/cabana/dbc/dbcfile.h index bd267898..13841a94 100644 --- a/tools/cabana/dbc/dbcfile.h +++ b/tools/cabana/dbc/dbcfile.h @@ -1,44 +1,44 @@ #pragma once #include -#include +#include #include "tools/cabana/dbc/dbc.h" class DBCFile { public: - DBCFile(const QString &dbc_file_name); - DBCFile(const QString &name, const QString &content); + DBCFile(const std::string &dbc_file_name); + DBCFile(const std::string &name, const std::string &content); ~DBCFile() {} bool save(); - bool saveAs(const QString &new_filename); - bool writeContents(const QString &fn); - QString generateDBC(); + bool saveAs(const std::string &new_filename); + bool writeContents(const std::string &fn); + std::string generateDBC(); - void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment); + void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment); inline void removeMsg(const MessageId &id) { msgs.erase(id.address); } inline const std::map &getMessages() const { return msgs; } cabana::Msg *msg(uint32_t address); - cabana::Msg *msg(const QString &name); + cabana::Msg *msg(const std::string &name); inline cabana::Msg *msg(const MessageId &id) { return msg(id.address); } - cabana::Signal *signal(uint32_t address, const QString &name); + cabana::Signal *signal(uint32_t address, const std::string &name); - inline QString name() const { return name_.isEmpty() ? "untitled" : name_; } - inline bool isEmpty() const { return msgs.empty() && name_.isEmpty(); } + inline std::string name() const { return name_.empty() ? "untitled" : name_; } + inline bool isEmpty() const { return msgs.empty() && name_.empty(); } - QString filename; + std::string filename; private: - void parse(const QString &content); - cabana::Msg *parseBO(const QString &line); - void parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt); - void parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream); - void parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream); - void parseVAL(const QString &line); + void parse(const std::string &content); + cabana::Msg *parseBO(const std::string &line); + void parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt); + void parseCM_BO(const std::string &line); + void parseCM_SG(const std::string &line); + void parseVAL(const std::string &line); - QString header; + std::string header; std::map msgs; - QString name_; + std::string name_; }; diff --git a/tools/cabana/dbc/dbcmanager.cc b/tools/cabana/dbc/dbcmanager.cc index 8e98d953..7a95a4f8 100644 --- a/tools/cabana/dbc/dbcmanager.cc +++ b/tools/cabana/dbc/dbcmanager.cc @@ -1,10 +1,10 @@ #include "tools/cabana/dbc/dbcmanager.h" -#include #include -#include +#include +#include -bool DBCManager::open(const SourceSet &sources, const QString &dbc_file_name, QString *error) { +bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error) { try { auto it = std::find_if(dbc_files.begin(), dbc_files.end(), [&](auto &f) { return f.second && f.second->filename == dbc_file_name; }); @@ -17,11 +17,11 @@ bool DBCManager::open(const SourceSet &sources, const QString &dbc_file_name, QS return false; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); return true; } -bool DBCManager::open(const SourceSet &sources, const QString &name, const QString &content, QString *error) { +bool DBCManager::open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error) { try { auto file = std::make_shared(name, content); for (auto s : sources) { @@ -32,7 +32,7 @@ bool DBCManager::open(const SourceSet &sources, const QString &name, const QStri return false; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); return true; } @@ -40,69 +40,71 @@ void DBCManager::close(const SourceSet &sources) { for (auto s : sources) { dbc_files[s] = nullptr; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::close(DBCFile *dbc_file) { for (auto &[_, f] : dbc_files) { if (f.get() == dbc_file) f = nullptr; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::closeAll() { dbc_files.clear(); - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->addSignal(sig)) { - emit signalAdded(id, s); - emit maskUpdated(); + if (callbacks_.signal_added) callbacks_.signal_added(id, s); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } -void DBCManager::updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig) { +void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->updateSignal(sig_name, sig)) { - emit signalUpdated(s); - emit maskUpdated(); + if (callbacks_.signal_updated) callbacks_.signal_updated(s); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } -void DBCManager::removeSignal(const MessageId &id, const QString &sig_name) { +void DBCManager::removeSignal(const MessageId &id, const std::string &sig_name) { if (auto m = msg(id)) { if (auto s = m->sig(sig_name)) { - emit signalRemoved(s); + if (callbacks_.signal_removed) callbacks_.signal_removed(s); m->removeSignal(sig_name); - emit maskUpdated(); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } -void DBCManager::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) { +void DBCManager::updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment) { auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->updateMsg(id, name, size, node, comment); - emit msgUpdated(id); + if (callbacks_.msg_updated) callbacks_.msg_updated(id); } void DBCManager::removeMsg(const MessageId &id) { auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->removeMsg(id); - emit msgRemoved(id); - emit maskUpdated(); + if (callbacks_.msg_removed) callbacks_.msg_removed(id); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } -QString DBCManager::newMsgName(const MessageId &id) { - return QString("NEW_MSG_") + QString::number(id.address, 16).toUpper(); +std::string DBCManager::newMsgName(const MessageId &id) { + char buf[64]; + snprintf(buf, sizeof(buf), "NEW_MSG_%X", id.address); + return buf; } -QString DBCManager::newSignalName(const MessageId &id) { +std::string DBCManager::newSignalName(const MessageId &id) { auto m = msg(id); return m ? m->newSignalName() : ""; } @@ -118,14 +120,14 @@ cabana::Msg *DBCManager::msg(const MessageId &id) { return dbc_file ? dbc_file->msg(id) : nullptr; } -cabana::Msg *DBCManager::msg(uint8_t source, const QString &name) { +cabana::Msg *DBCManager::msg(uint8_t source, const std::string &name) { auto dbc_file = findDBCFile(source); return dbc_file ? dbc_file->msg(name) : nullptr; } -QStringList DBCManager::signalNames() { +std::vector DBCManager::signalNames() { // Used for autocompletion - QSet names; + std::set names; for (auto &f : allDBCFiles()) { for (auto &[_, m] : f->getMessages()) { for (auto sig : m.getSignals()) { @@ -133,8 +135,8 @@ QStringList DBCManager::signalNames() { } } } - QStringList ret = names.values(); - ret.sort(); + std::vector ret(names.begin(), names.end()); + std::sort(ret.begin(), ret.end()); return ret; } @@ -165,14 +167,16 @@ const SourceSet DBCManager::sources(const DBCFile *dbc_file) const { return sources; } -QString toString(const SourceSet &ss) { - return std::accumulate(ss.cbegin(), ss.cend(), QString(), [](QString str, int source) { - if (!str.isEmpty()) str += ", "; - return str + (source == -1 ? QStringLiteral("all") : QString::number(source)); - }); +std::string toString(const SourceSet &ss) { + std::string result; + for (int source : ss) { + if (!result.empty()) result += ", "; + result += (source == -1) ? "all" : std::to_string(source); + } + return result; } DBCManager *dbc() { - static DBCManager dbc_manager(nullptr); + static DBCManager dbc_manager; return &dbc_manager; } diff --git a/tools/cabana/dbc/dbcmanager.h b/tools/cabana/dbc/dbcmanager.h index 5f183752..5a09fae0 100644 --- a/tools/cabana/dbc/dbcmanager.h +++ b/tools/cabana/dbc/dbcmanager.h @@ -1,44 +1,52 @@ #pragma once -#include +#include #include #include #include +#include +#include #include "tools/cabana/dbc/dbcfile.h" typedef std::set SourceSet; const SourceSet SOURCE_ALL = {-1}; -const int INVALID_SOURCE = 0xff; inline bool operator<(const std::shared_ptr &l, const std::shared_ptr &r) { return l.get() < r.get(); } -class DBCManager : public QObject { - Q_OBJECT - +class DBCManager { public: - DBCManager(QObject *parent) : QObject(parent) {} - ~DBCManager() {} - bool open(const SourceSet &sources, const QString &dbc_file_name, QString *error = nullptr); - bool open(const SourceSet &sources, const QString &name, const QString &content, QString *error = nullptr); + struct Callbacks { + std::function signal_added; + std::function signal_removed; + std::function signal_updated; + std::function msg_updated; + std::function msg_removed; + std::function file_changed; + std::function mask_updated; + }; + + DBCManager() = default; + bool open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error = nullptr); + bool open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error = nullptr); void close(const SourceSet &sources); void close(DBCFile *dbc_file); void closeAll(); void addSignal(const MessageId &id, const cabana::Signal &sig); - void updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig); - void removeSignal(const MessageId &id, const QString &sig_name); + void updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig); + void removeSignal(const MessageId &id, const std::string &sig_name); - void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment); + void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment); void removeMsg(const MessageId &id); - QString newMsgName(const MessageId &id); - QString newSignalName(const MessageId &id); + std::string newMsgName(const MessageId &id); + std::string newSignalName(const MessageId &id); const std::map &getMessages(uint8_t source); cabana::Msg *msg(const MessageId &id); - cabana::Msg* msg(uint8_t source, const QString &name); + cabana::Msg* msg(uint8_t source, const std::string &name); - QStringList signalNames(); + std::vector signalNames(); inline int dbcCount() { return allDBCFiles().size(); } int nonEmptyDBCCount(); @@ -46,24 +54,17 @@ public: DBCFile *findDBCFile(const uint8_t source); inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); } std::set allDBCFiles(); - -signals: - void signalAdded(MessageId id, const cabana::Signal *sig); - void signalRemoved(const cabana::Signal *sig); - void signalUpdated(const cabana::Signal *sig); - void msgUpdated(MessageId id); - void msgRemoved(MessageId id); - void DBCFileChanged(); - void maskUpdated(); + void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } private: std::map> dbc_files; + Callbacks callbacks_; }; DBCManager *dbc(); -QString toString(const SourceSet &ss); -inline QString msgName(const MessageId &id) { +std::string toString(const SourceSet &ss); +inline std::string msgName(const MessageId &id) { auto msg = dbc()->msg(id); return msg ? msg->name : UNTITLED; } diff --git a/tools/cabana/dbc/dbcqt.cc b/tools/cabana/dbc/dbcqt.cc new file mode 100644 index 00000000..4354caf3 --- /dev/null +++ b/tools/cabana/dbc/dbcqt.cc @@ -0,0 +1,18 @@ +#include "tools/cabana/dbc/dbcqt.h" + +QtDBCNotifier::QtDBCNotifier(QObject *parent) : QObject(parent) { + dbc()->setCallbacks({ + .signal_added = [this](MessageId id, const cabana::Signal *sig) { emit signalAdded(id, sig); }, + .signal_removed = [this](const cabana::Signal *sig) { emit signalRemoved(sig); }, + .signal_updated = [this](const cabana::Signal *sig) { emit signalUpdated(sig); }, + .msg_updated = [this](MessageId id) { emit msgUpdated(id); }, + .msg_removed = [this](MessageId id) { emit msgRemoved(id); }, + .file_changed = [this]() { emit DBCFileChanged(); }, + .mask_updated = [this]() { emit maskUpdated(); }, + }); +} + +QtDBCNotifier *dbcNotifier() { + static QtDBCNotifier notifier; + return ¬ifier; +} diff --git a/tools/cabana/dbc/dbcqt.h b/tools/cabana/dbc/dbcqt.h new file mode 100644 index 00000000..b889f854 --- /dev/null +++ b/tools/cabana/dbc/dbcqt.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" + +Q_DECLARE_METATYPE(MessageId) +Q_DECLARE_METATYPE(ValueDescription) + +class QtDBCNotifier : public QObject { + Q_OBJECT + +public: + explicit QtDBCNotifier(QObject *parent = nullptr); + +signals: + void signalAdded(MessageId id, const cabana::Signal *sig); + void signalRemoved(const cabana::Signal *sig); + void signalUpdated(const cabana::Signal *sig); + void msgUpdated(MessageId id); + void msgRemoved(MessageId id); + void DBCFileChanged(); + void maskUpdated(); +}; + +QtDBCNotifier *dbcNotifier(); diff --git a/tools/cabana/dbc/generate_dbc_json.py b/tools/cabana/dbc/generate_dbc_json.py index 68ab5666..193f7695 100755 --- a/tools/cabana/dbc/generate_dbc_json.py +++ b/tools/cabana/dbc/generate_dbc_json.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import argparse import json - from iqdbc.car import Bus from iqdbc.car.fingerprints import MIGRATION from iqdbc.car.values import PLATFORMS diff --git a/tools/cabana/deqt.md b/tools/cabana/deqt.md new file mode 100644 index 00000000..3bef2d7a --- /dev/null +++ b/tools/cabana/deqt.md @@ -0,0 +1,53 @@ +we're migrating cabana away from Qt and to eventually entirely use imgui + +we are doing it incrementally, in small pieces that are easy to execute and verify. +we will repeat this until we're all done. + +# Cabana Qt API inventory + +these are all still in cabana. we remove them from this list once they're gone. +each bullet is an atomic unit of work. + +our workflow is: +- pick the easiest of the bulleted items from below +- implement it and make] sure it builds +- spin up reviewer agents to review the code in a clean context and a separate one to click around in xvfb as a gui test +- then implement the fixes from the above reviewer agents + +some rules +- do not add more Qt usage ever + +- `QObject`, `QMetaObject`, `QMetaType` +- `QApplication`, `QCoreApplication`, `QGuiApplication` +- `QString`, `QStringList`, `QStringBuilder`, `QChar`, `QLatin1Char` +- `QVariant` +- `QTimer` +- `QWidget`, `QMainWindow`, `QWindow` +- `QDialog`, `QDialogButtonBox`, `QMessageBox`, `QProgressDialog` +- `QFileDialog` +- `QMenu`, `QMenuBar`, `QAction`, `QActionGroup`, `QWidgetAction` +- `QToolBar`, `QToolButton`, `QPushButton` +- `QCheckBox`, `QRadioButton`, `QButtonGroup`, `QAbstractButton` +- `QComboBox`, `QLineEdit`, `QTextEdit`, `QSpinBox`, `QSlider` +- `QLabel`, `QGroupBox`, `QFrame` +- `QTabBar`, `QTabWidget`, `QSplitter`, `QScrollArea`, `QScrollBar` +- `QDockWidget`, `QStatusBar`, `QProgressBar` +- `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout` +- `QSizePolicy` +- `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex` +- `QAbstractItemView`, `QTableView`, `QTreeView` +- `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem` +- `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange` +- `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem` +- `QValidator`, `QIntValidator` +- `QColor`, `QRgb`, `QPalette` +- `QBrush`, `QPen` +- `QPainter`, `QPainterPath`, `QStylePainter` +- `QImage`, `QPixmap`, `QPixmapCache`, `QStaticText` +- `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument` +- `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider` +- `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion` +- `QSize`, `QSizeF` +- `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent` +- `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent` +- `QKeySequence`, `QShortcut`, `QToolTip` diff --git a/tools/cabana/detailwidget.cc b/tools/cabana/detailwidget.cc index 35492c8e..6b62f549 100644 --- a/tools/cabana/detailwidget.cc +++ b/tools/cabana/detailwidget.cc @@ -1,4 +1,5 @@ #include "tools/cabana/detailwidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -56,8 +57,8 @@ DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(chart QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight); QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); }); QObject::connect(can, &AbstractStream::msgsReceived, this, &DetailWidget::updateState); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &DetailWidget::refresh); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &DetailWidget::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &DetailWidget::refresh); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &DetailWidget::refresh); QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { if (index != -1) { @@ -124,9 +125,9 @@ int DetailWidget::findOrAddTab(const MessageId& message_id) { if (tabbar->tabData(index).value() == message_id) break; } if (index == -1) { - index = tabbar->addTab(message_id.toString()); + index = tabbar->addTab(QString::fromStdString(message_id.toString())); tabbar->setTabData(index, QVariant::fromValue(message_id)); - tabbar->setTabToolTip(index, msgName(message_id)); + tabbar->setTabToolTip(index, QString::fromStdString(msgName(message_id))); } return index; } @@ -151,21 +152,21 @@ std::pair DetailWidget::serializeMessageIds() const { QStringList msgs; for (int i = 0; i < tabbar->count(); ++i) { MessageId id = tabbar->tabData(i).value(); - msgs.append(id.toString()); + msgs.append(QString::fromStdString(id.toString())); } - return std::make_pair(msg_id.toString(), msgs); + return std::make_pair(QString::fromStdString(msg_id.toString()), msgs); } void DetailWidget::restoreTabs(const QString active_msg_id, const QStringList& msg_ids) { tabbar->blockSignals(true); for (const auto& str_id : msg_ids) { - MessageId id = MessageId::fromString(str_id); + MessageId id = MessageId::fromString(str_id.toStdString()); if (dbc()->msg(id) != nullptr) findOrAddTab(id); } tabbar->blockSignals(false); - auto active_id = MessageId::fromString(active_msg_id); + auto active_id = MessageId::fromString(active_msg_id.toStdString()); if (dbc()->msg(active_id) != nullptr) setMessage(active_id); } @@ -180,10 +181,10 @@ void DetailWidget::refresh() { warnings.push_back(tr("Message size (%1) is incorrect.").arg(msg->size)); } for (auto s : binary_view->getOverlappingSignals()) { - warnings.push_back(tr("%1 has overlapping bits.").arg(s->name)); + warnings.push_back(tr("%1 has overlapping bits.").arg(QString::fromStdString(s->name))); } } - QString msg_name = msg ? QString("%1 (%2)").arg(msg->name, msg->transmitter) : msgName(msg_id); + QString msg_name = msg ? QString("%1 (%2)").arg(QString::fromStdString(msg->name), QString::fromStdString(msg->transmitter)) : QString::fromStdString(msgName(msg_id)); name_label->setText(msg_name); name_label->setToolTip(msg_name); action_remove_msg->setEnabled(msg != nullptr); @@ -208,22 +209,22 @@ void DetailWidget::updateState(const std::set *msgs) { void DetailWidget::editMsg() { auto msg = dbc()->msg(msg_id); int size = msg ? msg->size : can->lastMessage(msg_id).dat.size(); - EditMessageDialog dlg(msg_id, msgName(msg_id), size, this); + EditMessageDialog dlg(msg_id, QString::fromStdString(msgName(msg_id)), size, this); if (dlg.exec()) { - UndoStack::push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed(), dlg.size_spin->value(), - dlg.node->text().trimmed(), dlg.comment_edit->toPlainText().trimmed())); + UndoStack::instance()->push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(), + dlg.node->text().trimmed().toStdString(), dlg.comment_edit->toPlainText().trimmed().toStdString())); } } void DetailWidget::removeMsg() { - UndoStack::push(new RemoveMsgCommand(msg_id)); + UndoStack::instance()->push(new RemoveMsgCommand(msg_id)); } // EditMessageDialog EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent) : original_name(title), msg_id(msg_id), QDialog(parent) { - setWindowTitle(tr("Edit message: %1").arg(msg_id.toString())); + setWindowTitle(tr("Edit message: %1").arg(QString::fromStdString(msg_id.toString()))); QFormLayout *form_layout = new QFormLayout(this); form_layout->addRow("", error_label = new QLabel); @@ -241,8 +242,8 @@ EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &tit form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel)); if (auto msg = dbc()->msg(msg_id)) { - node->setText(msg->transmitter); - comment_edit->setText(msg->comment); + node->setText(QString::fromStdString(msg->transmitter)); + comment_edit->setText(QString::fromStdString(msg->comment)); } validateName(name_edit->text()); setFixedWidth(parent->width() * 0.9); @@ -252,10 +253,10 @@ EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &tit } void EditMessageDialog::validateName(const QString &text) { - bool valid = text.compare(UNTITLED, Qt::CaseInsensitive) != 0; + bool valid = text.compare(QString::fromStdString(UNTITLED), Qt::CaseInsensitive) != 0; error_label->setVisible(false); if (!text.isEmpty() && valid && text != original_name) { - valid = dbc()->msg(msg_id.source, text) == nullptr; + valid = dbc()->msg(msg_id.source, text.toStdString()) == nullptr; if (!valid) { error_label->setText(tr("Name already exists")); error_label->setVisible(true); diff --git a/tools/cabana/historylog.cc b/tools/cabana/historylog.cc index 3dbdf5a7..3fd569c6 100644 --- a/tools/cabana/historylog.cc +++ b/tools/cabana/historylog.cc @@ -1,4 +1,5 @@ #include "tools/cabana/historylog.h" +#include "tools/cabana/dbc/dbcqt.h" #include @@ -14,7 +15,7 @@ QVariant HistoryLogModel::data(const QModelIndex &index, int role) const { const int col = index.column(); if (role == Qt::DisplayRole) { if (col == 0) return QString::number(can->toSeconds(m.mono_time), 'f', 3); - if (!isHexMode()) return sigs[col - 1]->formatValue(m.sig_values[col - 1], false); + if (!isHexMode()) return QString::fromStdString(sigs[col - 1]->formatValue(m.sig_values[col - 1], false)); } else if (role == Qt::TextAlignmentRole) { return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter); } @@ -49,12 +50,12 @@ QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, i if (section == 0) return "Time"; if (isHexMode()) return "Data"; - QString name = sigs[section - 1]->name; - QString unit = sigs[section - 1]->unit; + QString name = QString::fromStdString(sigs[section - 1]->name); + QString unit = QString::fromStdString(sigs[section - 1]->unit); return unit.isEmpty() ? name : QString("%1 (%2)").arg(name, unit); } else if (role == Qt::BackgroundRole && section > 0 && !isHexMode()) { // Alpha-blend the signal color with the background to ensure contrast - QColor sigColor = sigs[section - 1]->color; + QColor sigColor = toQColor(sigs[section - 1]->color); sigColor.setAlpha(128); return QBrush(sigColor); } @@ -207,8 +208,8 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged); QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV); QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::reset); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, model, &HistoryLogModel::reset); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &HistoryLogModel::reset); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &HistoryLogModel::reset); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &HistoryLogModel::reset); QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset); QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); }); } @@ -216,7 +217,7 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { void LogsWidget::modelReset() { signals_cb->clear(); for (auto s : model->sigs) { - signals_cb->addItem(s->name); + signals_cb->addItem(QString::fromStdString(s->name)); } export_btn->setEnabled(false); value_edit->clear(); @@ -238,11 +239,11 @@ void LogsWidget::filterChanged() { } void LogsWidget::exportToCSV() { - QString dir = QString("%1/%2_%3.csv").arg(settings.last_dir).arg(can->routeName()).arg(msgName(model->msg_id)); - QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(msgName(model->msg_id)), + QString dir = QString("%1/%2_%3.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id))); + QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(QString::fromStdString(msgName(model->msg_id))), dir, tr("csv (*.csv)")); if (!fn.isEmpty()) { - model->isHexMode() ? utils::exportToCSV(fn, model->msg_id) - : utils::exportSignalsToCSV(fn, model->msg_id); + model->isHexMode() ? utils::exportToCSV(fn.toStdString(), model->msg_id) + : utils::exportSignalsToCSV(fn.toStdString(), model->msg_id); } } diff --git a/tools/cabana/historylog.h b/tools/cabana/historylog.h index 1ac6e5bb..1d3200b2 100644 --- a/tools/cabana/historylog.h +++ b/tools/cabana/historylog.h @@ -40,7 +40,7 @@ public: uint64_t mono_time = 0; std::vector sig_values; std::vector data; - std::vector colors; + std::vector colors; }; void fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); diff --git a/tools/cabana/konn3kt_canproxy.py b/tools/cabana/konn3kt_canproxy.py new file mode 100755 index 00000000..fa99fd43 --- /dev/null +++ b/tools/cabana/konn3kt_canproxy.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +konn3kt_canproxy — view a remote device's live CAN in Cabana, through konn3kt. + +Run this on your laptop. It connects to the konn3kt CAN stream for a device you have +access to and re-publishes the raw capnp frames onto a LOCAL ZMQ "can" socket. Then you +just open Cabana → Live → Device, pick ZMQ, and enter 127.0.0.1 — exactly the local +workflow, but the frames are coming from a device across the internet. + +Flow: + device canlived ──ws──> konn3kt relay ──ws──> THIS PROXY ──zmq──> Cabana (127.0.0.1) + +The proxy never parses the CAN bytes; it forwards the exact capnp Event frames the device +produced, so Cabana decodes them identically to a local ZMQ bridge. + +Auth: you authenticate as yourself (a konn3kt user JWT), not as the device. Get your JWT +from the konn3kt app/web session and pass it via --token or the KONN3KT_JWT env var. +Access is enforced server-side (owner-only, or a superuser with an explicit owner grant). + +Usage: + export KONN3KT_JWT="" + ./konn3kt_canproxy.py + # then in Cabana: Live → Device → ZMQ → 127.0.0.1 + +Requires the iqpilot/openpilot Python env (for cereal.messaging) and websocket-client. +""" +import argparse +import os +import sys +import time + +from websocket import create_connection, ABNF, WebSocketException + +# Force cereal messaging onto ZMQ so the local "can" publisher binds a TCP port that +# Cabana's ZMQ Device stream connects to. Must be set before importing messaging. +os.environ["ZMQ"] = "1" +import cereal.messaging as messaging # noqa: E402 + +DEFAULT_HOST = os.getenv("KONN3KT_HOST", "wss://api-iqlabs.konn3kt.com") +RECONNECT_MIN = 1.0 +RECONNECT_MAX = 10.0 + + +def _ws_host(host: str) -> str: + host = host.rstrip("/") + if host.startswith("https://"): + return "wss://" + host[len("https://"):] + if host.startswith("http://"): + return "ws://" + host[len("http://"):] + if host.startswith(("ws://", "wss://")): + return host + return "wss://" + host + + +def run(dongle_id: str, host: str, token: str) -> None: + ws_uri = f"{_ws_host(host)}/v1/devices/{dongle_id}/can-stream?sig={token}" + # Local ZMQ publisher for "can"; Cabana subscribes to this on 127.0.0.1. + pub = messaging.pub_sock("can") + + backoff = RECONNECT_MIN + while True: + try: + print(f"[canproxy] connecting to konn3kt for {dongle_id} ...", file=sys.stderr) + ws = create_connection(ws_uri, enable_multithread=True, timeout=30.0) + print("[canproxy] connected. Open Cabana → Live → Device → ZMQ → 127.0.0.1", file=sys.stderr) + backoff = RECONNECT_MIN + n = 0 + while True: + opcode, data = ws.recv_data(control_frame=True) + if opcode == ABNF.OPCODE_BINARY: + # Republish the exact capnp Event bytes locally. + pub.send(data) + n += 1 + if n % 1000 == 0: + print(f"[canproxy] forwarded {n} frames", file=sys.stderr) + elif opcode == ABNF.OPCODE_CLOSE: + print("[canproxy] server closed the stream", file=sys.stderr) + break + elif opcode == ABNF.OPCODE_PING: + ws.pong(data) + except (WebSocketException, OSError) as e: + print(f"[canproxy] disconnected: {e}; reconnecting in {backoff:.0f}s", file=sys.stderr) + time.sleep(backoff) + backoff = min(backoff * 2, RECONNECT_MAX) + finally: + try: + ws.close() + except Exception: + pass + + +def main() -> None: + ap = argparse.ArgumentParser(description="Bridge a konn3kt remote CAN stream into local ZMQ for Cabana.") + ap.add_argument("dongle_id", help="dongle id of the device to stream") + ap.add_argument("--host", default=DEFAULT_HOST, help=f"konn3kt host (default: {DEFAULT_HOST})") + ap.add_argument("--token", default=os.getenv("KONN3KT_JWT"), + help="konn3kt user JWT (or set KONN3KT_JWT)") + args = ap.parse_args() + + if not args.token: + ap.error("no token: pass --token or set KONN3KT_JWT") + + try: + run(args.dongle_id, args.host, args.token) + except KeyboardInterrupt: + print("\n[canproxy] bye", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tools/cabana/mainwin.cc b/tools/cabana/mainwin.cc index 7d96dbc7..76a73c6f 100644 --- a/tools/cabana/mainwin.cc +++ b/tools/cabana/mainwin.cc @@ -1,29 +1,31 @@ #include "tools/cabana/mainwin.h" +#include "tools/cabana/dbc/dbcqt.h" #include +#include +#include #include +#include #include +#include -#include -#include -#include #include -#include -#include #include #include #include #include #include #include -#include #include -#include +#include "third_party/json11/json11.hpp" #include "tools/cabana/commands.h" #include "tools/cabana/streamselector.h" #include "tools/cabana/tools/findsignal.h" #include "tools/cabana/utils/export.h" +// IQ.Pilot patch: iqpilot has no py_downloader; installDownloadProgressHandler still +// lives in replay/util.h here. +#include "tools/replay/util.h" MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainWindow() { loadFingerprints(); @@ -34,14 +36,11 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW createShortcuts(); // save default window state to allow resetting it - default_state = saveState(); + default_state = utils::toBytes(saveState()); - // restore states - restoreGeometry(settings.geometry); - if (isMaximized()) { - setGeometry(QApplication::desktop()->availableGeometry(this)); - } - restoreState(settings.window_state); + // restore states; restoreGeometry() itself corrects stale off-screen geometry + restoreGeometry(utils::qbytes(settings.geometry)); + restoreState(utils::qbytes(settings.window_state)); // install handlers static auto static_main_win = this; @@ -50,11 +49,9 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { emit static_main_win->updateProgressBar(cur, total, success); }); - qInstallMessageHandler([](QtMsgType type, const QMessageLogContext &context, const QString &msg) { - if (type == QtDebugMsg) return; - emit static_main_win->showMessage(msg, 2000); + installMessageHandler([](ReplyMsgType type, const std::string msg) { + emit static_main_win->showMessage(QString::fromStdString(msg), 2000); }); - installMessageHandler([](ReplyMsgType type, const std::string msg) { qInfo() << msg.c_str(); }); setStyleSheet(QString(R"(QMainWindow::separator { width: %1px; /* when vertical */ @@ -63,8 +60,8 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW QObject::connect(this, &MainWindow::showMessage, statusBar(), &QStatusBar::showMessage); QObject::connect(this, &MainWindow::updateProgressBar, this, &MainWindow::updateDownloadProgress); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &MainWindow::DBCFileChanged); - QObject::connect(UndoStack::instance(), &QUndoStack::cleanChanged, this, &MainWindow::undoStackCleanChanged); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &MainWindow::DBCFileChanged); + QObject::connect(undoNotifier(), &QtUndoNotifier::cleanChanged, this, &MainWindow::undoStackCleanChanged); QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus); QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); }); @@ -72,9 +69,17 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW } void MainWindow::loadFingerprints() { - QFile json_file(QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json"); - if (json_file.open(QIODevice::ReadOnly)) { - fingerprint_to_dbc = QJsonDocument::fromJson(json_file.readAll()); + std::ifstream json_file((QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json").toStdString()); + if (!json_file) return; + const std::string contents{std::istreambuf_iterator(json_file), std::istreambuf_iterator()}; + std::string err; + auto doc = json11::Json::parse(contents, err); + if (!err.empty() || !doc.is_object()) return; + fingerprint_to_dbc.clear(); + for (const auto &kv : doc.object_items()) { + if (kv.second.is_string()) { + fingerprint_to_dbc.emplace(kv.first, kv.second.string_value()); + } } } @@ -100,8 +105,17 @@ void MainWindow::createActions() { file_menu->addSeparator(); QMenu *load_iqdbc_menu = file_menu->addMenu(tr("Load DBC from commaai/iqdbc")); // load_iqdbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }"); - for (const auto &dbc_name : QDir(OPENDBC_FILE_PATH).entryList({"*.dbc"}, QDir::Files, QDir::Name)) { - load_iqdbc_menu->addAction(dbc_name, [this, name = dbc_name]() { loadDBCFromOpendbc(name); }); + std::vector dbc_names; + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".dbc") { + dbc_names.push_back(entry.path().filename().string()); + } + } + std::sort(dbc_names.begin(), dbc_names.end()); + for (const auto &dbc_name : dbc_names) { + QString name = QString::fromStdString(dbc_name); + load_iqdbc_menu->addAction(name, [this, name]() { loadDBCFromOpendbc(name); }); } file_menu->addAction(tr("Load DBC From Clipboard"), [=]() { loadFromClipboard(); }); @@ -119,18 +133,12 @@ void MainWindow::createActions() { // Edit Menu QMenu *edit_menu = menuBar()->addMenu(tr("&Edit")); - auto undo_act = UndoStack::instance()->createUndoAction(this, tr("&Undo")); + undo_act = edit_menu->addAction(tr("&Undo"), []() { UndoStack::instance()->undo(); }); undo_act->setShortcuts(QKeySequence::Undo); - edit_menu->addAction(undo_act); - auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Redo")); + redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); redo_act->setShortcuts(QKeySequence::Redo); - edit_menu->addAction(redo_act); - edit_menu->addSeparator(); - - QMenu *commands_menu = edit_menu->addMenu(tr("Command &List")); - QWidgetAction *commands_act = new QWidgetAction(this); - commands_act->setDefaultWidget(new QUndoView(UndoStack::instance())); - commands_menu->addAction(commands_act); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &MainWindow::updateUndoRedoActions); + updateUndoRedoActions(); // View Menu QMenu *view_menu = menuBar()->addMenu(tr("&View")); @@ -140,7 +148,7 @@ void MainWindow::createActions() { view_menu->addAction(messages_dock->toggleViewAction()); view_menu->addAction(video_dock->toggleViewAction()); view_menu->addSeparator(); - view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(default_state); }); + view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(utils::qbytes(default_state)); }); // Tools Menu tools_menu = menuBar()->addMenu(tr("&Tools")); @@ -187,7 +195,7 @@ void MainWindow::createDockWidgets() { video_splitter->addWidget(charts_container); video_splitter->setStretchFactor(1, 1); - video_splitter->restoreState(settings.video_splitter_state); + video_splitter->restoreState(utils::qbytes(settings.video_splitter_state)); video_splitter->handle(1)->setEnabled(!can->liveStreaming()); video_dock->setWidget(video_splitter); QObject::connect(charts_widget, &ChartsWidget::toggleChartsDocking, this, &MainWindow::toggleChartsDocking); @@ -218,6 +226,14 @@ void MainWindow::undoStackCleanChanged(bool clean) { setWindowModified(!clean); } +void MainWindow::updateUndoRedoActions() { + auto stack = UndoStack::instance(); + undo_act->setEnabled(stack->canUndo()); + undo_act->setText(stack->canUndo() ? tr("&Undo %1").arg(QString::fromStdString(stack->undoText())) : tr("&Undo")); + redo_act->setEnabled(stack->canRedo()); + redo_act->setText(stack->canRedo() ? tr("&Redo %1").arg(QString::fromStdString(stack->redoText())) : tr("&Redo")); +} + void MainWindow::DBCFileChanged() { UndoStack::instance()->clear(); @@ -232,7 +248,7 @@ void MainWindow::DBCFileChanged() { QStringList title; for (auto f : dbc()->allDBCFiles()) { - title.push_back(tr("(%1) %2").arg(toString(dbc()->sources(f)), f->name())); + title.push_back(tr("(%1) %2").arg(QString::fromStdString(toString(dbc()->sources(f))), QString::fromStdString(f->name()))); } setWindowFilePath(title.join(" | ")); @@ -251,27 +267,27 @@ void MainWindow::selectAndOpenStream() { void MainWindow::closeStream() { openStream(new DummyStream(this)); if (dbc()->nonEmptyDBCCount() > 0) { - emit dbc()->DBCFileChanged(); + emit dbcNotifier()->DBCFileChanged(); } statusBar()->showMessage(tr("stream closed")); } void MainWindow::exportToCSV() { - QString dir = QString("%1/%2.csv").arg(settings.last_dir).arg(can->routeName()); + QString dir = QString("%1/%2.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())); QString fn = QFileDialog::getSaveFileName(this, "Export stream to CSV file", dir, tr("csv (*.csv)")); if (!fn.isEmpty()) { - utils::exportToCSV(fn); + utils::exportToCSV(fn.toStdString()); } } void MainWindow::newFile(SourceSet s) { closeFile(s); - dbc()->open(s, "", ""); + dbc()->open(s, std::string(""), std::string("")); } void MainWindow::openFile(SourceSet s) { remindSaveChanges(); - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)"); + QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); if (!fn.isEmpty()) { loadFile(fn, s); } @@ -281,13 +297,13 @@ void MainWindow::loadFile(const QString &fn, SourceSet s) { if (!fn.isEmpty()) { closeFile(s); - QString error; - if (dbc()->open(s, fn, &error)) { + std::string error; + if (dbc()->open(s, fn.toStdString(), &error)) { updateRecentFiles(fn); statusBar()->showMessage(tr("DBC File %1 loaded").arg(fn), 2000); } else { QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC file"), tr("Failed to parse DBC file %1").arg(fn)); - msg_box.setDetailedText(error); + msg_box.setDetailedText(QString::fromStdString(error)); msg_box.exec(); } } @@ -298,16 +314,25 @@ void MainWindow::loadDBCFromOpendbc(const QString &name) { } void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { + std::string text; + if (!utils::getClipboardText(&text)) { + QMessageBox::warning(this, tr("Load From Clipboard"), tr("No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland).")); + return; + } + if (text.empty()) { + QMessageBox::warning(this, tr("Load From Clipboard"), tr("Clipboard is empty.")); + return; + } + closeFile(s); - QString dbc_str = QGuiApplication::clipboard()->text(); - QString error; - bool ret = dbc()->open(s, "", dbc_str, &error); + std::string error; + bool ret = dbc()->open(s, std::string(""), text, &error); if (ret && dbc()->nonEmptyDBCCount() > 0) { QMessageBox::information(this, tr("Load From Clipboard"), tr("DBC Successfully Loaded!")); } else { QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC from clipboard"), tr("Make sure that you paste the text with correct format.")); - msg_box.setDetailedText(error); + msg_box.setDetailedText(QString::fromStdString(error)); msg_box.exec(); } } @@ -331,7 +356,7 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { can->start(); loadFile(dbc_file); - statusBar()->showMessage(tr("Stream [%1] started").arg(can->routeName()), 2000); + statusBar()->showMessage(tr("Stream [%1] started").arg(QString::fromStdString(can->routeName())), 2000); bool has_stream = dynamic_cast(can) == nullptr; close_stream_act->setEnabled(has_stream); @@ -339,7 +364,7 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { tools_menu->setEnabled(has_stream); createDockWidgets(); - video_dock->setWindowTitle(can->routeName()); + video_dock->setWindowTitle(QString::fromStdString(can->routeName())); if (can->liveStreaming() || video_splitter->sizes()[0] == 0) { // display video at minimum size. video_splitter->setSizes({1, 1}); @@ -366,13 +391,16 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { } void MainWindow::eventsMerged() { - if (!can->liveStreaming() && std::exchange(car_fingerprint, can->carFingerprint()) != car_fingerprint) { + if (!can->liveStreaming() && std::exchange(car_fingerprint, QString::fromStdString(can->carFingerprint())) != car_fingerprint) { video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2") - .arg(can->routeName()) + .arg(QString::fromStdString(can->routeName())) .arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint)); // Don't overwrite already loaded DBC - if (!dbc()->nonEmptyDBCCount() && fingerprint_to_dbc.object().contains(car_fingerprint)) { - QTimer::singleShot(0, this, [this]() { loadDBCFromOpendbc(fingerprint_to_dbc[car_fingerprint].toString() + ".dbc"); }); + auto it = fingerprint_to_dbc.find(car_fingerprint.toStdString()); + if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc.end()) { + QTimer::singleShot(0, this, [this, dbc_name = QString::fromStdString(it->second)]() { + loadDBCFromOpendbc(dbc_name + ".dbc"); + }); } } } @@ -414,7 +442,7 @@ void MainWindow::closeFile(DBCFile *dbc_file) { void MainWindow::saveFile(DBCFile *dbc_file) { assert(dbc_file != nullptr); - if (!dbc_file->filename.isEmpty()) { + if (!dbc_file->filename.empty()) { dbc_file->save(); UndoStack::instance()->setClean(); statusBar()->showMessage(tr("File saved"), 2000); @@ -424,10 +452,10 @@ void MainWindow::saveFile(DBCFile *dbc_file) { } void MainWindow::saveFileAs(DBCFile *dbc_file) { - QString title = tr("Save File (bus: %1)").arg(toString(dbc()->sources(dbc_file))); - QString fn = QFileDialog::getSaveFileName(this, title, QDir::cleanPath(settings.last_dir + "/untitled.dbc"), tr("DBC (*.dbc)")); + QString title = tr("Save File (bus: %1)").arg(QString::fromStdString(toString(dbc()->sources(dbc_file)))); + QString fn = QFileDialog::getSaveFileName(this, title, QString::fromStdString((std::filesystem::path(settings.last_dir) / "untitled.dbc").string()), tr("DBC (*.dbc)")); if (!fn.isEmpty()) { - dbc_file->saveAs(fn); + dbc_file->saveAs(fn.toStdString()); UndoStack::instance()->setClean(); statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000); updateRecentFiles(fn); @@ -444,8 +472,11 @@ void MainWindow::saveToClipboard() { void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { assert(dbc_file != nullptr); - QGuiApplication::clipboard()->setText(dbc_file->generateDBC()); - QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); + if (utils::setClipboardText(dbc_file->generateDBC())) { + QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); + } else { + QMessageBox::warning(this, tr("Copy To Clipboard"), tr("Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland).")); + } } void MainWindow::updateLoadSaveMenus() { @@ -465,26 +496,27 @@ void MainWindow::updateLoadSaveMenus() { auto dbc_file = dbc()->findDBCFile(source); if (dbc_file) { bus_menu->addSeparator(); - bus_menu->addAction(dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")")->setEnabled(false); + bus_menu->addAction(QString::fromStdString(dbc_file->name()) + " (" + QString::fromStdString(toString(dbc()->sources(dbc_file))) + ")")->setEnabled(false); bus_menu->addAction(tr("Save..."), [=]() { saveFile(dbc_file); }); bus_menu->addAction(tr("Save As..."), [=]() { saveFileAs(dbc_file); }); bus_menu->addAction(tr("Copy to Clipboard..."), [=]() { saveFileToClipboard(dbc_file); }); bus_menu->addAction(tr("Remove from this bus..."), [=]() { closeFile(ss); }); bus_menu->addAction(tr("Remove from all buses..."), [=]() { closeFile(dbc_file); }); } - bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? dbc_file->name() : "No DBCs loaded")); + bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? QString::fromStdString(dbc_file->name()) : "No DBCs loaded")); manage_dbcs_menu->addMenu(bus_menu); } } void MainWindow::updateRecentFiles(const QString &fn) { - settings.recent_files.removeAll(fn); - settings.recent_files.prepend(fn); + const std::string filename = fn.toStdString(); + settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), filename), settings.recent_files.end()); + settings.recent_files.insert(settings.recent_files.begin(), filename); while (settings.recent_files.size() > MAX_RECENT_FILES) { - settings.recent_files.removeLast(); + settings.recent_files.pop_back(); } - settings.last_dir = QFileInfo(fn).absolutePath(); + settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); } void MainWindow::updateRecentFileMenu() { @@ -497,8 +529,8 @@ void MainWindow::updateRecentFileMenu() { } for (int i = 0; i < num_recent_files; ++i) { - QString text = tr("&%1 %2").arg(i + 1).arg(QFileInfo(settings.recent_files[i]).fileName()); - open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(file); }); + QString text = tr("&%1 %2").arg(i + 1).arg(QString::fromStdString(std::filesystem::path(settings.recent_files[i]).filename().string())); + open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(QString::fromStdString(file)); }); } } @@ -558,22 +590,23 @@ void MainWindow::closeEvent(QCloseEvent *event) { remindSaveChanges(); installDownloadProgressHandler(nullptr); - qInstallMessageHandler(nullptr); + installMessageHandler(nullptr); if (floating_window) floating_window->deleteLater(); // save states - settings.geometry = saveGeometry(); - settings.window_state = saveState(); + settings.geometry = utils::toBytes(saveGeometry()); + settings.window_state = utils::toBytes(saveState()); if (can && !can->liveStreaming()) { - settings.video_splitter_state = video_splitter->saveState(); + settings.video_splitter_state = utils::toBytes(video_splitter->saveState()); } if (messages_widget) { settings.message_header_state = messages_widget->saveHeaderState(); } saveSessionState(); + settings.save(); QWidget::closeEvent(event); } @@ -629,26 +662,35 @@ void MainWindow::saveSessionState() { if (auto *detail = center_widget->getDetailWidget()) { auto [active_id, ids] = detail->serializeMessageIds(); - settings.active_msg_id = active_id; - settings.selected_msg_ids = ids; + settings.active_msg_id = active_id.toStdString(); + settings.selected_msg_ids.clear(); + for (const auto &id : ids) settings.selected_msg_ids.push_back(id.toStdString()); + } + if (charts_widget) { + settings.active_charts.clear(); + for (const auto &id : charts_widget->serializeChartIds()) settings.active_charts.push_back(id.toStdString()); } - if (charts_widget) - settings.active_charts = charts_widget->serializeChartIds(); } void MainWindow::restoreSessionState() { - if (settings.recent_dbc_file.isEmpty() || dbc()->nonEmptyDBCCount() == 0) return; + if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; QString dbc_file; for (auto& f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { dbc_file = f->filename; break; } - if (dbc_file != settings.recent_dbc_file) return; + if (!f->isEmpty()) { dbc_file = QString::fromStdString(f->filename); break; } + if (dbc_file.toStdString() != settings.recent_dbc_file) return; - if (!settings.selected_msg_ids.isEmpty()) - center_widget->ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids); + if (!settings.selected_msg_ids.empty()) { + QStringList ids; + for (const auto &id : settings.selected_msg_ids) ids.push_back(QString::fromStdString(id)); + center_widget->ensureDetailWidget()->restoreTabs(QString::fromStdString(settings.active_msg_id), ids); + } - if (charts_widget != nullptr && !settings.active_charts.empty()) - charts_widget->restoreChartsFromIds(settings.active_charts); + if (charts_widget != nullptr && !settings.active_charts.empty()) { + QStringList ids; + for (const auto &id : settings.active_charts) ids.push_back(QString::fromStdString(id)); + charts_widget->restoreChartsFromIds(ids); + } } // HelpOverlay diff --git a/tools/cabana/mainwin.h b/tools/cabana/mainwin.h index 92c2714a..279ea3b9 100644 --- a/tools/cabana/mainwin.h +++ b/tools/cabana/mainwin.h @@ -1,13 +1,16 @@ #pragma once #include -#include #include #include #include #include #include +#include #include +#include +#include +#include #include "tools/cabana/chart/chartswidget.h" #include "tools/cabana/dbc/dbcmanager.h" @@ -67,6 +70,7 @@ protected: void findSimilarBits(); void findSignal(); void undoStackCleanChanged(bool clean); + void updateUndoRedoActions(); void onlineHelp(); void toggleFullScreen(); void updateStatus(); @@ -85,7 +89,7 @@ protected: QVBoxLayout *charts_layout; QProgressBar *progress_bar; QLabel *status_label; - QJsonDocument fingerprint_to_dbc; + std::unordered_map fingerprint_to_dbc; QSplitter *video_splitter = nullptr; enum { MAX_RECENT_FILES = 15 }; QMenu *open_recent_menu = nullptr; @@ -96,8 +100,10 @@ protected: QAction *save_dbc = nullptr; QAction *save_dbc_as = nullptr; QAction *copy_dbc_to_clipboard = nullptr; + QAction *undo_act = nullptr; + QAction *redo_act = nullptr; QString car_fingerprint; - QByteArray default_state; + std::vector default_state; }; class HelpOverlay : public QWidget { diff --git a/tools/cabana/messageswidget.cc b/tools/cabana/messageswidget.cc index ed9aeaf3..b07e3ea0 100644 --- a/tools/cabana/messageswidget.cc +++ b/tools/cabana/messageswidget.cc @@ -1,4 +1,5 @@ #include "tools/cabana/messageswidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -43,8 +44,8 @@ MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent); QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions); QObject::connect(can, &AbstractStream::msgsReceived, model, &MessageListModel::msgsReceived); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, model, &MessageListModel::dbcModified); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &MessageListModel::dbcModified); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &MessageListModel::dbcModified); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &MessageListModel::dbcModified); QObject::connect(model, &MessageListModel::modelReset, [this]() { if (current_msg_id) { selectMessage(*current_msg_id); @@ -64,7 +65,7 @@ MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget setWhatsThis(tr(R"( Message View
- + Byte color
constant changing
increasing
@@ -146,7 +147,7 @@ void MessagesWidget::menuAboutToShow() { action->setCheckable(true); action->setChecked(settings.multiple_lines_hex); - action = menu->addAction(tr("Show inactive Messages"), model, &MessageListModel::showInactivemessages); + action = menu->addAction(tr("Show inactive messages"), model, &MessageListModel::showInactiveMessages); action->setCheckable(true); action->setChecked(model->show_inactive_messages); } @@ -205,18 +206,18 @@ QVariant MessageListModel::data(const QModelIndex &index, int role) const { } else if (role == Qt::ToolTipRole && index.column() == Column::NAME) { auto msg = dbc()->msg(item.id); auto tooltip = item.name; - if (msg && !msg->comment.isEmpty()) tooltip += "
" + msg->comment + ""; + if (msg && !msg->comment.empty()) tooltip += "
" + QString::fromStdString(msg->comment) + ""; return tooltip; } return {}; } -void MessageListModel::setFilterStrings(const QMap &filters) { +void MessageListModel::setFilterStrings(const std::map &filters) { filters_ = filters; filterAndSort(); } -void MessageListModel::showInactivemessages(bool show) { +void MessageListModel::showInactiveMessages(bool show) { show_inactive_messages = show; filterAndSort(); } @@ -264,20 +265,20 @@ static bool parseRange(const QString &filter, uint32_t value, int base = 10) { } bool MessageListModel::match(const MessageListModel::Item &item) { - if (filters_.isEmpty()) + if (filters_.empty()) return true; bool match = true; const auto &data = can->lastMessage(item.id); for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) { - const QString &txt = it.value(); - switch (it.key()) { + const QString &txt = it->second; + switch (it->first) { case Column::NAME: { match = item.name.contains(txt, Qt::CaseInsensitive); if (!match) { const auto m = dbc()->msg(item.id); match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), - [&txt](const auto &s) { return s->name.contains(txt, Qt::CaseInsensitive); }); + [&txt](const auto &s) { return QString::fromStdString(s->name).contains(txt, Qt::CaseInsensitive); }); } break; } @@ -323,8 +324,8 @@ bool MessageListModel::filterAndSort() { if (show_inactive_messages || can->isMessageActive(id)) { auto msg = dbc()->msg(id); Item item = {.id = id, - .name = msg ? msg->name : UNTITLED, - .node = msg ? msg->transmitter : QString()}; + .name = msg ? QString::fromStdString(msg->name) : QString::fromStdString(UNTITLED), + .node = msg ? QString::fromStdString(msg->transmitter) : QString()}; if (match(item)) items.emplace_back(item); } @@ -387,10 +388,13 @@ void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, painter->setPen(oldPen); } -void MessageView::dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { +void MessageView::setModel(QAbstractItemModel *model) { + QTreeView::setModel(model); // Bypass the slow call to QTreeView::dataChanged. // QTreeView::dataChanged will invalidate the height cache and that's what we don't need in MessageView. - QAbstractItemView::dataChanged(topLeft, bottomRight, roles); + QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr); + QObject::connect(model, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); } void MessageView::updateBytesSectionSize() { @@ -421,9 +425,9 @@ MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizont } void MessageViewHeader::updateFilters() { - QMap filters; - for (int i = 0; i < count(); i++) { - if (editors[i] && !editors[i]->text().isEmpty()) { + std::map filters; + for (int i = 0; i < (int)editors.size(); i++) { + if (!editors[i]->text().isEmpty()) { filters[i] = editors[i]->text(); } } @@ -432,27 +436,24 @@ void MessageViewHeader::updateFilters() { void MessageViewHeader::updateHeaderPositions() { QSize sz = QHeaderView::sizeHint(); - for (int i = 0; i < count(); i++) { - if (editors[i]) { - int h = editors[i]->sizeHint().height(); - editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h); - editors[i]->setHidden(isSectionHidden(i)); - } + for (int i = 0; i < (int)editors.size(); i++) { + int h = editors[i]->sizeHint().height(); + editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h); + editors[i]->setHidden(isSectionHidden(i)); } } void MessageViewHeader::updateGeometries() { - for (int i = 0; i < count(); i++) { - if (!editors[i]) { - QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString(); - editors[i] = new QLineEdit(this); - editors[i]->setClearButtonEnabled(true); - editors[i]->setPlaceholderText(tr("Filter %1").arg(column_name)); + for (int i = (int)editors.size(); i < count(); i++) { + QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString(); + auto edit = new QLineEdit(this); + edit->setClearButtonEnabled(true); + edit->setPlaceholderText(tr("Filter %1").arg(column_name)); - QObject::connect(editors[i], &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); - } + QObject::connect(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); + editors.push_back(edit); } - setViewportMargins(0, 0, 0, editors[0] ? editors[0]->sizeHint().height() : 0); + setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0); QHeaderView::updateGeometries(); updateHeaderPositions(); @@ -460,5 +461,5 @@ void MessageViewHeader::updateGeometries() { QSize MessageViewHeader::sizeHint() const { QSize sz = QHeaderView::sizeHint(); - return editors[0] ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; + return !editors.empty() ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; } diff --git a/tools/cabana/messageswidget.h b/tools/cabana/messageswidget.h index 0fc09519..0a9cd256 100644 --- a/tools/cabana/messageswidget.h +++ b/tools/cabana/messageswidget.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include #include @@ -35,8 +37,8 @@ public: QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); } void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override; - void setFilterStrings(const QMap &filters); - void showInactivemessages(bool show); + void setFilterStrings(const std::map &filters); + void showInactiveMessages(bool show); void msgsReceived(const std::set *new_msgs, bool has_new_ids); bool filterAndSort(); void dbcModified(); @@ -56,7 +58,7 @@ private: void sortItems(std::vector &items); bool match(const MessageListModel::Item &id); - QMap filters_; + std::map filters_; std::set dbc_messages_; int sort_column = 0; Qt::SortOrder sort_order = Qt::AscendingOrder; @@ -68,11 +70,11 @@ class MessageView : public QTreeView { public: MessageView(QWidget *parent) : QTreeView(parent) {} void updateBytesSectionSize(); + void setModel(QAbstractItemModel *model) override; protected: void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; void drawBranches(QPainter *painter, const QRect &rect, const QModelIndex &index) const override {} - void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles = QVector()) override; void wheelEvent(QWheelEvent *event) override; }; @@ -86,7 +88,7 @@ public: QSize sizeHint() const override; void updateFilters(); - QMap editors; + std::vector editors; }; class MessagesWidget : public QWidget { @@ -95,8 +97,13 @@ class MessagesWidget : public QWidget { public: MessagesWidget(QWidget *parent); void selectMessage(const MessageId &message_id); - QByteArray saveHeaderState() const { return view->header()->saveState(); } - bool restoreHeaderState(const QByteArray &state) const { return view->header()->restoreState(state); } + std::vector saveHeaderState() const { + const auto state = view->header()->saveState(); + return {state.begin(), state.end()}; + } + bool restoreHeaderState(const std::vector &state) const { + return view->header()->restoreState({(const char *)state.data(), (int)state.size()}); + } void suppressHighlighted(); signals: diff --git a/tools/cabana/panda.cc b/tools/cabana/panda.cc index eb836d22..0612d677 100644 --- a/tools/cabana/panda.cc +++ b/tools/cabana/panda.cc @@ -26,16 +26,14 @@ static libusb_context *init_usb_ctx() { return context; } -Panda::Panda(std::string serial, uint32_t bus_offset, bool passive_mode) : bus_offset(bus_offset), passive_mode(passive_mode) { +Panda::Panda(std::string serial, uint32_t bus_offset) : bus_offset(bus_offset) { if (!init_usb_connection(serial)) { throw std::runtime_error("Error connecting to panda"); } LOGW("connected to %s over USB", serial.c_str()); hw_type = get_hw_type(); - if (!passive_mode) { - can_reset_communications(); - } + can_reset_communications(); } Panda::~Panda() { diff --git a/tools/cabana/panda.h b/tools/cabana/panda.h index 6ef3dcc5..d318c33f 100644 --- a/tools/cabana/panda.h +++ b/tools/cabana/panda.h @@ -48,12 +48,11 @@ struct can_frame { class Panda { public: - Panda(std::string serial="", uint32_t bus_offset=0, bool passive_mode=false); + Panda(std::string serial="", uint32_t bus_offset=0); ~Panda(); cereal::PandaState::PandaType hw_type = cereal::PandaState::PandaType::UNKNOWN; const uint32_t bus_offset; - const bool passive_mode; bool connected(); bool comms_healthy(); diff --git a/tools/cabana/settings.cc b/tools/cabana/settings.cc index e7b1129a..8a86f62a 100644 --- a/tools/cabana/settings.cc +++ b/tools/cabana/settings.cc @@ -1,15 +1,37 @@ #include "tools/cabana/settings.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#ifdef __APPLE__ +#include +#endif + #include #include -#include #include #include #include -#include -#include #include +#include "third_party/json11/json11.hpp" #include "tools/cabana/utils/util.h" const int MIN_CACHE_MINIUTES = 30; @@ -17,9 +39,442 @@ const int MAX_CACHE_MINIUTES = 120; Settings settings; -template -void settings_op(SettingOperation op) { - QSettings s("cabana"); +namespace { + +std::filesystem::path settingsFile() { + return utils::configPath() / "cabana.json"; +} + +struct LoadedSettings { + json11::Json::object values; + bool exists = false; + bool valid = true; +}; + +class FileLock { +public: + explicit FileLock(const std::filesystem::path &path) { + fd = open(path.c_str(), O_CREAT | O_CLOEXEC, 0600); + if (fd < 0 || flock(fd, LOCK_EX) < 0) { + fprintf(stderr, "failed to lock Cabana settings %s: %s\n", path.c_str(), strerror(errno)); + if (fd >= 0) close(fd); + fd = -1; + } + } + ~FileLock() { + if (fd >= 0) close(fd); + } + bool isLocked() const { return fd >= 0; } + +private: + int fd = -1; +}; + +LoadedSettings loadSettings() { + std::ifstream input(settingsFile()); + if (!input) return {}; + + const std::string contents{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + std::string error; + auto settings_json = json11::Json::parse(contents, error); + if (!error.empty() || !settings_json.is_object()) { + fprintf(stderr, "failed to read Cabana settings %s%s%s\n", settingsFile().c_str(), error.empty() ? "" : ": ", error.c_str()); + return {.exists = true, .valid = false}; + } + return {.values = settings_json.object_items(), .exists = true}; +} + +bool ensureSettingsDirectory() { + const auto path = settingsFile(); + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + if (error) { + fprintf(stderr, "failed to create Cabana settings directory %s: %s\n", path.parent_path().c_str(), error.message().c_str()); + return false; + } + return true; +} + +bool writeAll(int fd, const std::string &data) { + size_t written = 0; + while (written < data.size()) { + ssize_t result = write(fd, data.data() + written, data.size() - written); + if (result < 0 && errno == EINTR) continue; + if (result <= 0) return false; + written += result; + } + return true; +} + +bool saveSettings(const json11::Json::object &settings_json) { + const auto path = settingsFile(); + const std::string contents = json11::Json(settings_json).dump(); + std::string temporary_path = path.string() + ".tmp.XXXXXX"; + int fd = mkstemp(temporary_path.data()); + if (fd < 0) { + fprintf(stderr, "failed to create temporary Cabana settings %s: %s\n", temporary_path.c_str(), strerror(errno)); + return false; + } + + bool success = writeAll(fd, contents) && fsync(fd) == 0; + if (close(fd) < 0) success = false; + if (success && rename(temporary_path.c_str(), path.c_str()) < 0) success = false; + + if (success) { + int dir_fd = open(path.parent_path().c_str(), O_RDONLY | O_CLOEXEC); + success = dir_fd >= 0 && fsync(dir_fd) == 0; + if (dir_fd >= 0 && close(dir_fd) < 0) success = false; + } + + if (!success) { + const int saved_errno = errno; + unlink(temporary_path.c_str()); + fprintf(stderr, "failed to save Cabana settings to %s: %s\n", path.c_str(), strerror(saved_errno)); + } + return success; +} + +bool preserveCorruptSettings() { + const auto path = settingsFile(); + auto backup = path; + backup += ".corrupt"; + for (int i = 1; std::filesystem::exists(backup); ++i) { + backup = path; + backup += ".corrupt." + std::to_string(i); + } + if (rename(path.c_str(), backup.c_str()) < 0) { + fprintf(stderr, "failed to preserve corrupt Cabana settings %s: %s\n", path.c_str(), strerror(errno)); + return false; + } + fprintf(stderr, "preserved corrupt Cabana settings at %s\n", backup.c_str()); + return true; +} + +// TODO: Remove the legacy QSettings migration after users have had time to migrate to cabana.json. +struct LegacyValue { + std::vector strings; + std::string bytes; + bool is_byte_array = false; +}; + +using LegacySettings = std::map; + +int hexDigit(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +#ifndef __APPLE__ + +void appendUtf8(std::string &result, uint32_t codepoint) { + if (codepoint <= 0x7f) { + result.push_back(codepoint); + } else if (codepoint <= 0x7ff) { + result.push_back(0xc0 | (codepoint >> 6)); + result.push_back(0x80 | (codepoint & 0x3f)); + } else if (codepoint <= 0xffff) { + result.push_back(0xe0 | (codepoint >> 12)); + result.push_back(0x80 | ((codepoint >> 6) & 0x3f)); + result.push_back(0x80 | (codepoint & 0x3f)); + } else { + result.push_back(0xf0 | (codepoint >> 18)); + result.push_back(0x80 | ((codepoint >> 12) & 0x3f)); + result.push_back(0x80 | ((codepoint >> 6) & 0x3f)); + result.push_back(0x80 | (codepoint & 0x3f)); + } +} + +LegacyValue decodeIniValue(std::string_view encoded) { + std::vector> decoded(1); + std::vector quoted(1, false); + bool in_quotes = false; + + for (size_t i = 0; i < encoded.size();) { + char c = encoded[i++]; + if (c == '"') { + in_quotes = !in_quotes; + quoted.back() = true; + } else if (c == ',' && !in_quotes) { + decoded.emplace_back(); + quoted.push_back(false); + while (i < encoded.size() && (encoded[i] == ' ' || encoded[i] == '\t')) ++i; + } else if (c == '\\' && i < encoded.size()) { + c = encoded[i++]; + static const std::map escapes = { + {'a', '\a'}, {'b', '\b'}, {'f', '\f'}, {'n', '\n'}, {'r', '\r'}, {'t', '\t'}, + {'v', '\v'}, {'"', '"'}, {'?', '?'}, {'\'', '\''}, {'\\', '\\'}, + }; + if (auto it = escapes.find(c); it != escapes.end()) { + decoded.back().push_back(static_cast(it->second)); + } else if (c == 'x' && i < encoded.size() && hexDigit(encoded[i]) >= 0) { + uint32_t value = 0; + while (i < encoded.size() && hexDigit(encoded[i]) >= 0) value = (value << 4) + hexDigit(encoded[i++]); + decoded.back().push_back(value & 0xffff); + } else if (c >= '0' && c <= '7') { + uint32_t value = c - '0'; + while (i < encoded.size() && encoded[i] >= '0' && encoded[i] <= '7') value = (value << 3) + (encoded[i++] - '0'); + decoded.back().push_back(value & 0xffff); + } + } else { + decoded.back().push_back(static_cast(c)); + } + } + + LegacyValue result; + for (size_t i = 0; i < decoded.size(); ++i) { + auto &value = decoded[i]; + if (!quoted[i]) { + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(value.begin()); + while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) value.pop_back(); + } + + std::string string_value; + for (size_t j = 0; j < value.size(); ++j) { + uint32_t codepoint = value[j]; + if (codepoint >= 0xd800 && codepoint <= 0xdbff && j + 1 < value.size() && value[j + 1] >= 0xdc00 && value[j + 1] <= 0xdfff) { + codepoint = 0x10000 + ((codepoint - 0xd800) << 10) + (value[++j] - 0xdc00); + } + appendUtf8(string_value, codepoint); + } + result.strings.push_back(std::move(string_value)); + } + + if (result.strings.size() == 1 && result.strings[0] == "@Invalid()") { + result.strings.clear(); + } else if (decoded.size() == 1) { + static constexpr std::string_view prefix = "@ByteArray("; + const auto &value = decoded[0]; + if (value.size() >= prefix.size() + 1 && std::equal(prefix.begin(), prefix.end(), value.begin()) && value.back() == ')') { + result.is_byte_array = true; + result.bytes.reserve(value.size() - prefix.size() - 1); + for (size_t i = prefix.size(); i + 1 < value.size(); ++i) result.bytes.push_back(value[i] & 0xff); + } + } + if (!result.is_byte_array) { + for (auto &value : result.strings) { + if (value.compare(0, 2, "@@") == 0) value.erase(0, 1); + } + } + return result; +} + +LegacySettings loadLegacySettings() { + auto path = settingsFile(); + path.replace_filename("cabana.conf"); + std::ifstream input(path); + if (!input) return {}; + + LegacySettings settings; + bool in_general_section = false; + std::string line; + while (std::getline(input, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line == "[General]") { + in_general_section = true; + continue; + } + if (!line.empty() && line.front() == '[') { + in_general_section = false; + continue; + } + if (!in_general_section || line.empty() || line.front() == ';') continue; + if (auto separator = line.find('='); separator != std::string::npos) { + settings[line.substr(0, separator)] = decodeIniValue(std::string_view(line).substr(separator + 1)); + } + } + return settings; +} + +#else + +std::string cfStringToUtf8(CFStringRef value) { + CFIndex length = CFStringGetLength(value); + CFIndex size = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1; + std::string result(size, '\0'); + if (!CFStringGetCString(value, result.data(), size, kCFStringEncodingUTF8)) return {}; + result.resize(strlen(result.c_str())); + return result; +} + +LegacyValue cfStringValue(CFStringRef string) { + LegacyValue value; + if (CFStringHasPrefix(string, CFSTR("@ByteArray(")) && CFStringHasSuffix(string, CFSTR(")"))) { + CFRange range{11, CFStringGetLength(string) - 12}; + std::vector data(range.length); + CFStringGetCharacters(string, range, data.data()); + value.is_byte_array = true; + value.bytes.reserve(data.size()); + for (UniChar c : data) value.bytes.push_back(c & 0xff); + } else { + std::string string_value = cfStringToUtf8(string); + if (string_value.compare(0, 2, "@@") == 0) string_value.erase(0, 1); + value.strings.push_back(std::move(string_value)); + } + return value; +} + +LegacySettings loadLegacySettings() { + LegacySettings settings; + CFDictionaryRef values = CFPreferencesCopyMultiple(nullptr, CFSTR("com.cabana"), + kCFPreferencesCurrentUser, kCFPreferencesAnyHost); + if (values == nullptr) return settings; + + CFIndex count = CFDictionaryGetCount(values); + std::vector keys(count); + std::vector objects(count); + CFDictionaryGetKeysAndValues(values, keys.data(), objects.data()); + for (CFIndex i = 0; i < count; ++i) { + if (CFGetTypeID(keys[i]) != CFStringGetTypeID()) continue; + std::string key = cfStringToUtf8(static_cast(keys[i])); + CFTypeRef object = objects[i]; + LegacyValue value; + if (CFGetTypeID(object) == CFBooleanGetTypeID()) { + value.strings.push_back(CFBooleanGetValue(static_cast(object)) ? "true" : "false"); + } else if (CFGetTypeID(object) == CFNumberGetTypeID()) { + int number = 0; + if (CFNumberGetValue(static_cast(object), kCFNumberIntType, &number)) value.strings.push_back(std::to_string(number)); + } else if (CFGetTypeID(object) == CFStringGetTypeID()) { + value = cfStringValue(static_cast(object)); + } else if (CFGetTypeID(object) == CFDataGetTypeID()) { + auto data = static_cast(object); + value.is_byte_array = true; + value.bytes.assign(reinterpret_cast(CFDataGetBytePtr(data)), CFDataGetLength(data)); + } else if (CFGetTypeID(object) == CFArrayGetTypeID()) { + auto array = static_cast(object); + for (CFIndex j = 0; j < CFArrayGetCount(array); ++j) { + CFTypeRef item = CFArrayGetValueAtIndex(array, j); + if (CFGetTypeID(item) != CFStringGetTypeID()) { + value.strings.clear(); + break; + } + auto item_value = cfStringValue(static_cast(item)); + if (item_value.strings.size() != 1) { + value.strings.clear(); + break; + } + value.strings.push_back(std::move(item_value.strings[0])); + } + } + if (!value.strings.empty() || value.is_byte_array || CFGetTypeID(object) == CFArrayGetTypeID()) { + settings.emplace(std::move(key), std::move(value)); + } + } + CFRelease(values); + return settings; +} + +#endif + +template +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, T &value) { + auto it = legacy_settings.find(key); + if (it == legacy_settings.end() || it->second.strings.size() != 1) return; + const auto &stored = it->second.strings[0]; + + if constexpr (std::is_same_v) { + if (stored == "true") value = true; + if (stored == "false") value = false; + } else if constexpr (std::is_integral_v || std::is_enum_v) { + int number = 0; + auto [end, error] = std::from_chars(stored.data(), stored.data() + stored.size(), number); + if (error == std::errc{} && end == stored.data() + stored.size()) value = static_cast(number); + } +} + +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::string &value) { + auto it = legacy_settings.find(key); + if (it != legacy_settings.end() && it->second.strings.size() == 1) value = it->second.strings[0]; +} + +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector &value) { + auto it = legacy_settings.find(key); + if (it != legacy_settings.end() && !it->second.is_byte_array) value = it->second.strings; +} + +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector &value) { + auto it = legacy_settings.find(key); + if (it != legacy_settings.end() && it->second.is_byte_array) { + value.assign(it->second.bytes.begin(), it->second.bytes.end()); + } +} + +template +void readSetting(const json11::Json::object &settings_json, const char *key, T &value) { + auto it = settings_json.find(key); + if (it == settings_json.end()) return; + + if constexpr (std::is_same_v) { + if (it->second.is_bool()) value = it->second.bool_value(); + } else if constexpr (std::is_integral_v) { + if (it->second.is_number()) value = it->second.int_value(); + } else if constexpr (std::is_enum_v) { + if (it->second.is_number()) value = static_cast(it->second.int_value()); + } +} + +void readSetting(const json11::Json::object &settings_json, const char *key, std::string &value) { + auto it = settings_json.find(key); + if (it != settings_json.end() && it->second.is_string()) value = it->second.string_value(); +} + +void readSetting(const json11::Json::object &settings_json, const char *key, std::vector &value) { + auto it = settings_json.find(key); + if (it == settings_json.end() || !it->second.is_array()) return; + + std::vector stored; + for (const auto &item : it->second.array_items()) { + if (!item.is_string()) return; + stored.push_back(item.string_value()); + } + value = std::move(stored); +} + +void readSetting(const json11::Json::object &settings_json, const char *key, std::vector &value) { + auto it = settings_json.find(key); + if (it == settings_json.end() || !it->second.is_string()) return; + + const auto &hex = it->second.string_value(); + if (hex.size() % 2 == 0 && std::all_of(hex.begin(), hex.end(), [](unsigned char c) { return std::isxdigit(c); })) { + value.clear(); + value.reserve(hex.size() / 2); + for (size_t i = 0; i < hex.size(); i += 2) { + value.push_back((hexDigit(hex[i]) << 4) | hexDigit(hex[i + 1])); + } + } +} + +template +void writeSetting(json11::Json::object &settings_json, const char *key, const T &value) { + if constexpr (std::is_same_v) { + settings_json[key] = value; + } else if constexpr (std::is_integral_v || std::is_enum_v) { + settings_json[key] = static_cast(value); + } +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::string &value) { + settings_json[key] = value; +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector &value) { + settings_json[key] = value; +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector &value) { + static const char digits[] = "0123456789abcdef"; + std::string hex; + hex.reserve(value.size() * 2); + for (uint8_t b : value) { + hex.push_back(digits[b >> 4]); + hex.push_back(digits[b & 0xf]); + } + settings_json[key] = hex; +} + +template +void settingsOp(Store &s, SettingOperation op) { op(s, "absolute_time", settings.absolute_time); op(s, "fps", settings.fps); op(s, "max_cached_minutes", settings.max_cached_minutes); @@ -47,17 +502,38 @@ void settings_op(SettingOperation op) { op(s, "active_charts", settings.active_charts); } +} // namespace + Settings::Settings() { - last_dir = last_route_dir = QDir::homePath(); - log_path = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/cabana_live_stream/"; - settings_op([](QSettings &s, const QString &key, auto &value) { - if (auto v = s.value(key); v.canConvert>()) - value = v.value>(); - }); + last_dir = last_route_dir = utils::homePath(); + log_path = utils::homePath() + "/cabana_live_stream/"; + const auto stored_settings = loadSettings(); + if (stored_settings.valid) { + if (stored_settings.exists) { + settingsOp(stored_settings.values, [](const auto &s, const char *key, auto &value) { readSetting(s, key, value); }); + } else { + auto legacy_settings = loadLegacySettings(); + settingsOp(legacy_settings, [](const auto &s, const char *key, auto &value) { readLegacySetting(s, key, value); }); + } + } + fps = std::clamp(fps, 1, 100); } -Settings::~Settings() { - settings_op([](QSettings &s, const QString &key, auto &v) { s.setValue(key, v); }); +// Must be called before main() returns: json11's internal statistics are constructed on first +// use at runtime, so they are destroyed before this pre-main global. Saving from ~Settings +// would use them after destruction and corrupt the heap. +void Settings::save() { + if (!ensureSettingsDirectory()) return; + + auto lock_path = settingsFile(); + lock_path += ".lock"; + FileLock lock(lock_path); + if (!lock.isLocked()) return; + + auto stored_settings = loadSettings(); + if (!stored_settings.valid && !preserveCorruptSettings()) return; + settingsOp(stored_settings.values, [](auto &s, const char *key, const auto &value) { writeSetting(s, key, value); }); + saveSettings(stored_settings.values); } // SettingsDlg @@ -101,8 +577,9 @@ SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { log_livestream = new QGroupBox(tr("Enable live stream logging"), this); log_livestream->setCheckable(true); + log_livestream->setChecked(settings.log_livestream); QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); - path_layout->addWidget(log_path = new QLineEdit(settings.log_path, this)); + path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(settings.log_path), this)); log_path->setReadOnly(true); auto browse_btn = new QPushButton(tr("B&rowse...")); path_layout->addWidget(browse_btn); @@ -115,7 +592,7 @@ SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { QObject::connect(browse_btn, &QPushButton::clicked, [this]() { QString fn = QFileDialog::getExistingDirectory( this, tr("Log File Location"), - QStandardPaths::writableLocation(QStandardPaths::HomeLocation), + QString::fromStdString(utils::homePath()), QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); if (!fn.isEmpty()) { log_path->setText(fn); @@ -134,7 +611,7 @@ void SettingsDlg::save() { settings.max_cached_minutes = cached_minutes->value(); settings.chart_height = chart_height->value(); settings.log_livestream = log_livestream->isChecked(); - settings.log_path = log_path->text(); + settings.log_path = log_path->text().toStdString(); settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); emit settings.changed(); QDialog::accept(); diff --git a/tools/cabana/settings.h b/tools/cabana/settings.h index 7ab50d14..7357ecf4 100644 --- a/tools/cabana/settings.h +++ b/tools/cabana/settings.h @@ -1,56 +1,28 @@ #pragma once -#include +#include +#include + #include #include #include #include #include -#define LIGHT_THEME 1 -#define DARK_THEME 2 +#include "tools/cabana/core/settings.h" -class Settings : public QObject { +class Settings : public QObject, public CabanaSettingsState { Q_OBJECT public: - enum DragDirection { - MsbFirst, - LsbFirst, - AlwaysLE, - AlwaysBE, - }; - Settings(); - ~Settings(); + void save(); - bool absolute_time = false; - int fps = 10; - int max_cached_minutes = 30; - int chart_height = 200; - int chart_column_count = 1; - int chart_range = 3 * 60; // 3 minutes - int chart_series_type = 0; - int theme = 0; - int sparkline_range = 15; // 15 seconds - bool multiple_lines_hex = false; - bool log_livestream = true; - bool suppress_defined_signals = false; - QString log_path; - QString last_dir; - QString last_route_dir; - QByteArray geometry; - QByteArray video_splitter_state; - QByteArray window_state; - QStringList recent_files; - QByteArray message_header_state; - DragDirection drag_direction = MsbFirst; - - // session data - QString recent_dbc_file; - QString active_msg_id; - QStringList selected_msg_ids; - QStringList active_charts; + // Qt frontend layout state. This intentionally stays outside CabanaSettingsState. + std::vector geometry; + std::vector video_splitter_state; + std::vector window_state; + std::vector message_header_state; signals: void changed(); diff --git a/tools/cabana/signalview.cc b/tools/cabana/signalview.cc index 0a0c07a1..ebb53741 100644 --- a/tools/cabana/signalview.cc +++ b/tools/cabana/signalview.cc @@ -1,8 +1,9 @@ #include "tools/cabana/signalview.h" +#include "tools/cabana/dbc/dbcqt.h" #include +#include -#include #include #include #include @@ -11,10 +12,10 @@ #include #include #include -#include #include #include "tools/cabana/commands.h" +#include "tools/cabana/utils/util.h" // SignalModel @@ -25,21 +26,21 @@ static QString signalTypeToString(cabana::Signal::Type type) { } SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &SignalModel::refresh); - QObject::connect(dbc(), &DBCManager::msgUpdated, this, &SignalModel::handleMsgChanged); - QObject::connect(dbc(), &DBCManager::msgRemoved, this, &SignalModel::handleMsgChanged); - QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalModel::handleSignalAdded); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalModel::handleSignalUpdated); - QObject::connect(dbc(), &DBCManager::signalRemoved, this, &SignalModel::handleSignalRemoved); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &SignalModel::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &SignalModel::handleMsgChanged); + QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &SignalModel::handleMsgChanged); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalModel::handleSignalAdded); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalModel::handleSignalUpdated); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &SignalModel::handleSignalRemoved); } void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { - Item *parent_item = new Item{.sig = sig, .parent = root_item, .title = sig->name, .type = Item::Sig}; - root_item->children.insert(pos, parent_item); + Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = QString::fromStdString(sig->name)}; + root_item->children.insert(root_item->children.begin() + pos, parent_item); QString titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; for (int i = 0; i < std::size(titles); ++i) { - auto item = new Item{.sig = sig, .parent = parent_item, .title = titles[i], .type = (Item::Type)(i + Item::Name)}; + auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]}; parent_item->children.push_back(item); if (item->type == Item::ExtraInfo) { parent_item = item; @@ -63,7 +64,7 @@ void SignalModel::refresh() { root.reset(new SignalModel::Item); if (auto msg = dbc()->msg(msg_id)) { for (auto s : msg->getSignals()) { - if (filter_str.isEmpty() || s->name.contains(filter_str, Qt::CaseInsensitive)) { + if (filter_str.isEmpty() || QString::fromStdString(s->name).contains(filter_str, Qt::CaseInsensitive)) { insertItem(root.get(), root->children.size(), s); } } @@ -124,25 +125,25 @@ QVariant SignalModel::data(const QModelIndex &index, int role) const { const Item *item = getItem(index); if (role == Qt::DisplayRole || role == Qt::EditRole) { if (index.column() == 0) { - return item->type == Item::Sig ? item->sig->name : item->title; + return item->type == Item::Sig ? QString::fromStdString(item->sig->name) : item->title; } else { switch (item->type) { case Item::Sig: return item->sig_val; - case Item::Name: return item->sig->name; + case Item::Name: return QString::fromStdString(item->sig->name); case Item::Size: return item->sig->size; - case Item::Node: return item->sig->receiver_name; + case Item::Node: return QString::fromStdString(item->sig->receiver_name); case Item::SignalType: return signalTypeToString(item->sig->type); case Item::MultiplexValue: return item->sig->multiplex_value; - case Item::Offset: return doubleToString(item->sig->offset); - case Item::Factor: return doubleToString(item->sig->factor); - case Item::Unit: return item->sig->unit; - case Item::Comment: return item->sig->comment; - case Item::Min: return doubleToString(item->sig->min); - case Item::Max: return doubleToString(item->sig->max); + case Item::Offset: return QString::fromStdString(doubleToString(item->sig->offset)); + case Item::Factor: return QString::fromStdString(doubleToString(item->sig->factor)); + case Item::Unit: return QString::fromStdString(item->sig->unit); + case Item::Comment: return QString::fromStdString(item->sig->comment); + case Item::Min: return QString::fromStdString(doubleToString(item->sig->min)); + case Item::Max: return QString::fromStdString(doubleToString(item->sig->max)); case Item::Desc: { QStringList val_desc; for (auto &[val, desc] : item->sig->val_desc) { - val_desc << QString("%1 \"%2\"").arg(val).arg(desc); + val_desc << QString("%1 \"%2\"").arg(val).arg(QString::fromStdString(desc)); } return val_desc.join(" "); } @@ -165,17 +166,17 @@ bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int r Item *item = getItem(index); cabana::Signal s = *item->sig; switch (item->type) { - case Item::Name: s.name = value.toString(); break; + case Item::Name: s.name = value.toString().toStdString(); break; case Item::Size: s.size = value.toInt(); break; - case Item::Node: s.receiver_name = value.toString().trimmed(); break; + case Item::Node: s.receiver_name = value.toString().trimmed().toStdString(); break; case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; case Item::Endian: s.is_little_endian = value.toBool(); break; case Item::Signed: s.is_signed = value.toBool(); break; case Item::Offset: s.offset = value.toDouble(); break; case Item::Factor: s.factor = value.toDouble(); break; - case Item::Unit: s.unit = value.toString(); break; - case Item::Comment: s.comment = value.toString(); break; + case Item::Unit: s.unit = value.toString().toStdString(); break; + case Item::Comment: s.comment = value.toString().toStdString(); break; case Item::Min: s.min = value.toDouble(); break; case Item::Max: s.max = value.toDouble(); break; case Item::Desc: s.val_desc = value.value(); break; @@ -189,7 +190,7 @@ bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int r bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { auto msg = dbc()->msg(msg_id); if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { - QString text = tr("There is already a signal with the same name '%1'").arg(s.name); + QString text = tr("There is already a signal with the same name '%1'").arg(QString::fromStdString(s.name)); QMessageBox::warning(nullptr, tr("Failed to save signal"), text); return false; } @@ -197,7 +198,7 @@ bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) if (s.is_little_endian != origin_s->is_little_endian) { s.start_bit = flipBitPos(s.start_bit); } - UndoStack::push(new EditSignalCommand(msg_id, origin_s, s)); + UndoStack::instance()->push(new EditSignalCommand(msg_id, origin_s, s)); return true; } @@ -214,7 +215,7 @@ void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { beginInsertRows({}, i, i); insertItem(root.get(), i, sig); endInsertRows(); - } else if (sig->name.contains(filter_str, Qt::CaseInsensitive)) { + } else if (QString::fromStdString(sig->name).contains(filter_str, Qt::CaseInsensitive)) { refresh(); } } @@ -229,7 +230,9 @@ void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { int to = dbc()->msg(msg_id)->indexOf(sig); if (to != row) { beginMoveRows({}, row, row, {}, to > row ? to + 1 : to); - root->children.move(row, to); + auto item = root->children[row]; + root->children.erase(root->children.begin() + row); + root->children.insert(root->children.begin() + to, item); endMoveRows(); } } @@ -239,7 +242,8 @@ void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { if (int row = signalRow(sig); row != -1) { beginRemoveRows({}, row, row); - delete root->children.takeAt(row); + delete root->children[row]; + root->children.erase(root->children.begin() + row); endRemoveRows(); } } @@ -248,7 +252,7 @@ void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { name_validator = new NameValidator(this); - node_validator = new QRegExpValidator(QRegExp("^\\w+(,\\w+)*$"), this); + node_validator = new NodeValidator(this); double_validator = new DoubleValidator(this); label_font.setPointSize(8); @@ -301,7 +305,7 @@ void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op path.addRoundedRect(icon_rect, 3, 3); painter->setPen(item->highlight ? Qt::white : Qt::black); painter->setFont(label_font); - painter->fillPath(path, item->sig->color.darker(item->highlight ? 125 : 0)); + painter->fillPath(path, toQColor(item->sig->color.darker(item->highlight ? 125 : 0))); painter->drawText(icon_rect, Qt::AlignCenter, QString::number(item->row() + 1)); rect.setLeft(icon_rect.right() + h_margin * 2); @@ -372,12 +376,6 @@ QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionVie else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator); else e->setValidator(double_validator); - if (item->type == SignalModel::Item::Name) { - QCompleter *completer = new QCompleter(dbc()->signalNames(), e); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - e->setCompleter(completer); - } return e; } else if (item->type == SignalModel::Item::Size) { QSpinBox *spin = new QSpinBox(parent); @@ -395,7 +393,7 @@ QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionVie return c; } else if (item->type == SignalModel::Item::Desc) { ValueDescriptionDlg dlg(item->sig->val_desc, parent); - dlg.setWindowTitle(item->sig->name); + dlg.setWindowTitle(QString::fromStdString(item->sig->name)); if (dlg.exec()) { ((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc)); } @@ -422,8 +420,7 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QHBoxLayout *hl = new QHBoxLayout(title_bar); hl->addWidget(signal_count_lb = new QLabel()); filter_edit = new QLineEdit(this); - QRegularExpression re("\\S+"); - filter_edit->setValidator(new QRegularExpressionValidator(re, this)); + filter_edit->setValidator(new NonWhitespaceValidator(this)); filter_edit->setClearButtonEnabled(true); filter_edit->setPlaceholderText(tr("Filter Signal")); hl->addWidget(filter_edit); @@ -475,8 +472,8 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); }); QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged); QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged); - QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalView::handleSignalAdded); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalView::handleSignalUpdated); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalView::handleSignalAdded); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalView::handleSignalUpdated); QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); }); QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); }); QObject::connect(can, &AbstractStream::msgsReceived, this, &SignalView::updateState); @@ -489,7 +486,7 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), setWhatsThis(tr(R"( Signal view
- + )")); } @@ -518,7 +515,7 @@ void SignalView::rowsChanged() { tree->setIndexWidget(index, w); auto sig = model->getItem(index)->sig; - QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::push(new RemoveSigCommand(model->msg_id, sig)); }); + QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, sig)); }); QObject::connect(plot_btn, &QToolButton::clicked, [=](bool checked) { emit showChart(model->msg_id, sig, checked, QGuiApplication::keyboardModifiers() & Qt::ShiftModifier); }); @@ -621,7 +618,7 @@ void SignalView::updateState(const std::set *msgs) { for (auto item : model->root->children) { double value = 0; if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { - item->sig_val = item->sig->formatValue(value); + item->sig_val = QString::fromStdString(item->sig->formatValue(value)); max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val)); } } @@ -635,13 +632,13 @@ void SignalView::updateState(const std::set *msgs) { delegate->button_size.height() - style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2); auto [first, last] = can->eventsInRange(model->msg_id, std::make_pair(last_msg.ts -settings.sparkline_range, last_msg.ts)); - QFutureSynchronizer synchronizer; + std::vector> futures; for (int i = first_visible.row(); i <= last_visible.row(); ++i) { auto item = model->getItem(model->index(i, 1)); - synchronizer.addFuture(QtConcurrent::run( - &item->sparkline, &Sparkline::update, item->sig, first, last, settings.sparkline_range, size)); + futures.push_back(std::async(std::launch::async, + &Sparkline::update, &item->sparkline, item->sig, first, last, settings.sparkline_range, size)); } - synchronizer.waitForFinished(); + for (auto &f : futures) f.get(); } for (int i = 0; i < model->rowCount(); ++i) { @@ -677,7 +674,7 @@ ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, Q int row = 0; for (auto &[val, desc] : descriptions) { table->setItem(row, 0, new QTableWidgetItem(QString::number(val))); - table->setItem(row, 1, new QTableWidgetItem(desc)); + table->setItem(row, 1, new QTableWidgetItem(QString::fromStdString(desc))); ++row; } @@ -706,7 +703,7 @@ void ValueDescriptionDlg::save() { QString val = table->item(i, 0)->text().trimmed(); QString desc = table->item(i, 1)->text().trimmed(); if (!val.isEmpty() && !desc.isEmpty()) { - val_desc.push_back({val.toDouble(), desc}); + val_desc.push_back({val.toDouble(), desc.toStdString()}); } } QDialog::accept(); diff --git a/tools/cabana/signalview.h b/tools/cabana/signalview.h index 4e746ea1..bccfbab0 100644 --- a/tools/cabana/signalview.h +++ b/tools/cabana/signalview.h @@ -20,12 +20,15 @@ class SignalModel : public QAbstractItemModel { public: struct Item { enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; - ~Item() { qDeleteAll(children); } - inline int row() { return parent->children.indexOf(this); } + ~Item() { for (auto c : children) delete c; } + inline int row() { + auto it = std::find(parent->children.begin(), parent->children.end(), this); + return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; + } Type type = Type::Root; Item *parent = nullptr; - QList children; + std::vector children; const cabana::Signal *sig = nullptr; QString title; @@ -126,9 +129,12 @@ private: // update widget geometries in QTreeView::rowsInserted QTreeView::rowsInserted(parent, start, end); } - void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles = QVector()) override { + void setModel(QAbstractItemModel *m) override { + QTreeView::setModel(m); // Bypass the slow call to QTreeView::dataChanged. - QAbstractItemView::dataChanged(topLeft, bottomRight, roles); + QObject::disconnect(m, &QAbstractItemModel::dataChanged, this, nullptr); + QObject::connect(m, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); } void leaveEvent(QEvent *event) override { emit static_cast(parentWidget())->highlight(nullptr); diff --git a/tools/cabana/streams/abstractstream.cc b/tools/cabana/streams/abstractstream.cc index 1582fcd3..c58a9808 100644 --- a/tools/cabana/streams/abstractstream.cc +++ b/tools/cabana/streams/abstractstream.cc @@ -1,4 +1,5 @@ #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -18,8 +19,8 @@ AbstractStream::AbstractStream(QObject *parent) : QObject(parent) { QObject::connect(this, &AbstractStream::privateUpdateLastMsgsSignal, this, &AbstractStream::updateLastMessages, Qt::QueuedConnection); QObject::connect(this, &AbstractStream::seekedTo, this, &AbstractStream::updateLastMsgsTo); QObject::connect(this, &AbstractStream::seeking, this, [this](double sec) { current_sec_ = sec; }); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &AbstractStream::updateMasks); - QObject::connect(dbc(), &DBCManager::maskUpdated, this, &AbstractStream::updateMasks); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &AbstractStream::updateMasks); + QObject::connect(dbcNotifier(), &QtDBCNotifier::maskUpdated, this, &AbstractStream::updateMasks); } void AbstractStream::updateMasks() { @@ -233,18 +234,18 @@ std::pair AbstractStream::eventsInRange(const Messag namespace { enum Color { GREYISH_BLUE, CYAN, RED}; -QColor getColor(int c) { +CabanaColor getColor(int c) { constexpr int start_alpha = 128; - static const QColor colors[] = { - [GREYISH_BLUE] = QColor(102, 86, 169, start_alpha / 2), - [CYAN] = QColor(0, 187, 255, start_alpha), - [RED] = QColor(255, 0, 0, start_alpha), + static const CabanaColor colors[] = { + [GREYISH_BLUE] = CabanaColor(102, 86, 169, start_alpha / 2), + [CYAN] = CabanaColor(0, 187, 255, start_alpha), + [RED] = CabanaColor(255, 0, 0, start_alpha), }; return settings.theme == LIGHT_THEME ? colors[c] : colors[c].lighter(135); } -inline QColor blend(const QColor &a, const QColor &b) { - return QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2); +inline CabanaColor blend(const CabanaColor &a, const CabanaColor &b) { + return CabanaColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2); } // Calculate the frequency from the past one minute data @@ -271,7 +272,7 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in if (dat.size() != size) { dat.assign(can_data, can_data + size); - colors.assign(size, QColor(0, 0, 0, 0)); + colors.assign(size, CabanaColor(0, 0, 0, 0)); last_changes.resize(size); bit_flip_counts.resize(size); std::for_each(last_changes.begin(), last_changes.end(), [current_sec](auto &c) { c.ts = current_sec; }); @@ -317,7 +318,7 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in last_change.delta = delta; } else { // Fade out - colors[i].setAlphaF(std::max(0.0, colors[i].alphaF() - alpha_delta)); + colors[i].setAlphaF(std::max(0.0f, colors[i].alphaF() - alpha_delta)); } } } diff --git a/tools/cabana/streams/abstractstream.h b/tools/cabana/streams/abstractstream.h index f35b19d3..dbbf4514 100644 --- a/tools/cabana/streams/abstractstream.h +++ b/tools/cabana/streams/abstractstream.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -11,51 +12,12 @@ #include #include -#include -#include - #include "cereal/messaging/messaging.h" +#include "tools/cabana/core/can_data.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/utils/util.h" #include "tools/replay/util.h" -struct CanData { - void compute(const MessageId &msg_id, const uint8_t *dat, const int size, double current_sec, - double playback_speed, const std::vector &mask, double in_freq = 0); - - double ts = 0.; - uint32_t count = 0; - double freq = 0; - std::vector dat; - std::vector colors; - - struct ByteLastChange { - double ts = 0; - int delta = 0; - int same_delta_counter = 0; - bool suppressed = false; - }; - std::vector last_changes; - std::vector> bit_flip_counts; - double last_freq_update_ts = 0; -}; - -struct CanEvent { - uint8_t src; - uint32_t address; - uint64_t mono_time; - uint8_t size; - uint8_t dat[]; -}; - -struct CompareCanEvent { - constexpr bool operator()(const CanEvent *const e, uint64_t ts) const { return e->mono_time < ts; } - constexpr bool operator()(uint64_t ts, const CanEvent *const e) const { return ts < e->mono_time; } -}; - -typedef std::unordered_map> MessageEventsMap; -using CanEventIter = std::vector::const_iterator; - class AbstractStream : public QObject { Q_OBJECT @@ -65,9 +27,9 @@ public: virtual void start() = 0; virtual bool liveStreaming() const { return true; } virtual void seekTo(double ts) {} - virtual QString routeName() const = 0; - virtual QString carFingerprint() const { return ""; } - virtual QDateTime beginDateTime() const { return {}; } + virtual std::string routeName() const = 0; + virtual std::string carFingerprint() const { return ""; } + virtual std::chrono::system_clock::time_point beginDateTime() const { return {}; } virtual uint64_t beginMonoTime() const { return 0; } virtual double minSeconds() const { return 0; } virtual double maxSeconds() const { return 0; } @@ -113,12 +75,12 @@ protected: const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c); void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size); void waitForSeekFinshed(); + virtual void updateLastMessages(); std::vector all_events_; double current_sec_ = 0; std::optional> time_range_; private: - void updateLastMessages(); void updateLastMsgsTo(double sec); void updateMasks(); @@ -149,7 +111,7 @@ class DummyStream : public AbstractStream { Q_OBJECT public: DummyStream(QObject *parent) : AbstractStream(parent) {} - QString routeName() const override { return tr("No Stream"); } + std::string routeName() const override { return "No Stream"; } void start() override {} }; diff --git a/tools/cabana/streams/devicestream.cc b/tools/cabana/streams/devicestream.cc index 462dd7a3..35291012 100644 --- a/tools/cabana/streams/devicestream.cc +++ b/tools/cabana/streams/devicestream.cc @@ -1,34 +1,123 @@ #include "tools/cabana/streams/devicestream.h" +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include #include "cereal/services.h" #include #include +#include #include -#include -#include -#include + +#include "tools/cabana/utils/util.h" // DeviceStream -DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(address), LiveStream(parent) { +DeviceStream::DeviceStream(QObject *parent, Mode mode, QString address) + : mode_(mode), address_(address.isEmpty() ? "127.0.0.1" : address), LiveStream(parent) { +} + +DeviceStream::~DeviceStream() { + stop(); + stopBridge(); +} + +void DeviceStream::stopBridge() { + if (bridge_pid <= 0) return; + + ::kill(bridge_pid, SIGTERM); + for (int i = 0; i < 30; ++i) { + int status = 0; + pid_t r = ::waitpid(bridge_pid, &status, WNOHANG); + if (r == bridge_pid || (r < 0 && errno == ECHILD)) { + bridge_pid = -1; + return; + } + usleep(100000); // 100ms, up to ~3s + } + ::kill(bridge_pid, SIGKILL); + ::waitpid(bridge_pid, nullptr, 0); + bridge_pid = -1; +} + +void DeviceStream::start() { + if (mode_ == Mode::Bridge) { + stopBridge(); + const std::string path = (std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()) / + "../../cereal/messaging/bridge").lexically_normal().string(); + const std::string addr = address_.toStdString(); + const char *can_filter = "/\"can/\""; + + // Self-pipe: write end is CLOEXEC so it closes on successful exec. If exec + // fails, the child writes errno and the parent aborts stream start. + int err_pipe[2] = {-1, -1}; + if (::pipe(err_pipe) != 0) { + QMessageBox::warning(nullptr, tr("Error"), + tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + return; + } + + pid_t pid = ::fork(); + if (pid == 0) { + ::close(err_pipe[0]); + ::fcntl(err_pipe[1], F_SETFD, FD_CLOEXEC); + execl(path.c_str(), path.c_str(), addr.c_str(), can_filter, static_cast(nullptr)); + const int err = errno; + (void)!::write(err_pipe[1], &err, sizeof(err)); + _exit(127); + } + + ::close(err_pipe[1]); + if (pid < 0) { + ::close(err_pipe[0]); + QMessageBox::warning(nullptr, tr("Error"), + tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + return; + } + + int exec_errno = 0; + const ssize_t n = ::read(err_pipe[0], &exec_errno, sizeof(exec_errno)); + ::close(err_pipe[0]); + if (n == static_cast(sizeof(exec_errno))) { + // Child failed to exec; reap and surface the error. + int status = 0; + ::waitpid(pid, &status, 0); + QMessageBox::warning(nullptr, tr("Error"), + tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(exec_errno)))); + return; + } + + bridge_pid = pid; + } + + LiveStream::start(); } void DeviceStream::streamThread() { - zmq_address.isEmpty() ? unsetenv("ZMQ") : setenv("ZMQ", "1", 1); + // Bridge mode republishes into local msgq, so only the direct Zmq mode talks ZMQ. + // (Upstream sets ZMQ=1 for its bridge path too, which reads nothing — the bridge + // publishes to msgq.) + mode_ == Mode::Zmq ? setenv("ZMQ", "1", 1) : unsetenv("ZMQ"); + const std::string address = mode_ == Mode::Zmq ? address_.toStdString() : "127.0.0.1"; std::unique_ptr context(Context::create()); - std::string address = zmq_address.isEmpty() ? "127.0.0.1" : zmq_address.toStdString(); std::unique_ptr sock(SubSocket::create(context.get(), "can", address, false, true, services.at("can").queue_size)); assert(sock != NULL); // run as fast as messages come in - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!exit_) { std::unique_ptr msg(sock->receive(true)); if (!msg) { - QThread::msleep(50); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); continue; } handleEvent(kj::ArrayPtr((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word))); @@ -40,28 +129,30 @@ void DeviceStream::streamThread() { OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { QRadioButton *msgq = new QRadioButton(tr("MSGQ")); QRadioButton *zmq = new QRadioButton(tr("ZMQ")); + QRadioButton *bridge = new QRadioButton(tr("Bridge")); + zmq->setToolTip(tr("Subscribe directly to a ZMQ 'can' publisher: a device running " + "cereal/messaging/bridge, or konn3kt_canproxy.py on 127.0.0.1.")); + bridge->setToolTip(tr("Run cereal/messaging/bridge locally against the device and read msgq.")); ip_address = new QLineEdit(this); ip_address->setPlaceholderText(tr("Enter device Ip Address")); - QString ip_range = "(?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])"; - QString pattern("^" + ip_range + "\\." + ip_range + "\\." + ip_range + "\\." + ip_range + "$"); - QRegularExpression re(pattern); - ip_address->setValidator(new QRegularExpressionValidator(re, this)); + ip_address->setValidator(new IpAddressValidator(this)); group = new QButtonGroup(this); - group->addButton(msgq, 0); - group->addButton(zmq, 1); + group->addButton(msgq, static_cast(DeviceStream::Mode::Msgq)); + group->addButton(zmq, static_cast(DeviceStream::Mode::Zmq)); + group->addButton(bridge, static_cast(DeviceStream::Mode::Bridge)); QFormLayout *form_layout = new QFormLayout(this); form_layout->addRow(msgq); form_layout->addRow(zmq, ip_address); + form_layout->addRow(bridge); QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { - ip_address->setEnabled(button == zmq && checked); + if (checked) ip_address->setEnabled(button != msgq); }); zmq->setChecked(true); } AbstractStream *OpenDeviceWidget::open() { - QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text(); - bool msgq = group->checkedId() == 0; - return new DeviceStream(qApp, msgq ? "" : ip); + auto mode = static_cast(group->checkedId()); + return new DeviceStream(qApp, mode, mode == DeviceStream::Mode::Msgq ? "" : ip_address->text()); } diff --git a/tools/cabana/streams/devicestream.h b/tools/cabana/streams/devicestream.h index 3bdf2249..4c87147b 100644 --- a/tools/cabana/streams/devicestream.h +++ b/tools/cabana/streams/devicestream.h @@ -2,17 +2,37 @@ #include "tools/cabana/streams/livestream.h" +#include + +// IQ.Pilot patch: upstream (#38484) folded the ZMQ path into "fork a local bridge and +// read msgq". iqpilot needs the direct ZMQ attach kept as a first-class mode, because +// tools/cabana/konn3kt_canproxy.py publishes a remote device's CAN onto a LOCAL ZMQ +// "can" socket and Cabana attaches to it — see that script's header for the topology. +// So the mode is explicit rather than inferred from whether an address was entered: +// +// Msgq - local msgq, no address (cabana running on the device) +// Zmq - ZMQ subscribe straight to
(konn3kt_canproxy, or `bridge` on the device) +// Bridge - fork cereal/messaging/bridge
, (upstream's convenience path) +// which ZMQ-subscribes there and republishes +// to local msgq, then read msgq class DeviceStream : public LiveStream { Q_OBJECT public: - DeviceStream(QObject *parent, QString address = {}); - inline QString routeName() const override { - return QString("Live Streaming From %1").arg(zmq_address.isEmpty() ? "127.0.0.1" : zmq_address); + enum class Mode { Msgq, Zmq, Bridge }; + + DeviceStream(QObject *parent, Mode mode = Mode::Msgq, QString address = {}); + ~DeviceStream(); + inline std::string routeName() const override { + return "Live Streaming From " + address_.toStdString(); } protected: + void start() override; void streamThread() override; - const QString zmq_address; + void stopBridge(); + pid_t bridge_pid = -1; + const Mode mode_; + const QString address_; }; class OpenDeviceWidget : public AbstractOpenStreamWidget { diff --git a/tools/cabana/streams/livestream.cc b/tools/cabana/streams/livestream.cc index ac9a6fa1..019a67e8 100644 --- a/tools/cabana/streams/livestream.cc +++ b/tools/cabana/streams/livestream.cc @@ -1,9 +1,11 @@ #include "tools/cabana/streams/livestream.h" -#include #include +#include #include +#include #include +#include #include "common/timing.h" #include "common/util.h" @@ -14,9 +16,14 @@ struct LiveStream::Logger { void write(kj::ArrayPtr data) { int n = (seconds_since_epoch() - start_ts) / 60.0; if (std::exchange(segment_num, n) != segment_num) { + const time_t start_time = start_ts; + std::tm local_time = {}; + localtime_r(&start_time, &local_time); + std::ostringstream date; + date << std::put_time(&local_time, "%Y-%m-%d--%H-%M-%S"); QString dir = QString("%1/%2--%3") - .arg(settings.log_path) - .arg(QDateTime::fromSecsSinceEpoch(start_ts).toString("yyyy-MM-dd--hh-mm-ss")) + .arg(QString::fromStdString(settings.log_path)) + .arg(QString::fromStdString(date.str())) .arg(n); util::create_directories(dir.toStdString(), 0755); fs.reset(new std::ofstream((dir + "/rlog").toStdString(), std::ios::binary | std::ios::out)); @@ -35,37 +42,34 @@ LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) { if (settings.log_livestream) { logger = std::make_unique(); } - stream_thread = new QThread(this); - - QObject::connect(&settings, &Settings::changed, this, &LiveStream::startUpdateTimer); - QObject::connect(stream_thread, &QThread::started, [=]() { streamThread(); }); - QObject::connect(stream_thread, &QThread::finished, stream_thread, &QThread::deleteLater); } LiveStream::~LiveStream() { stop(); } -void LiveStream::startUpdateTimer() { - update_timer.stop(); - update_timer.start(1000.0 / settings.fps, this); - timer_id = update_timer.timerId(); -} - void LiveStream::start() { - stream_thread->start(); - startUpdateTimer(); - begin_date_time = QDateTime::currentDateTime(); + begin_date_time = std::chrono::system_clock::now(); + fps_ = settings.fps; + exit_ = false; + stream_thread = std::thread(&LiveStream::streamThread, this); + update_thread = std::thread(&LiveStream::updateThread, this); } void LiveStream::stop() { - if (!stream_thread) return; + exit_ = true; + if (stream_thread.joinable()) stream_thread.join(); + if (update_thread.joinable()) update_thread.join(); +} - update_timer.stop(); - stream_thread->requestInterruption(); - stream_thread->quit(); - stream_thread->wait(); - stream_thread = nullptr; +void LiveStream::updateThread() { + while (!exit_) { + std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_)); + // coalesce: skip the emit if the main thread hasn't processed the previous one yet. + if (!update_pending_.exchange(true)) { + emit privateUpdateLastMsgsSignal(); + } + } } // called in streamThread @@ -85,23 +89,22 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { } } -void LiveStream::timerEvent(QTimerEvent *event) { - if (event->timerId() == timer_id) { - { - // merge events received from live stream thread. - std::lock_guard lk(lock); - mergeEvents(received_events_); - uint64_t last_received_ts = !received_events_.empty() ? received_events_.back()->mono_time : 0; - lastest_event_ts = std::max(lastest_event_ts, last_received_ts); - received_events_.clear(); - } - if (!all_events_.empty()) { - begin_event_ts = all_events_.front()->mono_time; - updateEvents(); - return; - } +// called on the main thread by the queued privateUpdateLastMsgsSignal connection +void LiveStream::updateLastMessages() { + update_pending_ = false; + fps_ = settings.fps; + { + // merge events received from live stream thread. + std::lock_guard lk(lock); + mergeEvents(received_events_); + uint64_t last_received_ts = !received_events_.empty() ? received_events_.back()->mono_time : 0; + lastest_event_ts = std::max(lastest_event_ts, last_received_ts); + received_events_.clear(); + } + if (!all_events_.empty()) { + begin_event_ts = all_events_.front()->mono_time; + updateEvents(); } - QObject::timerEvent(event); } void LiveStream::updateEvents() { @@ -131,7 +134,7 @@ void LiveStream::updateEvents() { updateEvent(id, (e->mono_time - begin_event_ts) / 1e9, e->dat, e->size); current_event_ts = e->mono_time; } - emit privateUpdateLastMsgsSignal(); + AbstractStream::updateLastMessages(); } void LiveStream::seekTo(double sec) { diff --git a/tools/cabana/streams/livestream.h b/tools/cabana/streams/livestream.h index 24b92850..5d65b174 100644 --- a/tools/cabana/streams/livestream.h +++ b/tools/cabana/streams/livestream.h @@ -1,11 +1,11 @@ #pragma once #include +#include #include +#include #include -#include - #include "tools/cabana/streams/abstractstream.h" class LiveStream : public AbstractStream { @@ -16,7 +16,7 @@ public: virtual ~LiveStream(); void start() override; void stop(); - inline QDateTime beginDateTime() const { return begin_date_time; } + inline std::chrono::system_clock::time_point beginDateTime() const override { return begin_date_time; } inline uint64_t beginMonoTime() const override { return begin_event_ts; } double maxSeconds() const override { return std::max(1.0, (lastest_event_ts - begin_event_ts) / 1e9); } void setSpeed(float speed) override { speed_ = speed; } @@ -29,19 +29,20 @@ protected: virtual void streamThread() = 0; void handleEvent(kj::ArrayPtr event); + std::atomic exit_ = false; + private: - void startUpdateTimer(); - void timerEvent(QTimerEvent *event) override; + void updateThread(); + void updateLastMessages() override; void updateEvents(); std::mutex lock; - QThread *stream_thread; + std::thread stream_thread, update_thread; + std::atomic update_pending_ = false; + std::atomic fps_ = 10; std::vector received_events_; - int timer_id; - QBasicTimer update_timer; - - QDateTime begin_date_time; + std::chrono::system_clock::time_point begin_date_time; uint64_t begin_event_ts = 0; uint64_t lastest_event_ts = 0; uint64_t current_event_ts = 0; diff --git a/tools/cabana/streams/pandastream.cc b/tools/cabana/streams/pandastream.cc index 03854ee5..7ccb18a7 100644 --- a/tools/cabana/streams/pandastream.cc +++ b/tools/cabana/streams/pandastream.cc @@ -1,11 +1,13 @@ #include "tools/cabana/streams/pandastream.h" -#include +#include +#include +#include + #include #include #include #include -#include #include PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) { @@ -16,34 +18,48 @@ PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(co bool PandaStream::connect() { try { - qDebug() << "Connecting to panda " << config.serial; - panda.reset(new Panda(config.serial.toStdString(), 0, true)); + fprintf(stderr, "Connecting to panda %s\n", config.serial.c_str()); + panda.reset(new Panda(config.serial)); config.bus_config.resize(3); - qDebug() << "Connected"; + fprintf(stderr, "Connected\n"); } catch (const std::exception& e) { return false; } + panda->set_safety_model(cereal::CarParams::SafetyModel::NO_OUTPUT); + for (int bus = 0; bus < config.bus_config.size(); bus++) { + panda->set_can_speed_kbps(bus, config.bus_config[bus].can_speed_kbps); + + // CAN-FD + if (panda->hw_type == cereal::PandaState::PandaType::RED_PANDA || panda->hw_type == cereal::PandaState::PandaType::RED_PANDA_V2) { + if (config.bus_config[bus].can_fd) { + panda->set_data_speed_kbps(bus, config.bus_config[bus].data_speed_kbps); + } else { + // Hack to disable can-fd by setting data speed to a low value + panda->set_data_speed_kbps(bus, 10); + } + } + } return true; } void PandaStream::streamThread() { std::vector raw_can_data; - while (!QThread::currentThread()->isInterruptionRequested()) { - QThread::msleep(1); + while (!exit_) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); if (!panda->connected()) { - qDebug() << "Connection to panda lost. Attempting reconnect."; + fprintf(stderr, "Connection to panda lost. Attempting reconnect.\n"); if (!connect()){ - QThread::msleep(1000); + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); continue; } } raw_can_data.clear(); if (!panda->can_receive(raw_can_data)) { - qDebug() << "failed to receive"; + fprintf(stderr, "failed to receive\n"); continue; } @@ -58,6 +74,7 @@ void PandaStream::streamThread() { handleEvent(capnp::messageToFlatArray(msg)); + panda->send_heartbeat(false); } } @@ -66,7 +83,7 @@ void PandaStream::streamThread() { OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { form_layout = new QFormLayout(this); if (can && dynamic_cast(can) != nullptr) { - form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(can->routeName()))); + form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); return; @@ -105,16 +122,16 @@ void OpenPandaWidget::buildConfigForm() { bool has_panda = !serial.isEmpty(); if (has_panda) { try { - Panda panda(serial.toStdString(), 0, true); + Panda panda(serial.toStdString()); has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); } catch (const std::exception& e) { - qDebug() << "failed to open panda" << serial; + fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); has_panda = false; } } if (has_panda) { - config.serial = serial; + config.serial = serial.toStdString(); config.bus_config.resize(3); for (int i = 0; i < config.bus_config.size(); i++) { QHBoxLayout *bus_layout = new QHBoxLayout; diff --git a/tools/cabana/streams/pandastream.h b/tools/cabana/streams/pandastream.h index e17ad887..f8847f65 100644 --- a/tools/cabana/streams/pandastream.h +++ b/tools/cabana/streams/pandastream.h @@ -19,7 +19,7 @@ struct BusConfig { }; struct PandaStreamConfig { - QString serial = ""; + std::string serial = ""; std::vector bus_config; }; @@ -28,8 +28,8 @@ class PandaStream : public LiveStream { public: PandaStream(QObject *parent, PandaStreamConfig config_ = {}); ~PandaStream() { stop(); } - inline QString routeName() const override { - return QString("Panda: %1").arg(config.serial); + inline std::string routeName() const override { + return "Panda: " + config.serial; } protected: diff --git a/tools/cabana/streams/replaystream.cc b/tools/cabana/streams/replaystream.cc index b8cf1be2..dbd44b54 100644 --- a/tools/cabana/streams/replaystream.cc +++ b/tools/cabana/streams/replaystream.cc @@ -1,5 +1,7 @@ #include "tools/cabana/streams/replaystream.h" +#include + #include #include #include @@ -14,10 +16,7 @@ ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) { unsetenv("ZMQ"); setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1); - // TODO: Remove when OpenpilotPrefix supports ZMQ -#ifndef __APPLE__ op_prefix = std::make_unique(); -#endif QObject::connect(&settings, &Settings::changed, this, [this]() { if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes); @@ -46,9 +45,9 @@ void ReplayStream::mergeSegments() { } } -bool ReplayStream::loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags, bool auto_source) { - replay.reset(new Replay(route.toStdString(), {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"}, - {}, nullptr, replay_flags, data_dir.toStdString(), auto_source)); +bool ReplayStream::loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags, bool auto_source) { + replay.reset(new Replay(route, {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"}, + {}, nullptr, replay_flags, data_dir, auto_source)); replay->setSegmentCacheLimit(settings.max_cached_minutes); replay->installEventFilter([this](const Event *event) { return eventFilter(event); }); @@ -68,21 +67,21 @@ bool ReplayStream::loadRoute(const QString &route, const QString &data_dir, uint QString message; if (auth_content.empty()) { message = "Authentication Required. Please run the following command to authenticate:\n\n" - "python3 tools/lib/auth.py\n\n" + "python3 openpilot/tools/lib/auth.py\n\n" "This will grant access to routes from your comma account."; } else { message = tr("Access Denied. You do not have permission to access route:\n\n%1\n\n" - "This is likely a private route.").arg(route); + "This is likely a private route.").arg(QString::fromStdString(route)); } QMessageBox::warning(nullptr, tr("Access Denied"), message); } else if (replay->lastRouteError() == RouteLoadError::NetworkError) { QMessageBox::warning(nullptr, tr("Network Error"), - tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(route)); + tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route))); } else if (replay->lastRouteError() == RouteLoadError::FileNotFound) { QMessageBox::warning(nullptr, tr("Route Not Found"), - tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(route)); + tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route))); } else { - QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(route)); + QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(QString::fromStdString(route))); } } return success; @@ -136,10 +135,10 @@ OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(p setMinimumWidth(550); QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), settings.last_route_dir); + QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); if (!dir.isEmpty()) { route_edit->setText(dir); - settings.last_route_dir = QFileInfo(dir).absolutePath(); + settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); } }); QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { @@ -168,7 +167,7 @@ AbstractStream *OpenReplayWidget::open() { if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_ECAM; if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - if (replay_stream->loadRoute(route, data_dir, flags)) { + if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { return replay_stream.release(); } } diff --git a/tools/cabana/streams/replaystream.h b/tools/cabana/streams/replaystream.h index d429ed1f..eecd3457 100644 --- a/tools/cabana/streams/replaystream.h +++ b/tools/cabana/streams/replaystream.h @@ -18,15 +18,17 @@ class ReplayStream : public AbstractStream { public: ReplayStream(QObject *parent); void start() override { replay->start(); } - bool loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false); + bool loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false); bool eventFilter(const Event *event); void seekTo(double ts) override { replay->seekTo(std::max(double(0), ts), false); } bool liveStreaming() const override { return false; } - inline QString routeName() const override { return QString::fromStdString(replay->route().name()); } - inline QString carFingerprint() const override { return replay->carFingerprint().c_str(); } + inline std::string routeName() const override { return replay->route().name(); } + inline std::string carFingerprint() const override { return replay->carFingerprint(); } double minSeconds() const override { return replay->minSeconds(); } double maxSeconds() const { return replay->maxSeconds(); } - inline QDateTime beginDateTime() const { return QDateTime::fromSecsSinceEpoch(replay->routeDateTime()); } + inline std::chrono::system_clock::time_point beginDateTime() const override { + return std::chrono::system_clock::from_time_t(replay->routeDateTime()); + } inline uint64_t beginMonoTime() const override { return replay->routeStartNanos(); } inline void setSpeed(float speed) override { replay->setSpeed(speed); } inline float getSpeed() const { return replay->getSpeed(); } diff --git a/tools/cabana/streams/routes.cc b/tools/cabana/streams/routes.cc index 5915c980..97703996 100644 --- a/tools/cabana/streams/routes.cc +++ b/tools/cabana/streams/routes.cc @@ -1,27 +1,77 @@ #include "tools/cabana/streams/routes.h" -#include +#include +#include +#include +#include +#include + +#include #include #include -#include -#include #include #include #include -class OneShotHttpRequest : public HttpRequest { -public: - OneShotHttpRequest(QObject *parent) : HttpRequest(parent, false) {} - void send(const QString &url) { - if (reply) { - reply->disconnect(); - reply->abort(); - reply->deleteLater(); - reply = nullptr; - } - sendRequest(url); +#include "third_party/json11/json11.hpp" +// IQ.Pilot patch: iqpilot's tools/replay has no py_downloader — device and route +// listing come from the konn3kt API over libcurl. CommaApi2 returns the same JSON +// shapes and the same {"error": ...} envelope upstream's PyDownloader produces, so +// only the call sites change. +#include "tools/replay/api.h" + +namespace { + +// Parse a konn3kt API JSON response into (success, error_code). +std::pair checkApiResponse(const std::string &result) { + if (result.empty()) return {false, 500}; + std::string err; + auto doc = json11::Json::parse(result, err); + if (!err.empty()) return {false, 500}; + if (doc.is_object() && doc["error"].is_string()) { + return {false, doc["error"].string_value() == "unauthorized" ? 401 : 500}; } -}; + return {true, 0}; +} + +int64_t nowUnixMs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +// Parse ISO-8601 (with optional fractional seconds / Z) to unix ms. Returns 0 on failure. +int64_t parseIsoToUnixMs(const std::string &s) { + std::string bytes = s; + if (!bytes.empty() && (bytes.back() == 'Z' || bytes.back() == 'z')) bytes.pop_back(); + int millis = 0; + auto dot = bytes.find('.'); + if (dot != std::string::npos) { + std::string frac = bytes.substr(dot + 1); + bytes = bytes.substr(0, dot); + while (frac.size() < 3) frac.push_back('0'); + millis = std::atoi(frac.substr(0, 3).c_str()); + } + std::tm tm{}; + const char *ret = strptime(bytes.c_str(), "%Y-%m-%dT%H:%M:%S", &tm); + if (!ret) ret = strptime(bytes.c_str(), "%Y-%m-%d %H:%M:%S", &tm); + if (!ret) return 0; + tm.tm_isdst = -1; + time_t secs = timegm(&tm); + if (secs == static_cast(-1)) return 0; + return static_cast(secs) * 1000 + millis; +} + +QString formatUnixMs(int64_t ms) { + time_t secs = static_cast(ms / 1000); + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + return QString::fromUtf8(buf); +} + +} // namespace // The RouteListWidget class extends QListWidget to display a custom message when empty class RouteListWidget : public QListWidget { @@ -41,7 +91,7 @@ public: QString empty_text_ = tr("No items"); }; -RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent), route_requester_(new OneShotHttpRequest(this)) { +RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Remote routes")); QFormLayout *layout = new QFormLayout(this); @@ -52,41 +102,42 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent), route_requester_( layout->addRow(button_box); device_list_->addItem(tr("Loading...")); - // Populate period selector with predefined durations period_selector_->addItem(tr("Last week"), 7); period_selector_->addItem(tr("Last 2 weeks"), 14); period_selector_->addItem(tr("Last month"), 30); period_selector_->addItem(tr("Last 6 months"), 180); period_selector_->addItem(tr("Preserved"), -1); - // Connect signals and slots - QObject::connect(route_requester_, &HttpRequest::requestDone, this, &RoutesDialog::parseRouteList); connect(device_list_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); connect(period_selector_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept); - QObject::connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - QObject::connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - // Send request to fetch devices - HttpRequest *http = new HttpRequest(this, false); - QObject::connect(http, &HttpRequest::requestDone, this, &RoutesDialog::parseDeviceList); - http->sendRequest(CommaApi::BASE_URL + "/v1/me/devices/"); + // Fetch devices + std::thread([this, alive = std::weak_ptr(alive_)]() { + std::string result = CommaApi2::getDevices(); + QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() { + if (!alive.expired()) parseDeviceList(r, response.first, response.second); + }, Qt::QueuedConnection); + }).detach(); } -void RoutesDialog::parseDeviceList(const QString &json, bool success, QNetworkReply::NetworkError err) { +void RoutesDialog::parseDeviceList(const QString &json, bool success, int error_code) { if (success) { device_list_->clear(); - auto devices = QJsonDocument::fromJson(json.toUtf8()).array(); - for (const QJsonValue &device : devices) { - QString dongle_id = device["dongle_id"].toString(); - device_list_->addItem(dongle_id, dongle_id); + std::string err; + auto doc = json11::Json::parse(json.toStdString(), err); + if (err.empty() && doc.is_array()) { + for (const auto &device : doc.array_items()) { + QString dongle_id = QString::fromStdString(device["dongle_id"].string_value()); + device_list_->addItem(dongle_id, dongle_id); + } } } else { - bool unauthorized = (err == QNetworkReply::ContentAccessDenied || err == QNetworkReply::AuthenticationRequiredError); - QMessageBox::warning(this, tr("Error"), unauthorized ? tr("Unauthorized, Authenticate with tools/lib/auth.py") : tr("Network error")); + QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with tools/lib/auth.py") : tr("Network error")); reject(); } - sender()->deleteLater(); } void RoutesDialog::fetchRoutes() { @@ -95,34 +146,45 @@ void RoutesDialog::fetchRoutes() { route_list_->clear(); route_list_->setEmptyText(tr("Loading...")); - // Construct URL with selected device and date range - QString url = QString("%1/v1/devices/%2").arg(CommaApi::BASE_URL, device_list_->currentText()); + + std::string did = device_list_->currentText().toStdString(); int period = period_selector_->currentData().toInt(); - if (period == -1) { - url += "/routes/preserved"; - } else { - QDateTime now = QDateTime::currentDateTime(); - url += QString("/routes_segments?start=%1&end=%2") - .arg(now.addDays(-period).toMSecsSinceEpoch()) - .arg(now.toMSecsSinceEpoch()); + + bool preserved = (period == -1); + int64_t start_ms = 0, end_ms = 0; + if (!preserved) { + end_ms = nowUnixMs(); + start_ms = end_ms - static_cast(period) * 24LL * 60LL * 60LL * 1000LL; } - route_requester_->send(url); + + int request_id = ++fetch_id_; + std::thread([this, alive = std::weak_ptr(alive_), did, start_ms, end_ms, preserved, request_id]() { + std::string result = CommaApi2::getDeviceRoutes(did, start_ms, end_ms, preserved); + QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { + if (!alive.expired() && fetch_id_ == request_id) parseRouteList(r, response.first, response.second); + }, Qt::QueuedConnection); + }).detach(); } -void RoutesDialog::parseRouteList(const QString &json, bool success, QNetworkReply::NetworkError err) { +void RoutesDialog::parseRouteList(const QString &json, bool success, int error_code) { if (success) { - for (const QJsonValue &route : QJsonDocument::fromJson(json.toUtf8()).array()) { - QDateTime from, to; - if (period_selector_->currentData().toInt() == -1) { - from = QDateTime::fromString(route["start_time"].toString(), Qt::ISODateWithMs); - to = QDateTime::fromString(route["end_time"].toString(), Qt::ISODateWithMs); - } else { - from = QDateTime::fromMSecsSinceEpoch(route["start_time_utc_millis"].toDouble()); - to = QDateTime::fromMSecsSinceEpoch(route["end_time_utc_millis"].toDouble()); + std::string err; + auto doc = json11::Json::parse(json.toStdString(), err); + if (err.empty() && doc.is_array()) { + for (const auto &route : doc.array_items()) { + int64_t from_ms = 0, to_ms = 0; + if (period_selector_->currentData().toInt() == -1) { + from_ms = parseIsoToUnixMs(route["start_time"].string_value()); + to_ms = parseIsoToUnixMs(route["end_time"].string_value()); + } else { + from_ms = static_cast(route["start_time_utc_millis"].number_value()); + to_ms = static_cast(route["end_time_utc_millis"].number_value()); + } + const int mins = static_cast((to_ms - from_ms) / 60000); + auto item = new QListWidgetItem(QString("%1 %2min").arg(formatUnixMs(from_ms)).arg(mins)); + item->setData(Qt::UserRole, QString::fromStdString(route["fullname"].string_value())); + route_list_->addItem(item); } - auto item = new QListWidgetItem(QString("%1 %2min").arg(from.toString()).arg(from.secsTo(to) / 60)); - item->setData(Qt::UserRole, route["fullname"].toString()); - route_list_->addItem(item); } if (route_list_->count() > 0) route_list_->setCurrentRow(0); } else { diff --git a/tools/cabana/streams/routes.h b/tools/cabana/streams/routes.h index 045dc672..6ed14560 100644 --- a/tools/cabana/streams/routes.h +++ b/tools/cabana/streams/routes.h @@ -1,11 +1,12 @@ #pragma once +#include +#include + #include #include -#include "tools/cabana/utils/api.h" class RouteListWidget; -class OneShotHttpRequest; class RoutesDialog : public QDialog { Q_OBJECT @@ -14,12 +15,14 @@ public: QString route(); protected: - void parseDeviceList(const QString &json, bool success, QNetworkReply::NetworkError err); - void parseRouteList(const QString &json, bool success, QNetworkReply::NetworkError err); + void parseDeviceList(const QString &json, bool success, int error_code); + void parseRouteList(const QString &json, bool success, int error_code); void fetchRoutes(); QComboBox *device_list_; QComboBox *period_selector_; RouteListWidget *route_list_; - OneShotHttpRequest *route_requester_; + std::atomic fetch_id_{0}; + // expires on destruction; guards main-thread callbacks from detached worker threads + std::shared_ptr alive_ = std::make_shared(true); }; diff --git a/tools/cabana/streams/socketcanstream.cc b/tools/cabana/streams/socketcanstream.cc index e4801df9..b616e7f2 100644 --- a/tools/cabana/streams/socketcanstream.cc +++ b/tools/cabana/streams/socketcanstream.cc @@ -1,67 +1,99 @@ #include "tools/cabana/streams/socketcanstream.h" -#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + #include #include #include #include -#include SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) { if (!available()) { - throw std::runtime_error("SocketCAN plugin not available"); + throw std::runtime_error("SocketCAN not available"); } - qDebug() << "Connecting to SocketCAN device" << config.device; + fprintf(stderr, "Connecting to SocketCAN device %s\n", config.device.c_str()); if (!connect()) { throw std::runtime_error("Failed to connect to SocketCAN device"); } } +SocketCanStream::~SocketCanStream() { + stop(); + if (sock_fd >= 0) { + ::close(sock_fd); + sock_fd = -1; + } +} + bool SocketCanStream::available() { - return QCanBus::instance()->plugins().contains("socketcan"); + int fd = socket(PF_CAN, SOCK_RAW, CAN_RAW); + if (fd < 0) return false; + ::close(fd); + return true; } bool SocketCanStream::connect() { - // Connecting might generate some warnings about missing socketcan/libsocketcan libraries - // These are expected and can be ignored, we don't need the advanced features of libsocketcan - QString errorString; - device.reset(QCanBus::instance()->createDevice("socketcan", config.device, &errorString)); - device->setConfigurationParameter(QCanBusDevice::CanFdKey, true); - - if (!device) { - qDebug() << "Failed to create SocketCAN device" << errorString; + sock_fd = socket(PF_CAN, SOCK_RAW, CAN_RAW); + if (sock_fd < 0) { + fprintf(stderr, "Failed to create CAN socket\n"); return false; } - if (!device->connectDevice()) { - qDebug() << "Failed to connect to device"; + // Enable CAN-FD + int fd_enable = 1; + setsockopt(sock_fd, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, &fd_enable, sizeof(fd_enable)); + + struct ifreq ifr = {}; + strncpy(ifr.ifr_name, config.device.c_str(), IFNAMSIZ - 1); + if (ioctl(sock_fd, SIOCGIFINDEX, &ifr) < 0) { + fprintf(stderr, "Failed to get interface index for %s\n", config.device.c_str()); + ::close(sock_fd); + sock_fd = -1; return false; } + struct sockaddr_can addr = {}; + addr.can_family = AF_CAN; + addr.can_ifindex = ifr.ifr_ifindex; + if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + fprintf(stderr, "Failed to bind CAN socket\n"); + ::close(sock_fd); + sock_fd = -1; + return false; + } + + // Set read timeout so the thread can check for interruption + struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; // 100ms + setsockopt(sock_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + return true; } void SocketCanStream::streamThread() { - while (!QThread::currentThread()->isInterruptionRequested()) { - QThread::msleep(1); + struct canfd_frame frame; - auto frames = device->readAllFrames(); - if (frames.size() == 0) continue; + while (!exit_) { + ssize_t nbytes = read(sock_fd, &frame, sizeof(frame)); + if (nbytes <= 0) continue; + + uint8_t len = (nbytes == CAN_MTU) ? frame.len : frame.len; // works for both CAN and CAN-FD MessageBuilder msg; auto evt = msg.initEvent(); - auto canData = evt.initCan(frames.size()); - - for (uint i = 0; i < frames.size(); i++) { - if (!frames[i].isValid()) continue; - - canData[i].setAddress(frames[i].frameId()); - canData[i].setSrc(0); - - auto payload = frames[i].payload(); - canData[i].setDat(kj::arrayPtr((uint8_t*)payload.data(), payload.size())); - } + auto canData = evt.initCan(1); + canData[0].setAddress(frame.can_id & CAN_EFF_MASK); + canData[0].setSrc(0); + canData[0].setDat(kj::arrayPtr(frame.data, len)); handleEvent(capnp::messageToFlatArray(msg)); } @@ -87,7 +119,7 @@ OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWi main_layout->addStretch(1); QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); - QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText(); }); + QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); // Populate devices refreshDevices(); @@ -95,12 +127,17 @@ OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWi void OpenSocketCanWidget::refreshDevices() { device_edit->clear(); - for (auto device : QCanBus::instance()->availableDevices(QStringLiteral("socketcan"))) { - device_edit->addItem(device.name()); + // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { + std::ifstream type_file(entry.path() / "type"); + int type = 0; + if (type_file >> type && type == 280) { + device_edit->addItem(QString::fromStdString(entry.path().filename().string())); + } } } - AbstractStream *OpenSocketCanWidget::open() { try { return new SocketCanStream(qApp, config); diff --git a/tools/cabana/streams/socketcanstream.h b/tools/cabana/streams/socketcanstream.h index 8083b687..3c5cd184 100644 --- a/tools/cabana/streams/socketcanstream.h +++ b/tools/cabana/streams/socketcanstream.h @@ -1,27 +1,22 @@ #pragma once -#include - -#include -#include -#include #include #include "tools/cabana/streams/livestream.h" struct SocketCanStreamConfig { - QString device = ""; // TODO: support multiple devices/buses at once + std::string device = ""; // TODO: support multiple devices/buses at once }; class SocketCanStream : public LiveStream { Q_OBJECT public: SocketCanStream(QObject *parent, SocketCanStreamConfig config_ = {}); - ~SocketCanStream() { stop(); } + ~SocketCanStream(); static bool available(); - inline QString routeName() const override { - return QString("Live Streaming From Socket CAN %1").arg(config.device); + inline std::string routeName() const override { + return "Live Streaming From Socket CAN " + config.device; } protected: @@ -29,7 +24,7 @@ protected: bool connect(); SocketCanStreamConfig config = {}; - std::unique_ptr device; + int sock_fd = -1; }; class OpenSocketCanWidget : public AbstractOpenStreamWidget { diff --git a/tools/cabana/streamselector.cc b/tools/cabana/streamselector.cc index efd00d39..7e8adc56 100644 --- a/tools/cabana/streamselector.cc +++ b/tools/cabana/streamselector.cc @@ -1,14 +1,17 @@ #include "tools/cabana/streamselector.h" +#include + #include #include #include -#include "streams/socketcanstream.h" #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" +#ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" +#endif StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Open stream")); @@ -35,9 +38,11 @@ StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { addStreamWidget(new OpenReplayWidget, tr("&Replay")); addStreamWidget(new OpenPandaWidget, tr("&Panda")); +#ifdef __linux__ if (SocketCanStream::available()) { addStreamWidget(new OpenSocketCanWidget, tr("&SocketCAN")); } +#endif addStreamWidget(new OpenDeviceWidget, tr("&Device")); QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); @@ -49,10 +54,10 @@ StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { setEnabled(true); }); QObject::connect(file_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)"); + QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); if (!fn.isEmpty()) { dbc_file->setText(fn); - settings.last_dir = QFileInfo(fn).absolutePath(); + settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); } }); } diff --git a/tools/cabana/test_cabana_konn3kt.py b/tools/cabana/test_cabana_konn3kt.py new file mode 100644 index 00000000..552154b9 --- /dev/null +++ b/tools/cabana/test_cabana_konn3kt.py @@ -0,0 +1,103 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +CABANA_DIR = Path(__file__).parent +CABANA_BIN = CABANA_DIR / "_cabana" + +pytestmark = pytest.mark.skipif(not CABANA_BIN.exists(), + reason="cabana not built (scons -u tools/cabana/_cabana)") + + +def read(name): + return (CABANA_DIR / name).read_text() + + +class TestCabanaBinary: + def test_help(self): + result = subprocess.run([str(CABANA_BIN), "--help"], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr + + def test_help_documents_both_live_can_modes(self): + # --zmq attaches directly (konn3kt_canproxy); --bridge is upstream's local-bridge path + out = subprocess.run([str(CABANA_BIN), "--help"], capture_output=True, text=True, timeout=60).stderr + assert "--zmq" in out + assert "--bridge" in out + assert "konn3kt_canproxy.py" in out + + def test_launcher_builds_the_right_targets(self): + launcher = read("cabana") + # iqpilot is not nested under openpilot/ + assert "scons -u tools/cabana/_cabana cereal/messaging/bridge" in launcher + assert "openpilot/tools/cabana" not in launcher + + +class TestKonn3ktIntegration: + def test_routes_dialog_uses_konn3kt_api(self): + # upstream shells into tools/lib via PyDownloader; iqpilot goes through CommaApi2 + routes = read("streams/routes.cc") + assert "PyDownloader::" not in routes + assert "py_downloader.h" not in routes + assert "CommaApi2::getDevices()" in routes + assert "CommaApi2::getDeviceRoutes(" in routes + + def test_no_duplicate_qt_api_client(self): + # the old Qt HttpRequest/JWT client was folded into tools/replay/api.cc + assert not (CABANA_DIR / "utils" / "api.cc").exists() + assert not (CABANA_DIR / "utils" / "api.h").exists() + + def test_no_comma_endpoints(self): + for name in ("streams/routes.cc", "cabana.cc", "README.md"): + body = read(name) + assert "connect.comma.ai" not in body, name + assert "api.comma.ai" not in body, name + + def test_dbc_menu_points_at_iqdbc(self): + mainwin = read("mainwin.cc") + assert "commaai/iqdbc" in mainwin + assert "commaai/opendbc" not in mainwin + + def test_dbc_json_generator_imports_iqdbc(self): + gen = read("dbc/generate_dbc_json.py") + assert "from iqdbc.car" in gen + assert "from opendbc.car" not in gen + + def test_generated_dbc_json_covers_iqdbc_platforms(self): + # built by scons; cabana reads it to auto-select a DBC per car fingerprint + path = CABANA_DIR / "dbc" / "car_fingerprint_to_dbc.json" + assert path.is_file(), "run scons -u tools/cabana" + mapping = json.loads(path.read_text()) + assert len(mapping) > 100 + assert all(isinstance(v, str) and v for v in mapping.values()) + + def test_canproxy_targets_konn3kt(self): + proxy = read("konn3kt_canproxy.py") + assert "konn3kt" in proxy + # the proxy publishes on a local ZMQ 'can' socket that --zmq attaches to + assert 'os.environ["ZMQ"] = "1"' in proxy + + +class TestDeviceStreamModes: + """The ZMQ attach is what konn3kt_canproxy feeds; upstream #38484 replaced it with a + bridge fork. Both must exist, and only the direct attach may set ZMQ=1.""" + + def test_three_modes_exist(self): + header = read("streams/devicestream.h") + assert "enum class Mode { Msgq, Zmq, Bridge };" in header + + def test_only_zmq_mode_talks_zmq(self): + src = read("streams/devicestream.cc") + assert 'mode_ == Mode::Zmq ? setenv("ZMQ", "1", 1) : unsetenv("ZMQ")' in src + + def test_zmq_mode_subscribes_to_the_given_address(self): + # regression guard: upstream hardcodes 127.0.0.1 and relies on the bridge fork, + # which never reads the canproxy publisher + src = read("streams/devicestream.cc") + assert 'const std::string address = mode_ == Mode::Zmq ? address_.toStdString() : "127.0.0.1";' in src + + def test_only_bridge_mode_forks_the_bridge(self): + src = read("streams/devicestream.cc") + assert "if (mode_ == Mode::Bridge) {" in src diff --git a/tools/cabana/tests/test_cabana.cc b/tools/cabana/tests/test_cabana.cc index 48a250b2..184b70af 100644 --- a/tools/cabana/tests/test_cabana.cc +++ b/tools/cabana/tests/test_cabana.cc @@ -1,14 +1,15 @@ -#undef INFO -#include +#include +#include -#include "catch2/catch.hpp" +#include "common/tests/native_test.h" +#include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; -TEST_CASE("DBCFile::generateDBC") { - QString fn = QString("%1/%2.dbc").arg(OPENDBC_FILE_PATH, "tesla_can"); +void test_generate_dbc() { + std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc"; DBCFile dbc_origin(fn); DBCFile dbc_from_generated("", dbc_origin.generateDBC()); @@ -28,9 +29,9 @@ TEST_CASE("DBCFile::generateDBC") { } } -TEST_CASE("DBCFile::generateDBC - comment order") { +void test_comment_order() { // Ensure that message comments are followed by signal comments and in the correct order - auto content = R"(BO_ 160 message_1: 8 EON + std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX BO_ 162 message_2: 8 EON @@ -45,8 +46,8 @@ CM_ SG_ 162 signal_2 "signal comment"; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("DBCFile::generateDBC -- preserve original header") { - QString content = R"(VERSION "1.0" +void test_preserve_original_header() { + std::string content = R"(VERSION "1.0" NS_ : CM_ @@ -65,8 +66,8 @@ CM_ SG_ 160 signal_1 "signal comment"; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("DBCFile::generateDBC - escaped quotes") { - QString content = R"(BO_ 160 message_1: 8 EON +void test_escaped_quotes() { + std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX CM_ BO_ 160 "message comment with \"escaped quotes\""; @@ -76,8 +77,8 @@ CM_ SG_ 160 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("parse_dbc") { - QString content = R"( +void test_parse_dbc() { + std::string content = R"( BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX SG_ signal_2 : 12|1@1+ (1.0,0.0) [0.0|1] "" XXX @@ -119,9 +120,9 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(sig_1->comment == "signal comment"); REQUIRE(sig_1->receiver_name == "XXX"); REQUIRE(sig_1->val_desc.size() == 3); - REQUIRE(sig_1->val_desc[0] == std::pair{0, "disabled"}); - REQUIRE(sig_1->val_desc[1] == std::pair{1.2, "initializing"}); - REQUIRE(sig_1->val_desc[2] == std::pair{2, "fault"}); + REQUIRE(sig_1->val_desc[0] == std::pair{0, "disabled"}); + REQUIRE(sig_1->val_desc[1] == std::pair{1.2, "initializing"}); + REQUIRE(sig_1->val_desc[2] == std::pair{2, "fault"}); auto &sig_2 = msg->sigs[1]; REQUIRE(sig_2->comment == "multiple line comment \n1\n2"); @@ -142,16 +143,66 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(msg->sigs[0]->comment == "signal comment with \"escaped quotes\""); } -TEST_CASE("parse_iqdbc") { - QDir dir(OPENDBC_FILE_PATH); - QStringList errors; - for (auto fn : dir.entryList({"*.dbc"}, QDir::Files, QDir::Name)) { +// OPENDBC_FILE_PATH points at iqdbc/dbc here — this is the load-bearing check that +// cabana can still parse every DBC iqpilot ships. +void test_parse_iqdbc() { + std::vector errors; + int parsed = 0; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH)) { + if (!entry.is_regular_file() || entry.path().extension() != ".dbc") continue; try { - auto dbc = DBCFile(dir.filePath(fn)); + auto dbc = DBCFile(entry.path().string()); + ++parsed; } catch (std::exception &e) { errors.push_back(e.what()); } } - INFO(errors.join("\n").toStdString()); + // guard against OPENDBC_FILE_PATH pointing somewhere empty, which would make the + // loop above pass vacuously + REQUIRE(parsed > 100); + std::ostringstream details; + for (const auto &error : errors) details << error << '\n'; + if (!errors.empty()) std::cerr << details.str(); REQUIRE(errors.empty()); } + +void test_dbc_manager() { + DBCManager manager; + int files_changed = 0; + int signals_added = 0; + int masks_updated = 0; + manager.setCallbacks({ + .signal_added = [&](MessageId, const cabana::Signal *) { ++signals_added; }, + .file_changed = [&]() { ++files_changed; }, + .mask_updated = [&]() { ++masks_updated; }, + }); + + std::string error; + REQUIRE(manager.open(SOURCE_ALL, "test", "BO_ 160 message: 8 XXX\n", &error)); + REQUIRE(error.empty()); + REQUIRE(files_changed == 1); + + cabana::Signal signal{}; + signal.name = "speed"; + signal.start_bit = 0; + signal.size = 8; + signal.is_little_endian = true; + manager.addSignal({.source = 0, .address = 160}, signal); + REQUIRE(signals_added == 1); + REQUIRE(masks_updated == 1); + REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr); +} + +void test_cabana_core() { + test_generate_dbc(); + test_comment_order(); + test_preserve_original_header(); + test_escaped_quotes(); + test_parse_dbc(); + test_parse_iqdbc(); + test_dbc_manager(); +} + +int main() { + return run_native_test(test_cabana_core); +} diff --git a/tools/cabana/tests/test_runner.cc b/tools/cabana/tests/test_runner.cc deleted file mode 100644 index b20ac86c..00000000 --- a/tools/cabana/tests/test_runner.cc +++ /dev/null @@ -1,10 +0,0 @@ -#define CATCH_CONFIG_RUNNER -#include "catch2/catch.hpp" -#include - -int main(int argc, char **argv) { - // unit tests for Qt - QCoreApplication app(argc, argv); - const int res = Catch::Session().run(argc, argv); - return (res < 0xff ? res : 0xff); -} diff --git a/tools/cabana/tools/findsignal.cc b/tools/cabana/tools/findsignal.cc index ec56fcaa..4511af7c 100644 --- a/tools/cabana/tools/findsignal.cc +++ b/tools/cabana/tools/findsignal.cc @@ -1,10 +1,12 @@ #include "tools/cabana/tools/findsignal.h" +#include +#include + #include #include #include #include -#include #include #include @@ -20,7 +22,7 @@ QVariant FindSignalModel::data(const QModelIndex &index, int role) const { if (role == Qt::DisplayRole) { const auto &s = filtered_signals[index.row()]; switch (index.column()) { - case 0: return s.id.toString(); + case 0: return QString::fromStdString(s.id.toString()); case 1: return QString("%1, %2").arg(s.sig.start_bit).arg(s.sig.size); case 2: return s.values.join(" "); } @@ -32,36 +34,49 @@ void FindSignalModel::search(std::function cmp) { beginResetModel(); std::mutex lock; - const auto prev_sigs = !histories.isEmpty() ? histories.back() : initial_signals; + const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; filtered_signals.clear(); filtered_signals.reserve(prev_sigs.size()); - QtConcurrent::blockingMap(prev_sigs, [&](auto &s) { - const auto &events = can->events(s.id); - auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); - auto last = events.cend(); - if (last_time < std::numeric_limits::max()) { - last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); - } - auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); - if (it != last) { - auto values = s.values; - values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig)); - std::lock_guard lk(lock); - filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); - } - }); + unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); + size_t chunk = (prev_sigs.size() + num_threads - 1) / num_threads; + std::vector threads; + for (unsigned int t = 0; t < num_threads && t * chunk < (size_t)prev_sigs.size(); ++t) { + size_t start = t * chunk; + size_t end = std::min(start + chunk, (size_t)prev_sigs.size()); + threads.emplace_back([&, start, end]() { + for (size_t i = start; i < end; ++i) { + const auto &s = prev_sigs[i]; + const auto &events = can->events(s.id); + auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); + auto last = events.cend(); + if (last_time < std::numeric_limits::max()) { + last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); + } + + auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); + if (it != last) { + auto values = s.values; + values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig)); + std::lock_guard lk(lock); + filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); + } + } + }); + } + for (auto &th : threads) th.join(); + histories.push_back(filtered_signals); endResetModel(); } void FindSignalModel::undo() { - if (!histories.isEmpty()) { + if (!histories.empty()) { beginResetModel(); histories.pop_back(); filtered_signals.clear(); - if (!histories.isEmpty()) filtered_signals = histories.back(); + if (!histories.empty()) filtered_signals = histories.back(); endResetModel(); } } @@ -84,9 +99,9 @@ FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags( message_group = new QGroupBox(tr("Messages"), this); QFormLayout *message_layout = new QFormLayout(message_group); message_layout->addRow(tr("Bus"), bus_edit = new QLineEdit()); - bus_edit->setPlaceholderText(tr("comma-seperated values. Leave blank for all")); + bus_edit->setPlaceholderText(tr("comma-separated values. Leave blank for all")); message_layout->addRow(tr("Address"), address_edit = new QLineEdit()); - address_edit->setPlaceholderText(tr("comma-seperated hex values. Leave blank for all")); + address_edit->setPlaceholderText(tr("comma-separated hex values. Leave blank for all")); QHBoxLayout *hlayout = new QHBoxLayout(); hlayout->addWidget(first_time_edit = new QLineEdit("0")); hlayout->addWidget(new QLabel("-")); @@ -172,7 +187,7 @@ FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags( } void FindSignalDlg::search() { - if (model->histories.isEmpty()) { + if (model->histories.empty()) { setInitialSignals(); } auto v1 = value1->text().toDouble(); @@ -196,13 +211,13 @@ void FindSignalDlg::search() { } void FindSignalDlg::setInitialSignals() { - QSet buses; + std::set buses; for (auto bus : bus_edit->text().trimmed().split(",")) { bus = bus.trimmed(); if (!bus.isEmpty()) buses.insert(bus.toUShort()); } - QSet addresses; + std::set addresses; for (auto addr : address_edit->text().trimmed().split(",")) { addr = addr.trimmed(); if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16)); @@ -225,7 +240,7 @@ void FindSignalDlg::setInitialSignals() { model->initial_signals.clear(); for (const auto &[id, m] : can->lastMessages()) { - if ((buses.isEmpty() || buses.contains(id.source)) && (addresses.isEmpty() || addresses.contains(id.address))) { + if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(id.address))) { const auto &events = can->events(id); auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent()); if (e != events.cend()) { @@ -246,12 +261,12 @@ void FindSignalDlg::setInitialSignals() { } void FindSignalDlg::modelReset() { - properties_group->setEnabled(model->histories.isEmpty()); - message_group->setEnabled(model->histories.isEmpty()); - search_btn->setText(model->histories.isEmpty() ? tr("Find") : tr("Find Next")); - reset_btn->setEnabled(!model->histories.isEmpty()); + properties_group->setEnabled(model->histories.empty()); + message_group->setEnabled(model->histories.empty()); + search_btn->setText(model->histories.empty() ? tr("Find") : tr("Find Next")); + reset_btn->setEnabled(!model->histories.empty()); undo_btn->setEnabled(model->histories.size() > 1); - search_btn->setEnabled(model->rowCount() > 0 || model->histories.isEmpty()); + search_btn->setEnabled(model->rowCount() > 0 || model->histories.empty()); stats_label->setVisible(true); stats_label->setText(tr("%1 matches. right click on an item to create signal. double click to open message").arg(model->filtered_signals.size())); } @@ -262,7 +277,7 @@ void FindSignalDlg::customMenuRequested(const QPoint &pos) { menu.addAction(tr("Create Signal")); if (menu.exec(view->mapToGlobal(pos))) { auto &s = model->filtered_signals[index.row()]; - UndoStack::push(new AddSigCommand(s.id, s.sig)); + UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); emit openMessage(s.id); } } diff --git a/tools/cabana/tools/findsignal.h b/tools/cabana/tools/findsignal.h index 5ef7461f..239a08c9 100644 --- a/tools/cabana/tools/findsignal.h +++ b/tools/cabana/tools/findsignal.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -26,14 +28,14 @@ public: QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 3; } - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min(filtered_signals.size(), 300); } + int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min((int)filtered_signals.size(), 300); } void search(std::function cmp); void reset(); void undo(); - QList filtered_signals; - QList initial_signals; - QList> histories; + std::vector filtered_signals; + std::vector initial_signals; + std::vector> histories; uint64_t last_time = std::numeric_limits::max(); }; diff --git a/tools/cabana/tools/findsimilarbits.cc b/tools/cabana/tools/findsimilarbits.cc index c3c65979..8062b611 100644 --- a/tools/cabana/tools/findsimilarbits.cc +++ b/tools/cabana/tools/findsimilarbits.cc @@ -1,6 +1,7 @@ #include "tools/cabana/tools/findsimilarbits.h" #include +#include #include #include @@ -31,7 +32,7 @@ FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::Wi msg_cb = new QComboBox(this); // TODO: update when src_bus_combo changes for (auto &[address, msg] : dbc()->getMessages(-1)) { - msg_cb->addItem(msg.name, address); + msg_cb->addItem(QString::fromStdString(msg.name), address); } msg_cb->model()->sort(0); msg_cb->setCurrentIndex(0); @@ -114,10 +115,10 @@ void FindSimilarBitsDlg::find() { search_btn->setEnabled(true); } -QList FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, - int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { - QHash> mismatches; - QHash msg_count; +std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, + int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { + std::unordered_map> mismatches; + std::unordered_map msg_count; const auto &events = can->allEvents(); int bit_to_find = -1; for (const CanEvent *e : events) { @@ -143,14 +144,14 @@ QList FindSimilarBitsDlg::calcBits(uint8_ } } - QList result; + std::vector result; result.reserve(mismatches.size()); for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { - if (auto cnt = msg_count[it.key()]; cnt > min_msgs_cnt) { - auto &mismatched = it.value(); - for (int i = 0; i < mismatched.size(); ++i) { + if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { + auto &mismatched = it->second; + for (int i = 0; i < (int)mismatched.size(); ++i) { if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { - result.push_back({it.key(), (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); + result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); } } } diff --git a/tools/cabana/tools/findsimilarbits.h b/tools/cabana/tools/findsimilarbits.h index 77bfac19..34513606 100644 --- a/tools/cabana/tools/findsimilarbits.h +++ b/tools/cabana/tools/findsimilarbits.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -22,7 +24,7 @@ private: uint32_t address, byte_idx, bit_idx, mismatches, total; float perc; }; - QList calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, + std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt); void find(); diff --git a/tools/cabana/utils/api.cc b/tools/cabana/utils/api.cc deleted file mode 100644 index 89379a84..00000000 --- a/tools/cabana/utils/api.cc +++ /dev/null @@ -1,171 +0,0 @@ -#include "tools/cabana/utils/api.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "common/params.h" -#include "common/util.h" -#include "system/hardware/hw.h" -#include "tools/cabana/utils/util.h" - -QString getVersion() { - static QString version = QString::fromStdString(Params().get("Version")); - return version; -} - -QString getUserAgent() { - return "openpilot-" + getVersion(); -} - -std::optional getDongleId() { - std::string id = Params().get("DongleId"); - - if (!id.empty() && (id != "UnregisteredDevice")) { - return QString::fromStdString(id); - } else { - return {}; - } -} - -namespace CommaApi { - -EVP_PKEY *get_private_key() { - static std::unique_ptr pkey(nullptr, EVP_PKEY_free); - if (!pkey) { - FILE *fp = fopen(Path::rsa_file().c_str(), "rb"); - if (!fp) { - qDebug() << "No private key found, please run manager.py or registration.py"; - return nullptr; - } - pkey.reset(PEM_read_PrivateKey(fp, nullptr, nullptr, nullptr)); - fclose(fp); - } - return pkey.get(); -} - -QByteArray rsa_sign(const QByteArray &data) { - EVP_PKEY *pkey = get_private_key(); - if (!pkey) return {}; - - EVP_MD_CTX *mdctx = EVP_MD_CTX_new(); - if (!mdctx) return {}; - - QByteArray sig(EVP_PKEY_size(pkey), Qt::Uninitialized); - size_t sig_len = sig.size(); - - int ret = EVP_DigestSignInit(mdctx, nullptr, EVP_sha256(), nullptr, pkey); - ret &= EVP_DigestSignUpdate(mdctx, data.data(), data.size()); - ret &= EVP_DigestSignFinal(mdctx, (unsigned char*)sig.data(), &sig_len); - - EVP_MD_CTX_free(mdctx); - - if (ret != 1) return {}; - sig.resize(sig_len); - return sig; -} - -QString create_jwt(const QJsonObject &payloads, int expiry) { - QJsonObject header = {{"alg", "RS256"}}; - - auto t = QDateTime::currentSecsSinceEpoch(); - QJsonObject payload = {{"identity", getDongleId().value_or("")}, {"nbf", t}, {"iat", t}, {"exp", t + expiry}}; - for (auto it = payloads.begin(); it != payloads.end(); ++it) { - payload.insert(it.key(), it.value()); - } - - auto b64_opts = QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals; - QString jwt = QJsonDocument(header).toJson(QJsonDocument::Compact).toBase64(b64_opts) + '.' + - QJsonDocument(payload).toJson(QJsonDocument::Compact).toBase64(b64_opts); - - auto hash = QCryptographicHash::hash(jwt.toUtf8(), QCryptographicHash::Sha256); - return jwt + "." + rsa_sign(hash).toBase64(b64_opts); -} - -} // namespace CommaApi - -HttpRequest::HttpRequest(QObject *parent, bool create_jwt, int timeout) : create_jwt(create_jwt), QObject(parent) { - networkTimer = new QTimer(this); - networkTimer->setSingleShot(true); - networkTimer->setInterval(timeout); - connect(networkTimer, &QTimer::timeout, this, &HttpRequest::requestTimeout); -} - -bool HttpRequest::active() const { - return reply != nullptr; -} - -bool HttpRequest::timeout() const { - return reply && reply->error() == QNetworkReply::OperationCanceledError; -} - -void HttpRequest::sendRequest(const QString &requestURL, const HttpRequest::Method method) { - if (active()) { - qDebug() << "HttpRequest is active"; - return; - } - QString token; - if (create_jwt) { - token = CommaApi::create_jwt(); - } else { - QString token_json = QString::fromStdString(util::read_file(util::getenv("HOME") + "/.comma/auth.json")); - QJsonDocument json_d = QJsonDocument::fromJson(token_json.toUtf8()); - token = json_d["access_token"].toString(); - } - - QNetworkRequest request; - request.setUrl(QUrl(requestURL)); - request.setRawHeader("User-Agent", getUserAgent().toUtf8()); - - if (!token.isEmpty()) { - request.setRawHeader(QByteArray("Authorization"), ("JWT " + token).toUtf8()); - } - - if (method == HttpRequest::Method::GET) { - reply = nam()->get(request); - } else if (method == HttpRequest::Method::DELETE) { - reply = nam()->deleteResource(request); - } - - networkTimer->start(); - connect(reply, &QNetworkReply::finished, this, &HttpRequest::requestFinished); -} - -void HttpRequest::requestTimeout() { - reply->abort(); -} - -void HttpRequest::requestFinished() { - networkTimer->stop(); - - if (reply->error() == QNetworkReply::NoError) { - emit requestDone(reply->readAll(), true, reply->error()); - } else { - QString error; - if (reply->error() == QNetworkReply::OperationCanceledError) { - nam()->clearAccessCache(); - nam()->clearConnectionCache(); - error = "Request timed out"; - } else { - error = reply->errorString(); - } - emit requestDone(error, false, reply->error()); - } - - reply->deleteLater(); - reply = nullptr; -} - -QNetworkAccessManager *HttpRequest::nam() { - static QNetworkAccessManager *networkAccessManager = new QNetworkAccessManager(qApp); - return networkAccessManager; -} diff --git a/tools/cabana/utils/api.h b/tools/cabana/utils/api.h deleted file mode 100644 index 1507a1ba..00000000 --- a/tools/cabana/utils/api.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "common/util.h" - -namespace CommaApi { - -const QString BASE_URL = util::getenv("API_HOST", "https://api-iqlabs.konn3kt.com").c_str(); -QByteArray rsa_sign(const QByteArray &data); -QString create_jwt(const QJsonObject &payloads = {}, int expiry = 3600); - -} // namespace CommaApi - -/** - * Makes a request to the request endpoint. - */ - -class HttpRequest : public QObject { - Q_OBJECT - -public: - enum class Method {GET, DELETE}; - - explicit HttpRequest(QObject* parent, bool create_jwt = true, int timeout = 20000); - void sendRequest(const QString &requestURL, const Method method = Method::GET); - bool active() const; - bool timeout() const; - -signals: - void requestDone(const QString &response, bool success, QNetworkReply::NetworkError error); - -protected: - QNetworkReply *reply = nullptr; - -private: - static QNetworkAccessManager *nam(); - QTimer *networkTimer = nullptr; - bool create_jwt; - -private slots: - void requestTimeout(); - void requestFinished(); -}; diff --git a/tools/cabana/utils/export.cc b/tools/cabana/utils/export.cc index 79ca97ba..d585827e 100644 --- a/tools/cabana/utils/export.cc +++ b/tools/cabana/utils/export.cc @@ -1,41 +1,41 @@ #include "tools/cabana/utils/export.h" -#include -#include +#include +#include #include "tools/cabana/streams/abstractstream.h" namespace utils { -void exportToCSV(const QString &file_name, std::optional msg_id) { - QFile file(file_name); - if (file.open(QIODevice::ReadWrite | QIODevice::Truncate)) { - QTextStream stream(&file); +void exportToCSV(const std::string &file_name, std::optional msg_id) { + std::ofstream stream(file_name, std::ios::trunc); + if (stream) { stream << "time,addr,bus,data\n"; for (auto e : msg_id ? can->events(*msg_id) : can->allEvents()) { - stream << QString::number(can->toSeconds(e->mono_time), 'f', 3) << "," - << "0x" << QString::number(e->address, 16) << "," << e->src << "," - << "0x" << QByteArray::fromRawData((const char *)e->dat, e->size).toHex().toUpper() << "\n"; + stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << "," + << "0x" << std::hex << e->address << std::dec << "," << static_cast(e->src) << ",0x" + << std::uppercase << std::hex << std::setfill('0'); + for (int i = 0; i < e->size; ++i) stream << std::setw(2) << static_cast(e->dat[i]); + stream << std::nouppercase << std::dec << "\n"; } } } -void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id) { - QFile file(file_name); - if (auto msg = dbc()->msg(msg_id); msg && msg->sigs.size() && file.open(QIODevice::ReadWrite | QIODevice::Truncate)) { - QTextStream stream(&file); +void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id) { + std::ofstream stream(file_name, std::ios::trunc); + if (auto msg = dbc()->msg(msg_id); msg && !msg->sigs.empty() && stream) { stream << "time,addr,bus"; for (auto s : msg->sigs) - stream << "," << s->name; + stream << "," << s->name.c_str(); stream << "\n"; for (auto e : can->events(msg_id)) { - stream << QString::number(can->toSeconds(e->mono_time), 'f', 3) << "," - << "0x" << QString::number(e->address, 16) << "," << e->src; + stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << "," + << "0x" << std::hex << e->address << std::dec << "," << static_cast(e->src); for (auto s : msg->sigs) { double value = 0; s->getValue(e->dat, e->size, &value); - stream << "," << QString::number(value, 'f', s->precision); + stream << "," << std::fixed << std::setprecision(s->precision) << value; } stream << "\n"; } diff --git a/tools/cabana/utils/export.h b/tools/cabana/utils/export.h index 270906b1..ee110e83 100644 --- a/tools/cabana/utils/export.h +++ b/tools/cabana/utils/export.h @@ -1,10 +1,11 @@ #pragma once #include +#include #include "tools/cabana/dbc/dbcmanager.h" namespace utils { -void exportToCSV(const QString &file_name, std::optional msg_id = std::nullopt); -void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id); +void exportToCSV(const std::string &file_name, std::optional msg_id = std::nullopt); +void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id); } // namespace utils diff --git a/tools/cabana/utils/util.cc b/tools/cabana/utils/util.cc index ca069b35..d989a850 100644 --- a/tools/cabana/utils/util.cc +++ b/tools/cabana/utils/util.cc @@ -1,24 +1,26 @@ #include "tools/cabana/utils/util.h" #include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include #include +#include #include #include -#include -#include #include -#include #include -#include -#include #include -#include -#include +#include #include "common/util.h" // SegmentTree @@ -101,7 +103,7 @@ void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem & // Paint hex column const auto &bytes = *static_cast *>(data.value()); - const auto &colors = *static_cast *>(index.data(ColorsRole).value()); + const auto &colors = *static_cast *>(index.data(ColorsRole).value()); painter->setFont(fixed_font); const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); @@ -116,7 +118,7 @@ void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem & painter->setPen(option.palette.color(QPalette::Text)); painter->fillRect(r, option.palette.color(QPalette::Window)); } - painter->fillRect(r, colors[i]); + painter->fillRect(r, toQColor(colors[i])); } else { painter->setPen(text_pen); } @@ -149,54 +151,211 @@ void TabBar::closeTabClicked() { // UnixSignalHandler -UnixSignalHandler::UnixSignalHandler(QObject *parent) : QObject(nullptr) { +UnixSignalHandler::UnixSignalHandler() { if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) { qFatal("Couldn't create TERM socketpair"); } - sn = new QSocketNotifier(sig_fd[1], QSocketNotifier::Read, this); - connect(sn, &QSocketNotifier::activated, this, &UnixSignalHandler::handleSigTerm); + waiter = std::thread([this]() { + int tmp = 0; + while (::read(sig_fd[1], &tmp, sizeof(tmp)) < 0) { + if (errno != EINTR) return; + } + if (shutting_down.load()) return; + + // Marshal exit onto the GUI thread (qApp methods are not thread-safe). + QMetaObject::invokeMethod(qApp, []() { + printf("\nexiting...\n"); + qApp->closeAllWindows(); + qApp->exit(); + }, Qt::QueuedConnection); + }); + std::signal(SIGINT, signalHandler); std::signal(SIGTERM, UnixSignalHandler::signalHandler); } UnixSignalHandler::~UnixSignalHandler() { + shutting_down.store(true); + int dummy = 0; + (void)!::write(sig_fd[0], &dummy, sizeof(dummy)); + if (waiter.joinable()) waiter.join(); ::close(sig_fd[0]); ::close(sig_fd[1]); } void UnixSignalHandler::signalHandler(int s) { - ::write(sig_fd[0], &s, sizeof(s)); -} - -void UnixSignalHandler::handleSigTerm() { - sn->setEnabled(false); - int tmp; - ::read(sig_fd[1], &tmp, sizeof(tmp)); - - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); + (void)!::write(sig_fd[0], &s, sizeof(s)); } // NameValidator -NameValidator::NameValidator(QObject *parent) : QRegExpValidator(QRegExp("^(\\w+)"), parent) {} +NameValidator::NameValidator(QObject *parent) : QValidator(parent) {} QValidator::State NameValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); input.replace(' ', '_'); - return QRegExpValidator::validate(input, pos); + if (input.isEmpty()) return QValidator::Intermediate; + for (const QChar &c : input) { + if (!c.isLetterOrNumber() && c != '_') return QValidator::Invalid; + } + return QValidator::Acceptable; } -DoubleValidator::DoubleValidator(QObject *parent) : QDoubleValidator(parent) { - // Match locale of QString::toDouble() instead of system - QLocale locale(QLocale::C); - locale.setNumberOptions(QLocale::RejectGroupSeparator); - setLocale(locale); +// NodeValidator + +NodeValidator::NodeValidator(QObject *parent) : QValidator(parent) {} + +QValidator::State NodeValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); + if (input.isEmpty()) return QValidator::Intermediate; + // Match ^\w+(,\w+)*$ ; a trailing comma is Intermediate (user still typing). + bool need_word = true; + for (const QChar &c : input) { + if (c.isLetterOrNumber() || c == '_') { + need_word = false; + } else if (c == ',' && !need_word) { + need_word = true; + } else { + return QValidator::Invalid; + } + } + return need_word ? QValidator::Intermediate : QValidator::Acceptable; +} + +// NonWhitespaceValidator + +NonWhitespaceValidator::NonWhitespaceValidator(QObject *parent) : QValidator(parent) {} + +QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); + if (input.isEmpty()) return QValidator::Intermediate; + for (const QChar &c : input) { + if (c.isSpace()) return QValidator::Invalid; + } + return QValidator::Acceptable; +} + +// IpAddressValidator + +IpAddressValidator::IpAddressValidator(QObject *parent) : QValidator(parent) {} + +QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); + if (input.isEmpty()) return QValidator::Intermediate; + + int dots = 0; + int value = 0; + bool has_digit = false; + for (const QChar &c : input) { + if (c.isDigit()) { + value = has_digit ? value * 10 + c.digitValue() : c.digitValue(); + if (value > 255) return QValidator::Invalid; + has_digit = true; + } else if (c == '.') { + if (!has_digit || dots >= 3) return QValidator::Invalid; + ++dots; + has_digit = false; + value = 0; + } else { + return QValidator::Invalid; + } + } + return (dots == 3 && has_digit) ? QValidator::Acceptable : QValidator::Intermediate; +} + +DoubleValidator::DoubleValidator(QObject *parent) : QValidator(parent) {} + +QValidator::State DoubleValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); + if (input.isEmpty()) return QValidator::Intermediate; + + // Match QString::toDouble(): C locale, no hex floats / inf / nan. + const std::string bytes = input.toLatin1().toStdString(); + // strtod accepts 0x… hex floats and p-exponents; QString::toDouble does not. + if (bytes.find_first_of("xXpP") != std::string::npos) { + return QValidator::Invalid; + } + + const char *start = bytes.c_str(); + char *end = nullptr; + const double value = std::strtod(start, &end); + if (end == start) { + // Still typing a sign, decimal point, or exponent prefix. + if (input == "-" || input == "+" || input == "." || input == "-." || input == "+.") { + return QValidator::Intermediate; + } + return QValidator::Invalid; + } + if (*end == '\0') { + // Reject inf/nan (strtod accepts them; QDoubleValidator / toDouble path should not). + return std::isfinite(value) ? QValidator::Acceptable : QValidator::Invalid; + } + + // Partial exponent / trailing sign while typing (e.g. "1e", "1e-", "1."). + for (const char *p = end; *p; ++p) { + const char c = *p; + if (!(c == 'e' || c == 'E' || c == '+' || c == '-' || c == '.' || (c >= '0' && c <= '9'))) { + return QValidator::Invalid; + } + } + return QValidator::Intermediate; } namespace utils { +std::string homePath() { + const char *home = ::getenv("HOME"); + return home ? home : ""; +} + +std::filesystem::path configPath() { +#ifdef __APPLE__ + return std::filesystem::path(homePath()) / "Library/Preferences"; +#else + const char *xdg = ::getenv("XDG_CONFIG_HOME"); + return (xdg && xdg[0]) ? std::filesystem::path(xdg) : std::filesystem::path(homePath()) / ".config"; +#endif +} + +#ifdef __APPLE__ +static const char *clipboard_read_cmds[] = {"pbpaste"}; +static const char *clipboard_write_cmds[] = {"pbcopy"}; +#else +static const char *clipboard_read_cmds[] = {"wl-paste --no-newline 2>/dev/null", "xclip -selection clipboard -o 2>/dev/null", "xsel -ob 2>/dev/null"}; +static const char *clipboard_write_cmds[] = {"wl-copy 2>/dev/null", "xclip -selection clipboard 2>/dev/null", "xsel -ib 2>/dev/null"}; +#endif + +bool getClipboardText(std::string *text) { + text->clear(); + bool has_tool = false; + for (const char *cmd : clipboard_read_cmds) { + FILE *f = ::popen(cmd, "r"); + if (!f) continue; + std::string out; + char buf[4096]; + for (size_t n; (n = ::fread(buf, 1, sizeof(buf), f)) > 0;) out.append(buf, n); + int status = ::pclose(f); + if (status == 0) { + *text = std::move(out); + return true; + } + has_tool |= WIFEXITED(status) && WEXITSTATUS(status) != 127; // 127: command not found + } + return has_tool; // tool present but clipboard empty +} + +bool setClipboardText(const std::string &text) { + std::signal(SIGPIPE, SIG_IGN); + for (const char *cmd : clipboard_write_cmds) { + FILE *f = ::popen(cmd, "w"); + if (!f) continue; + size_t written = ::fwrite(text.data(), 1, text.size(), f); + if (::pclose(f) == 0 && written == text.size()) return true; + } + return false; +} + bool isDarkTheme() { QColor windowColor = QApplication::palette().color(QPalette::Window); return windowColor.lightness() < 128; @@ -209,7 +368,10 @@ QPixmap icon(const QString &id) { QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); if (!QPixmapCache::find(key, &pm)) { pm = bootstrapPixmap(id); - if (dark_theme) { + // IQ.Pilot patch: ToolButton("") (chartswidget.cc) asks for an empty id, so pm can + // be null. Painting a null QPixmap is a no-op that logs two QPainter warnings on + // every dark-theme start. Upstream candidate. + if (dark_theme && !pm.isNull()) { QPainter p(&pm); p.setCompositionMode(QPainter::CompositionMode_SourceIn); p.fillRect(pm.rect(), QColor("#bbbbbb")); @@ -258,10 +420,34 @@ void setTheme(int theme) { } QString formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { - QString format = absolute_time ? "yyyy-MM-dd hh:mm:ss" - : (sec > 60 * 60 ? "hh:mm:ss" : "mm:ss"); - if (include_milliseconds) format += ".zzz"; - return QDateTime::fromMSecsSinceEpoch(sec * 1000).toString(format); + if (absolute_time) { + const auto ms_total = static_cast(std::llround(sec * 1000.0)); + const std::time_t secs = static_cast(ms_total / 1000); + int millis = static_cast(ms_total % 1000); + if (millis < 0) millis = -millis; + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + if (include_milliseconds) { + return QString::asprintf("%s.%03d", buf, millis); + } + return QString::fromUtf8(buf); + } + + // Relative duration (not wall-clock). + const bool show_hours = sec > 60 * 60; + int total_ms = static_cast(std::llround(std::max(0.0, sec) * 1000.0)); + const int hours = total_ms / (3600 * 1000); + const int minutes = (total_ms / (60 * 1000)) % 60; + const int seconds = (total_ms / 1000) % 60; + const int millis = total_ms % 1000; + if (show_hours) { + return include_milliseconds ? QString::asprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis) + : QString::asprintf("%02d:%02d:%02d", hours, minutes, seconds); + } + return include_milliseconds ? QString::asprintf("%02d:%02d.%03d", minutes, seconds, millis) + : QString::asprintf("%02d:%02d", minutes, seconds); } } // namespace utils @@ -278,24 +464,10 @@ QString signalToolTip(const cabana::Signal *sig) { Start Bit: %2 Size: %3
MSB: %4 LSB: %5
Little Endian: %6 Signed: %7 - )").arg(sig->name).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb) + )").arg(QString::fromStdString(sig->name)).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb) .arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N"); } -void setSurfaceFormat() { - QSurfaceFormat fmt; -#ifdef __APPLE__ - fmt.setVersion(3, 2); - fmt.setProfile(QSurfaceFormat::OpenGLContextProfile::CoreProfile); - fmt.setRenderableType(QSurfaceFormat::OpenGL); -#else - fmt.setRenderableType(QSurfaceFormat::OpenGLES); -#endif - fmt.setSamples(16); - fmt.setStencilBufferSize(1); - QSurfaceFormat::setDefaultFormat(fmt); -} - void sigTermHandler(int s) { std::signal(s, SIG_DFL); qApp->quit(); @@ -306,55 +478,68 @@ void initApp(int argc, char *argv[], bool disable_hidpi) { std::signal(SIGINT, sigTermHandler); std::signal(SIGTERM, sigTermHandler); - QString app_dir; + std::filesystem::path app_dir; #ifdef __APPLE__ // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering QApplication tmp(argc, argv); - app_dir = QCoreApplication::applicationDirPath(); + app_dir = QCoreApplication::applicationDirPath().toStdString(); if (disable_hidpi) { qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); } #else - app_dir = QFileInfo(util::readlink("/proc/self/exe").c_str()).path(); + app_dir = std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); #endif - qputenv("QT_DBL_CLICK_DIST", QByteArray::number(150)); + qputenv("QT_DBL_CLICK_DIST", "150"); // ensure the current dir matches the exectuable's directory - QDir::setCurrent(app_dir); - - setSurfaceFormat(); + std::error_code ec; + std::filesystem::current_path(app_dir, ec); } -static QHash load_bootstrap_icons() { - QHash icons; +// embedded at build time from the bootstrap_icons package (see SConscript) +extern const unsigned char bootstrap_icons_svg[]; +extern const size_t bootstrap_icons_svg_len; - QFile f(":/bootstrap-icons.svg"); - if (f.open(QIODevice::ReadOnly | QIODevice::Text)) { - QDomDocument xml; - xml.setContent(&f); - QDomNode n = xml.documentElement().firstChild(); - while (!n.isNull()) { - QDomElement e = n.toElement(); - if (!e.isNull() && e.hasAttribute("id")) { - QString svg_str; - QTextStream stream(&svg_str); - n.save(stream, 0); - svg_str.replace("", ""); - icons[e.attribute("id")] = svg_str.toUtf8(); +static std::unordered_map load_bootstrap_icons() { + std::unordered_map icons; + + const std::string content(reinterpret_cast(bootstrap_icons_svg), bootstrap_icons_svg_len); + const std::string sym_open = " with + svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) + icons[id] = std::move(svg_str); } - n = n.nextSibling(); } + pos = end; } return icons; } QPixmap bootstrapPixmap(const QString &id) { - static QHash icons = load_bootstrap_icons(); + static auto icons = load_bootstrap_icons(); QPixmap pixmap; - if (auto it = icons.find(id); it != icons.end()) { - pixmap.loadFromData(it.value(), "svg"); + auto it = icons.find(id.toStdString()); + if (it != icons.end()) { + pixmap.loadFromData((const uchar *)it->second.data(), it->second.size(), "svg"); } return pixmap; } diff --git a/tools/cabana/utils/util.h b/tools/cabana/utils/util.h index f839ffe7..5a3c62d1 100644 --- a/tools/cabana/utils/util.h +++ b/tools/cabana/utils/util.h @@ -1,26 +1,32 @@ #pragma once #include +#include #include +#include +#include +#include #include #include #include -#include -#include +#include #include #include #include -#include -#include #include #include #include #include +#include #include "tools/cabana/dbc/dbc.h" #include "tools/cabana/settings.h" +inline QColor toQColor(const CabanaColor &color) { + return QColor(color.r, color.g, color.b, color.a); +} + class LogSlider : public QSlider { Q_OBJECT @@ -84,22 +90,53 @@ private: int h_margin, v_margin; }; -class NameValidator : public QRegExpValidator { +// Accepts a single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_'. +class NameValidator : public QValidator { Q_OBJECT public: NameValidator(QObject *parent=nullptr); QValidator::State validate(QString &input, int &pos) const override; }; -class DoubleValidator : public QDoubleValidator { +// Accepts comma-separated identifiers: \w+(,\w+)* +class NodeValidator : public QValidator { + Q_OBJECT +public: + NodeValidator(QObject *parent=nullptr); + QValidator::State validate(QString &input, int &pos) const override; +}; + +// Accepts one or more non-whitespace characters (\S+). +class NonWhitespaceValidator : public QValidator { + Q_OBJECT +public: + NonWhitespaceValidator(QObject *parent=nullptr); + QValidator::State validate(QString &input, int &pos) const override; +}; + +// Accepts a dotted IPv4 address (0-255 per octet). +class IpAddressValidator : public QValidator { + Q_OBJECT +public: + IpAddressValidator(QObject *parent=nullptr); + QValidator::State validate(QString &input, int &pos) const override; +}; + +// C-locale floating-point validator (matches QString::toDouble). +class DoubleValidator : public QValidator { Q_OBJECT public: DoubleValidator(QObject *parent = nullptr); + QValidator::State validate(QString &input, int &pos) const override; }; namespace utils { QPixmap icon(const QString &id); +std::string homePath(); +std::filesystem::path configPath(); +bool getClipboardText(std::string *text); // false if no clipboard tool is available +bool setClipboardText(const std::string &text); bool isDarkTheme(); void setTheme(int theme); QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); @@ -108,7 +145,22 @@ inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); } inline QString toHex(const std::vector &dat, char separator = '\0') { - return QByteArray::fromRawData((const char *)dat.data(), dat.size()).toHex(separator).toUpper(); + static const char digits[] = "0123456789ABCDEF"; + QString hex; + hex.reserve(dat.size() * (separator ? 3 : 2)); + for (size_t i = 0; i < dat.size(); ++i) { + if (separator && i) hex += QLatin1Char(separator); + hex += QLatin1Char(digits[dat[i] >> 4]); + hex += QLatin1Char(digits[dat[i] & 0xf]); + } + return hex; +} + +// boundary conversions for the remaining Qt byte-array based state APIs +template +std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } +inline auto qbytes(const std::vector &dat) { + return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); } } @@ -147,20 +199,18 @@ private: void closeTabClicked(); }; -class UnixSignalHandler : public QObject { - Q_OBJECT - +// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread +// (no Qt notifiers/timers). Exit is marshaled onto the GUI thread. +class UnixSignalHandler { public: - UnixSignalHandler(QObject *parent = nullptr); + UnixSignalHandler(); ~UnixSignalHandler(); static void signalHandler(int s); -public slots: - void handleSigTerm(); - private: inline static int sig_fd[2] = {}; - QSocketNotifier *sn; + std::atomic shutting_down{false}; + std::thread waiter; }; int num_decimals(double num); diff --git a/tools/cabana/videowidget.cc b/tools/cabana/videowidget.cc index 55018f28..67b89cb0 100644 --- a/tools/cabana/videowidget.cc +++ b/tools/cabana/videowidget.cc @@ -1,6 +1,7 @@ #include "tools/cabana/videowidget.h" #include +#include #include #include @@ -9,20 +10,20 @@ #include #include #include -#include #include "tools/cabana/tools/routeinfo.h" const int MIN_VIDEO_HEIGHT = 100; const int THUMBNAIL_MARGIN = 3; +// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark static const QColor timeline_colors[] = { - [(int)TimelineType::None] = QColor(111, 143, 175), - [(int)TimelineType::Engaged] = QColor(0, 163, 108), - [(int)TimelineType::UserBookmark] = Qt::magenta, - [(int)TimelineType::AlertInfo] = Qt::green, - [(int)TimelineType::AlertWarning] = QColor(255, 195, 0), - [(int)TimelineType::AlertCritical] = QColor(199, 0, 57), + QColor(111, 143, 175), + QColor(0, 163, 108), + Qt::green, + QColor(255, 195, 0), + QColor(199, 0, 57), + Qt::magenta, }; static Replay *getReplay() { @@ -50,7 +51,7 @@ VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) { updatePlayBtnState(); setWhatsThis(tr(R"( Video
- + Timeline color @@ -156,7 +157,7 @@ QWidget *VideoWidget::createCameraWidget() { slider->setTimeRange(can->minSeconds(), can->maxSeconds()); QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); }); - QObject::connect(can, &AbstractStream::paused, cam_widget, [c = cam_widget]() { c->showPausedOverlay(); }); + QObject::connect(can, &AbstractStream::paused, cam_widget, qOverload<>(&StreamCameraView::update)); QObject::connect(can, &AbstractStream::eventsMerged, this, [this]() { slider->update(); }); QObject::connect(cam_widget, &CameraWidget::clicked, []() { can->pause(!can->isPaused()); }); QObject::connect(cam_widget, &CameraWidget::vipcAvailableStreamsUpdated, this, &VideoWidget::vipcAvailableStreamsUpdated); @@ -202,7 +203,7 @@ void VideoWidget::timeRangeChanged() { QString VideoWidget::formatTime(double sec, bool include_milliseconds) { if (settings.absolute_time) - sec = can->beginDateTime().addMSecs(sec * 1000).toMSecsSinceEpoch() / 1000.0; + sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); } @@ -323,34 +324,40 @@ void Slider::mousePressEvent(QMouseEvent *e) { // StreamCameraView StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent) : CameraWidget(stream_name, stream_type, parent) { - fade_animation = new QPropertyAnimation(this, "overlayOpacity"); - fade_animation->setDuration(500); - fade_animation->setStartValue(0.2f); - fade_animation->setEndValue(0.7f); - fade_animation->setEasingCurve(QEasingCurve::InOutQuad); - connect(fade_animation, &QPropertyAnimation::valueChanged, this, QOverload<>::of(&StreamCameraView::update)); } void StreamCameraView::parseQLog(std::shared_ptr qlog) { std::mutex mutex; - QtConcurrent::blockingMap(qlog->events.cbegin(), qlog->events.cend(), [this, &mutex](const Event &e) { - if (e.which == cereal::Event::Which::THUMBNAIL) { - capnp::FlatArrayMessageReader reader(e.data); - auto thumb_data = reader.getRoot().getThumbnail(); - auto image_data = thumb_data.getThumbnail(); - if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) { - QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof())); - std::lock_guard lock(mutex); - thumbnails[thumb_data.getTimestampEof()] = generated_thumb; - big_thumbnails[thumb_data.getTimestampEof()] = thumb; + const auto &events = qlog->events; + unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); + size_t chunk = (events.size() + num_threads - 1) / num_threads; + std::vector threads; + for (unsigned int t = 0; t < num_threads && t * chunk < events.size(); ++t) { + size_t start = t * chunk; + size_t end = std::min(start + chunk, events.size()); + threads.emplace_back([this, &mutex, &events, start, end]() { + for (size_t i = start; i < end; ++i) { + const Event &e = events[i]; + if (e.which == cereal::Event::Which::THUMBNAIL) { + capnp::FlatArrayMessageReader reader(e.data); + auto thumb_data = reader.getRoot().getThumbnail(); + auto image_data = thumb_data.getThumbnail(); + if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) { + QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof())); + std::lock_guard lock(mutex); + thumbnails[thumb_data.getTimestampEof()] = generated_thumb; + big_thumbnails[thumb_data.getTimestampEof()] = thumb; + } + } } - } - }); + }); + } + for (auto &th : threads) th.join(); update(); } -void StreamCameraView::paintGL() { - CameraWidget::paintGL(); +void StreamCameraView::paintEvent(QPaintEvent *event) { + CameraWidget::paintEvent(event); QPainter p(this); bool scrubbing = false; @@ -363,7 +370,7 @@ void StreamCameraView::paintGL() { } if (can->isPaused()) { - p.setPen(QColor(200, 200, 200, static_cast(255 * fade_animation->currentValue().toFloat()))); + p.setPen(QColor(200, 200, 200, static_cast(255 * 0.7f))); p.setFont(QFont(font().family(), 16, QFont::Bold)); p.drawText(rect(), Qt::AlignCenter, tr("PAUSED")); } @@ -383,9 +390,9 @@ QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) { void StreamCameraView::drawScrubThumbnail(QPainter &p) { p.fillRect(rect(), Qt::black); - auto it = big_thumbnails.lowerBound(can->toMonoTime(thumbnail_dispaly_time)); + auto it = big_thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); if (it != big_thumbnails.end()) { - QPixmap scaled_thumb = it.value().scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); + QPixmap scaled_thumb = it->second.scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); QRect thumb_rect(rect().center() - scaled_thumb.rect().center(), scaled_thumb.size()); p.drawPixmap(thumb_rect.topLeft(), scaled_thumb); drawTime(p, thumb_rect, thumbnail_dispaly_time); @@ -393,9 +400,9 @@ void StreamCameraView::drawScrubThumbnail(QPainter &p) { } void StreamCameraView::drawThumbnail(QPainter &p) { - auto it = thumbnails.lowerBound(can->toMonoTime(thumbnail_dispaly_time)); + auto it = thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); if (it != thumbnails.end()) { - const QPixmap &thumb = it.value(); + const QPixmap &thumb = it->second; auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); int pos = (thumbnail_dispaly_time - min_sec) * width() / (max_sec - min_sec); int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1); diff --git a/tools/cabana/videowidget.h b/tools/cabana/videowidget.h index 6da00231..09f7a993 100644 --- a/tools/cabana/videowidget.h +++ b/tools/cabana/videowidget.h @@ -1,12 +1,12 @@ #pragma once +#include #include #include #include #include #include -#include #include #include #include @@ -35,8 +35,7 @@ class StreamCameraView : public CameraWidget { public: StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr); - void paintGL() override; - void showPausedOverlay() { fade_animation->start(); } + void paintEvent(QPaintEvent *event) override; void parseQLog(std::shared_ptr qlog); private: @@ -46,9 +45,8 @@ private: void drawScrubThumbnail(QPainter &p); void drawTime(QPainter &p, const QRect &rect, double seconds); - QPropertyAnimation *fade_animation; - QMap big_thumbnails; - QMap thumbnails; + std::map big_thumbnails; + std::map thumbnails; double thumbnail_dispaly_time = -1; friend class VideoWidget; }; diff --git a/tools/iqmacvisiond/README.md b/tools/iqmacvisiond/README.md new file mode 100644 index 00000000..63dffc39 --- /dev/null +++ b/tools/iqmacvisiond/README.md @@ -0,0 +1,54 @@ +# iqmacvisiond — IQ Vision offload server + +Runs the iqvd perception model on an Apple-Silicon Mac and serves 2D detections +to one IQ device over wifi. The device (`iqvd`) ships the camera frame, the Mac +runs YOLO on the Metal GPU/NPU, and only boxes come back — the device does no +inference, so vision dots no longer contend with the driving model. + +## Wire path + +``` +IQ device (auto-hotspot AP) ──wifi──▶ Mac (IQ Vision.app) + iqvd VisionClient iqmacvisiond server + read frame → downscale 640w cv2 decode → YOLOv8n (Metal) + JPEG encode → INFER ───────────────▶ detect + RESULT ◀─────────────────────────── {tracks: [2D boxes]} + publish iqVehicleTracks (dots) + publish iqEnvironment (3D via ground-plane + calibration) +``` + +- Discovery: the device UDP-broadcasts `IQVISION_DISCOVER_V1` on the subnet; the + Mac replies `IQVISION_HERE_V1:`. No config, no pairing. +- Protocol: `iqvd_private_src/offload/protocol.py` (length-prefixed frames, JSON + header + optional binary blob). Shared verbatim by both sides — it is the ABI. +- Ports: tcp/51998 inference, udp/51999 discovery, tcp/51995 localhost status. + +## The Mac app + +- Menu-bar app (`◎` waiting, `◉` connected). Menu shows device, frames served, + inference p50/p99, and **Quit**. +- Keeps the Mac awake while running (`caffeinate`). +- Ships the model in the dmg — no download on first run. +- First launch creates a small venv (numpy, opencv-headless, rumps); tinygrad is + bundled. + +## Build + +``` +tools/iqmacvisiond/macos/build_dmg.sh +``` + +Produces `IQ Vision.app` and `IQVision.dmg`. See `macos/SIGNING.md` for signing + +notarization. + +## Run from source (dev) + +``` +DEV=METAL python3 tools/iqmacvisiond/server.py # server only +python3 tools/iqmacvisiond/menubar.py # menu-bar wrapper +python3 tools/iqmacvisiond/test_offload.py # protocol/geometry/loopback +``` + +Gating on the device: `VisionVehicleTracks` enables iqvd; when `maciqmodeld` (the +eMac driving offload) is running, iqvd is Mac-or-nothing — it never runs local +inference. On non-eMac setups iqvd falls back to on-device YOLO if no Mac is found. diff --git a/tools/iqmacvisiond/macos/SIGNING.md b/tools/iqmacvisiond/macos/SIGNING.md new file mode 100644 index 00000000..dce30c50 --- /dev/null +++ b/tools/iqmacvisiond/macos/SIGNING.md @@ -0,0 +1,23 @@ +# Signing & notarization — IQ Vision.app + +Unsigned, the app runs after a right-click ▸ Open (Gatekeeper first-run). For +distribution, sign + notarize: + +```bash +APP="tools/iqmacvisiond/macos/dist/IQ Vision.app" +ENT="tools/iqmacvisiond/macos/entitlements.plist" +IDENTITY="Developer ID Application: ()" + +codesign --force --deep --options runtime --entitlements "$ENT" \ + --sign "$IDENTITY" "$APP" + +hdiutil create -volname "IQ Vision" -srcfolder "$(dirname "$APP")" -ov -format UDZO \ + tools/iqmacvisiond/macos/dist/IQVision.dmg + +xcrun notarytool submit tools/iqmacvisiond/macos/dist/IQVision.dmg \ + --apple-id "" --team-id "" --password "" --wait +xcrun stapler staple tools/iqmacvisiond/macos/dist/IQVision.dmg +``` + +The entitlements cover: JIT + unsigned exec memory + library-validation off +(tinygrad Metal JIT) and network server/client (LAN discovery + inference). diff --git a/tools/iqmacvisiond/macos/build_dmg.sh b/tools/iqmacvisiond/macos/build_dmg.sh new file mode 100755 index 00000000..8b44c0d1 --- /dev/null +++ b/tools/iqmacvisiond/macos/build_dmg.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" +REPO="$(cd "$HERE/../../.." >/dev/null && pwd)" +OUT="${1:-$HERE/dist}" +APP="$OUT/IQ Vision.app" + +rm -rf "$OUT" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +cat > "$APP/Contents/Info.plist" <<'PLIST' + + + + + CFBundleNameIQ Vision + CFBundleIdentifiercom.iqpilot.iqvision + CFBundleVersion1.0 + CFBundleShortVersionString1.0 + CFBundlePackageTypeAPPL + CFBundleExecutableiqvision + LSMinimumSystemVersion13.0 + LSUIElement + + +PLIST + +cat > "$APP/Contents/MacOS/iqvision" <<'LAUNCH' +#!/bin/bash +RES="$(cd "$(dirname "$0")/../Resources" && pwd)" +if [ ! -x "$HOME/Library/Application Support/IQVision/venv/bin/python" ]; then + osascript -e "tell application \"Terminal\" + activate + do script \"bash '$RES/macos/setup.sh'\" + end tell" +else + exec bash "$RES/macos/setup.sh" >/tmp/iqvision.log 2>&1 +fi +LAUNCH +chmod +x "$APP/Contents/MacOS/iqvision" + +RES="$APP/Contents/Resources" +SRC="$RES/openpilot/iqpilot/iqvd_private_src" +mkdir -p "$RES/tools/iqmacvisiond" "$RES/macos" "$SRC/offload" "$SRC/models" + +cp "$REPO/tools/iqmacvisiond/server.py" "$REPO/tools/iqmacvisiond/menubar.py" "$RES/tools/iqmacvisiond/" +cp "$HERE/setup.sh" "$RES/macos/" +cp "$REPO/iqpilot/iqvd_private_src/__init__.py" "$REPO/iqpilot/iqvd_private_src/yolov8_net.py" "$SRC/" +cp "$REPO/iqpilot/iqvd_private_src/offload/"*.py "$SRC/offload/" +cp "$REPO/iqpilot/iqvd_private_src/models/yolov8n.safetensors" "$SRC/models/" +touch "$RES/openpilot/__init__.py" "$RES/openpilot/iqpilot/__init__.py" "$RES/tools/__init__.py" \ + "$RES/tools/iqmacvisiond/__init__.py" +rsync -a --exclude=".git" --exclude="__pycache__" --exclude="extra" --exclude="test" \ + --exclude="examples" --exclude="docs" "$REPO/tinygrad_repo/" "$RES/tinygrad_repo/" + +hdiutil create -volname "IQ Vision" -srcfolder "$OUT" -ov -format UDZO "$OUT/IQVision.dmg" >/dev/null +echo "built: $OUT/IQVision.dmg ($(du -h "$OUT/IQVision.dmg" | cut -f1))" diff --git a/tools/iqmacvisiond/macos/entitlements.plist b/tools/iqmacvisiond/macos/entitlements.plist new file mode 100644 index 00000000..e1b1f7d8 --- /dev/null +++ b/tools/iqmacvisiond/macos/entitlements.plist @@ -0,0 +1,11 @@ + + + + + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.cs.disable-library-validation + com.apple.security.network.server + com.apple.security.network.client + + diff --git a/tools/iqmacvisiond/macos/setup.sh b/tools/iqmacvisiond/macos/setup.sh new file mode 100755 index 00000000..7776f377 --- /dev/null +++ b/tools/iqmacvisiond/macos/setup.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +set -euo pipefail + +RES="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null && pwd)" +SUPPORT="$HOME/Library/Application Support/IQVision" +VENV="$SUPPORT/venv" +PY="$VENV/bin/python" + +mkdir -p "$SUPPORT" + +if [ ! -x "$PY" ]; then + echo "Creating IQ Vision environment (one time)…" + /usr/bin/python3 -m venv "$VENV" + "$PY" -m pip install --upgrade --quiet pip + "$PY" -m pip install --quiet numpy "opencv-python-headless>=4.8" rumps +fi + +export PYTHONPATH="$RES:$RES/tinygrad_repo" +export DEV=METAL +exec "$PY" "$RES/tools/iqmacvisiond/menubar.py" diff --git a/tools/iqmacvisiond/menubar.py b/tools/iqmacvisiond/menubar.py new file mode 100755 index 00000000..1c4c203b --- /dev/null +++ b/tools/iqmacvisiond/menubar.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +import rumps + +HERE = Path(__file__).resolve().parent +STATUS_URL = "http://127.0.0.1:51995/status.json" +DASHBOARD_URL = "http://127.0.0.1:51995" + + +class IQVisionApp(rumps.App): + def __init__(self): + super().__init__("IQ Vision", title="◎", quit_button=None) + self.item_state = rumps.MenuItem("Starting…") + self.item_device = rumps.MenuItem("Device: —") + self.item_frames = rumps.MenuItem("Frames: —") + self.item_exec = rumps.MenuItem("Inference: —") + self.item_awake = rumps.MenuItem("Keep awake: —") + self.menu = [ + self.item_state, None, + self.item_device, self.item_frames, self.item_exec, self.item_awake, None, + rumps.MenuItem("Open Dashboard", callback=self.open_dashboard), + rumps.MenuItem("Quit IQ Vision", callback=self.quit_app), + ] + self.proc: subprocess.Popen | None = None + self._start_server() + self.timer = rumps.Timer(self.refresh, 1) + self.timer.start() + + def _start_server(self) -> None: + env = dict(os.environ) + self.proc = subprocess.Popen([sys.executable, str(HERE / "server.py")], env=env) + + def refresh(self, _) -> None: + if self.proc is not None and self.proc.poll() is not None: + self.title = "◎!" + self.item_state.title = "Server stopped — reopen the app" + return + try: + with urllib.request.urlopen(STATUS_URL, timeout=0.8) as r: + s = json.load(r) + except Exception: + self.title = "◎" + self.item_state.title = "Warming up…" + return + live = s.get("connected") and s.get("fresh") + self.title = "◉" if live else "◎" + self.item_state.title = "Connected" if live else "Waiting for device" + self.item_device.title = f"Device: {s.get('peer') or '—'}" + self.item_frames.title = f"Frames: {s.get('infer_count', 0):,}" + p50, p99 = s.get("exec_p50_ms", 0.0), s.get("exec_p99_ms", 0.0) + self.item_exec.title = f"Inference: {p50:.0f} / {p99:.0f} ms" if p50 else "Inference: —" + self.item_awake.title = f"Keep awake: {'on' if s.get('awake') else 'off'}" + + def open_dashboard(self, _) -> None: + subprocess.Popen(["open", DASHBOARD_URL]) + + def quit_app(self, _) -> None: + if self.proc is not None: + self.proc.terminate() + try: + self.proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.proc.kill() + rumps.quit_application() + + +if __name__ == "__main__": + IQVisionApp().run() diff --git a/tools/iqmacvisiond/server.py b/tools/iqmacvisiond/server.py new file mode 100755 index 00000000..2a3f5f2c --- /dev/null +++ b/tools/iqmacvisiond/server.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +""" +from __future__ import annotations + +import argparse +import json +import logging +import os +import signal +import socket +import subprocess +import sys +import threading +import time +from pathlib import Path + +os.environ.setdefault("DEV", "METAL") +os.environ.setdefault("JIT_BATCH_SIZE", "0") + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +import cv2 +import numpy as np + +from openpilot.iqpilot.iqvd_private_src.offload.protocol import ( + DISCOVERY_MAGIC, DISCOVERY_REPLY, DISCOVERY_PORT_DEFAULT, MSG_HELLO, MSG_HELLO_ACK, MSG_INFER, + MSG_PING, MSG_PONG, MSG_RESULT, ProtocolError, recv_msg, send_msg, +) +from openpilot.iqpilot.iqvd_private_src.offload.perception import Detector + +log = logging.getLogger("iqmacvisiond") + +DEFAULT_PORT = 51998 +STATUS_PORT = 51995 +MODEL_NAME = "yolov8n" +SESSION_IDLE_TIMEOUT_S = 8.0 + +STATUS: dict = { + "connected": False, "peer": "", "model": MODEL_NAME, + "exec_p50_ms": 0.0, "exec_p99_ms": 0.0, "infer_count": 0, + "last_seen": 0.0, "awake": False, +} + + +class KeepAwake: + def __init__(self): + self._proc: subprocess.Popen | None = None + + def start(self) -> None: + if sys.platform != "darwin" or self._proc is not None: + return + try: + self._proc = subprocess.Popen(["caffeinate", "-dimsu"]) + STATUS["awake"] = True + log.info("keep-awake active (caffeinate pid=%d)", self._proc.pid) + except OSError: + log.warning("caffeinate unavailable; display may sleep") + + def stop(self) -> None: + if self._proc is not None: + self._proc.terminate() + self._proc = None + STATUS["awake"] = False + + +class DiscoveryResponder: + def __init__(self, tcp_port: int, disc_port: int = DISCOVERY_PORT_DEFAULT): + self.tcp_port = tcp_port + self.disc_port = disc_port + + def start(self) -> None: + threading.Thread(target=self._serve, daemon=True).start() + + def _serve(self) -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("0.0.0.0", self.disc_port)) + except OSError: + log.exception("discovery bind failed on udp/%d", self.disc_port) + return + reply = DISCOVERY_REPLY + f":{self.tcp_port}".encode() + log.info("discovery responder on udp/%d -> tcp/%d", self.disc_port, self.tcp_port) + while True: + try: + data, addr = sock.recvfrom(256) + except OSError: + continue + if data.startswith(DISCOVERY_MAGIC): + try: + sock.sendto(reply, addr) + except OSError: + pass + + +class StatusServer: + def __init__(self, port: int = STATUS_PORT): + self.port = port + + def start(self) -> None: + threading.Thread(target=self._serve, daemon=True).start() + + def _serve(self) -> None: + from http.server import BaseHTTPRequestHandler, HTTPServer + + class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, ctype, body): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path.startswith("/status.json"): + st = dict(STATUS) + st["fresh"] = (time.time() - st["last_seen"]) < 6 if st["last_seen"] else False + self._send(200, "application/json", json.dumps(st).encode()) + else: + self._send(200, "text/html; charset=utf-8", _STATUS_HTML.encode()) + + try: + HTTPServer(("127.0.0.1", self.port), H).serve_forever() + except OSError: + log.exception("status server failed on %d", self.port) + + +_STATUS_HTML = """ +IQ Vision +
+

IQ Vision

+
Device
+
Model
+
Inference (p50 / p99)
+
Frames served
+
Keep awake
+
""" + + +class Session: + def __init__(self, conn: socket.socket, detector: Detector): + self.conn = conn + self.detector = detector + try: + self.peer = conn.getpeername()[0] + except OSError: + self.peer = "" + self.infer_count = 0 + self.exec_ms: list[float] = [] + + def handshake(self) -> bool: + msg_type, header, _ = recv_msg(self.conn) + if msg_type != MSG_HELLO: + raise ProtocolError(f"expected HELLO, got {msg_type}") + send_msg(self.conn, MSG_HELLO_ACK, {"ok": True, "model": MODEL_NAME, "hostname": socket.gethostname()}) + STATUS.update(connected=True, peer=self.peer, last_seen=time.time()) + log.info("device connected: %s dongle=%s", self.peer, header.get("dongle_id", "")) + return True + + def serve(self) -> None: + while True: + msg_type, header, blob = recv_msg(self.conn) + if msg_type == MSG_INFER: + self._infer(header, blob) + elif msg_type == MSG_PING: + send_msg(self.conn, MSG_PONG, {}) + else: + raise ProtocolError(f"unexpected message type {msg_type}") + + def _infer(self, header: dict, jpeg: bytes) -> None: + st = time.perf_counter() + tracks = [] + try: + rgb = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR) + if rgb is not None: + tracks = self.detector.detect(cv2.cvtColor(rgb, cv2.COLOR_BGR2RGB)) + except Exception: + log.exception("inference failed for frame %s", header.get("frame_id")) + dt = (time.perf_counter() - st) * 1e3 + send_msg(self.conn, MSG_RESULT, {"frame_id": header.get("frame_id", 0), "tracks": tracks, + "exec_ms": dt}) + self.infer_count += 1 + self.exec_ms.append(dt) + if len(self.exec_ms) > 400: + del self.exec_ms[:200] + if self.infer_count % 10 == 0 or self.infer_count == 1: + recent = self.exec_ms[-200:] + STATUS.update(connected=True, infer_count=self.infer_count, last_seen=time.time(), + exec_p50_ms=float(np.percentile(recent, 50)), + exec_p99_ms=float(np.percentile(recent, 99))) + + +def _weights_ok() -> bool: + from openpilot.iqpilot.iqvd_private_src.offload.perception import _weights_dir + return (_weights_dir() / "yolov8n.safetensors").exists() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--no-keep-awake", action="store_true") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") + + if not _weights_ok(): + log.error("yolov8n.safetensors not found; the app ships the model with it") + sys.exit(1) + + keep_awake = KeepAwake() + if not args.no_keep_awake: + keep_awake.start() + + def _shutdown(*_): + keep_awake.stop() + sys.exit(0) + try: + signal.signal(signal.SIGTERM, _shutdown) + signal.signal(signal.SIGINT, _shutdown) + except ValueError: + pass + + t0 = time.perf_counter() + detector = Detector(None) + for _ in range(3): + detector.detect(np.zeros((416, 640, 3), dtype=np.uint8)) + log.info("model warm in %.1fs", time.perf_counter() - t0) + + DiscoveryResponder(args.port).start() + StatusServer().start() + log.info("status dashboard on http://127.0.0.1:%d", STATUS_PORT) + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((args.host, args.port)) + server.listen(1) + log.info("READY listening on %s:%d", args.host, args.port) + + try: + while True: + conn, addr = server.accept() + conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + conn.settimeout(SESSION_IDLE_TIMEOUT_S) + try: + session = Session(conn, detector) + if session.handshake(): + session.serve() + except (ConnectionError, ProtocolError, OSError) as e: + log.info("session ended: %s", e) + finally: + conn.close() + STATUS.update(connected=False, peer="") + finally: + keep_awake.stop() + + +if __name__ == "__main__": + main() diff --git a/tools/iqmacvisiond/test_offload.py b/tools/iqmacvisiond/test_offload.py new file mode 100755 index 00000000..a6fc1fbe --- /dev/null +++ b/tools/iqmacvisiond/test_offload.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +""" +import socket +import sys +import threading +import time +from pathlib import Path + +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from openpilot.iqpilot.iqvd_private_src.offload import protocol +from openpilot.iqpilot.iqvd_private_src.offload.client import VisionClient, discover_server +from openpilot.iqpilot.iqvd_private_src.offload.geometry import pixel_to_ground, tracks_to_objects + + +def test_protocol_roundtrip(): + a, b = socket.socketpair() + protocol.send_msg(a, protocol.MSG_INFER, {"frame_id": 7, "w": 640}, b"\x00\x01\x02payload") + mt, header, blob = protocol.recv_msg(b) + assert mt == protocol.MSG_INFER + assert header == {"frame_id": 7, "w": 640} + assert blob == b"\x00\x01\x02payload" + a.close() + b.close() + + +def test_protocol_empty_blob(): + a, b = socket.socketpair() + protocol.send_msg(a, protocol.MSG_HELLO_ACK, {"ok": True, "model": "yolov8n"}) + mt, header, blob = protocol.recv_msg(b) + assert mt == protocol.MSG_HELLO_ACK and header["model"] == "yolov8n" and blob == b"" + a.close() + b.close() + + +def test_geometry_center_projects_forward(): + intr = np.array([[900.0, 0.0, 960.0], [0.0, 900.0, 604.0], [0.0, 0.0, 1.0]]) + device_from_calib = np.eye(3) + p = pixel_to_ground(960.0, 900.0, intr, device_from_calib, 1.22) + assert p is not None + assert p[0] > 0 + assert abs(p[1]) < 1.0 + + +def test_geometry_above_horizon_none(): + intr = np.array([[900.0, 0.0, 960.0], [0.0, 900.0, 604.0], [0.0, 0.0, 1.0]]) + assert pixel_to_ground(960.0, 100.0, intr, np.eye(3), 1.22) is None + + +def test_tracks_to_objects(): + intr = np.array([[900.0, 0.0, 960.0], [0.0, 900.0, 604.0], [0.0, 0.0, 1.0]]) + tracks = [{"x1": 0.45, "y1": 0.5, "x2": 0.55, "y2": 0.75, "prob": 0.9, "label": "car"}] + objs = tracks_to_objects(tracks, 1928, 1208, intr, [0.0, 0.0, 0.0]) + assert len(objs) == 1 + assert objs[0]["x"] > 0 and objs[0]["label"] == "car" + + +class _StubDetector: + def detect(self, rgb): + return [{"x1": 0.1, "y1": 0.2, "x2": 0.3, "y2": 0.5, "prob": 0.8, "label": "car"}] + + +def _run_server(port, ready): + from openpilot.iqpilot.iqvd_private_src.offload import protocol as p + import tools.iqmacvisiond.server as srv + disc = srv.DiscoveryResponder(port) + disc.start() + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("0.0.0.0", port)) + server.listen(1) + ready.set() + conn, _ = server.accept() + conn.settimeout(5) + session = srv.Session(conn, _StubDetector()) + session.handshake() + try: + session.serve() + except (p.ProtocolError, OSError): + pass + + +def test_discovery_and_loopback(): + port = 52050 + ready = threading.Event() + threading.Thread(target=_run_server, args=(port, ready), daemon=True).start() + assert ready.wait(5) + time.sleep(0.2) + + found = discover_server(timeout=2.0) + assert found is not None, "discovery failed" + assert found[1] == port + + import cv2 + ok, jpeg = cv2.imencode(".jpg", np.zeros((400, 640, 3), dtype=np.uint8)) + assert ok + + client = VisionClient("test-dongle") + assert client.connect(), "connect failed" + meta = {"frame_id": 42, "wide": False, "w": 640, "h": 400} + tracks = client.infer(jpeg.tobytes(), meta) + assert tracks is not None and len(tracks) == 1 + assert tracks[0]["label"] == "car" + client.close() + + +if __name__ == "__main__": + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + for fn in fns: + fn() + print(f"ok {fn.__name__}") + print(f"\n{len(fns)} passed") diff --git a/tools/jotpluggler/SConscript b/tools/jotpluggler/SConscript index 06d5e110..8d84fbc9 100644 --- a/tools/jotpluggler/SConscript +++ b/tools/jotpluggler/SConscript @@ -17,12 +17,14 @@ from iqdbc.car.fingerprints import MIGRATION from iqdbc.car.values import PLATFORMS from openpilot.common.basedir import BASEDIR -Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib') +Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') jot_env = env.Clone() # imgui.MESA_DIR only exists on the larch64 wheel; skip missing paths to avoid an ld search-path warning jot_env["LIBPATH"] += [p for p in [imgui.MESA_DIR, libusb.LIB_DIR] if os.path.isdir(p)] jot_env["CPPPATH"] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR] +# IQ.Pilot patch: no comma-deps-bootstrap-icons wheel here, so BOOTSTRAP_ICONS_TTF is +# not defined; icons.cc reads the TTF vendored under tools/jotpluggler/assets instead. jot_env["CXXFLAGS"] += [ "-DGLFW_INCLUDE_NONE", '-DJOTP_REPO_ROOT=\'"%s"\'' % os.path.realpath(BASEDIR), @@ -104,16 +106,15 @@ event_extractors = jot_env.Command("generated_event_extractors.h", [ jot_env.PrettyAction(generate_event_extractors, 'GEN'), ) -libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a"), - # IQ.Pilot patch: iqpilot's replay_lib resolves URL/api work via libcurl - # (vs upstream's Python downloader), so jotpluggler needs to link curl too. - # iqpilot's api.cc additionally uses OpenSSL primitives (PEM/RSA/SHA256) for - # JWT signing, and visionipc references OpenCL — both must also be linked. - "avformat", "avcodec", "avutil", "x264", "yuv", "z", "bz2", "zstd", "curl", "ssl", "crypto", "m", "pthread", "usb-1.0"] +# IQ.Pilot patch: iqpilot's replay_lib resolves route/API work via libcurl (vs upstream's +# Python downloader), so jotpluggler needs curl; api.cc signs konn3kt JWTs with OpenSSL +# primitives, and visionipc references OpenCL. Both must also be linked. +libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a")] + \ + ffmpeg_libs + ["bz2", "zstd", "curl", "ssl", "crypto", "m", "pthread", "usb-1.0"] if arch == "Darwin": jot_env["FRAMEWORKS"] = ["OpenGL", "OpenCL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"] else: - libs += ["GL", "OpenCL", "dl", "va", "va-drm", "drm"] + libs += ["GL", "OpenCL", "dl"] program = jot_env.Program("jotpluggler", jot_env.Glob("*.cc"), LIBS=libs) jot_env.Depends(program, generated_dbc_stamp) diff --git a/tools/jotpluggler/app.cc b/tools/jotpluggler/app.cc index efc84849..a55e50b9 100644 --- a/tools/jotpluggler/app.cc +++ b/tools/jotpluggler/app.cc @@ -3,6 +3,7 @@ #include "tools/jotpluggler/common.h" #include "tools/jotpluggler/internal.h" #include "tools/jotpluggler/map.h" +#include "tools/jotpluggler/thumbnail.h" #include "system/hardware/hw.h" #include "imgui_impl_glfw.h" @@ -1033,10 +1034,12 @@ bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pa if (pane.title == UNTITLED_PANE_TITLE || previous_kind != PaneKind::Plot) { pane.title = spec->label; } - } else { + } else if (spec->kind == PaneKind::Camera) { pane.title = spec->label; resize_tab_pane_state(tab_state, tab->panes.size()); tab_state->camera_panes[static_cast(pane_index)].fit_to_pane = true; + } else { + pane.title = spec->label; } tab_state->active_pane_index = pane_index; return true; @@ -1565,6 +1568,8 @@ void draw_pane_windows(AppSession *session, UiState *state) { } if (pane.kind == PaneKind::Map) { draw_map_pane(session, state, &pane, static_cast(i)); + } else if (pane.kind == PaneKind::Thumbnail) { + draw_thumbnail_pane(session, state); } else if (pane.kind == PaneKind::Camera) { draw_camera_pane(session, state, tab_state, static_cast(i), pane); } else { @@ -1847,6 +1852,7 @@ int run(const Options &options) { for (std::unique_ptr &feed : session.pane_camera_feeds) { feed = std::make_unique(); } + session.thumbnail_view = std::make_unique(); sync_camera_feeds(&session); if (session.async_route_loading) { @@ -1892,6 +1898,7 @@ int run(const Options &options) { for (std::unique_ptr &feed : session.pane_camera_feeds) { feed.reset(); } + session.thumbnail_view.reset(); return 0; } catch (const std::exception &err) { std::cerr << err.what() << "\n"; diff --git a/tools/jotpluggler/app.h b/tools/jotpluggler/app.h index 872a6973..c7b6fb7c 100644 --- a/tools/jotpluggler/app.h +++ b/tools/jotpluggler/app.h @@ -81,6 +81,7 @@ struct Curve { enum class PaneKind : uint8_t { Plot, Map, + Thumbnail, Camera, }; @@ -141,9 +142,15 @@ struct CameraFeedIndex { std::vector entries; }; +struct ThumbnailFrame { + double timestamp = 0.0; + int segment = -1; + std::vector jpeg; +}; + enum class LogOrigin : uint8_t { Log, - Android, + OperatingSystem, Alert, }; @@ -318,6 +325,7 @@ struct RouteData { CameraFeedIndex driver_camera; CameraFeedIndex wide_road_camera; CameraFeedIndex qroad_camera; + std::vector thumbnails; GpsTrace gps_trace; std::vector logs; std::vector timeline; @@ -445,6 +453,7 @@ bool icon_menu_item(const char *glyph, class AsyncRouteLoader; class CameraFeedView; +class ThumbnailView; class StreamPoller; class MapDataManager; @@ -486,6 +495,7 @@ struct AppSession { std::unique_ptr route_loader; std::unique_ptr stream_poller; std::array, 4> pane_camera_feeds; + std::unique_ptr thumbnail_view; std::unique_ptr map_data; bool async_route_loading = false; double next_stream_custom_refresh_time = 0.0; @@ -885,3 +895,20 @@ private: struct Impl; std::unique_ptr impl_; }; + +class ThumbnailView { +public: + ThumbnailView(); + ~ThumbnailView(); + + ThumbnailView(const ThumbnailView &) = delete; + ThumbnailView &operator=(const ThumbnailView &) = delete; + + void setThumbnails(const std::vector &thumbnails); + void update(double tracker_time); + void drawSized(ImVec2 size, bool loading); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/tools/jotpluggler/common.cc b/tools/jotpluggler/common.cc index 74ca8f5e..41d444ce 100644 --- a/tools/jotpluggler/common.cc +++ b/tools/jotpluggler/common.cc @@ -47,11 +47,11 @@ const char *special_item_label(std::string_view item_id) { } bool pane_kind_is_special(PaneKind kind) { - return kind == PaneKind::Map || kind == PaneKind::Camera; + return kind == PaneKind::Map || kind == PaneKind::Thumbnail || kind == PaneKind::Camera; } bool is_default_special_title(std::string_view title) { - if (title == "Map") return true; + if (title == "Map" || title == "Thumbnail") return true; return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { return title == spec.label; }); @@ -162,14 +162,12 @@ void open_external_url(std::string_view url) { } } -std::string route_useradmin_url(const RouteIdentifier &route_id) { - return route_id.empty() ? std::string() - : "https://useradmin.comma.ai/?onebox=" + route_id.dongle_id + "%7C" + route_id.log_id; -} - -std::string route_connect_url(const RouteIdentifier &route_id) { - return route_id.empty() ? std::string() - : "https://connect.comma.ai/" + route_id.canonical(); +// IQ.Pilot patch: iqpilot routes live on konn3kt, not comma connect. konn3kt has no +// useradmin equivalent, so that link is gone; the share link keeps connect's +// / path shape (see tools/lib/logreader.py parse_indirect). +std::string route_konn3kt_url(const RouteIdentifier &route_id) { + static const std::string host = util::getenv("KONN3KT_APP_HOST", "https://konn3kt.com"); + return route_id.empty() ? std::string() : host + "/" + route_id.canonical(); } std::string route_google_maps_url(const GpsTrace &trace) { diff --git a/tools/jotpluggler/common.h b/tools/jotpluggler/common.h index 14db83fd..b1f85caf 100644 --- a/tools/jotpluggler/common.h +++ b/tools/jotpluggler/common.h @@ -28,8 +28,9 @@ inline constexpr std::array kCameraViewSpecs = {{ {CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera}, }}; -inline constexpr std::array kSpecialItemSpecs = {{ +inline constexpr std::array kSpecialItemSpecs = {{ {"map", "Map", PaneKind::Map, CameraViewKind::Road}, + {"thumbnail", "Thumbnail", PaneKind::Thumbnail, CameraViewKind::Road}, {kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view}, {kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view}, {kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view}, @@ -62,6 +63,5 @@ bool app_begin_popup_modal(const char *name, bool *p_open = nullptr, ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize); void open_external_url(std::string_view url); -std::string route_useradmin_url(const RouteIdentifier &route_id); -std::string route_connect_url(const RouteIdentifier &route_id); +std::string route_konn3kt_url(const RouteIdentifier &route_id); std::string route_google_maps_url(const GpsTrace &trace); diff --git a/tools/jotpluggler/dbc.h b/tools/jotpluggler/dbc.h index f8fe4f7c..4de10bd2 100644 --- a/tools/jotpluggler/dbc.h +++ b/tools/jotpluggler/dbc.h @@ -276,6 +276,7 @@ inline void Database::parseSg(const std::string &line, int line_number, Message signal.type = Signal::Type::Multiplexor; } else if (!indicator.empty() && indicator.front() == 'm') { signal.type = Signal::Type::Multiplexed; + // IQ.Pilot patch: guard a bare "m" indicator; std::stoi("") throws. const std::string mux = indicator.substr(1); signal.multiplex_value = mux.empty() ? 0 : std::stoi(mux); } else { diff --git a/tools/jotpluggler/generate_event_extractors.py b/tools/jotpluggler/generate_event_extractors.py index a2deaed3..c68a4b6a 100644 --- a/tools/jotpluggler/generate_event_extractors.py +++ b/tools/jotpluggler/generate_event_extractors.py @@ -62,6 +62,8 @@ class Generator: def __init__(self, event_schema): self.event_schema = event_schema self.fixed_paths = [] + self.event_base_slots = {} + self.static_enums = [] self.tmp_index = 0 self.lines = [] self.emits_memo = {} @@ -103,9 +105,13 @@ class Generator: self.emit(indent, f"append_dynamic_scalar_point({path_expr}, tm, {double_expr}, series);") else: slot = self.add_fixed_path(path) - if kind == "Enum": - self.emit_enum_capture(indent, cxx_string(path), enum_names(schema)) - self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") + names = enum_names(schema) if kind == "Enum" else [] + if names: + enum_index = len(self.static_enums) + self.static_enums.append(names) + self.emit(indent, f"append_fixed_enum_point({slot}, {enum_index}, tm, {double_expr}, series);") + else: + self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") return if type_kind == "struct": @@ -144,9 +150,13 @@ class Generator: self.emit(indent, f"if ({' && '.join(conditions)}) {{") indent += 2 - value_var = self.tmp("value") - self.emit(indent, f"const auto {value_var} = {get_call};") - self.emit_node(indent, type_kind, type_proto, value_schema, value_var, field_path, field_path_expr, dynamic_path) + # Scalar getters are only consumed once. Emitting them directly avoids + # thousands of single-use locals in the generated extractor. + value_expr = get_call + if kind is None: + value_expr = self.tmp("value") + self.emit(indent, f"const auto {value_expr} = {get_call};") + self.emit_node(indent, type_kind, type_proto, value_schema, value_expr, field_path, field_path_expr, dynamic_path) if conditions: indent -= 2 @@ -249,35 +259,47 @@ class Generator: self.emit(indent + 2, "}") self.emit(indent, "}") self.emit(indent, "if (skip_raw_can) {") - self.emit(indent + 2, "return true;") + self.emit(indent + 2, "return;") self.emit(indent, "}") - def emit_event_case(self, field_name): + def emit_event_reader(self, field_name): field = self.event_schema.fields[field_name] proto = field.proto type_kind = field_type(field) type_proto = field_type_proto(field) kind = scalar_kind(type_proto) schema = field.schema if kind == "Enum" or type_kind in NESTED_TYPE_KINDS else None - self.emit(4, f"case static_cast({proto.discriminantValue}): {{") valid_slot = self.add_fixed_path(f"/{field_name}/valid") - mono_slot = self.add_fixed_path(f"/{field_name}/logMonoTime") - seconds_slot = self.add_fixed_path(f"/{field_name}/t") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{valid_slot}], tm, event.getValid() ? 1.0 : 0.0);") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{mono_slot}], tm, static_cast(event.getLogMonoTime()));") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{seconds_slot}], tm, tm);") - if field_name in {"can", "sendcan"}: - self.emit_can_special(6, field_name) - if self.node_emits(type_kind, type_proto, schema): + self.add_fixed_path(f"/{field_name}/logMonoTime") + self.add_fixed_path(f"/{field_name}/t") + self.event_base_slots[proto.discriminantValue] = valid_slot + + emits_payload = self.node_emits(type_kind, type_proto, schema) + if field_name not in {"can", "sendcan"} and not emits_payload: + return None + + reader_name = f"append_event_{proto.discriminantValue}" + needs_can = field_name in {"can", "sendcan"} + header_index = len(self.lines) + self.emit(0, "") + if needs_can: + self.emit_can_special(2, field_name) + if emits_payload: payload = self.tmp("payload") - self.emit(6, f"const auto {payload} = event.{accessor('get', field_name)}();") - self.emit_node(6, type_kind, type_proto, schema, payload, f"/{field_name}", None, False) - self.emit(6, "return true;") - self.emit(4, "}") + self.emit(2, f"const auto {payload} = event.{accessor('get', field_name)}();") + self.emit_node(2, type_kind, type_proto, schema, payload, f"/{field_name}", None, False) + self.emit(0, "}") + self.emit(0, "") + if needs_can: + signature = "const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double tm, SeriesAccumulator *series" + else: + signature = "const cereal::Event::Reader &event, double tm, SeriesAccumulator *series" + self.lines[header_index] = f"__attribute__((noinline)) void {reader_name}({signature}) {{" + return reader_name, needs_can def generate(self): self.lines = [] - self.emit(0, "// Generated by tools/jotpluggler/generate_event_extractors.py; do not edit.") + self.emit(0, "// Generated by openpilot/tools/jotpluggler/generate_event_extractors.py; do not edit.") self.emit(0, "") self.emit(0, "const std::vector &static_event_fixed_paths() {") self.emit(2, "static const std::vector paths = {") @@ -300,11 +322,66 @@ class Generator: self.emit(2, "}") self.emit(0, "}") self.emit(0, "") + self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series);") # noqa: E501 + self.emit(0, "") + + self.emit(0, "// Keep each event payload behind its own optimizer boundary. Combining the") + self.emit(0, "// whole schema into one function creates much more code and runs slower.") + event_readers = {} + for field_name in self.event_schema.union_fields: + event_readers[field_name] = self.emit_event_reader(field_name) + + self.emit(0, "static const std::initializer_list static_event_enum_names[] = {") + for names in self.static_enums: + names_expr = "{" + ", ".join(cxx_string(name) for name in names) + "}" + self.emit(2, f"{names_expr},") + self.emit(0, "};") + self.emit(0, "") + self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series) {") # noqa: E501 + self.emit(2, "RouteSeries *fixed_series = &series->fixed_series[series_slot];") + self.emit(2, "capture_static_enum_info(fixed_series->path, static_event_enum_names[enum_index], series);") + self.emit(2, "fixed_series->times.push_back(tm);") + self.emit(2, "fixed_series->values.push_back(value);") + self.emit(0, "}") + self.emit(0, "") + self.emit(0, "bool append_event_static_reader(cereal::Event::Which which, const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double time_offset, SeriesAccumulator *series) {") # noqa: E501 - self.emit(2, "const double tm = static_cast(event.getLogMonoTime()) / 1.0e9 - time_offset;") + self.emit(2, "const auto log_mono_time = event.getLogMonoTime();") + self.emit(2, "const double tm = static_cast(log_mono_time) / 1.0e9 - time_offset;") + + invalid_slot = "static_cast(-1)" + max_discriminant = max(self.event_base_slots) + base_slots = [self.event_base_slots.get(i, invalid_slot) for i in range(max_discriminant + 1)] + self.emit(2, "static constexpr size_t event_base_slots[] = {") + for slot in base_slots: + self.emit(4, f"{slot},") + self.emit(2, "};") + self.emit(2, "const size_t event_index = static_cast(which);") + self.emit(2, "if (event_index >= sizeof(event_base_slots) / sizeof(event_base_slots[0])) {") + self.emit(4, "return false;") + self.emit(2, "}") + self.emit(2, "const size_t base_slot = event_base_slots[event_index];") + self.emit(2, f"if (base_slot == {invalid_slot}) {{") + self.emit(4, "return false;") + self.emit(2, "}") + self.emit(2, "RouteSeries *base_series = &series->fixed_series[base_slot];") + self.emit(2, "base_series[0].times.push_back(tm);") + self.emit(2, "base_series[0].values.push_back(event.getValid() ? 1.0 : 0.0);") + self.emit(2, "base_series[1].times.push_back(tm);") + self.emit(2, "base_series[1].values.push_back(static_cast(log_mono_time));") + self.emit(2, "base_series[2].times.push_back(tm);") + self.emit(2, "base_series[2].values.push_back(tm);") self.emit(2, "switch (which) {") for field_name in self.event_schema.union_fields: - self.emit_event_case(field_name) + field = self.event_schema.fields[field_name] + self.emit(4, f"case static_cast({field.proto.discriminantValue}):") + reader = event_readers[field_name] + if reader is not None: + if reader[1]: + self.emit(6, f"{reader[0]}(event, can_dbc, skip_raw_can, tm, series);") + else: + self.emit(6, f"{reader[0]}(event, tm, series);") + self.emit(6, "return true;") self.emit(4, "default:") self.emit(6, "return false;") self.emit(2, "}") @@ -323,6 +400,8 @@ if __name__ == "__main__": repo_root = Path(sys.argv[1]).resolve() output = Path(sys.argv[2]) capnp.remove_import_hook() + # IQ.Pilot patch: iqpilot's tree is not nested under openpilot/ and car.capnp lives + # in cereal/ alongside log.capnp, so no extra import path is needed. log = capnp.load(str(repo_root / "cereal" / "log.capnp")) generated = Generator(log.Event.schema).generate() output.parent.mkdir(parents=True, exist_ok=True) diff --git a/tools/jotpluggler/icons.cc b/tools/jotpluggler/icons.cc index 1bbc2cdc..df8cedc3 100644 --- a/tools/jotpluggler/icons.cc +++ b/tools/jotpluggler/icons.cc @@ -6,7 +6,7 @@ void icon_add_font(float size, bool merge, const ImFont *base_font) { // IQ.Pilot patch: iqpilot's third_party/bootstrap is git-lfs and the TTF isn't // checked in, so we vendor the font alongside jotpluggler instead of relying on - // third_party/bootstrap/bootstrap-icons.ttf. + // upstream's BOOTSTRAP_ICONS_TTF define from the comma-deps-bootstrap-icons wheel. const std::filesystem::path ttf = repo_root() / "tools" / "jotpluggler" / "assets" / "bootstrap-icons.ttf"; ImGuiIO &io = ImGui::GetIO(); ImFontConfig config; diff --git a/tools/jotpluggler/layout_io.cc b/tools/jotpluggler/layout_io.cc index f984c0f0..e8980156 100644 --- a/tools/jotpluggler/layout_io.cc +++ b/tools/jotpluggler/layout_io.cc @@ -62,6 +62,8 @@ json11::Json workspace_node_to_json(const WorkspaceNode &node, const WorkspaceTa }; if (pane.kind == PaneKind::Map) { obj["kind"] = "map"; + } else if (pane.kind == PaneKind::Thumbnail) { + obj["kind"] = "thumbnail"; } else if (pane.kind == PaneKind::Camera) { obj["kind"] = "camera"; obj["camera_view"] = camera_view_spec(pane.camera_view).layout_name; diff --git a/tools/jotpluggler/runtime.cc b/tools/jotpluggler/runtime.cc index bfc30d70..ea103eaf 100644 --- a/tools/jotpluggler/runtime.cc +++ b/tools/jotpluggler/runtime.cc @@ -7,7 +7,7 @@ #include "imgui_impl_opengl3.h" #include "imgui_impl_opengl3_loader.h" #include "implot.h" -#include "libyuv.h" +#include "common/yuv.h" #include "msgq_repo/msgq/ipc.h" #include "tools/replay/framereader.h" @@ -1173,18 +1173,14 @@ struct CameraFeedView::Impl { result.width = reader->width; result.height = reader->height; result.rgba.resize(static_cast(result.width) * static_cast(result.height) * 4U, 0); - // IQ.Pilot patch: vendored libyuv lacks NV12ToABGR; use NV12ToARGB + in-place ARGBToABGR swap. - libyuv::NV12ToARGB(decode_buffer.y, - static_cast(decode_buffer.stride), - decode_buffer.uv, - static_cast(decode_buffer.stride), - result.rgba.data(), - result.width * 4, - result.width, - result.height); - libyuv::ARGBToABGR(result.rgba.data(), result.width * 4, - result.rgba.data(), result.width * 4, - result.width, result.height); + yuv::nv12_to_rgba(decode_buffer.y, + static_cast(decode_buffer.stride), + decode_buffer.uv, + static_cast(decode_buffer.stride), + result.rgba.data(), + result.width * 4, + result.width, + result.height); result.success = true; result.decode_ms = std::chrono::duration(std::chrono::steady_clock::now() - decode_begin).count(); publish_result(*request, std::move(result)); @@ -1207,18 +1203,14 @@ struct CameraFeedView::Impl { .height = reader->height, }; prefetched.rgba.resize(static_cast(prefetched.width) * static_cast(prefetched.height) * 4U, 0); - // IQ.Pilot patch: vendored libyuv lacks NV12ToABGR; use NV12ToARGB + in-place ARGBToABGR swap. - libyuv::NV12ToARGB(decode_buffer.y, - static_cast(decode_buffer.stride), - decode_buffer.uv, - static_cast(decode_buffer.stride), - prefetched.rgba.data(), - prefetched.width * 4, - prefetched.width, - prefetched.height); - libyuv::ARGBToABGR(prefetched.rgba.data(), prefetched.width * 4, - prefetched.rgba.data(), prefetched.width * 4, - prefetched.width, prefetched.height); + yuv::nv12_to_rgba(decode_buffer.y, + static_cast(decode_buffer.stride), + decode_buffer.uv, + static_cast(decode_buffer.stride), + prefetched.rgba.data(), + prefetched.width * 4, + prefetched.width, + prefetched.height); remember_cached_result(prefetched); } } diff --git a/tools/jotpluggler/session.cc b/tools/jotpluggler/session.cc index beb0a292..bb5edd68 100644 --- a/tools/jotpluggler/session.cc +++ b/tools/jotpluggler/session.cc @@ -19,6 +19,9 @@ void sync_camera_feeds(AppSession *session) { session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view); } } + if (session->thumbnail_view) { + session->thumbnail_view->setThumbnails(session->route_data.thumbnails); + } } void apply_route_data(AppSession *session, UiState *state, RouteData route_data) { @@ -456,8 +459,7 @@ void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) { const char *copy_icon = icon::CLIPBOARD; const char *link_icon = icon::BOX_ARROW_UP_RIGHT; - const std::string useradmin_label = std::string("Useradmin ") + link_icon; - const std::string connect_label = std::string("comma connect ") + link_icon; + const std::string konn3kt_label = std::string("konn3kt ") + link_icon; if (ImGui::Button(copy_icon, ImVec2(34.0f, 26.0f))) { ImGui::SetClipboardText(session->route_id.canonical().c_str()); state->status_text = "Copied route to clipboard"; @@ -470,14 +472,9 @@ void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) { ImGui::EndTooltip(); } ImGui::SameLine(); - if (ImGui::Button(useradmin_label.c_str(), ImVec2(132.0f, 26.0f))) { - open_external_url(route_useradmin_url(session->route_id)); - state->status_text = "Opened useradmin"; - } - ImGui::SameLine(); - if (ImGui::Button(connect_label.c_str(), ImVec2(156.0f, 26.0f))) { - open_external_url(route_connect_url(session->route_id)); - state->status_text = "Opened comma connect"; + if (ImGui::Button(konn3kt_label.c_str(), ImVec2(120.0f, 26.0f))) { + open_external_url(route_konn3kt_url(session->route_id)); + state->status_text = "Opened konn3kt"; } ImGui::Spacing(); diff --git a/tools/jotpluggler/sketch_layout.cc b/tools/jotpluggler/sketch_layout.cc index a8e982d8..3581bffb 100644 --- a/tools/jotpluggler/sketch_layout.cc +++ b/tools/jotpluggler/sketch_layout.cc @@ -24,10 +24,11 @@ #include "common/util.h" #include "third_party/json11/json11.hpp" -#include "tools/replay/api.h" #include "tools/replay/logreader.h" // IQ.Pilot patch: iqpilot's tools/replay has no py_downloader; route file listing is -// served directly by CommaApi2::httpGet against the IQ.Pilot konn3kt API. +// served by the konn3kt CommaApi2 helper, which returns the same JSON shape (and the +// same {"error": ...} envelope) as upstream's PyDownloader. +#include "tools/replay/api.h" namespace fs = std::filesystem; @@ -100,6 +101,7 @@ struct LoadedRouteArtifacts { std::vector can_messages; std::vector logs; std::vector timeline; + std::vector thumbnails; std::unordered_map enum_info; }; @@ -263,7 +265,8 @@ RouteSelection parse_route_selection(std::string route_name) { if (separator == "/") { size_t pos = range_str.find(':'); int begin_segment = 0; - if (!parse_segment_number(range_str.substr(0, pos), &begin_segment)) { + const std::string begin_str = range_str.substr(0, pos); + if (!begin_str.empty() && !parse_segment_number(begin_str, &begin_segment)) { return {}; } route.begin_segment = begin_segment; @@ -330,11 +333,7 @@ std::map load_segments_from_json(const json11::Json &json) { } std::map load_segments_from_server(const RouteSelection &route) { - // IQ.Pilot patch: replaces upstream PyDownloader::getRouteFiles with the existing - // iqpilot CommaApi2 helper (same JSON shape: /v1/route//files). - const std::string url = CommaApi2::BASE_URL + "/v1/route/" + route.canonical_name + "/files"; - long response_code = 0; - const std::string result = CommaApi2::httpGet(url, &response_code); + const std::string result = CommaApi2::getRouteFiles(route.canonical_name); if (result.empty()) throw std::runtime_error("Failed to fetch route files for " + route.canonical_name); std::string parse_error; @@ -438,7 +437,7 @@ std::array parse_color(std::string_view color) { return out; } -uint8_t android_priority_to_level(uint8_t priority) { +uint8_t operating_system_priority_to_level(uint8_t priority) { switch (priority) { case 2: case 3: @@ -497,7 +496,7 @@ void append_timeline_entry(std::vector *timeline, double mono_tim }); } -double android_wall_time_seconds(uint64_t timestamp) { +double operating_system_wall_time_seconds(uint64_t timestamp) { if (timestamp == 0) return 0.0; if (timestamp > 1000000000000ULL) return static_cast(timestamp) / 1.0e9; if (timestamp > 1000000000ULL) return static_cast(timestamp) / 1.0e6; @@ -615,13 +614,15 @@ void append_log_event(cereal::Event::Which which, logs->push_back(std::move(entry)); break; } + // IQ.Pilot patch: upstream renamed cereal's androidLog -> operatingSystemLog (#38209); + // iqpilot's cereal still carries the original field name. Only the accessor differs. case cereal::Event::Which::ANDROID_LOG: { - const auto android = event.getAndroidLog(); - auto entry = make_entry(LogOrigin::Android, android_priority_to_level(android.getPriority())); - entry.wall_time = android_wall_time_seconds(android.getTs()); - entry.source = android.hasTag() ? android.getTag().cStr() : "android"; - entry.message = android.hasMessage() ? android.getMessage().cStr() : std::string(); - entry.context = "pid=" + std::to_string(android.getPid()) + ", tid=" + std::to_string(android.getTid()); + const auto operating_system_log = event.getAndroidLog(); + auto entry = make_entry(LogOrigin::OperatingSystem, operating_system_priority_to_level(operating_system_log.getPriority())); + entry.wall_time = operating_system_wall_time_seconds(operating_system_log.getTs()); + entry.source = operating_system_log.hasTag() ? operating_system_log.getTag().cStr() : "operating_system"; + entry.message = operating_system_log.hasMessage() ? operating_system_log.getMessage().cStr() : std::string(); + entry.context = "pid=" + std::to_string(operating_system_log.getPid()) + ", tid=" + std::to_string(operating_system_log.getTid()); if (!entry.message.empty()) { std::string err; if (const auto p = json11::Json::parse(entry.message, err); err.empty() && p.is_object()) { @@ -629,10 +630,10 @@ void append_log_event(cereal::Event::Which which, if (p["SYSLOG_IDENTIFIER"].is_string() && !p["SYSLOG_IDENTIFIER"].string_value().empty()) entry.source = p["SYSLOG_IDENTIFIER"].string_value(); if (auto pri = json_int_value(p["PRIORITY"]); pri.has_value()) - entry.level = android_priority_to_level(*pri); + entry.level = operating_system_priority_to_level(*pri); if (auto ts = json_u64_value(p["__REALTIME_TIMESTAMP"]); ts.has_value()) - entry.wall_time = android_wall_time_seconds(*ts); - entry.context = format_journal_context(p, android.getPid(), android.getTid()); + entry.wall_time = operating_system_wall_time_seconds(*ts); + entry.context = format_journal_context(p, operating_system_log.getPid(), operating_system_log.getTid()); } } logs->push_back(std::move(entry)); @@ -690,6 +691,25 @@ std::vector extract_segment_logs(const std::vector &events) { return logs; } +std::vector extract_segment_thumbnails(const std::vector &events, int segment) { + std::vector thumbnails; + for (const Event &event_record : events) { + if (event_record.which != cereal::Event::Which::THUMBNAIL) continue; + with_parseable_event(event_record.data, [&](const cereal::Event::Reader &event) { + const auto thumbnail = event.getThumbnail(); + const auto jpeg = thumbnail.getThumbnail(); + if (jpeg.size() == 0) return; + const uint64_t timestamp = thumbnail.getTimestampEof(); + ThumbnailFrame frame; + frame.timestamp = static_cast(timestamp != 0 ? timestamp : event.getLogMonoTime()) / 1.0e9; + frame.segment = segment; + frame.jpeg.assign(jpeg.begin(), jpeg.end()); + thumbnails.push_back(std::move(frame)); + }); + } + return thumbnails; +} + RouteMetadata extract_segment_metadata(const std::vector &events) { RouteMetadata metadata; for (const Event &event_record : events) { @@ -802,6 +822,8 @@ Pane parse_dock_area(const json11::Json &dock_area_node) { const std::string kind = dock_area_node["kind"].string_value(); if (kind == "map") { pane.kind = PaneKind::Map; + } else if (kind == "thumbnail") { + pane.kind = PaneKind::Thumbnail; } else if (kind == "camera") { pane.kind = PaneKind::Camera; const std::string camera_view = dock_area_node["camera_view"].string_value(); @@ -910,7 +932,9 @@ void append_scalar_point(RouteSeries *series, series->values.push_back(value); } -void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { +// This has thousands of generated call sites. Inlining it duplicates vector +// growth logic throughout the extractor and is slower both to compile and run. +__attribute__((noinline)) void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { series->times.push_back(tm); series->values.push_back(value); } @@ -1173,6 +1197,7 @@ RouteData build_route_data(std::vector &&series_list, std::vector &&can_messages, std::vector &&logs, std::vector &&timeline, + std::vector &&thumbnails, std::unordered_map &&enum_info, std::string car_fingerprint, std::string dbc_name) { @@ -1239,6 +1264,14 @@ RouteData build_route_data(std::vector &&series_list, route_data.x_min = timeline.front().start_time; route_data.x_max = timeline.back().end_time; } + std::sort(thumbnails.begin(), thumbnails.end(), [](const ThumbnailFrame &a, const ThumbnailFrame &b) { + return a.timestamp < b.timestamp; + }); + if (!route_data.has_time_range && !thumbnails.empty()) { + route_data.has_time_range = true; + route_data.x_min = thumbnails.front().timestamp; + route_data.x_max = thumbnails.back().timestamp; + } if (route_data.has_time_range) { const double time_offset = route_data.x_min; @@ -1260,6 +1293,9 @@ RouteData build_route_data(std::vector &&series_list, entry.start_time -= time_offset; entry.end_time -= time_offset; } + for (ThumbnailFrame &thumbnail : thumbnails) { + thumbnail.timestamp -= time_offset; + } route_data.x_max -= time_offset; route_data.x_min = 0.0; } @@ -1277,6 +1313,7 @@ RouteData build_route_data(std::vector &&series_list, merged_timeline.push_back(std::move(entry)); } route_data.timeline = std::move(merged_timeline); + route_data.thumbnails = std::move(thumbnails); std::sort(can_messages.begin(), can_messages.end(), [](const CanMessageData &a, const CanMessageData &b) { return std::make_tuple(a.id.service, a.id.bus, a.id.address) < std::make_tuple(b.id.service, b.id.bus, b.id.address); @@ -1530,6 +1567,7 @@ LoadedRouteArtifacts load_route_series_parallel( SeriesAccumulator series; std::vector logs; std::vector timeline; + std::vector thumbnails; }; const std::vector> segment_list(segments.begin(), segments.end()); @@ -1579,15 +1617,12 @@ LoadedRouteArtifacts load_route_series_parallel( continue; } - // IQ.Pilot patch: iqpilot's LogReader does not expose load-time telemetry - // (download/decompress/parse seconds, compressed/decompressed sizes). Stub - // these to 0 so the loader UI still renders without per-segment diagnostics. - segment_stats.download_seconds = 0.0; - segment_stats.decompress_seconds = 0.0; - segment_stats.parse_seconds = 0.0; - segment_stats.compressed_bytes = 0; - segment_stats.decompressed_bytes = 0; - stats->bytes_downloaded.fetch_add(0); + segment_stats.download_seconds = reader.download_seconds(); + segment_stats.decompress_seconds = reader.decompress_seconds(); + segment_stats.parse_seconds = reader.parse_seconds(); + segment_stats.compressed_bytes = reader.compressed_size(); + segment_stats.decompressed_bytes = reader.decompressed_size(); + stats->bytes_downloaded.fetch_add(reader.compressed_size()); stats->segments_downloaded.fetch_add(1); stats->publish(RouteLoadStage::DownloadingSegment, index, std::to_string(segment_number)); @@ -1595,6 +1630,7 @@ LoadedRouteArtifacts load_route_series_parallel( results[index].series = extract_segment_series(reader.events, schema, can_dbc, skip_raw_can, worker_budget, segment_workers); results[index].logs = extract_segment_logs(reader.events); results[index].timeline = extract_segment_timeline(reader.events); + results[index].thumbnails = extract_segment_thumbnails(reader.events, segment_number); segment_stats.extract_seconds = std::chrono::duration(LoadStats::Clock::now() - extract_start).count(); segment_stats.event_count = reader.events.size(); segment_stats.series_count = populated_series_count(results[index].series); @@ -1621,6 +1657,7 @@ LoadedRouteArtifacts load_route_series_parallel( } std::vector logs; std::vector timeline; + std::vector thumbnails; for (SegmentResult &result : results) { if (!result.logs.empty()) { logs.insert(logs.end(), @@ -1632,12 +1669,18 @@ LoadedRouteArtifacts load_route_series_parallel( std::make_move_iterator(result.timeline.begin()), std::make_move_iterator(result.timeline.end())); } + if (!result.thumbnails.empty()) { + thumbnails.insert(thumbnails.end(), + std::make_move_iterator(result.thumbnails.begin()), + std::make_move_iterator(result.thumbnails.end())); + } } LoadedRouteArtifacts artifacts; artifacts.series = collect_series(std::move(merged)); artifacts.can_messages = std::move(merged.can_messages); artifacts.logs = std::move(logs); artifacts.timeline = std::move(timeline); + artifacts.thumbnails = std::move(thumbnails); artifacts.enum_info = std::move(merged.enum_info); stats->merge_end = LoadStats::Clock::now(); return artifacts; @@ -1843,6 +1886,7 @@ RouteData load_route_data(const std::string &route_name, std::move(artifacts.can_messages), std::move(artifacts.logs), std::move(artifacts.timeline), + std::move(artifacts.thumbnails), std::move(artifacts.enum_info), metadata.car_fingerprint, resolved_dbc); diff --git a/tools/jotpluggler/test_jotpluggler.py b/tools/jotpluggler/test_jotpluggler.py new file mode 100644 index 00000000..9834f270 --- /dev/null +++ b/tools/jotpluggler/test_jotpluggler.py @@ -0,0 +1,53 @@ +import subprocess +from pathlib import Path + +import pytest + +JOTPLUGGLER_DIR = Path(__file__).parent +JOTPLUGGLER_BIN = JOTPLUGGLER_DIR / "jotpluggler" + +pytestmark = pytest.mark.skipif(not JOTPLUGGLER_BIN.exists(), + reason="jotpluggler not built (scons -u tools/jotpluggler)") + + +def run_jotpluggler(*args): + return subprocess.run([str(JOTPLUGGLER_BIN), *args], cwd=JOTPLUGGLER_DIR, + capture_output=True, text=True, timeout=60) + + +class TestJotpluggler: + def test_help(self): + result = run_jotpluggler("-h") + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr + + def test_generated_dbcs_materialized(self): + # the build materializes iqdbc's *_generated.dbc files; layout.cc and + # sketch_layout.cc both resolve DBC names against this directory + generated = JOTPLUGGLER_DIR / "generated_dbcs" + assert generated.is_dir() + assert list(generated.glob("*.dbc")), "no generated DBCs — iqdbc create_all did not run" + + def test_car_fingerprint_header_uses_iqdbc_platforms(self): + header = (JOTPLUGGLER_DIR / "car_fingerprint_to_dbc.h").read_text() + assert "kCarFingerprintToDbc" in header + assert "dbc_for_car_fingerprint" in header + + def test_bootstrap_icons_vendored(self): + # IQ.Pilot vendors the TTF because third_party/bootstrap is git-lfs; icons.cc + # reads this exact path instead of upstream's BOOTSTRAP_ICONS_TTF define + assert (JOTPLUGGLER_DIR / "assets" / "bootstrap-icons.ttf").is_file() + + def test_no_comma_connect_links(self): + # iqpilot routes live on konn3kt; the comma connect / useradmin buttons are gone + common = (JOTPLUGGLER_DIR / "common.cc").read_text() + assert "connect.comma.ai" not in common + assert "useradmin.comma.ai" not in common + assert "route_konn3kt_url" in common + + def test_route_files_go_through_konn3kt_api(self): + # upstream calls PyDownloader::getRouteFiles here; iqpilot has no py_downloader + sketch = (JOTPLUGGLER_DIR / "sketch_layout.cc").read_text() + assert "PyDownloader::" not in sketch + assert "py_downloader.h" not in sketch + assert "CommaApi2::getRouteFiles" in sketch diff --git a/tools/jotpluggler/thumbnail.cc b/tools/jotpluggler/thumbnail.cc new file mode 100644 index 00000000..11e18432 --- /dev/null +++ b/tools/jotpluggler/thumbnail.cc @@ -0,0 +1,253 @@ +#include "tools/jotpluggler/thumbnail.h" + +#include "imgui_impl_opengl3_loader.h" + +#include +#include +#include + +extern "C" { +#include +#include +} + +namespace { + +bool decode_jpeg(const std::vector &jpeg, int *width, int *height, std::vector *rgba) { + if (jpeg.empty()) return false; + + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + AVCodecContext *context = codec != nullptr ? avcodec_alloc_context3(codec) : nullptr; + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + if (context == nullptr || frame == nullptr || packet == nullptr) { + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + const bool packet_ready = jpeg.size() <= static_cast(std::numeric_limits::max()) + && av_new_packet(packet, static_cast(jpeg.size())) >= 0; + if (packet_ready) { + std::copy(jpeg.begin(), jpeg.end(), packet->data); + } + const bool decoded = packet_ready + && avcodec_open2(context, codec, nullptr) >= 0 + && avcodec_send_packet(context, packet) >= 0 + && avcodec_receive_frame(context, frame) >= 0; + if (!decoded || frame->width <= 0 || frame->height <= 0) { + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + int chroma_x_shift = 0; + int chroma_y_shift = 0; + switch (static_cast(frame->format)) { + case AV_PIX_FMT_YUV420P: + case AV_PIX_FMT_YUVJ420P: + chroma_x_shift = 1; + chroma_y_shift = 1; + break; + case AV_PIX_FMT_YUV422P: + case AV_PIX_FMT_YUVJ422P: + chroma_x_shift = 1; + break; + case AV_PIX_FMT_YUV444P: + case AV_PIX_FMT_YUVJ444P: + break; + default: + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + *width = frame->width; + *height = frame->height; + rgba->resize(static_cast(*width) * static_cast(*height) * 4U); + const bool full_range = frame->color_range == AVCOL_RANGE_JPEG + || frame->format == AV_PIX_FMT_YUVJ420P + || frame->format == AV_PIX_FMT_YUVJ422P + || frame->format == AV_PIX_FMT_YUVJ444P; + for (int y = 0; y < *height; ++y) { + const uint8_t *y_row = frame->data[0] + y * frame->linesize[0]; + const uint8_t *u_row = frame->data[1] + (y >> chroma_y_shift) * frame->linesize[1]; + const uint8_t *v_row = frame->data[2] + (y >> chroma_y_shift) * frame->linesize[2]; + uint8_t *out = rgba->data() + static_cast(y) * static_cast(*width) * 4U; + for (int x = 0; x < *width; ++x) { + const double luma = full_range ? static_cast(y_row[x]) + : 1.164383 * (static_cast(y_row[x]) - 16.0); + const double u = static_cast(u_row[x >> chroma_x_shift]) - 128.0; + const double v = static_cast(v_row[x >> chroma_x_shift]) - 128.0; + const double red = luma + (full_range ? 1.402 : 1.596027) * v; + const double green = luma - (full_range ? 0.344136 : 0.391762) * u + - (full_range ? 0.714136 : 0.812968) * v; + const double blue = luma + (full_range ? 1.772 : 2.017232) * u; + out[x * 4 + 0] = static_cast(std::clamp(std::lround(red), 0L, 255L)); + out[x * 4 + 1] = static_cast(std::clamp(std::lround(green), 0L, 255L)); + out[x * 4 + 2] = static_cast(std::clamp(std::lround(blue), 0L, 255L)); + out[x * 4 + 3] = 255; + } + } + + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return true; +} + +std::string format_thumbnail_time(double seconds) { + const int rounded = std::max(0, static_cast(std::lround(seconds))); + const int hours = rounded / 3600; + const int minutes = (rounded % 3600) / 60; + const int secs = rounded % 60; + if (hours > 0) { + return util::string_format("%d:%02d:%02d", hours, minutes, secs); + } + return util::string_format("%02d:%02d", minutes, secs); +} + +} // namespace + +struct ThumbnailView::Impl { + ~Impl() { + destroy_texture(); + } + + void setThumbnails(const std::vector &next_thumbnails) { + destroy_texture(); + thumbnails = &next_thumbnails; + displayed_index = -1; + failed_index = -1; + } + + void update(double tracker_time) { + if (thumbnails == nullptr || thumbnails->empty()) return; + auto it = std::lower_bound(thumbnails->begin(), thumbnails->end(), tracker_time, + [](const ThumbnailFrame &frame, double time) { + return frame.timestamp < time; + }); + if (it == thumbnails->end()) { + it = std::prev(thumbnails->end()); + } else if (it != thumbnails->begin()) { + const auto previous = std::prev(it); + if (std::abs(previous->timestamp - tracker_time) <= std::abs(it->timestamp - tracker_time)) { + it = previous; + } + } + const int index = static_cast(std::distance(thumbnails->begin(), it)); + if (index == displayed_index || index == failed_index) return; + + int width = 0; + int height = 0; + std::vector rgba; + if (!decode_jpeg(it->jpeg, &width, &height, &rgba)) { + failed_index = index; + return; + } + + if (texture == 0) { + glGenTextures(1, &texture); + } + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data()); + glBindTexture(GL_TEXTURE_2D, 0); + texture_width = width; + texture_height = height; + displayed_index = index; + failed_index = -1; + } + + void drawSized(ImVec2 size, bool loading) const { + size.x = std::max(1.0f, size.x); + size.y = std::max(1.0f, size.y); + ImGui::InvisibleButton("##thumbnail_sized", size); + const ImVec2 pane_min = ImGui::GetItemRectMin(); + const ImVec2 pane_max = ImGui::GetItemRectMax(); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddRectFilled(pane_min, pane_max, IM_COL32(24, 24, 24, 255)); + + if (texture != 0 && texture_width > 0 && texture_height > 0) { + const float scale = std::min(size.x / static_cast(texture_width), + size.y / static_cast(texture_height)); + const ImVec2 image_size(static_cast(texture_width) * scale, + static_cast(texture_height) * scale); + const ImVec2 image_min(pane_min.x + (size.x - image_size.x) * 0.5f, + pane_min.y + (size.y - image_size.y) * 0.5f); + const ImVec2 image_max(image_min.x + image_size.x, image_min.y + image_size.y); + draw_list->AddImage(static_cast(texture), image_min, image_max); + + if (thumbnails != nullptr && displayed_index >= 0 + && displayed_index < static_cast(thumbnails->size())) { + const ThumbnailFrame &frame = (*thumbnails)[static_cast(displayed_index)]; + const std::string label = util::string_format("%s · segment %d · %d/%zu", + format_thumbnail_time(frame.timestamp).c_str(), + frame.segment, + displayed_index + 1, + thumbnails->size()); + const ImVec2 text_size = ImGui::CalcTextSize(label.c_str()); + const ImVec2 label_min(image_min.x, std::max(image_min.y, image_max.y - text_size.y - 14.0f)); + draw_list->AddRectFilled(label_min, image_max, IM_COL32(0, 0, 0, 175)); + draw_list->AddText(ImVec2(label_min.x + 7.0f, label_min.y + 7.0f), IM_COL32_WHITE, label.c_str()); + } + return; + } + + const bool has_thumbnails = thumbnails != nullptr && !thumbnails->empty(); + const char *label = loading ? "loading" : (has_thumbnails ? "invalid thumbnail" : "no thumbnails"); + const ImVec2 text_size = ImGui::CalcTextSize(label); + draw_list->AddText(ImVec2(pane_min.x + (size.x - text_size.x) * 0.5f, + pane_min.y + (size.y - text_size.y) * 0.5f), + IM_COL32(187, 187, 187, 255), label); + } + + void destroy_texture() { + if (texture != 0) { + glDeleteTextures(1, &texture); + } + texture = 0; + texture_width = 0; + texture_height = 0; + } + + const std::vector *thumbnails = nullptr; + int displayed_index = -1; + int failed_index = -1; + GLuint texture = 0; + int texture_width = 0; + int texture_height = 0; +}; + +ThumbnailView::ThumbnailView() : impl_(std::make_unique()) {} +ThumbnailView::~ThumbnailView() = default; + +void ThumbnailView::setThumbnails(const std::vector &thumbnails) { + impl_->setThumbnails(thumbnails); +} + +void ThumbnailView::update(double tracker_time) { + impl_->update(tracker_time); +} + +void ThumbnailView::drawSized(ImVec2 size, bool loading) { + impl_->drawSized(size, loading); +} + +void draw_thumbnail_pane(AppSession *session, UiState *state) { + if (session->thumbnail_view == nullptr) { + ImGui::TextDisabled("Thumbnails unavailable"); + return; + } + if (state->has_tracker_time) { + session->thumbnail_view->update(state->tracker_time); + } + session->thumbnail_view->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading); +} diff --git a/tools/jotpluggler/thumbnail.h b/tools/jotpluggler/thumbnail.h new file mode 100644 index 00000000..6970173a --- /dev/null +++ b/tools/jotpluggler/thumbnail.h @@ -0,0 +1,5 @@ +#pragma once + +#include "tools/jotpluggler/app.h" + +void draw_thumbnail_pane(AppSession *session, UiState *state); diff --git a/tools/joystick/joystickd.py b/tools/joystick/joystickd.py index f7b1ea4a..8ccfe9c9 100755 --- a/tools/joystick/joystickd.py +++ b/tools/joystick/joystickd.py @@ -3,7 +3,7 @@ import math import numpy as np -from cereal import messaging, car +from cereal import messaging, car, custom from iqdbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL, Ratekeeper from openpilot.common.params import Params @@ -35,6 +35,7 @@ def joystickd_thread(): params = Params() cloudlog.info("joystickd is waiting for CarParams") CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) + CP_IQ = messaging.log_from_bytes(params.get("IQCarParams", block=True), custom.IQCarParams) VM = VehicleModel(CP) sm = messaging.SubMaster(['carState', 'onroadEvents', 'liveParameters', 'selfdriveState', 'iqState', 'testJoystick'], frequency=1. / DT_CTRL) @@ -64,7 +65,9 @@ def joystickd_thread(): CC.enabled = bool(ss.enabled or aol_enabled) CC.latActive = bool(ss.active or joystick_angle_lat_active) and not sm['carState'].steerFaultTemporary and not sm['carState'].steerFaultPermanent - CC.longActive = bool(ss.enabled) and not any(e.overrideLongitudinal for e in sm['onroadEvents']) and CP.openpilotLongitudinalControl + long_through_override = CP_IQ.longActiveWithGasOverride and CP.openpilotLongitudinalControl + override_longitudinal = any(e.overrideLongitudinal for e in sm['onroadEvents']) + CC.longActive = bool(ss.enabled) and (not override_longitudinal or long_through_override) and CP.openpilotLongitudinalControl CC.cruiseControl.cancel = sm['carState'].cruiseState.enabled and (not CC.enabled or not CP.pcmCruise) CC.hudControl.leadDistanceBars = 2 diff --git a/tools/replay/.gitignore b/tools/replay/.gitignore index 83f0e99a..47f51689 100644 --- a/tools/replay/.gitignore +++ b/tools/replay/.gitignore @@ -3,3 +3,4 @@ moc_* replay tests/test_replay +tests/test_api diff --git a/tools/replay/SConscript b/tools/replay/SConscript index 136c4119..951d4b9a 100644 --- a/tools/replay/SConscript +++ b/tools/replay/SConscript @@ -22,3 +22,4 @@ replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_fram if GetOption('extras'): replay_env.Program('tests/test_replay', ['tests/test_replay.cc'], LIBS=replay_libs) + replay_env.Program('tests/test_api', ['tests/test_runner.cc', 'tests/test_api.cc'], LIBS=replay_libs) diff --git a/tools/replay/api.cc b/tools/replay/api.cc index 092b1612..53cfe399 100644 --- a/tools/replay/api.cc +++ b/tools/replay/api.cc @@ -160,4 +160,54 @@ std::string httpGet(const std::string &url, long *response_code) { return res == CURLE_OK ? readBuffer : std::string{}; } +std::string apiResponse(const std::string &body, long response_code) { + if (response_code == 401 || response_code == 403) { + return R"({"error": "unauthorized"})"; + } + if (response_code == 404) { + return R"({"error": "not_found"})"; + } + if (body.empty() || response_code < 200 || response_code >= 300) { + return R"({"error": "network"})"; + } + return body; +} + +std::string routeFilesPath(const std::string &route) { + return "v1/route/" + route + "/files"; +} + +std::string devicesPath() { + return "v1/me/devices/"; +} + +std::string deviceRoutesPath(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { + if (preserved) { + return "v1/devices/" + dongle_id + "/routes/preserved"; + } + + std::string query; + if (start_ms > 0) query += "start=" + std::to_string(start_ms); + if (end_ms > 0) query += (query.empty() ? "" : "&") + ("end=" + std::to_string(end_ms)); + return "v1/devices/" + dongle_id + "/routes_segments" + (query.empty() ? "" : "?" + query); +} + +static std::string apiCall(const std::string &path) { + long response_code = 0; + const std::string body = httpGet(BASE_URL + "/" + path, &response_code); + return apiResponse(body, response_code); +} + +std::string getRouteFiles(const std::string &route) { + return apiCall(routeFilesPath(route)); +} + +std::string getDevices() { + return apiCall(devicesPath()); +} + +std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { + return apiCall(deviceRoutesPath(dongle_id, start_ms, end_ms, preserved)); +} + } // namespace CommaApi diff --git a/tools/replay/api.h b/tools/replay/api.h index caa53c70..77f73cdc 100644 --- a/tools/replay/api.h +++ b/tools/replay/api.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "common/util.h" @@ -12,4 +13,21 @@ const std::string BASE_URL = util::getenv("API_HOST", "https://api-iqlabs.konn3k std::string create_token(bool use_jwt, const json11::Json& payloads = {}, int expiry = 3600); std::string httpGet(const std::string &url, long *response_code = nullptr); +// konn3kt equivalents of upstream's PyDownloader API helpers. Same endpoints and +// same response shapes, but served over the C++/libcurl path instead of shelling +// into tools/lib. On failure they return {"error": ""} so callers can tell +// unauthorized from a transport error without a second out-param. +std::string getRouteFiles(const std::string &route); +std::string getDevices(); +std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms = 0, int64_t end_ms = 0, bool preserved = false); + +// Endpoint paths, relative to BASE_URL. Split out from the fetchers so the konn3kt +// routing can be asserted without network access. +std::string routeFilesPath(const std::string &route); +std::string devicesPath(); +std::string deviceRoutesPath(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved); + +// Maps an HTTP status + body to either the body or an {"error": ...} envelope. +std::string apiResponse(const std::string &body, long response_code); + } // namespace CommaApi2 diff --git a/tools/replay/framereader.cc b/tools/replay/framereader.cc index 96ec5915..c3c60acf 100644 --- a/tools/replay/framereader.cc +++ b/tools/replay/framereader.cc @@ -6,7 +6,7 @@ #include #include "common/util.h" -#include "third_party/libyuv/include/libyuv.h" +#include "common/yuv.h" #include "tools/replay/util.h" #include "system/hardware/hw.h" @@ -257,12 +257,12 @@ bool FFmpegVideoDecoder::copyBuffer(AVFrame *f, VisionBuf *buf) { memcpy(buf->uv + i*buf->stride, f->data[1] + i*f->linesize[1], width); } } else { - libyuv::I420ToNV12(f->data[0], f->linesize[0], - f->data[1], f->linesize[1], - f->data[2], f->linesize[2], - buf->y, buf->stride, - buf->uv, buf->stride, - width, height); + yuv::i420_to_nv12(f->data[0], f->linesize[0], + f->data[1], f->linesize[1], + f->data[2], f->linesize[2], + buf->y, buf->stride, + buf->uv, buf->stride, + width, height); } return true; } diff --git a/tools/replay/logreader.cc b/tools/replay/logreader.cc index 75abb841..5e357c7a 100644 --- a/tools/replay/logreader.cc +++ b/tools/replay/logreader.cc @@ -1,19 +1,31 @@ #include "tools/replay/logreader.h" #include +#include #include #include "tools/replay/filereader.h" #include "tools/replay/util.h" #include "common/util.h" bool LogReader::load(const std::string &url, std::atomic *abort, bool local_cache, int chunk_size, int retries) { + using Clock = std::chrono::steady_clock; + compressed_size_ = decompressed_size_ = 0; + download_seconds_ = decompress_seconds_ = parse_seconds_ = 0.0; + + const auto download_start = Clock::now(); std::string data = FileReader(local_cache, chunk_size, retries).read(url, abort); + download_seconds_ = std::chrono::duration(Clock::now() - download_start).count(); + compressed_size_ = decompressed_size_ = data.size(); + if (!data.empty()) { + const auto decompress_start = Clock::now(); if (url.find(".bz2") != std::string::npos || util::starts_with(data, "BZh9")) { data = decompressBZ2(data, abort); } else if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) { data = decompressZST(data, abort); } + decompress_seconds_ = std::chrono::duration(Clock::now() - decompress_start).count(); + decompressed_size_ = data.size(); } bool success = !data.empty() && load(data.data(), data.size(), abort); @@ -23,6 +35,8 @@ bool LogReader::load(const std::string &url, std::atomic *abort, bool loca } bool LogReader::load(const char *data, size_t size, std::atomic *abort) { + using Clock = std::chrono::steady_clock; + const auto parse_start = Clock::now(); try { events.reserve(65000); kj::ArrayPtr words((const capnp::word *)data, size / sizeof(capnp::word)); @@ -65,6 +79,8 @@ bool LogReader::load(const char *data, size_t size, std::atomic *abort) { migrateOldEvents(); } + parse_seconds_ = std::chrono::duration(Clock::now() - parse_start).count(); + if (!events.empty() && !(abort && *abort)) { events.shrink_to_fit(); std::sort(events.begin(), events.end()); diff --git a/tools/replay/logreader.h b/tools/replay/logreader.h index f8d60ffa..2a861dc0 100644 --- a/tools/replay/logreader.h +++ b/tools/replay/logreader.h @@ -32,6 +32,15 @@ public: bool load(const char *data, size_t size, std::atomic *abort = nullptr); std::vector events; + // Per-load instrumentation, consumed by jotpluggler's route load stats panel. + // Unlike upstream these are measured around iqpilot's own FileReader/decompress + // steps rather than around a Python downloader, so decompress is timed separately. + uint64_t compressed_size() const { return compressed_size_; } + uint64_t decompressed_size() const { return decompressed_size_; } + double download_seconds() const { return download_seconds_; } + double decompress_seconds() const { return decompress_seconds_; } + double parse_seconds() const { return parse_seconds_; } + private: void migrateOldEvents(); @@ -39,4 +48,9 @@ private: bool requires_migration = true; std::vector filters_; MonotonicBuffer buffer_{1024 * 1024}; + uint64_t compressed_size_ = 0; + uint64_t decompressed_size_ = 0; + double download_seconds_ = 0.0; + double decompress_seconds_ = 0.0; + double parse_seconds_ = 0.0; }; diff --git a/tools/replay/tests/test_api.cc b/tools/replay/tests/test_api.cc new file mode 100644 index 00000000..e86565b5 --- /dev/null +++ b/tools/replay/tests/test_api.cc @@ -0,0 +1,72 @@ +#include "catch2/catch.hpp" + +#include "tools/replay/api.h" + +// These cover the konn3kt endpoints that replaced upstream's PyDownloader shell-outs. +// Cabana's remote-routes dialog and jotpluggler's route-file listing both route through +// here, so a silent change to a path or to the error envelope breaks konn3kt without +// breaking the build. + +TEST_CASE("konn3kt api paths") { + SECTION("route files") { + REQUIRE(CommaApi2::routeFilesPath("a2a0ccea32023010|2023-07-27--13-01-19") == + "v1/route/a2a0ccea32023010|2023-07-27--13-01-19/files"); + } + + SECTION("devices") { + REQUIRE(CommaApi2::devicesPath() == "v1/me/devices/"); + } + + SECTION("device routes over a time range") { + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 1000, 2000, false) == + "v1/devices/dongle/routes_segments?start=1000&end=2000"); + } + + SECTION("device routes with only one bound") { + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 1000, 0, false) == + "v1/devices/dongle/routes_segments?start=1000"); + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 0, 2000, false) == + "v1/devices/dongle/routes_segments?end=2000"); + } + + SECTION("device routes with no bounds omits the query entirely") { + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 0, 0, false) == + "v1/devices/dongle/routes_segments"); + } + + SECTION("preserved routes ignore the time range") { + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 1000, 2000, true) == + "v1/devices/dongle/routes/preserved"); + } +} + +TEST_CASE("konn3kt api error envelope") { + // Cabana and jotpluggler both branch on these exact strings. + SECTION("success passes the body through") { + REQUIRE(CommaApi2::apiResponse("[{\"dongle_id\": \"abc\"}]", 200) == "[{\"dongle_id\": \"abc\"}]"); + } + + SECTION("401 and 403 both map to unauthorized") { + REQUIRE(CommaApi2::apiResponse("", 401) == R"({"error": "unauthorized"})"); + REQUIRE(CommaApi2::apiResponse("nope", 403) == R"({"error": "unauthorized"})"); + } + + SECTION("404 maps to not_found") { + REQUIRE(CommaApi2::apiResponse("", 404) == R"({"error": "not_found"})"); + } + + SECTION("transport failure and 5xx map to network") { + REQUIRE(CommaApi2::apiResponse("", 0) == R"({"error": "network"})"); + REQUIRE(CommaApi2::apiResponse("boom", 500) == R"({"error": "network"})"); + } + + SECTION("a 200 with an empty body is still an error") { + REQUIRE(CommaApi2::apiResponse("", 200) == R"({"error": "network"})"); + } +} + +TEST_CASE("konn3kt base url") { + // Everything is built off BASE_URL; an accidental revert to comma's host would + // silently point every tool at api.comma.ai. + REQUIRE(CommaApi2::BASE_URL.find("comma.ai") == std::string::npos); +} diff --git a/tools/replay/tests/test_runner.cc b/tools/replay/tests/test_runner.cc new file mode 100644 index 00000000..62bf7476 --- /dev/null +++ b/tools/replay/tests/test_runner.cc @@ -0,0 +1,2 @@ +#define CATCH_CONFIG_MAIN +#include "catch2/catch.hpp" diff --git a/tools/test_tools_local_route.py b/tools/test_tools_local_route.py new file mode 100644 index 00000000..ad872f8f --- /dev/null +++ b/tools/test_tools_local_route.py @@ -0,0 +1,117 @@ +"""End-to-end check that cabana and jotpluggler can still load a route and decode CAN. + +Uses a synthesized local route so it runs offline — the konn3kt API paths are covered +separately by tools/replay/tests/test_api.cc and the per-tool konn3kt tests. +""" +import os +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +import cereal.messaging as messaging +from cereal import log + +TOOLS_DIR = Path(__file__).parent +CABANA_BIN = TOOLS_DIR / "cabana" / "_cabana" +JOTPLUGGLER_BIN = TOOLS_DIR / "jotpluggler" / "jotpluggler" + +DONGLE_ID = "0000000000000000" +TIMESTAMP = "2024-01-01--00-00-00" +ROUTE = f"{DONGLE_ID}|{TIMESTAMP}" + + +def write_rlog(path: Path, n_frames: int = 200): + """A minimal but valid rlog: carParams once, then CAN frames at 100Hz.""" + with open(path, "wb") as f: + cp = messaging.new_message('carParams') + cp.carParams.carFingerprint = "TOYOTA_RAV4_TSS2" + cp.carParams.brand = "toyota" + f.write(cp.to_bytes()) + + for i in range(n_frames): + msg = messaging.new_message('can', 2) + msg.logMonoTime = int(i * 1e7) + for j, addr in enumerate((0x1D2, 0x260)): + msg.can[j].address = addr + msg.can[j].src = 0 + msg.can[j].dat = bytes([i % 256] * 8) + f.write(msg.to_bytes()) + + +@pytest.fixture(scope="module") +def local_route(tmp_path_factory): + data_dir = tmp_path_factory.mktemp("routes") + for seg in range(2): + seg_dir = data_dir / f"{DONGLE_ID}|{TIMESTAMP}--{seg}" + seg_dir.mkdir() + write_rlog(seg_dir / "rlog") + return data_dir + + +def run(cmd, timeout=180): + env = {**os.environ, "QT_QPA_PLATFORM": "offscreen"} + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env, + cwd=TOOLS_DIR.parent) + + +@pytest.mark.skipif(not JOTPLUGGLER_BIN.exists(), reason="jotpluggler not built") +def test_jotpluggler_renders_a_local_route(local_route, tmp_path): + out = tmp_path / "plot.png" + result = run([str(JOTPLUGGLER_BIN), "--data-dir", str(local_route), + "--sync-load", "--output", str(out), ROUTE]) + assert result.returncode == 0, result.stdout + result.stderr + assert out.is_file(), result.stdout + result.stderr + # a blank/failed render still writes a file, so require real content + assert out.stat().st_size > 5000, f"suspiciously small render: {out.stat().st_size} bytes" + + +@pytest.mark.skipif(not CABANA_BIN.exists(), reason="cabana not built") +def test_cabana_loads_a_local_route(local_route): + # cabana has no headless render mode: on success it enters the Qt event loop and + # never returns, so run it briefly and assert on what it logged before we kill it. + # A load failure exits before the event loop and logs "failed to load route". + env = {**os.environ, "QT_QPA_PLATFORM": "offscreen"} + proc = subprocess.Popen([str(CABANA_BIN), "--data_dir", str(local_route), "--no-vipc", ROUTE], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + env=env, cwd=TOOLS_DIR.parent) + loaded = f"loaded route {ROUTE} with 2 valid segments" + lines = [] + deadline = time.monotonic() + 60 + try: + while time.monotonic() < deadline: + line = proc.stdout.readline() + if not line: # cabana exited, which means the load failed + break + lines.append(line) + if loaded in line: + break + finally: + proc.kill() + proc.wait() + proc.stdout.close() + + out = "".join(lines) + assert "failed to load route" not in out, out + assert "invalid route format" not in out, out + assert loaded in out, out + + +@pytest.mark.skipif(not shutil.which("python3"), reason="no python3") +def test_replay_logreader_reports_load_stats(local_route): + """LogReader gained download/decompress/parse instrumentation during the sync; + jotpluggler's load-stats panel reads it.""" + header = (TOOLS_DIR / "replay" / "logreader.h").read_text() + for accessor in ("compressed_size", "decompressed_size", "download_seconds", + "decompress_seconds", "parse_seconds"): + assert f"{accessor}() const" in header + + +def test_can_capnp_field_matches_extractor_codegen(): + """jotpluggler's generated extractor reads busTimeDEPRECATED; if cereal is ever + renamed to upstream's `deprecated` group this must be re-patched.""" + assert 'busTimeDEPRECATED' in log.CanData.schema.fields + gen = (TOOLS_DIR / "jotpluggler" / "generate_event_extractors.py").read_text() + assert "getBusTimeDEPRECATED()" in gen
Disengaged